- Deterministic Classification: MOAS/HIJACK/LEAK type detection - Severity scoring: CRITICAL/HIGH/MEDIUM/LOW based on prefix length - Optional Ollama enrichment (qwen2.5:3b) for CRITICAL only (5s timeout) - PostgreSQL backend: hijack_events, webhook_subscriptions, webhook_deliveries - HMAC-SHA256 webhook signing with exponential backoff retry - Retry scheduler: node-cron job every 5 minutes - 6 API endpoints: POST/GET/DELETE webhooks, test delivery, list/resolve hijacks - 22 comprehensive tests (80%+ coverage) - Zero external API costs (deterministic + local Ollama only)
503 lines
13 KiB
HTML
503 lines
13 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>ASPA Adoption Tracker - PeerCortex</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
header {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 30px;
|
|
margin-bottom: 30px;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
h1 {
|
|
color: #333;
|
|
font-size: 28px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.subtitle {
|
|
color: #666;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 32px;
|
|
font-weight: bold;
|
|
margin: 10px 0;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 12px;
|
|
opacity: 0.9;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.stat-change {
|
|
font-size: 12px;
|
|
margin-top: 8px;
|
|
padding-top: 8px;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.3);
|
|
}
|
|
|
|
.change-up {
|
|
color: #4ade80;
|
|
}
|
|
|
|
.change-down {
|
|
color: #f87171;
|
|
}
|
|
|
|
main {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
|
gap: 30px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.chart-section {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 25px;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.chart-title {
|
|
color: #333;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 10px;
|
|
border-bottom: 2px solid #667eea;
|
|
}
|
|
|
|
.chart-container {
|
|
position: relative;
|
|
height: 300px;
|
|
}
|
|
|
|
.region-table,
|
|
.ixp-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.region-table th,
|
|
.ixp-table th {
|
|
background: #f5f5f5;
|
|
color: #333;
|
|
font-weight: 600;
|
|
padding: 12px;
|
|
text-align: left;
|
|
font-size: 12px;
|
|
border-bottom: 2px solid #ddd;
|
|
}
|
|
|
|
.region-table td,
|
|
.ixp-table td {
|
|
padding: 12px;
|
|
border-bottom: 1px solid #eee;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.region-table tr:hover,
|
|
.ixp-table tr:hover {
|
|
background: #f9f9f9;
|
|
}
|
|
|
|
.coverage-bar {
|
|
background: #e5e7eb;
|
|
height: 6px;
|
|
border-radius: 3px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.coverage-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.forecast-box {
|
|
background: #f0f9ff;
|
|
border-left: 4px solid #3b82f6;
|
|
padding: 15px;
|
|
border-radius: 6px;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.forecast-value {
|
|
font-size: 20px;
|
|
font-weight: bold;
|
|
color: #1e40af;
|
|
}
|
|
|
|
.forecast-label {
|
|
font-size: 12px;
|
|
color: #1e40af;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.confidence-badge {
|
|
display: inline-block;
|
|
background: #dbeafe;
|
|
color: #1e40af;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
margin-left: 8px;
|
|
}
|
|
|
|
footer {
|
|
background: white;
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
text-align: center;
|
|
color: #666;
|
|
font-size: 12px;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #666;
|
|
}
|
|
|
|
.error {
|
|
background: #fee;
|
|
color: #c33;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
main {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 20px;
|
|
}
|
|
|
|
.stats-grid {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1>🌐 ASPA Adoption Tracker</h1>
|
|
<p class="subtitle">Global BGP ASPA (Autonomous System Provider Authorization) adoption trends</p>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Current Coverage</div>
|
|
<div class="stat-value" id="current-coverage">-</div>
|
|
<div class="stat-change">
|
|
<span id="change-24h" class="change-up">+0.5%</span> (24h)
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<div class="stat-label">Trend</div>
|
|
<div class="stat-value" id="trend-indicator">↑</div>
|
|
<div class="stat-change" id="trend-strength">Moderate growth</div>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<div class="stat-label">6-Month Forecast</div>
|
|
<div class="stat-value" id="forecast-coverage">-</div>
|
|
<div class="stat-change">
|
|
<span id="forecast-confidence">-</span> confidence
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stat-card">
|
|
<div class="stat-label">Data Points</div>
|
|
<div class="stat-value" id="data-points">-</div>
|
|
<div class="stat-change" id="last-update">Updated -</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main>
|
|
<div class="chart-section">
|
|
<div class="chart-title">📈 Adoption Trend (30 days)</div>
|
|
<div class="chart-container">
|
|
<canvas id="adoption-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chart-section">
|
|
<div class="chart-title">🗺️ Regional Coverage</div>
|
|
<table class="region-table" id="regions-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Region</th>
|
|
<th>Coverage</th>
|
|
<th>ASNs</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="regions-body">
|
|
<tr>
|
|
<td colspan="3" style="text-align: center; color: #999">Loading...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="chart-section">
|
|
<div class="chart-title">🏢 IXP Coverage</div>
|
|
<table class="ixp-table" id="ixps-table">
|
|
<thead>
|
|
<tr>
|
|
<th>IXP</th>
|
|
<th>Coverage</th>
|
|
<th>Participants</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="ixps-body">
|
|
<tr>
|
|
<td colspan="3" style="text-align: center; color: #999">Loading...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="chart-section">
|
|
<div class="chart-title">🎯 Top Adopters</div>
|
|
<div style="margin-top: 15px" id="top-adopters-list">
|
|
<p style="color: #999">Loading...</p>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<footer>
|
|
<p>Last updated: <span id="footer-timestamp">-</span></p>
|
|
<p style="margin-top: 10px; color: #999">Data sources: RIPE Stat, PeeringDB, CAIDA. Updated daily at 2:00 AM UTC.</p>
|
|
</footer>
|
|
</div>
|
|
|
|
<script>
|
|
let adoptionChart = null
|
|
|
|
async function loadData() {
|
|
try {
|
|
const response = await fetch('/api/aspa-adoption-stats?period=30d')
|
|
if (!response.ok) throw new Error('Failed to load data')
|
|
const stats = await response.json()
|
|
|
|
updateStats(stats)
|
|
renderAdoptionChart(stats.trend)
|
|
loadRegionalData()
|
|
loadIXPData()
|
|
} catch (error) {
|
|
console.error('Error loading data:', error)
|
|
document.body.innerHTML += `<div class="error">Failed to load ASPA adoption data</div>`
|
|
}
|
|
}
|
|
|
|
function updateStats(stats) {
|
|
document.getElementById('current-coverage').textContent = stats.current.coverage.toFixed(1) + '%'
|
|
document.getElementById('change-24h').textContent =
|
|
(stats.current.change24h >= 0 ? '+' : '') + stats.current.change24h.toFixed(2) + '%'
|
|
document.getElementById('change-24h').className = stats.current.change24h >= 0 ? 'change-up' : 'change-down'
|
|
|
|
const trendSymbols = { up: '↑', down: '↓', stable: '→' }
|
|
document.getElementById('trend-indicator').textContent = trendSymbols[stats.current.trend] || '→'
|
|
document.getElementById('trend-strength').textContent =
|
|
stats.current.trend.charAt(0).toUpperCase() + stats.current.trend.slice(1) + ' trend'
|
|
|
|
document.getElementById('forecast-coverage').textContent = stats.forecast.predictedCoverage6m.toFixed(1) + '%'
|
|
document.getElementById('forecast-confidence').textContent = (stats.forecast.confidence * 100).toFixed(0) + '%'
|
|
|
|
document.getElementById('data-points').textContent = stats.trend.length
|
|
const lastUpdate = new Date()
|
|
document.getElementById('last-update').textContent = 'Updated ' + formatTime(lastUpdate)
|
|
document.getElementById('footer-timestamp').textContent = lastUpdate.toLocaleString()
|
|
|
|
renderTopAdopters(stats.topAdopters)
|
|
}
|
|
|
|
function renderAdoptionChart(trendData) {
|
|
const ctx = document.getElementById('adoption-chart').getContext('2d')
|
|
|
|
if (adoptionChart) {
|
|
adoptionChart.destroy()
|
|
}
|
|
|
|
adoptionChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: trendData.map((p) => p.date),
|
|
datasets: [
|
|
{
|
|
label: 'ASPA Coverage (%)',
|
|
data: trendData.map((p) => p.coveragePercentage),
|
|
borderColor: '#667eea',
|
|
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
|
borderWidth: 2,
|
|
fill: true,
|
|
tension: 0.4,
|
|
pointRadius: 4,
|
|
pointBackgroundColor: '#667eea',
|
|
pointBorderColor: '#fff',
|
|
pointBorderWidth: 2,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false },
|
|
},
|
|
scales: {
|
|
y: {
|
|
min: 0,
|
|
max: 100,
|
|
ticks: { callback: (v) => v + '%' },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
async function loadRegionalData() {
|
|
try {
|
|
const response = await fetch('/api/aspa-adoption-stats/regional')
|
|
if (!response.ok) throw new Error('Failed to load regional data')
|
|
const data = await response.json()
|
|
|
|
const tbody = document.getElementById('regions-body')
|
|
tbody.innerHTML = data.regions
|
|
.map(
|
|
(r) => `
|
|
<tr>
|
|
<td>${r.region}</td>
|
|
<td>
|
|
<div style="display: flex; align-items: center; gap: 10px;">
|
|
<div style="flex: 1;">
|
|
<div class="coverage-bar">
|
|
<div class="coverage-fill" style="width: ${r.coveragePercentage}%"></div>
|
|
</div>
|
|
</div>
|
|
<span>${r.coveragePercentage.toFixed(1)}%</span>
|
|
</div>
|
|
</td>
|
|
<td>${r.ASNsWithASPA} / ${r.totalASNs}</td>
|
|
</tr>
|
|
`
|
|
)
|
|
.join('')
|
|
} catch (error) {
|
|
console.error('Error loading regional data:', error)
|
|
}
|
|
}
|
|
|
|
async function loadIXPData() {
|
|
try {
|
|
const response = await fetch('/api/aspa-adoption-stats/ixps?top=5')
|
|
if (!response.ok) throw new Error('Failed to load IXP data')
|
|
const data = await response.json()
|
|
|
|
const tbody = document.getElementById('ixps-body')
|
|
tbody.innerHTML = data.ixps
|
|
.map(
|
|
(i) => `
|
|
<tr>
|
|
<td>${i.ixpName}</td>
|
|
<td>
|
|
<div style="display: flex; align-items: center; gap: 10px;">
|
|
<div style="flex: 1;">
|
|
<div class="coverage-bar">
|
|
<div class="coverage-fill" style="width: ${i.coveragePercentage}%"></div>
|
|
</div>
|
|
</div>
|
|
<span>${i.coveragePercentage.toFixed(1)}%</span>
|
|
</div>
|
|
</td>
|
|
<td>${i.participantsWithASPA} / ${i.participants}</td>
|
|
</tr>
|
|
`
|
|
)
|
|
.join('')
|
|
} catch (error) {
|
|
console.error('Error loading IXP data:', error)
|
|
}
|
|
}
|
|
|
|
function renderTopAdopters(adopters) {
|
|
const list = document.getElementById('top-adopters-list')
|
|
list.innerHTML = adopters
|
|
.slice(0, 5)
|
|
.map(
|
|
(a, i) => `
|
|
<div style="padding: 10px 0; border-bottom: 1px solid #eee;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<span style="font-weight: 600;">
|
|
<span style="color: #667eea; margin-right: 8px;">#${i + 1}</span>
|
|
${a.name}
|
|
</span>
|
|
<span style="color: #666; font-size: 12px;">${a.providers} provider${a.providers !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
</div>
|
|
`
|
|
)
|
|
.join('')
|
|
}
|
|
|
|
function formatTime(date) {
|
|
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', loadData)
|
|
setInterval(loadData, 5 * 60 * 1000)
|
|
</script>
|
|
</body>
|
|
</html>
|