feat: ASPA adoption tracker with time-series, dashboard, and export
- Persistent JSON history (DATA_DIR/aspa-adoption-history.json, up to 365 days) - Snapshots recorded on every RPKI feed refresh (~4h) + startup - Coverage metric: % of Atlas-visible ASNs (4696+) with ASPA objects in RPKI - GET /aspa-adoption → interactive Chart.js dashboard (MAGATAMA indigo theme) - Current coverage %, ASPA object count, 7-day delta, 90-day forecast - 30-day trend line chart + ASPA object bar chart - GET /api/aspa-adoption-stats?period=7d|30d|90d|1y → JSON trend + linear forecast - GET /api/aspa-adoption-stats/export?format=csv|json&period=30d → CSV/JSON download - Integrates with existing RPKI feed pipeline (no new external dependencies) - Fixes health endpoint to use new aspaAdoptionDailyHistory structure
This commit is contained in:
parent
3515244d27
commit
eeff755cc2
409
server.js
409
server.js
@ -409,9 +409,10 @@ function decodeCommunities(communityList) {
|
|||||||
// FEATURE 1: BGP Hijack Alerting + Webhooks
|
// FEATURE 1: BGP Hijack Alerting + Webhooks
|
||||||
// ============================================================
|
// ============================================================
|
||||||
const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/opt/peercortex-app') ? '/opt/peercortex-app' : __dirname);
|
const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/opt/peercortex-app') ? '/opt/peercortex-app' : __dirname);
|
||||||
const HIJACK_SUBS_FILE = DATA_DIR + '/hijack-subs.json';
|
const HIJACK_SUBS_FILE = DATA_DIR + '/hijack-subs.json';
|
||||||
const HIJACK_ALERTS_FILE = DATA_DIR + '/hijack-alerts.json';
|
const HIJACK_ALERTS_FILE = DATA_DIR + '/hijack-alerts.json';
|
||||||
const WEBHOOK_SUBS_FILE = DATA_DIR + '/webhook-subs.json';
|
const WEBHOOK_SUBS_FILE = DATA_DIR + '/webhook-subs.json';
|
||||||
|
const ASPA_ADOPTION_FILE = DATA_DIR + '/aspa-adoption-history.json';
|
||||||
|
|
||||||
function loadHijackSubs() { try { return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE,'utf8')); } catch(_){ return []; } }
|
function loadHijackSubs() { try { return JSON.parse(fs.readFileSync(HIJACK_SUBS_FILE,'utf8')); } catch(_){ return []; } }
|
||||||
function loadHijackAlerts() { try { return JSON.parse(fs.readFileSync(HIJACK_ALERTS_FILE,'utf8')); } catch(_){ return []; } }
|
function loadHijackAlerts() { try { return JSON.parse(fs.readFileSync(HIJACK_ALERTS_FILE,'utf8')); } catch(_){ return []; } }
|
||||||
@ -551,7 +552,291 @@ async function runHijackCheck() {
|
|||||||
// Run hijack check every 30 minutes
|
// Run hijack check every 30 minutes
|
||||||
setInterval(runHijackCheck, 30 * 60 * 1000);
|
setInterval(runHijackCheck, 30 * 60 * 1000);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// FEATURE 3: ASPA Adoption Tracker
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load persisted ASPA adoption history from disk.
|
||||||
|
* Returns array of snapshot objects (up to 365 entries).
|
||||||
|
*/
|
||||||
|
function loadAspaAdoptionHistory() {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(ASPA_ADOPTION_FILE, 'utf8'));
|
||||||
|
return Array.isArray(raw) ? raw : [];
|
||||||
|
} catch(_) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save ASPA adoption history to disk (keep last 365 entries).
|
||||||
|
*/
|
||||||
|
function saveAspaAdoptionHistory(history) {
|
||||||
|
try {
|
||||||
|
const trimmed = history.slice(-365);
|
||||||
|
fs.writeFileSync(ASPA_ADOPTION_FILE, JSON.stringify(trimmed, null, 2), 'utf8');
|
||||||
|
} catch(e) {
|
||||||
|
console.error('[ASPA-ADOPT] Save failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-memory adoption history (persisted on each new snapshot) */
|
||||||
|
let aspaAdoptionDailyHistory = loadAspaAdoptionHistory();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take a new adoption snapshot and persist it.
|
||||||
|
* Called whenever the RPKI feed is refreshed.
|
||||||
|
* @param {number} aspaCount — total ASPA objects in rpkiAspaMap
|
||||||
|
* @param {number} roaCount — total ROAs in roaStore
|
||||||
|
*/
|
||||||
|
function recordAspaAdoptionSnapshot(aspaCount, roaCount) {
|
||||||
|
const now = new Date();
|
||||||
|
const date = now.toISOString().slice(0, 10); // "YYYY-MM-DD"
|
||||||
|
|
||||||
|
// Compute how many Atlas-visible ASNs have ASPA
|
||||||
|
let atlasTotal = 0;
|
||||||
|
let atlasWithAspa = 0;
|
||||||
|
if (typeof atlasProbeCache !== 'undefined' && atlasProbeCache?.asns_with_probes) {
|
||||||
|
atlasTotal = atlasProbeCache.asns_with_probes.length;
|
||||||
|
// rpkiAspaMap is keyed by integer ASN
|
||||||
|
atlasWithAspa = atlasProbeCache.asns_with_probes.filter(a => rpkiAspaMap.has(Number(a))).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coveragePct = atlasTotal > 0
|
||||||
|
? Math.round((atlasWithAspa / atlasTotal) * 10000) / 100 // 2 decimal places
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Compute delta from previous snapshot
|
||||||
|
const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
|
||||||
|
const delta = prev ? aspaCount - prev.aspa_objects : 0;
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
date,
|
||||||
|
ts: now.getTime(),
|
||||||
|
aspa_objects: aspaCount,
|
||||||
|
roa_count: roaCount,
|
||||||
|
atlas_asns_total: atlasTotal,
|
||||||
|
atlas_asns_with_aspa: atlasWithAspa,
|
||||||
|
coverage_pct_atlas: coveragePct,
|
||||||
|
aspa_delta: delta,
|
||||||
|
method: 'rpki_cloudflare_feed',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only store one snapshot per date (overwrite if same day)
|
||||||
|
const existingIdx = aspaAdoptionDailyHistory.findIndex(s => s.date === date);
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
aspaAdoptionDailyHistory[existingIdx] = snapshot;
|
||||||
|
} else {
|
||||||
|
aspaAdoptionDailyHistory.push(snapshot);
|
||||||
|
}
|
||||||
|
saveAspaAdoptionHistory(aspaAdoptionDailyHistory);
|
||||||
|
console.log(`[ASPA-ADOPT] Snapshot ${date}: ${aspaCount} objects, ${coveragePct}% Atlas coverage (${atlasWithAspa}/${atlasTotal})`);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get adoption trend for a given period string ('7d', '30d', '90d', '1y').
|
||||||
|
*/
|
||||||
|
function getAdoptionTrend(period) {
|
||||||
|
const days = period === '7d' ? 7 : period === '30d' ? 30 : period === '90d' ? 90 : 365;
|
||||||
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
return aspaAdoptionDailyHistory.filter(s => s.ts >= cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the ASPA adoption dashboard HTML.
|
||||||
|
*/
|
||||||
|
function buildAspaAdoptionDashboard() {
|
||||||
|
const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
|
||||||
|
const trend30 = getAdoptionTrend('30d');
|
||||||
|
const trend7 = getAdoptionTrend('7d');
|
||||||
|
|
||||||
|
// Forecast: simple linear regression over last 30 days
|
||||||
|
function linearForecast(data, daysAhead) {
|
||||||
|
if (data.length < 2) return null;
|
||||||
|
const n = data.length;
|
||||||
|
const xs = data.map((_, i) => i);
|
||||||
|
const ys = data.map(d => d.coverage_pct_atlas);
|
||||||
|
const xMean = xs.reduce((a, b) => a + b, 0) / n;
|
||||||
|
const yMean = ys.reduce((a, b) => a + b, 0) / n;
|
||||||
|
const num = xs.reduce((s, x, i) => s + (x - xMean) * (ys[i] - yMean), 0);
|
||||||
|
const den = xs.reduce((s, x) => s + (x - xMean) ** 2, 0);
|
||||||
|
if (den === 0) return yMean;
|
||||||
|
const slope = num / den;
|
||||||
|
return Math.max(0, Math.min(100, yMean + slope * (n - 1 + daysAhead)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const forecast90d = linearForecast(trend30, 90);
|
||||||
|
const change7d = trend7.length >= 2
|
||||||
|
? (trend7[trend7.length-1].aspa_objects - trend7[0].aspa_objects)
|
||||||
|
: 0;
|
||||||
|
const trendLabels = trend30.map(d => d.date);
|
||||||
|
const trendValues = trend30.map(d => d.coverage_pct_atlas);
|
||||||
|
const asnValues = trend30.map(d => d.aspa_objects);
|
||||||
|
|
||||||
|
const coverageNow = latest?.coverage_pct_atlas ?? 0;
|
||||||
|
const aspaNow = latest?.aspa_objects ?? 0;
|
||||||
|
const atlasTotal = latest?.atlas_asns_total ?? 0;
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||||
|
<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>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:-apple-system,'Segoe UI',system-ui,sans-serif;background:#f5f3ff;color:#1e1b4b;min-height:100vh}
|
||||||
|
nav{background:#4f46e5;color:#fff;padding:14px 24px;display:flex;align-items:center;gap:12px}
|
||||||
|
nav .logo{font-size:16px;font-weight:700;letter-spacing:-0.5px}
|
||||||
|
nav .logo span{opacity:0.7;font-weight:400}
|
||||||
|
nav a{color:rgba(255,255,255,0.8);text-decoration:none;font-size:13px;margin-left:16px}
|
||||||
|
nav a:hover{color:#fff}
|
||||||
|
.container{max-width:1100px;margin:0 auto;padding:24px 20px}
|
||||||
|
h1{font-size:22px;font-weight:700;color:#3730a3;margin-bottom:4px}
|
||||||
|
.subtitle{font-size:13px;color:#6b7280;margin-bottom:24px}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:28px}
|
||||||
|
.card{background:#fff;border:1px solid #e0e7ff;border-radius:12px;padding:18px 20px;box-shadow:0 1px 3px rgba(79,70,229,.08)}
|
||||||
|
.card .label{font-size:10px;color:#6b7280;text-transform:uppercase;letter-spacing:0.7px;margin-bottom:6px}
|
||||||
|
.card .value{font-size:28px;font-weight:700;color:#4f46e5;line-height:1}
|
||||||
|
.card .unit{font-size:11px;color:#9ca3af;margin-top:4px}
|
||||||
|
.card.green .value{color:#16a34a}
|
||||||
|
.card.amber .value{color:#d97706}
|
||||||
|
.chart-wrap{background:#fff;border:1px solid #e0e7ff;border-radius:12px;padding:20px;margin-bottom:24px;box-shadow:0 1px 3px rgba(79,70,229,.08)}
|
||||||
|
.chart-wrap h2{font-size:14px;font-weight:600;color:#3730a3;margin-bottom:16px}
|
||||||
|
.chart-row{display:grid;grid-template-columns:2fr 1fr;gap:20px;margin-bottom:24px}
|
||||||
|
.export-bar{display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap}
|
||||||
|
.btn{padding:8px 16px;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;text-decoration:none;border:none}
|
||||||
|
.btn-primary{background:#4f46e5;color:#fff}
|
||||||
|
.btn-outline{background:#fff;color:#4f46e5;border:1.5px solid #4f46e5}
|
||||||
|
.btn:hover{opacity:0.9}
|
||||||
|
.forecast-box{background:linear-gradient(135deg,#4f46e5,#6366f1);color:#fff;border-radius:12px;padding:18px 22px}
|
||||||
|
.forecast-box .label{font-size:10px;opacity:0.8;text-transform:uppercase;letter-spacing:0.7px;margin-bottom:6px}
|
||||||
|
.forecast-box .value{font-size:32px;font-weight:700;line-height:1}
|
||||||
|
.forecast-box .unit{font-size:11px;opacity:0.7;margin-top:4px}
|
||||||
|
.meta{font-size:11px;color:#9ca3af;margin-top:24px;text-align:center}
|
||||||
|
@media(max-width:700px){.chart-row{grid-template-columns:1fr}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<span class="logo">⬡ PeerCortex <span>Network Intelligence</span></span>
|
||||||
|
<a href="/">Dashboard</a>
|
||||||
|
<a href="/aspa-adoption">ASPA Tracker</a>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
<h1>ASPA Adoption Tracker</h1>
|
||||||
|
<p class="subtitle">Global ASPA deployment trends · Powered by RPKI Cloudflare feed · Updated every ~4 hours</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card ${coverageNow >= 15 ? 'green' : 'amber'}">
|
||||||
|
<div class="label">Atlas Coverage</div>
|
||||||
|
<div class="value">${coverageNow.toFixed(1)}%</div>
|
||||||
|
<div class="unit">of Atlas-visible ASNs</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">ASPA Objects</div>
|
||||||
|
<div class="value">${aspaNow.toLocaleString()}</div>
|
||||||
|
<div class="unit">total in RPKI</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Atlas ASNs</div>
|
||||||
|
<div class="value">${atlasTotal.toLocaleString()}</div>
|
||||||
|
<div class="unit">sampled denominator</div>
|
||||||
|
</div>
|
||||||
|
<div class="card ${change7d > 0 ? 'green' : ''}">
|
||||||
|
<div class="label">7-Day Delta</div>
|
||||||
|
<div class="value">${change7d > 0 ? '+' : ''}${change7d}</div>
|
||||||
|
<div class="unit">new ASPA objects</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Data Points</div>
|
||||||
|
<div class="value">${aspaAdoptionDailyHistory.length}</div>
|
||||||
|
<div class="unit">history snapshots</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="export-bar">
|
||||||
|
<a class="btn btn-primary" href="/api/aspa-adoption-stats/export?format=json&period=30d">↓ JSON (30d)</a>
|
||||||
|
<a class="btn btn-outline" href="/api/aspa-adoption-stats/export?format=csv&period=30d">↓ CSV (30d)</a>
|
||||||
|
<a class="btn btn-outline" href="/api/aspa-adoption-stats/export?format=csv&period=1y">↓ CSV (1y)</a>
|
||||||
|
<a class="btn btn-outline" href="/api/export/pdf?asn=0&format=report" style="display:none">PDF Report</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-row">
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<h2>Atlas Coverage % (last 30 days)</h2>
|
||||||
|
<canvas id="coverageChart" height="120"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="forecast-box">
|
||||||
|
<div class="label">90-Day Forecast</div>
|
||||||
|
<div class="value">${forecast90d !== null ? forecast90d.toFixed(1) + '%' : '—'}</div>
|
||||||
|
<div class="unit">linear projection from 30-day trend</div>
|
||||||
|
<div style="margin-top:16px;font-size:11px;opacity:0.8">
|
||||||
|
Based on ${trend30.length} data points.<br/>
|
||||||
|
Current: ${coverageNow.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<h2>Total ASPA Objects in RPKI (last 30 days)</h2>
|
||||||
|
<canvas id="asnChart" height="80"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta">
|
||||||
|
Last snapshot: ${latest?.date ?? 'none'} · Denominator: ${atlasTotal.toLocaleString()} Atlas ASNs ·
|
||||||
|
<a href="/api/aspa-adoption-stats?period=30d" style="color:#4f46e5">JSON API</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const labels = ${JSON.stringify(trendLabels)};
|
||||||
|
const covData = ${JSON.stringify(trendValues)};
|
||||||
|
const asnData = ${JSON.stringify(asnValues)};
|
||||||
|
|
||||||
|
const commonOpts = {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { grid: { color: '#f0eeff' }, ticks: { maxRotation: 45, font: { size: 10 } } },
|
||||||
|
y: { grid: { color: '#f0eeff' }, ticks: { font: { size: 10 } }, beginAtZero: false }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
new Chart(document.getElementById('coverageChart'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
data: covData,
|
||||||
|
borderColor: '#4f46e5',
|
||||||
|
backgroundColor: 'rgba(79,70,229,0.08)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 3,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: { ...commonOpts, scales: { ...commonOpts.scales, y: { ...commonOpts.scales.y, ticks: { callback: v => v + '%', font: { size: 10 } } } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
new Chart(document.getElementById('asnChart'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
data: asnData,
|
||||||
|
backgroundColor: 'rgba(79,70,229,0.6)',
|
||||||
|
borderColor: '#4f46e5',
|
||||||
|
borderWidth: 1,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: commonOpts
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
const UA = "PeerCortex/0.5.0 (+https://peercortex.org)";
|
const UA = "PeerCortex/0.5.0 (+https://peercortex.org)";
|
||||||
|
|
||||||
@ -1430,9 +1715,6 @@ const roaStore = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ASPA adoption tracking — store last 30 snapshots for trend analysis
|
|
||||||
const aspaAdoptionHistory = [];
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// PeeringDB Source Cache (L2) — net/netixlan/netfac per ASN
|
// PeeringDB Source Cache (L2) — net/netixlan/netfac per ASN
|
||||||
// Eliminates redundant PDB API calls under load
|
// Eliminates redundant PDB API calls under load
|
||||||
@ -1670,14 +1952,8 @@ function fetchRpkiAspaFeed() {
|
|||||||
roaStore.build(roas);
|
roaStore.build(roas);
|
||||||
roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");
|
roaStore.saveToDisk("/opt/peercortex-app/.roa-cache.json");
|
||||||
|
|
||||||
// Track ASPA adoption
|
// Track ASPA adoption — persist to disk + update in-memory trend
|
||||||
const adoptionSnapshot = {
|
recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count);
|
||||||
ts: Date.now(),
|
|
||||||
aspa_count: rpkiAspaMap.size,
|
|
||||||
roa_count: roaStore.count,
|
|
||||||
};
|
|
||||||
aspaAdoptionHistory.push(adoptionSnapshot);
|
|
||||||
if (aspaAdoptionHistory.length > 30) aspaAdoptionHistory.shift();
|
|
||||||
|
|
||||||
rpkiAspaLastFetch = Date.now();
|
rpkiAspaLastFetch = Date.now();
|
||||||
console.log("[RPKI] Loaded " + rpkiAspaMap.size + " ASPA objects + " + roaStore.count + " ROAs from Cloudflare feed");
|
console.log("[RPKI] Loaded " + rpkiAspaMap.size + " ASPA objects + " + roaStore.count + " ROAs from Cloudflare feed");
|
||||||
@ -2944,9 +3220,9 @@ const server = http.createServer(async (req, res) => {
|
|||||||
aspa_adoption: {
|
aspa_adoption: {
|
||||||
total_objects: rpkiAspaMap.size,
|
total_objects: rpkiAspaMap.size,
|
||||||
roa_count: roaStore.count,
|
roa_count: roaStore.count,
|
||||||
history_samples: aspaAdoptionHistory.length,
|
history_samples: aspaAdoptionDailyHistory.length,
|
||||||
delta_last: aspaAdoptionHistory.length >= 2
|
delta_last: aspaAdoptionDailyHistory.length >= 2
|
||||||
? aspaAdoptionHistory[aspaAdoptionHistory.length - 1].aspa_count - aspaAdoptionHistory[aspaAdoptionHistory.length - 2].aspa_count
|
? aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1].aspa_objects - aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2].aspa_objects
|
||||||
: 0,
|
: 0,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -6032,11 +6308,105 @@ ${html}
|
|||||||
return res.end();
|
return res.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// FEATURE 3: ASPA Adoption Tracker — API + Dashboard
|
||||||
|
// GET /aspa-adoption → dashboard HTML
|
||||||
|
// GET /api/aspa-adoption-stats?period=7d|30d|1y → JSON trend
|
||||||
|
// GET /api/aspa-adoption-stats/export?format=csv|json&period=30d
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
if (reqPath === '/aspa-adoption') {
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.writeHead(200);
|
||||||
|
return res.end(buildAspaAdoptionDashboard());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reqPath === '/api/aspa-adoption-stats' && req.method === 'GET') {
|
||||||
|
const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
|
||||||
|
? url.searchParams.get('period') : '30d';
|
||||||
|
const trend = getAdoptionTrend(period);
|
||||||
|
const latest = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 1];
|
||||||
|
const prev = aspaAdoptionDailyHistory[aspaAdoptionDailyHistory.length - 2];
|
||||||
|
|
||||||
|
// Linear regression forecast
|
||||||
|
function forecast(data, daysAhead) {
|
||||||
|
if (data.length < 2) return null;
|
||||||
|
const n = data.length;
|
||||||
|
const xs = data.map((_, i) => i);
|
||||||
|
const ys = data.map(d => d.coverage_pct_atlas);
|
||||||
|
const xMean = xs.reduce((a,b)=>a+b,0)/n;
|
||||||
|
const yMean = ys.reduce((a,b)=>a+b,0)/n;
|
||||||
|
const num = xs.reduce((s,x,i)=>s+(x-xMean)*(ys[i]-yMean),0);
|
||||||
|
const den = xs.reduce((s,x)=>s+(x-xMean)**2,0);
|
||||||
|
if (den===0) return yMean;
|
||||||
|
const slope = num/den;
|
||||||
|
return Math.max(0, Math.min(100, Math.round((yMean + slope*(n-1+daysAhead))*100)/100));
|
||||||
|
}
|
||||||
|
|
||||||
|
const trend30 = getAdoptionTrend('30d');
|
||||||
|
const result = {
|
||||||
|
period,
|
||||||
|
current: {
|
||||||
|
date: latest?.date,
|
||||||
|
aspa_objects: latest?.aspa_objects ?? rpkiAspaMap.size,
|
||||||
|
roa_count: latest?.roa_count,
|
||||||
|
atlas_asns_total: latest?.atlas_asns_total ?? 0,
|
||||||
|
atlas_asns_with_aspa: latest?.atlas_asns_with_aspa ?? 0,
|
||||||
|
coverage_pct_atlas: latest?.coverage_pct_atlas ?? 0,
|
||||||
|
delta_from_previous: prev ? (latest?.aspa_objects ?? 0) - prev.aspa_objects : 0,
|
||||||
|
},
|
||||||
|
trend: trend.map(s => ({
|
||||||
|
date: s.date,
|
||||||
|
aspa_objects: s.aspa_objects,
|
||||||
|
coverage_pct_atlas: s.coverage_pct_atlas,
|
||||||
|
atlas_asns_total: s.atlas_asns_total,
|
||||||
|
})),
|
||||||
|
forecast: {
|
||||||
|
predicted_90d: forecast(trend30, 90),
|
||||||
|
confidence: trend30.length >= 10 ? 0.75 : 0.5,
|
||||||
|
method: 'linear_regression',
|
||||||
|
based_on_samples: trend30.length,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
total_snapshots: aspaAdoptionDailyHistory.length,
|
||||||
|
last_updated: latest?.date ?? null,
|
||||||
|
denominator: 'atlas_visible_asns',
|
||||||
|
source: 'rpki_cloudflare_feed',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.writeHead(200);
|
||||||
|
return res.end(JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reqPath === '/api/aspa-adoption-stats/export' && req.method === 'GET') {
|
||||||
|
const fmt = url.searchParams.get('format') === 'csv' ? 'csv' : 'json';
|
||||||
|
const period = ['7d','30d','90d','1y'].includes(url.searchParams.get('period'))
|
||||||
|
? url.searchParams.get('period') : '30d';
|
||||||
|
const data = getAdoptionTrend(period);
|
||||||
|
|
||||||
|
if (fmt === 'csv') {
|
||||||
|
const header = 'date,aspa_objects,roa_count,atlas_asns_total,atlas_asns_with_aspa,coverage_pct_atlas,aspa_delta\n';
|
||||||
|
const rows = data.map(s =>
|
||||||
|
`${s.date},${s.aspa_objects},${s.roa_count},${s.atlas_asns_total},${s.atlas_asns_with_aspa},${s.coverage_pct_atlas},${s.aspa_delta??0}`
|
||||||
|
).join('\n');
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.csv"`);
|
||||||
|
res.writeHead(200);
|
||||||
|
return res.end(header + rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="peercortex-aspa-adoption-${period}.json"`);
|
||||||
|
res.writeHead(200);
|
||||||
|
return res.end(JSON.stringify({ period, count: data.length, data }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
// 404
|
// 404
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Not found. Endpoints: /api/health, /api/validate?asn=X, /api/lookup?asn=X, /api/aspa?asn=X, /api/aspa/verify?asn=X, /api/bgproutes?asn=X, /api/compare?asn1=X&asn2=Y, /api/peers/find?ix=NAME, /api/prefix/detail?prefix=X, /api/ix/detail?ix_id=X, /api/export/pdf?asn=X&format=report|executive|technical, /api/export/pdf/compare?asn1=X&asn2=Y",
|
error: "Not found. Endpoints: /api/health, /api/validate?asn=X, /api/lookup?asn=X, /api/aspa?asn=X, /api/aspa/verify?asn=X, /api/bgproutes?asn=X, /api/compare?asn1=X&asn2=Y, /api/peers/find?ix=NAME, /api/prefix/detail?prefix=X, /api/ix/detail?ix_id=X, /api/export/pdf?asn=X&format=report|executive|technical, /api/export/pdf/compare?asn1=X&asn2=Y, /api/aspa-adoption-stats?period=30d",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -6198,6 +6568,9 @@ loadRipeStatCacheFromDisk("/opt/peercortex-app/.ripe-stat-cache.json");
|
|||||||
// Phase 1: Fetch fresh RPKI feed (ASPA + ROA) + Atlas probes + PDB org countries + MANRS participants
|
// Phase 1: Fetch fresh RPKI feed (ASPA + ROA) + Atlas probes + PDB org countries + MANRS participants
|
||||||
ensureManrsCache(); // fire-and-forget, 24h cache
|
ensureManrsCache(); // fire-and-forget, 24h cache
|
||||||
Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]).then(() => {
|
Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes(), fetchPdbOrgCountries()]).then(() => {
|
||||||
|
// Re-record ASPA adoption snapshot now that Atlas data is fully loaded
|
||||||
|
recordAspaAdoptionSnapshot(rpkiAspaMap.size, roaStore.count);
|
||||||
|
|
||||||
server.listen(PORT, "0.0.0.0", () => {
|
server.listen(PORT, "0.0.0.0", () => {
|
||||||
console.log("PeerCortex v0.6.1 running on http://0.0.0.0:" + PORT);
|
console.log("PeerCortex v0.6.1 running on http://0.0.0.0:" + PORT);
|
||||||
console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured"));
|
console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured"));
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user