feat: Lia's Paradise, bug fixes, company descriptions

- Add /lia Easter egg page: RIPE Atlas coverage explorer showing
  34k+ networks grouped by country with probe/no-probe status,
  RIR filtering, search, and PDF export
- Add /api/lia/coverage endpoint combining PeeringDB + Atlas data
- Fix Provider Relationship Graph (renamed var to avoid shadowing)
- Fix ROV/ASPA double-value display (show worst single status)
- Add fallback: render provider graph from lookup data when ASPA fails
- Add company description (org_name) to Network Overview
- Add worstStatus() helper for frontend badge normalization
This commit is contained in:
Rene Fichtmueller 2026-03-27 01:32:30 +13:00
parent dee5871609
commit 41af8be7f4
3 changed files with 795 additions and 169 deletions

View File

@ -473,6 +473,91 @@ a{color:var(--blue);text-decoration:none;transition:color .2s}a:hover{color:var(
</div>
<!-- Footer -->
<!-- Peering Recommendations -->
<div class="card full hidden" id="peeringRecCard">
<div class="card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Peering Recommendations
</div>
<div id="peeringRecContent"><div style="color:var(--dim);font-size:.85rem">Analyzing IX overlap with top networks...</div></div>
</div>
<!-- Integrated Sources of Trust -->
<div class="card full hidden" id="sourcesCard">
<div class="card-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
Integrated Sources of Trust
</div>
<div id="sourcesContent">
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(156,206,106,.12);border:1px solid rgba(156,206,106,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">🌐</div>
<div><div style="font-weight:700;font-size:.85rem;color:var(--green)">PeeringDB</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">Network profiles, IX presence, facilities, peering policy. The authoritative source for interconnection data.</div><a href="https://www.peeringdb.com" target="_blank" style="font-size:.65rem;color:var(--blue)">peeringdb.com</a></div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(122,162,247,.12);border:1px solid rgba(122,162,247,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">📊</div>
<div><div style="font-weight:700;font-size:.85rem;color:var(--blue)">RIPE Stat</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">Announced prefixes, AS neighbours, routing visibility, BGP updates, geolocation, abuse contacts.</div><a href="https://stat.ripe.net" target="_blank" style="font-size:.65rem;color:var(--blue)">stat.ripe.net</a></div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(187,154,247,.12);border:1px solid rgba(187,154,247,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">🛡️</div>
<div><div style="font-weight:700;font-size:.85rem;color:var(--purple)">RPKI / ROA Validation</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">Per-prefix Route Origin Authorization validation via RIPE RPKI validators. Detects invalid or missing ROAs.</div><a href="https://rpki.cloudflare.com" target="_blank" style="font-size:.65rem;color:var(--blue)">rpki.cloudflare.com</a></div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(255,158,100,.12);border:1px solid rgba(255,158,100,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">🔐</div>
<div><div style="font-weight:700;font-size:.85rem;color:var(--orange)">ASPA (RFC 9582)</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">AS Provider Authorization from Cloudflare RPKI JSON feed. RFC-compliant upstream/downstream path verification with valley detection.</div><a href="https://www.ietf.org/archive/id/draft-ietf-sidrops-aspa-verification-14.html" target="_blank" style="font-size:.65rem;color:var(--blue)">IETF Draft-14</a></div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(125,207,255,.12);border:1px solid rgba(125,207,255,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">📡</div>
<div><div style="font-weight:700;font-size:.85rem;color:var(--cyan)">RIPE Atlas</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">Active measurement infrastructure. Probe presence, connectivity status, and anchor detection per ASN.</div><a href="https://atlas.ripe.net" target="_blank" style="font-size:.65rem;color:var(--blue)">atlas.ripe.net</a></div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(224,175,104,.12);border:1px solid rgba(224,175,104,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">🔭</div>
<div><div style="font-weight:700;font-size:.85rem;color:var(--yellow)">bgproutes.io</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">Next-gen BGP data collection. 3,294+ vantage points, RIB queries, ROV and ASPA validation status per route.</div><a href="https://bgproutes.io" target="_blank" style="font-size:.65rem;color:var(--blue)">bgproutes.io</a></div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(156,206,106,.12);border:1px solid rgba(156,206,106,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">🔍</div>
<div><div style="font-weight:700;font-size:.85rem;color:var(--green)">NLNOG IRR Explorer</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">Cross-references BGP origin announcements with Internet Routing Registry records. Detects mismatches and unauthorized announcements.</div><a href="https://irrexplorer.nlnog.net" target="_blank" style="font-size:.65rem;color:var(--blue)">irrexplorer.nlnog.net</a></div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(247,119,142,.12);border:1px solid rgba(247,119,142,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">🤝</div>
<div><div style="font-weight:700;font-size:.85rem;color:var(--red)">MANRS Observatory</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">Mutually Agreed Norms for Routing Security. Checks membership, conformance level, and routing security commitment.</div><a href="https://observatory.manrs.org" target="_blank" style="font-size:.65rem;color:var(--blue)">observatory.manrs.org</a></div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(192,202,245,.12);border:1px solid rgba(192,202,245,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">🌍</div>
<div><div style="font-weight:700;font-size:.85rem;color:var(--white)">bgp.he.net</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">Hurricane Electric BGP Toolkit. AS information, prefix lists, peer counts, and country attribution.</div><a href="https://bgp.he.net" target="_blank" style="font-size:.65rem;color:var(--blue)">bgp.he.net</a></div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(192,202,245,.12);border:1px solid rgba(192,202,245,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">🚫</div>
<div><div style="font-weight:700;font-size:.85rem;color:var(--white)">Team Cymru Bogon Reference</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">Bogon prefix and ASN detection. Identifies reserved, unallocated, and private address space in BGP announcements.</div><a href="https://team-cymru.com/community-services/bogon-reference/" target="_blank" style="font-size:.65rem;color:var(--blue)">team-cymru.com</a></div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(122,162,247,.12);border:1px solid rgba(122,162,247,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">📂</div>
<div><div style="font-weight:700;font-size:.85rem;color:var(--blue)">RIPE DB / IRR</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">Internet Routing Registry objects (aut-num, route, as-set). RPSL policy validation and object completeness checks.</div><a href="https://apps.db.ripe.net" target="_blank" style="font-size:.65rem;color:var(--blue)">apps.db.ripe.net</a></div>
</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(125,207,255,.12);border:1px solid rgba(125,207,255,.2);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:1rem">📈</div>
<div><div style="font-weight:700;font-size:.85rem;color:var(--cyan)">Route Views / RIPE RIS</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem">BGP route collectors providing global routing table visibility. Used for path analysis, visibility scoring, and anomaly detection.</div><a href="http://www.routeviews.org" target="_blank" style="font-size:.65rem;color:var(--blue)">routeviews.org</a></div>
</div>
</div>
<div style="margin-top:1rem;font-size:.7rem;color:var(--dim);text-align:center">All data is queried in real-time from authoritative sources. No data is stored or cached beyond 5 minutes.</div>
</div>
</div>
<footer class="footer">
<div style="margin-bottom:.75rem;font-size:.7rem;color:var(--dim)">
Data powered by
@ -493,6 +578,7 @@ a{color:var(--blue);text-decoration:none;transition:color .2s}a:hover{color:var(
<script>
const $ = id => document.getElementById(id);
let currentAsn = null;
let currentLookupData = null;
function countryFlag(code) {
if (!code || code.length !== 2) return '';
@ -517,6 +603,15 @@ function pct(n, total) {
return Math.round((n / total) * 100);
}
function worstStatus(raw) {
if (!raw) return 'unknown';
var parts = raw.split(',').map(function(s) { return s.trim().toLowerCase(); });
if (parts.indexOf('invalid') >= 0) return 'invalid';
if (parts.indexOf('unknown') >= 0) return 'unknown';
if (parts.indexOf('valid') >= 0) return 'valid';
return parts[0] || 'unknown';
}
function asnLink(asn) {
return '<span class="asn-link" onclick="lookupAsn(' + asn + ')" title="Lookup AS' + asn + '">AS' + asn + '</span>';
}
@ -593,6 +688,7 @@ async function doLookup() {
return;
}
currentLookupData = d;
renderDashboard(d);
$('skeleton').classList.add('hidden');
$('dashboard').classList.remove('hidden');
@ -601,6 +697,10 @@ async function doLookup() {
history.replaceState(null, '', '?asn=' + raw);
saveToHistory(raw, d.network ? d.network.name : 'AS' + raw);
$('sourcesCard').classList.remove('hidden');
// Load peering recommendations
if (d.ix_presence && d.ix_presence.connections) loadPeeringRecommendations(currentAsn, d.ix_presence.connections);
// Load ASPA and bgproutes.io data asynchronously
loadHealthReport(raw);
loadAspaData(raw);
@ -623,14 +723,26 @@ async function loadAspaData(asn) {
const d = await resp.json();
if (d.error) {
$('aspaContent').innerHTML = '<div style="color:var(--red);font-size:.85rem">ASPA check failed: ' + escHtml(d.error) + '</div>';
renderProviderGraphFromLookupFallback(asn);
return;
}
renderAspa(d);
} catch (e) {
$('aspaContent').innerHTML = '<div style="color:var(--red);font-size:.85rem">ASPA check failed: ' + escHtml(e.message) + '</div>';
renderProviderGraphFromLookupFallback(asn);
}
}
function renderProviderGraphFromLookupFallback(asn) {
if (!currentLookupData || !currentLookupData.neighbours) return;
var upstreams = currentLookupData.neighbours.upstreams || [];
if (upstreams.length === 0) return;
var providers = upstreams.map(function(u) {
return { asn: u.asn, name: u.name || '', frequency_pct: u.power ? Math.min(u.power * 10, 100) : 0 };
});
renderProviderGraph(asn, providers);
}
async function loadBgroutesData(asn) {
$('bgroutesContent').innerHTML = '<div class="section-loading">Loading bgproutes.io data...</div>';
try {
@ -787,12 +899,14 @@ function renderBgroutes(d) {
h += '<div class="expand-toggle" onclick="toggleExpand(this)">Show route data (' + d.routes.count + ' routes, showing ' + d.routes.sample.length + ')</div>';
h += '<div class="expand-body"><div class="scroll-wrap"><table class="tbl"><thead><tr><th>Prefix</th><th>AS Path</th><th>ROV</th><th>ASPA</th></tr></thead><tbody>';
d.routes.sample.forEach(function(r) {
var rovBadge = (r.rov_status || '').indexOf('valid') >= 0 ? 'badge-green' : (r.rov_status || '').indexOf('invalid') >= 0 ? 'badge-red' : 'badge-orange';
var aspaBadge = (r.aspa_status || '') === 'valid' ? 'badge-green' : (r.aspa_status || '') === 'invalid' ? 'badge-red' : 'badge-orange';
var rov = worstStatus(r.rov_status);
var aspa = worstStatus(r.aspa_status);
var rovBadge = rov === 'valid' ? 'badge-green' : rov === 'invalid' ? 'badge-red' : 'badge-orange';
var aspaBadge = aspa === 'valid' ? 'badge-green' : aspa === 'invalid' ? 'badge-red' : 'badge-orange';
h += '<tr><td style="font-family:monospace;font-size:.7rem">' + escHtml(r.prefix || '') + '</td>';
h += '<td style="font-family:monospace;font-size:.65rem;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escAttr(r.as_path || '') + '">' + escHtml(r.as_path || '') + '</td>';
h += '<td><span class="badge ' + rovBadge + '">' + escHtml(r.rov_status || '?') + '</span></td>';
h += '<td><span class="badge ' + aspaBadge + '">' + escHtml(r.aspa_status || '?') + '</span></td></tr>';
h += '<td><span class="badge ' + rovBadge + '">' + escHtml(rov) + '</span></td>';
h += '<td><span class="badge ' + aspaBadge + '">' + escHtml(aspa) + '</span></td></tr>';
});
h += '</tbody></table></div></div>';
}
@ -820,7 +934,8 @@ function renderDashboard(d) {
if (n.scope) ov += '<span class="badge badge-orange">' + escHtml(n.scope) + '</span>';
if (n.traffic) ov += '<span class="badge badge-cyan">' + escHtml(n.traffic) + '</span>';
if (n.website) ov += '<div style="margin-top:.5rem"><a href="' + escAttr(n.website) + '" target="_blank">' + escHtml(n.website) + '</a></div>';
if (n.notes) ov += '<div style="margin-top:.5rem;font-size:.8rem;color:var(--muted)">' + escHtml(n.notes) + '</div>';
if (n.org_name) ov += '<div style="margin-top:.4rem;font-size:.85rem;color:var(--dim)">' + escHtml(n.org_name) + '</div>';
if (n.notes) ov += '<div style="margin-top:.3rem;font-size:.8rem;color:var(--dim);line-height:1.5;max-height:4.5em;overflow:hidden">' + escHtml(n.notes) + '</div>';
ov += '<div class="ext-links">';
if (n.peeringdb_id) ov += '<a class="ext-link" href="https://www.peeringdb.com/net/' + n.peeringdb_id + '" target="_blank">PeeringDB</a>';
@ -1000,12 +1115,12 @@ function renderAtlas(atlas) {
h += '<div class="expand-toggle" onclick="toggleExpand(this)">Show probe details (' + atlas.probes.length + (atlas.total_probes > atlas.probes.length ? ' of ' + atlas.total_probes : '') + ')</div>';
h += '<div class="expand-body"><div class="scroll-wrap"><table class="tbl"><thead><tr><th>ID</th><th>Status</th><th>Anchor</th><th>Country</th><th>Prefix</th><th>Description</th></tr></thead><tbody>';
atlas.probes.forEach(function(p) {
var statusClass = p.status === 'Connected' ? 'badge-green' : 'badge-red';
var pStatus = typeof p.status === 'object' ? (p.status && p.status.name ? p.status.name : '') : (p.status || p.status_name || ''); var statusClass = pStatus.toLowerCase() === 'connected' ? 'badge-green' : 'badge-red';
var anchorBadge = p.is_anchor ? '<span class="badge badge-orange">Anchor</span>' : '-';
var prefix = p.prefix_v4 || p.prefix_v6 || '-';
h += '<tr>';
h += '<td><a href="https://atlas.ripe.net/probes/' + p.id + '/" target="_blank" style="color:var(--blue)">' + p.id + '</a></td>';
h += '<td><span class="badge ' + statusClass + '">' + escHtml(p.status) + '</span></td>';
h += '<td><span class="badge ' + statusClass + '">' + escHtml(pStatus) + '</span></td>';
h += '<td>' + anchorBadge + '</td>';
h += '<td>' + countryFlag(p.country) + ' ' + escHtml(p.country) + '</td>';
h += '<td style="font-family:monospace;font-size:.75rem">' + escHtml(prefix) + '</td>';
@ -1575,170 +1690,59 @@ function checkAspaChanges(asn, currentProviders) {
// Feature 9: Provider Relationship Graph (SVG)
// ============================================================
function renderProviderGraph(asn, providers) {
var card = $('providerGraphCard');
if (!card || !providers || providers.length === 0) return;
card.classList.remove('hidden');
var graphCard = $('providerGraphCard');
if (!providers || providers.length === 0) { graphCard.classList.add('hidden'); return; }
graphCard.classList.remove('hidden');
var maxProviders = Math.min(providers.length, 16);
var displayProviders = providers.slice(0, maxProviders);
var tier1List = [174, 209, 701, 1239, 1299, 2914, 3257, 3320, 3356, 5511, 6453, 6461, 6762, 6830, 7018, 12956];
var tier1 = providers.filter(function(p) { return tier1List.indexOf(p.asn) >= 0; });
var transit = providers.filter(function(p) { return tier1List.indexOf(p.asn) < 0 && (p.frequency_pct || 0) >= 20; });
var peers = providers.filter(function(p) { return tier1List.indexOf(p.asn) < 0 && (p.frequency_pct || 0) < 20; });
tier1.sort(function(a,b) { return (b.frequency_pct||0) - (a.frequency_pct||0); });
transit.sort(function(a,b) { return (b.frequency_pct||0) - (a.frequency_pct||0); });
peers.sort(function(a,b) { return (b.frequency_pct||0) - (a.frequency_pct||0); });
// Well-known Tier 1 ASNs
var tier1 = [174,209,701,1239,1299,2828,2914,3257,3320,3356,3491,5511,6453,6461,6762,6830,7018,12956];
var w = 600, h = 450;
var cx = w / 2, cy = h / 2;
var baseRadius = maxProviders <= 4 ? 140 : maxProviders <= 8 ? 160 : 185;
// Determine max frequency for scaling node sizes
var maxFreq = 1;
displayProviders.forEach(function(p) { if ((p.frequency_pct || 0) > maxFreq) maxFreq = p.frequency_pct || 1; });
// Unique ID for this render (avoid SVG filter collisions)
var uid = 'pg' + Date.now();
var svg = '<svg viewBox="0 0 ' + w + ' ' + h + '" xmlns="http://www.w3.org/2000/svg" style="background:transparent;overflow:visible">';
// ---- DEFS: gradients, filters, styles ----
svg += '<defs>';
// Center gradient (purple)
svg += '<radialGradient id="' + uid + 'cg"><stop offset="0%" stop-color="#c4b5fd"/><stop offset="60%" stop-color="#7c3aed"/><stop offset="100%" stop-color="#5b21b6"/></radialGradient>';
// Glow filter for center
svg += '<filter id="' + uid + 'cglow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>';
// Glow filter for providers (blue)
svg += '<filter id="' + uid + 'pglow" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur in="SourceGraphic" stdDeviation="5" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>';
// Provider gradients per type
svg += '<radialGradient id="' + uid + 'tier1g"><stop offset="0%" stop-color="#fde68a"/><stop offset="100%" stop-color="#b45309"/></radialGradient>';
svg += '<radialGradient id="' + uid + 'transitg"><stop offset="0%" stop-color="#93c5fd"/><stop offset="100%" stop-color="#1e40af"/></radialGradient>';
svg += '<radialGradient id="' + uid + 'peerg"><stop offset="0%" stop-color="#6ee7b7"/><stop offset="100%" stop-color="#047857"/></radialGradient>';
svg += '</defs>';
// ---- STYLE block for animations ----
svg += '<style>';
svg += '@keyframes ' + uid + 'pulse{0%,100%{opacity:.35}50%{opacity:.7}}';
svg += '@keyframes ' + uid + 'dash{to{stroke-dashoffset:20}}';
svg += '@keyframes ' + uid + 'fadeIn{0%{opacity:0;transform:scale(.3)}60%{opacity:1;transform:scale(1.08)}100%{opacity:1;transform:scale(1)}}';
svg += '@keyframes ' + uid + 'orbitPulse{0%,100%{opacity:.12}50%{opacity:.22}}';
svg += '.' + uid + 'pnode{cursor:pointer;transition:transform .2s ease,filter .2s ease;transform-origin:center center}';
svg += '.' + uid + 'pnode:hover{transform:scale(1.18);filter:brightness(1.4) drop-shadow(0 0 8px rgba(122,162,247,.6))}';
svg += '.' + uid + 'conn{transition:stroke-width .2s ease,stroke .2s ease}';
svg += '.' + uid + 'pnode:hover~.' + uid + 'conn{stroke-width:3}';
svg += '</style>';
// ---- Orbit ring (subtle) ----
svg += '<circle cx="' + cx + '" cy="' + cy + '" r="' + baseRadius + '" fill="none" stroke="#363b54" stroke-width="1" stroke-dasharray="3,6" opacity=".25">';
svg += '<animate attributeName="opacity" values=".12;.22;.12" dur="4s" repeatCount="indefinite"/>';
svg += '</circle>';
// ---- Second faint orbit ring ----
svg += '<circle cx="' + cx + '" cy="' + cy + '" r="' + (baseRadius + 30) + '" fill="none" stroke="#363b54" stroke-width=".5" stroke-dasharray="2,8" opacity=".12"/>';
// ---- Connection lines (draw first so they're behind nodes) ----
displayProviders.forEach(function(p, i) {
var angle = (i / maxProviders) * 2 * Math.PI - Math.PI / 2;
var px = cx + baseRadius * Math.cos(angle);
var py = cy + baseRadius * Math.sin(angle);
var isTier1 = tier1.indexOf(p.asn) !== -1;
var lineColor = isTier1 ? '#b45309' : ((p.frequency_pct || 0) < 20 ? '#047857' : '#1e40af');
var lineOpacity = 0.3 + 0.5 * ((p.frequency_pct || 10) / 100);
svg += '<line x1="' + cx + '" y1="' + cy + '" x2="' + px + '" y2="' + py + '" ';
svg += 'stroke="' + lineColor + '" stroke-width="1.8" stroke-dasharray="6,4" opacity="' + lineOpacity.toFixed(2) + '" class="' + uid + 'conn" data-idx="' + i + '">';
svg += '<animate attributeName="stroke-dashoffset" from="0" to="20" dur="' + (1.5 + i * 0.15).toFixed(1) + 's" repeatCount="indefinite"/>';
svg += '</line>';
// Power badge on line midpoint
var mx = (cx + px) / 2;
var my = (cy + py) / 2;
var pwr = (p.frequency_pct || 0);
if (pwr > 0) {
svg += '<rect x="' + (mx - 14) + '" y="' + (my - 7) + '" width="28" height="14" rx="4" fill="#1a1b26" stroke="' + lineColor + '" stroke-width=".8" opacity=".85"/>';
svg += '<text x="' + mx + '" y="' + (my + 3.5) + '" text-anchor="middle" fill="' + lineColor + '" font-size="7.5" font-weight="600" font-family="JetBrains Mono,monospace">' + pwr + '%</text>';
}
});
// ---- Center node with glow ----
// Glow aura
svg += '<circle cx="' + cx + '" cy="' + cy + '" r="45" fill="#7c3aed" opacity=".15" filter="url(#' + uid + 'cglow)">';
svg += '<animate attributeName="opacity" values=".1;.25;.1" dur="3s" repeatCount="indefinite"/>';
svg += '<animate attributeName="r" values="42;48;42" dur="3s" repeatCount="indefinite"/>';
svg += '</circle>';
// Main center circle
svg += '<circle cx="' + cx + '" cy="' + cy + '" r="34" fill="url(#' + uid + 'cg)" filter="url(#' + uid + 'cglow)" stroke="#c4b5fd" stroke-width="2"/>';
svg += '<text x="' + cx + '" y="' + (cy - 5) + '" text-anchor="middle" fill="#fff" font-size="13" font-weight="700" font-family="JetBrains Mono,Inter,sans-serif">AS' + asn + '</text>';
svg += '<text x="' + cx + '" y="' + (cy + 10) + '" text-anchor="middle" fill="#c4b5fd" font-size="8" font-family="Inter,sans-serif" letter-spacing="1.5">TARGET</text>';
// ---- Provider nodes ----
displayProviders.forEach(function(p, i) {
var angle = (i / maxProviders) * 2 * Math.PI - Math.PI / 2;
var px = cx + baseRadius * Math.cos(angle);
var py = cy + baseRadius * Math.sin(angle);
// Size node by frequency (min 18, max 32)
var nodeR = 18 + 14 * ((p.frequency_pct || 10) / Math.max(maxFreq, 1));
nodeR = Math.min(32, Math.max(18, nodeR));
var isTier1 = tier1.indexOf(p.asn) !== -1;
var isLowFreq = (p.frequency_pct || 0) < 20;
var gradId = isTier1 ? uid + 'tier1g' : (isLowFreq ? uid + 'peerg' : uid + 'transitg');
var strokeColor = isTier1 ? '#fbbf24' : (isLowFreq ? '#34d399' : '#60a5fa');
var textColor = isTier1 ? '#fef3c7' : (isLowFreq ? '#d1fae5' : '#dbeafe');
var typeLabel = isTier1 ? 'TIER 1' : (isLowFreq ? 'IX/PEER' : 'TRANSIT');
var delay = (0.1 + i * 0.08).toFixed(2);
// Truncate name
var fullName = p.name || '';
var shortName = fullName.length > 15 ? fullName.substring(0, 14) + '\u2026' : fullName;
// Group with entrance animation
svg += '<g class="' + uid + 'pnode" style="animation:' + uid + 'fadeIn .5s ease-out ' + delay + 's both" ';
svg += 'onclick="lookupAsn(' + p.asn + ')" ';
// Tooltip via title element
svg += '>';
// Glow behind node
svg += '<circle cx="' + px + '" cy="' + py + '" r="' + (nodeR + 6) + '" fill="' + strokeColor + '" opacity=".12" filter="url(#' + uid + 'pglow)"/>';
// Main node circle
svg += '<circle cx="' + px + '" cy="' + py + '" r="' + nodeR.toFixed(1) + '" fill="url(#' + gradId + ')" stroke="' + strokeColor + '" stroke-width="1.8"/>';
// ASN text
svg += '<text x="' + px + '" y="' + (py - 3) + '" text-anchor="middle" fill="' + textColor + '" font-size="' + (nodeR > 24 ? '10' : '8.5') + '" font-weight="700" font-family="JetBrains Mono,Inter,sans-serif" style="pointer-events:none">AS' + p.asn + '</text>';
// Name text
if (shortName && shortName !== 'AS' + p.asn) {
svg += '<text x="' + px + '" y="' + (py + 8) + '" text-anchor="middle" fill="' + textColor + '" font-size="6" font-family="Inter,sans-serif" opacity=".8" style="pointer-events:none">' + shortName.replace(/&/g,"&amp;").replace(/</g,"&lt;") + '</text>';
function provCard(p, color, label) {
var n = escHtml(p.name || '');
var f = p.frequency_pct ? p.frequency_pct.toFixed(0) + '%' : '';
return '<div onclick="lookupAsn(' + p.asn + ')" style="cursor:pointer;background:' + color + '08;border:1px solid ' + color + '30;border-radius:10px;padding:.65rem .85rem;display:flex;align-items:center;gap:.65rem;transition:all .15s" onmouseenter="this.style.transform=\'translateY(-1px)\';this.style.borderColor=\'' + color + '\'" onmouseleave="this.style.transform=\'none\';this.style.borderColor=\'' + color + '30\'">' +
'<div style="width:36px;height:36px;border-radius:50%;background:' + color + '18;border:2px solid ' + color + '60;display:flex;align-items:center;justify-content:center;flex-shrink:0"><span style="font-size:.6rem;font-weight:800;color:' + color + '">' + label + '</span></div>' +
'<div style="flex:1;min-width:0"><div style="font-weight:700;font-size:.85rem;color:#e2e8f0">AS' + p.asn + '</div><div style="font-size:.72rem;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + n + '</div></div>' +
(f ? '<div style="font-size:.72rem;font-weight:700;color:' + color + ';padding:.15rem .45rem;border-radius:5px;background:' + color + '12;flex-shrink:0">' + f + '</div>' : '') +
'</div>';
}
// Type label below node
svg += '<text x="' + px + '" y="' + (py + nodeR + 11) + '" text-anchor="middle" fill="' + strokeColor + '" font-size="5.5" font-weight="600" font-family="Inter,sans-serif" letter-spacing="0.8" opacity=".7" style="pointer-events:none">' + typeLabel + '</text>';
var h = '';
h += '<div style="text-align:center;margin-bottom:1.5rem"><div style="display:inline-flex;align-items:center;gap:.75rem;background:linear-gradient(135deg,#5b21b6,#7c3aed);padding:.6rem 1.75rem;border-radius:14px;border:2px solid #8b5cf680"><span style="font-size:1.1rem;font-weight:800;color:#fff">AS' + asn + '</span><span style="font-size:.65rem;color:#c4b5fd;text-transform:uppercase;letter-spacing:2px">Target</span></div></div>';
// SVG title for tooltip
svg += '<title>AS' + p.asn + ' - ' + (fullName || 'Unknown').replace(/&/g,"&amp;").replace(/</g,"&lt;") + '\nFrequency: ' + (p.frequency_pct || 0) + '% (' + (p.frequency || 0) + ' paths)\nType: ' + typeLabel + '\nClick to lookup</title>';
svg += '</g>';
});
// ---- Legend ----
var ly = h - 35;
svg += '<g opacity=".7">';
svg += '<circle cx="20" cy="' + ly + '" r="5" fill="url(#' + uid + 'tier1g)"/><text x="30" y="' + (ly + 3.5) + '" fill="#fbbf24" font-size="7" font-family="Inter,sans-serif">Tier 1</text>';
svg += '<circle cx="75" cy="' + ly + '" r="5" fill="url(#' + uid + 'transitg)"/><text x="85" y="' + (ly + 3.5) + '" fill="#60a5fa" font-size="7" font-family="Inter,sans-serif">Transit</text>';
svg += '<circle cx="135" cy="' + ly + '" r="5" fill="url(#' + uid + 'peerg)"/><text x="145" y="' + (ly + 3.5) + '" fill="#34d399" font-size="7" font-family="Inter,sans-serif">IX / Peer</text>';
svg += '</g>';
// ---- Watermark ----
svg += '<text x="' + (w - 10) + '" y="' + (h - 8) + '" text-anchor="end" fill="#414868" font-size="7" font-family="Inter,sans-serif">Provider Relationship Graph</text>';
svg += '</svg>';
var container = '<div class="provider-graph" style="position:relative">' + svg + '</div>';
$('providerGraphContent').innerHTML = container;
if (providers.length > maxProviders) {
$('providerGraphContent').innerHTML += '<div style="text-align:center;font-size:.75rem;color:var(--muted);margin-top:.5rem">Showing top ' + maxProviders + ' of ' + providers.length + ' providers</div>';
function section(items, color, title, label, limit) {
if (!items.length) return '';
var s = '<div style="margin-bottom:1.25rem"><div style="font-size:.7rem;font-weight:700;color:' + color + ';text-transform:uppercase;letter-spacing:.08em;margin-bottom:.5rem;display:flex;align-items:center;gap:.4rem"><span style="width:7px;height:7px;border-radius:50%;background:' + color + '"></span> ' + title + ' (' + items.length + ')</div>';
s += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.4rem">';
var show = items.slice(0, limit);
show.forEach(function(p) { s += provCard(p, color, label); });
s += '</div>';
if (items.length > limit) {
var moreId = 'pg_more_' + label;
s += '<div class="show-more-btn" onclick="var el=document.getElementById(\'' + moreId + '\');if(el.style.display===\'none\'){el.style.display=\'grid\';this.textContent=\'Hide\';}else{el.style.display=\'none\';this.textContent=\'Show ' + (items.length - limit) + ' more...\';}">Show ' + (items.length - limit) + ' more...</div>';
s += '<div id="' + moreId + '" style="display:none;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.4rem">';
items.slice(limit).forEach(function(p) { s += provCard(p, color, label); });
s += '</div>';
}
s += '</div>';
return s;
}
h += section(tier1, '#fbbf24', 'Tier 1 Providers', 'T1', 20);
h += section(transit, '#60a5fa', 'Transit Providers', 'TR', 12);
h += section(peers, '#4ade80', 'IX / Peers', 'IX', 12);
h += '<div style="font-size:.7rem;color:var(--dim);text-align:center;margin-top:.75rem">' + providers.length + ' providers total (Tier 1: ' + tier1.length + ' \u00b7 Transit: ' + transit.length + ' \u00b7 IX/Peer: ' + peers.length + ')</div>';
$('providerGraphContent').innerHTML = h;
}
// ============================================================
// Feature 2: Full Compare UI
// ============================================================
@ -2369,6 +2373,71 @@ function renderHealthReport(d) {
$('healthContent').innerHTML = h;
}
function loadPeeringRecommendations(asn, ixConnections) {
if (!ixConnections || ixConnections.length === 0) return;
$('peeringRecCard').classList.remove('hidden');
// Get the IXPs this network is on
var myIxIds = new Set(ixConnections.map(function(ix) { return ix.ix_id; }));
var myIxNames = {};
ixConnections.forEach(function(ix) { myIxNames[ix.ix_id] = ix.ix_name; });
// Top networks to check peering potential with
var topNets = [13335, 15169, 32934, 16509, 8075, 20940, 6939, 174, 1299, 2914, 3356, 3257, 714, 36459, 13414, 46489, 14618, 54113, 396982, 2906];
$('peeringRecContent').innerHTML = '<div style="color:var(--dim);font-size:.85rem">Checking peering potential with top 20 networks...</div>';
// Fetch IX presence for top networks
Promise.all(topNets.map(function(targetAsn) {
return fetch('/api/lookup?asn=' + targetAsn).then(function(r) { return r.json(); }).then(function(d) {
var name = d.network ? d.network.name : 'AS' + targetAsn;
var theirIx = (d.ix_presence && d.ix_presence.connections) || [];
var theirIxIds = new Set(theirIx.map(function(ix) { return ix.ix_id; }));
var common = [];
myIxIds.forEach(function(id) { if (theirIxIds.has(id)) common.push(myIxNames[id] || 'IX-' + id); });
return { asn: targetAsn, name: name, common_ixps: common, their_total: theirIx.length };
}).catch(function() { return null; });
})).then(function(results) {
results = results.filter(function(r) { return r && r.asn !== parseInt(asn); });
// Sort by common IXPs descending
results.sort(function(a, b) { return b.common_ixps.length - a.common_ixps.length; });
var h = '';
var withCommon = results.filter(function(r) { return r.common_ixps.length > 0; });
var without = results.filter(function(r) { return r.common_ixps.length === 0; });
if (withCommon.length > 0) {
h += '<div style="font-size:.7rem;font-weight:700;color:var(--green);text-transform:uppercase;letter-spacing:.08em;margin-bottom:.5rem">\u2705 Peering possible at shared IXPs (' + withCommon.length + ')</div>';
h += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:.5rem;margin-bottom:1rem">';
withCommon.forEach(function(r) {
h += '<div onclick="lookupAsn(' + r.asn + ')" style="cursor:pointer;background:var(--bg);border:1px solid rgba(156,206,106,.2);border-radius:10px;padding:.65rem .85rem;transition:all .15s" onmouseenter="this.style.borderColor=\'var(--green)\'" onmouseleave="this.style.borderColor=\'rgba(156,206,106,.2)\'">';
h += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.3rem"><span style="font-weight:700;font-size:.85rem;color:var(--green)">AS' + r.asn + '</span><span style="font-size:.65rem;color:var(--muted)">' + r.common_ixps.length + ' shared IXPs</span></div>';
h += '<div style="font-size:.75rem;color:var(--text-dim);margin-bottom:.3rem">' + escHtml(r.name) + '</div>';
h += '<div style="display:flex;flex-wrap:wrap;gap:.25rem">';
r.common_ixps.slice(0, 5).forEach(function(ix) {
h += '<span style="font-size:.6rem;padding:.1rem .35rem;border-radius:4px;background:rgba(156,206,106,.1);color:var(--green);border:1px solid rgba(156,206,106,.15)">' + escHtml(ix) + '</span>';
});
if (r.common_ixps.length > 5) h += '<span style="font-size:.6rem;color:var(--muted)">+' + (r.common_ixps.length - 5) + ' more</span>';
h += '</div></div>';
});
h += '</div>';
}
if (without.length > 0) {
h += '<div style="font-size:.7rem;font-weight:700;color:var(--orange);text-transform:uppercase;letter-spacing:.08em;margin-bottom:.5rem">\u26a0\ufe0f No shared IXP (' + without.length + ')</div>';
h += '<div style="display:flex;flex-wrap:wrap;gap:.4rem">';
without.forEach(function(r) {
h += '<span onclick="lookupAsn(' + r.asn + ')" style="cursor:pointer;font-size:.7rem;padding:.25rem .5rem;border-radius:6px;background:rgba(255,158,100,.08);border:1px solid rgba(255,158,100,.15);color:var(--orange)">' + escHtml(r.name) + ' (AS' + r.asn + ')</span>';
});
h += '</div>';
}
h += '<div style="font-size:.65rem;color:var(--dim);margin-top:.75rem;text-align:center">Compared with top 20 global networks by traffic volume</div>';
$('peeringRecContent').innerHTML = h;
});
}
</script>
</body>
</html>

326
deploy/public/lia.html Normal file
View File

@ -0,0 +1,326 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lia's Paradise — RIPE Atlas Coverage Explorer</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0f0f1a;--card:#1a1b26;--border:#2a2b3d;--border-light:#363750;--text:#e2e8f0;--text-dim:#94a3b8;--muted:#64748b;--dim:#475569;--purple:#a78bfa;--blue:#60a5fa;--green:#4ade80;--orange:#fbbf24;--red:#f87171;--cyan:#22d3ee;--pink:#f472b6}
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
.header{background:linear-gradient(135deg,#1a1b26 0%,#1e1f30 100%);border-bottom:1px solid var(--border);padding:1.5rem 2rem;text-align:center}
.header h1{font-size:1.8rem;font-weight:800;background:linear-gradient(135deg,var(--pink),var(--purple),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent;margin-bottom:.25rem}
.header p{color:var(--text-dim);font-size:.85rem}
.easter-egg{font-size:.65rem;color:var(--dim);margin-top:.25rem;font-style:italic}
.controls{max-width:1400px;margin:1.5rem auto;padding:0 1.5rem;display:flex;gap:1rem;flex-wrap:wrap;align-items:center}
.search-box{flex:1;min-width:200px;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:.7rem 1rem;font-size:.9rem;color:var(--text);font-family:inherit;outline:none}
.search-box:focus{border-color:var(--purple)}
.search-box::placeholder{color:var(--dim)}
.rir-tabs{display:flex;gap:.4rem;flex-wrap:wrap}
.rir-tab{padding:.5rem 1rem;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--text-dim);font-size:.8rem;font-weight:600;cursor:pointer;transition:all .2s;font-family:inherit}
.rir-tab:hover{border-color:var(--purple);color:var(--text)}
.rir-tab.active{background:linear-gradient(135deg,#5b21b6,#7c3aed);color:#fff;border-color:#7c3aed}
.export-btn{padding:.5rem 1.2rem;border-radius:8px;border:1px solid var(--cyan);background:transparent;color:var(--cyan);font-size:.8rem;font-weight:600;cursor:pointer;transition:all .2s;font-family:inherit}
.export-btn:hover{background:var(--cyan);color:var(--bg)}
.stats-bar{max-width:1400px;margin:.75rem auto;padding:0 1.5rem;display:flex;gap:1.5rem;flex-wrap:wrap}
.stat-pill{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:.5rem 1rem;display:flex;align-items:center;gap:.5rem}
.stat-pill .num{font-size:1.1rem;font-weight:800}
.stat-pill .label{font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
.content{max-width:1400px;margin:1rem auto;padding:0 1.5rem}
.loading{text-align:center;padding:3rem;color:var(--muted);font-size:.9rem}
.loading .spinner{display:inline-block;width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .6s linear infinite;margin-right:.5rem;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
.country-section{margin-bottom:1.5rem}
.country-header{display:flex;align-items:center;gap:.75rem;padding:.6rem 1rem;background:var(--card);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:all .2s;margin-bottom:.5rem}
.country-header:hover{border-color:var(--purple)}
.country-flag{font-size:1.2rem}
.country-name{font-weight:700;font-size:.95rem;flex:1}
.country-count{font-size:.75rem;color:var(--muted)}
.country-badge{padding:.2rem .6rem;border-radius:6px;font-size:.7rem;font-weight:700}
.badge-red{background:#f8717118;color:var(--red);border:1px solid #f8717130}
.badge-green{background:#4ade8018;color:var(--green);border:1px solid #4ade8030}
.badge-orange{background:#fbbf2418;color:var(--orange);border:1px solid #fbbf2430}
.asn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:.4rem;padding:0 0 .5rem 0}
.asn-row{display:flex;align-items:center;gap:.65rem;padding:.5rem .85rem;background:var(--bg);border:1px solid var(--border);border-radius:8px;font-size:.8rem;transition:all .15s;cursor:pointer}
.asn-row:hover{border-color:var(--purple);transform:translateY(-1px)}
.asn-num{font-weight:700;color:var(--purple);min-width:80px;font-family:'JetBrains Mono',monospace}
.asn-name{flex:1;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.asn-type{font-size:.65rem;padding:.15rem .4rem;border-radius:4px;background:var(--card);color:var(--dim);border:1px solid var(--border)}
.no-probe{color:var(--red);font-size:.65rem;font-weight:600}
.has-probe{color:var(--green);font-size:.65rem;font-weight:600}
.summary-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:.75rem;margin-bottom:1.5rem}
.summary-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:1.25rem;text-align:center}
.summary-card .rir-name{font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;margin-bottom:.5rem}
.summary-card .big-num{font-size:2rem;font-weight:800}
.summary-card .sub{font-size:.7rem;color:var(--dim);margin-top:.25rem}
.progress-bar{height:6px;background:var(--bg);border-radius:3px;margin-top:.5rem;overflow:hidden}
.progress-fill{height:100%;border-radius:3px;transition:width .5s ease}
.hidden{display:none}
.footer{text-align:center;padding:2rem;color:var(--dim);font-size:.7rem;margin-top:2rem;border-top:1px solid var(--border)}
.footer a{color:var(--purple);text-decoration:none}
</style>
</head>
<body>
<div class="header">
<h1>Lia's Paradise</h1>
<p>RIPE Atlas Coverage Explorer — Networks without Atlas Probes, Anchors & Software Probes</p>
<div class="easter-egg">For Lia, who makes the Internet measurable — one probe at a time</div>
</div>
<div class="controls">
<input type="text" class="search-box" id="searchInput" placeholder="Search by country, ASN, or network name..." oninput="filterResults()">
<div class="rir-tabs" id="rirTabs">
<button class="rir-tab active" onclick="switchRIR('all',this)">All RIRs</button>
<button class="rir-tab" onclick="switchRIR('ripencc',this)">RIPE NCC</button>
<button class="rir-tab" onclick="switchRIR('arin',this)">ARIN</button>
<button class="rir-tab" onclick="switchRIR('apnic',this)">APNIC</button>
<button class="rir-tab" onclick="switchRIR('lacnic',this)">LACNIC</button>
<button class="rir-tab" onclick="switchRIR('afrinic',this)">AFRINIC</button>
</div>
<button class="export-btn" onclick="exportPDF()">Export PDF</button>
</div>
<div class="stats-bar" id="statsBar"></div>
<div id="summaryGrid" class="summary-grid" style="max-width:1400px;margin:1rem auto;padding:0 1.5rem"></div>
<div class="content" id="mainContent">
<div class="loading"><span class="spinner"></span>Loading Atlas coverage data across all RIRs... This may take a moment.</div>
</div>
<div class="footer">
<a href="/">Back to PeerCortex</a> &middot; Data from <a href="https://atlas.ripe.net" target="_blank">RIPE Atlas</a> &amp; <a href="https://www.peeringdb.com" target="_blank">PeeringDB</a>
<br>Made with love for the Atlas community
</div>
<script>
var allData = [];
var currentRIR = 'all';
var countryFlags = {};
function $(id) { return document.getElementById(id); }
function escHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// Country code to flag emoji
function flag(cc) {
if (!cc || cc.length !== 2) return '';
return String.fromCodePoint(...cc.toUpperCase().split('').map(c => 0x1F1E6 + c.charCodeAt(0) - 65));
}
// RIR mapping by country
var rirMap = {
ripencc: new Set(['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IS','IE','IT','LV','LT','LU','MT','NL','NO','PL','PT','RO','SK','SI','ES','SE','CH','GB','UA','RU','TR','GE','AZ','AM','MD','BY','RS','BA','ME','MK','AL','XK','LI','MC','SM','VA','AD','FO','GL','AX','GG','JE','IM','BH','IQ','IR','IL','JO','KW','LB','OM','PS','QA','SA','SY','AE','YE','DZ','EG','LY','MA','TN','EH']),
arin: new Set(['US','CA','PR','VI','GU','AS','MP','MH','FM','PW','UM']),
apnic: new Set(['AU','NZ','JP','KR','CN','HK','MO','TW','IN','BD','PK','LK','NP','BT','MV','AF','MM','TH','VN','LA','KH','MY','SG','ID','PH','BN','TL','PG','FJ','WS','TO','VU','SB','KI','NR','TV','MN','KZ','KG','TJ','TM','UZ']),
lacnic: new Set(['MX','GT','BZ','SV','HN','NI','CR','PA','CO','VE','EC','PE','BO','CL','AR','UY','PY','BR','GY','SR','GF','CU','JM','HT','DO','TT','BB','AG','DM','GD','KN','LC','VC','BS','CW','AW','SX','BQ','TC','KY','BM']),
afrinic: new Set(['ZA','NG','KE','GH','TZ','UG','ET','RW','SN','CI','CM','CD','CG','GA','AO','MZ','ZW','ZM','MW','BW','NA','SZ','LS','MG','MU','SC','DJ','ER','SO','SD','SS','TD','NE','ML','BF','GW','GN','SL','LR','TG','BJ','CF','GQ']),
};
function getRIR(cc) {
if (!cc) return 'unknown';
for (var r in rirMap) { if (rirMap[r].has(cc.toUpperCase())) return r; }
return 'unknown';
}
async function loadData() {
try {
// Fetch PeeringDB networks with IX presence (active networks)
var resp = await fetch('/api/lia/coverage');
var d = await resp.json();
if (d.error) throw new Error(d.error);
allData = d.networks || [];
renderAll();
} catch (e) {
$('mainContent').innerHTML = '<div class="loading" style="color:var(--red)">Failed to load: ' + escHtml(e.message) + '</div>';
}
}
function renderAll() {
renderSummary();
renderStats();
renderCountries();
}
function renderSummary() {
var rir_stats = { ripencc: {total:0,noProbe:0}, arin: {total:0,noProbe:0}, apnic: {total:0,noProbe:0}, lacnic: {total:0,noProbe:0}, afrinic: {total:0,noProbe:0} };
var rirLabels = { ripencc:'RIPE NCC', arin:'ARIN', apnic:'APNIC', lacnic:'LACNIC', afrinic:'AFRINIC' };
var rirColors = { ripencc:'var(--blue)', arin:'var(--green)', apnic:'var(--orange)', lacnic:'var(--pink)', afrinic:'var(--cyan)' };
allData.forEach(function(n) {
var r = getRIR(n.country);
if (rir_stats[r]) {
rir_stats[r].total++;
if (!n.has_probe) rir_stats[r].noProbe++;
}
});
var h = '';
for (var r in rir_stats) {
var s = rir_stats[r];
var covPct = s.total > 0 ? ((s.total - s.noProbe) / s.total * 100).toFixed(1) : '0';
var col = rirColors[r];
h += '<div class="summary-card">';
h += '<div class="rir-name">' + rirLabels[r] + '</div>';
h += '<div class="big-num" style="color:' + col + '">' + s.noProbe.toLocaleString() + '</div>';
h += '<div class="sub">of ' + s.total.toLocaleString() + ' networks without a probe</div>';
h += '<div class="progress-bar"><div class="progress-fill" style="width:' + covPct + '%;background:' + col + '"></div></div>';
h += '<div class="sub" style="margin-top:.25rem">' + covPct + '% coverage</div>';
h += '</div>';
}
$('summaryGrid').innerHTML = h;
}
function renderStats() {
var filtered = getFiltered();
var noProbe = filtered.filter(function(n) { return !n.has_probe; }).length;
var withProbe = filtered.length - noProbe;
var countries = new Set(filtered.map(function(n) { return n.country; })).size;
var h = '';
h += '<div class="stat-pill"><span class="num" style="color:var(--red)">' + noProbe.toLocaleString() + '</span><span class="label">Without Probe</span></div>';
h += '<div class="stat-pill"><span class="num" style="color:var(--green)">' + withProbe.toLocaleString() + '</span><span class="label">With Probe</span></div>';
h += '<div class="stat-pill"><span class="num" style="color:var(--purple)">' + filtered.length.toLocaleString() + '</span><span class="label">Total Networks</span></div>';
h += '<div class="stat-pill"><span class="num" style="color:var(--cyan)">' + countries + '</span><span class="label">Countries</span></div>';
$('statsBar').innerHTML = h;
}
function getFiltered() {
var q = ($('searchInput').value || '').toLowerCase();
return allData.filter(function(n) {
if (currentRIR !== 'all' && getRIR(n.country) !== currentRIR) return false;
if (q) {
var haystack = (n.name + ' ' + n.country + ' ' + n.country_name + ' AS' + n.asn + ' ' + (n.info_type || '')).toLowerCase();
if (haystack.indexOf(q) < 0) return false;
}
return true;
});
}
function renderCountries() {
var filtered = getFiltered();
// Group by country
var byCountry = {};
filtered.forEach(function(n) {
var cc = n.country || 'XX';
if (!byCountry[cc]) byCountry[cc] = { name: n.country_name || cc, networks: [] };
byCountry[cc].networks.push(n);
});
// Sort countries by number of networks without probes (descending)
var countries = Object.keys(byCountry).sort(function(a, b) {
var aNo = byCountry[a].networks.filter(function(n) { return !n.has_probe; }).length;
var bNo = byCountry[b].networks.filter(function(n) { return !n.has_probe; }).length;
return bNo - aNo;
});
var h = '';
countries.forEach(function(cc) {
var c = byCountry[cc];
var noProbe = c.networks.filter(function(n) { return !n.has_probe; });
var withProbe = c.networks.filter(function(n) { return n.has_probe; });
var covPct = (withProbe.length / c.networks.length * 100).toFixed(0);
var badgeClass = covPct >= 70 ? 'badge-green' : covPct >= 30 ? 'badge-orange' : 'badge-red';
var secId = 'country_' + cc;
h += '<div class="country-section">';
h += '<div class="country-header" onclick="var el=document.getElementById(\'' + secId + '\');el.classList.toggle(\'hidden\');this.querySelector(\'.arrow\').textContent=el.classList.contains(\'hidden\')?\'\u25B6\':\'\u25BC\'">';
h += '<span class="country-flag">' + flag(cc) + '</span>';
h += '<span class="country-name">' + escHtml(c.name) + ' (' + cc + ')</span>';
h += '<span class="country-count">' + noProbe.length + ' without probe / ' + c.networks.length + ' total</span>';
h += '<span class="' + badgeClass + ' country-badge">' + covPct + '% coverage</span>';
h += '<span class="arrow" style="color:var(--muted)">\u25B6</span>';
h += '</div>';
h += '<div id="' + secId + '" class="hidden">';
// Show networks WITHOUT probes first
if (noProbe.length > 0) {
h += '<div style="font-size:.7rem;color:var(--red);font-weight:600;margin:.5rem 0 .3rem;padding-left:.5rem">NO PROBE (' + noProbe.length + ')</div>';
h += '<div class="asn-grid">';
noProbe.sort(function(a,b) { return a.asn - b.asn; }).forEach(function(n) {
h += '<div class="asn-row" onclick="window.open(\'/?asn=' + n.asn + '\',\'_blank\')">';
h += '<span class="asn-num">AS' + n.asn + '</span>';
h += '<span class="asn-name">' + escHtml(n.name || '') + '</span>';
if (n.info_type) h += '<span class="asn-type">' + escHtml(n.info_type) + '</span>';
h += '<span class="no-probe">\u2718 No Probe</span>';
h += '</div>';
});
h += '</div>';
}
// Networks WITH probes
if (withProbe.length > 0) {
h += '<div style="font-size:.7rem;color:var(--green);font-weight:600;margin:.75rem 0 .3rem;padding-left:.5rem">HAS PROBE (' + withProbe.length + ')</div>';
h += '<div class="asn-grid">';
withProbe.sort(function(a,b) { return a.asn - b.asn; }).slice(0, 20).forEach(function(n) {
h += '<div class="asn-row" onclick="window.open(\'/?asn=' + n.asn + '\',\'_blank\')">';
h += '<span class="asn-num">AS' + n.asn + '</span>';
h += '<span class="asn-name">' + escHtml(n.name || '') + '</span>';
if (n.info_type) h += '<span class="asn-type">' + escHtml(n.info_type) + '</span>';
h += '<span class="has-probe">\u2714 Probe</span>';
h += '</div>';
});
if (withProbe.length > 20) h += '<div style="font-size:.75rem;color:var(--dim);padding:.3rem .5rem">+ ' + (withProbe.length - 20) + ' more with probes</div>';
h += '</div>';
}
h += '</div>';
h += '</div>';
});
if (countries.length === 0) {
h = '<div class="loading">No results found. Try a different search or RIR filter.</div>';
}
$('mainContent').innerHTML = h;
}
function switchRIR(rir, btn) {
currentRIR = rir;
document.querySelectorAll('.rir-tab').forEach(function(t) { t.classList.remove('active'); });
btn.classList.add('active');
renderStats();
renderCountries();
}
function filterResults() {
renderStats();
renderCountries();
}
function exportPDF() {
// Generate a printable version
var w = window.open('', '_blank');
var filtered = getFiltered().filter(function(n) { return !n.has_probe; });
var byCountry = {};
filtered.forEach(function(n) {
var cc = n.country || 'XX';
if (!byCountry[cc]) byCountry[cc] = { name: n.country_name || cc, networks: [] };
byCountry[cc].networks.push(n);
});
var html = '<!DOCTYPE html><html><head><title>Atlas Coverage Report — Lia\'s Paradise</title>';
html += '<style>body{font-family:Arial,sans-serif;margin:2rem;color:#1a1a2e}h1{color:#5b21b6}h2{color:#7c3aed;margin-top:1.5rem;border-bottom:2px solid #e2e8f0;padding-bottom:.3rem}table{width:100%;border-collapse:collapse;margin:.5rem 0 1rem}th,td{text-align:left;padding:.4rem .6rem;border-bottom:1px solid #e2e8f0;font-size:.85rem}th{background:#f8fafc;font-weight:600}.no-probe{color:#dc2626;font-weight:600}.meta{color:#64748b;font-size:.8rem;margin-bottom:2rem}</style></head><body>';
html += '<h1>RIPE Atlas Coverage Report</h1>';
html += '<div class="meta">Generated by Lia\'s Paradise (peercortex.org/lia) on ' + new Date().toISOString().split('T')[0] + '<br>';
html += 'Filter: ' + (currentRIR === 'all' ? 'All RIRs' : currentRIR.toUpperCase()) + ' | Networks without Atlas Probe: ' + filtered.length + '</div>';
Object.keys(byCountry).sort(function(a,b) { return byCountry[b].networks.length - byCountry[a].networks.length; }).forEach(function(cc) {
var c = byCountry[cc];
html += '<h2>' + c.name + ' (' + cc + ') — ' + c.networks.length + ' networks</h2>';
html += '<table><thead><tr><th>ASN</th><th>Name</th><th>Type</th><th>Status</th></tr></thead><tbody>';
c.networks.sort(function(a,b) { return a.asn - b.asn; }).forEach(function(n) {
html += '<tr><td>AS' + n.asn + '</td><td>' + (n.name || '') + '</td><td>' + (n.info_type || '-') + '</td><td class="no-probe">No Probe</td></tr>';
});
html += '</tbody></table>';
});
html += '<div class="meta" style="margin-top:3rem">Data from RIPE Atlas &amp; PeeringDB | peercortex.org</div></body></html>';
w.document.write(html);
w.document.close();
w.print();
}
// Boot
loadData();
</script>
</body>
</html>

View File

@ -696,6 +696,148 @@ const server = http.createServer(async (req, res) => {
return res.end();
}
// Lia's Atlas Paradise - Easter egg page
if (reqPath === "/lia" || reqPath === "/lia/") {
try {
const liaHtml = fs.readFileSync(__dirname + "/public/lia.html", "utf8");
res.setHeader("Content-Type", "text/html; charset=utf-8");
return res.end(liaHtml);
} catch (_e) {
res.writeHead(500);
return res.end("lia.html not found");
}
}
// ============================================================
// Lia's Atlas Paradise: Atlas probe coverage endpoint
// ============================================================
if (reqPath === "/api/atlas/coverage") {
res.setHeader("Content-Type", "application/json");
if (!atlasProbeCache) {
res.writeHead(503);
return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
}
return res.end(JSON.stringify(atlasProbeCache, null, 2));
}
// ============================================================
// Lia's Paradise: Combined PeeringDB + Atlas coverage data
// ============================================================
if (reqPath === "/api/lia/coverage") {
res.setHeader("Content-Type", "application/json");
if (!atlasProbeCache) {
res.writeHead(503);
return res.end(JSON.stringify({ error: "Atlas probe data is still loading. Please try again in a minute." }));
}
// Cache this expensive response for 30 min
var liaCacheKey = "lia_coverage";
var liaCached = cacheGet(liaCacheKey);
if (liaCached) return res.end(liaCached);
// Fetch PeeringDB network list (all networks with status "ok")
fetchPeeringDB("/net?status=ok&depth=0").then(function(pdbData) {
if (!pdbData || !pdbData.data) {
return res.end(JSON.stringify({ error: "Could not fetch PeeringDB networks" }));
}
var probeAsns = new Set(atlasProbeCache.asns_with_probes || []);
// Country name lookup
var countryNames = {};
try { countryNames = require("./country-names.json"); } catch(_e) { /* optional */ }
var networks = pdbData.data.map(function(n) {
return {
asn: n.asn,
name: n.name || "",
country: n.info_prefixes4 > 0 || n.info_prefixes6 > 0 ? "" : "", // PeeringDB doesn't have country directly on net
info_type: n.info_type || "",
has_probe: probeAsns.has(n.asn),
};
});
// We need countries — fetch from RIPE Stat for each unique ASN is too slow.
// Instead, use the Atlas byCountry data to enrich. For PeeringDB, we need netfac or netixlan for country.
// Better approach: Use PeeringDB org country. Fetch with depth=1 to get org.
// But that's too heavy (50MB+). Instead, use a separate PeeringDB call for orgs.
// Pragmatic: Fetch net with depth=1 but limit fields
// Actually the simplest: PeeringDB net API doesn't expose country directly.
// Use the ix_count/fac_count fields and the "org" for country.
// Let's just add a second call for orgs.
// Simplest approach: use RIPE Stat resource-overview for country from ASN prefix
// But that's per-ASN. Instead, build country from Atlas probes data.
// Atlas byCountry has {CC: {asnSet}} — we can reverse-map ASN→country from there.
// Build ASN→country from atlas data
// We stored asnSet in byCountry but only in the internal function.
// atlasProbeCache.by_country has {CC: {total, connected, asn_count}} — no ASN list!
// We need to store ASN→country mapping. Let's add it.
// For now, return without country and let frontend handle it via RIR mapping
// Actually: PeeringDB net objects have no country, but we can batch-fetch orgs.
// The org object has country. Let's do net?depth=1 but that's huge.
// Compromise: Get first 5000 networks and their org_id, then batch-fetch orgs.
// PRAGMATIC FIX: Use net?depth=0 + a separate org fetch
// PeeringDB org API: /org?status=ok&limit=0 returns all orgs with country.
fetchPeeringDB("/org?status=ok&depth=0").then(function(orgData) {
// Build org_id → country map
var orgCountry = {};
if (orgData && orgData.data) {
orgData.data.forEach(function(o) {
orgCountry[o.id] = { country: o.country || "", name: o.name || "" };
});
}
// Enrich networks with org country
var enriched = pdbData.data.map(function(n) {
var org = orgCountry[n.org_id] || {};
var cc = org.country || "";
return {
asn: n.asn,
name: n.name || "",
org_name: org.name || "",
country: cc,
country_name: cc, // frontend will display full name from its own mapping
info_type: n.info_type || "",
has_probe: probeAsns.has(n.asn),
};
}).filter(function(n) { return n.asn > 0; });
var result = JSON.stringify({
networks: enriched,
total: enriched.length,
with_probes: enriched.filter(function(n) { return n.has_probe; }).length,
without_probes: enriched.filter(function(n) { return !n.has_probe; }).length,
atlas_unique_asns: probeAsns.size,
fetched_at: new Date().toISOString(),
});
cacheSet(liaCacheKey, result, 30 * 60 * 1000); // 30 min cache
res.end(result);
}).catch(function(e) {
// If org fetch fails, return without country
var result = JSON.stringify({
networks: networks,
total: networks.length,
with_probes: networks.filter(function(n) { return n.has_probe; }).length,
without_probes: networks.filter(function(n) { return !n.has_probe; }).length,
atlas_unique_asns: probeAsns.size,
error_note: "Country data unavailable: " + e.message,
fetched_at: new Date().toISOString(),
});
cacheSet(liaCacheKey, result, 5 * 60 * 1000);
res.end(result);
});
}).catch(function(e) {
res.end(JSON.stringify({ error: "PeeringDB fetch failed: " + e.message }));
});
return;
}
res.setHeader("Content-Type", "application/json");
// Health endpoint
@ -1147,8 +1289,20 @@ const server = http.createServer(async (req, res) => {
return {
prefix: pfx,
as_path: asPath,
rov_status: rovStatus.split(",").map((s) => s === "V" ? "valid" : s === "I" ? "invalid" : s === "U" ? "unknown" : s).join(","),
aspa_status: aspaStatus.split(",").map((s) => s === "V" ? "valid" : s === "I" ? "invalid" : s === "U" ? "unknown" : s).join(","),
rov_status: (function(rs) {
var parts = rs.split(",").map(function(s) { return s === "V" ? "valid" : s === "I" ? "invalid" : s === "U" ? "unknown" : s; });
if (parts.indexOf("invalid") >= 0) return "invalid";
if (parts.indexOf("unknown") >= 0) return "unknown";
if (parts.indexOf("valid") >= 0) return "valid";
return parts[0] || "unknown";
})(rovStatus),
aspa_status: (function(as) {
var parts = as.split(",").map(function(s) { return s === "V" ? "valid" : s === "I" ? "invalid" : s === "U" ? "unknown" : s; });
if (parts.indexOf("invalid") >= 0) return "invalid";
if (parts.indexOf("unknown") >= 0) return "unknown";
if (parts.indexOf("valid") >= 0) return "valid";
return parts[0] || "unknown";
})(aspaStatus),
};
});
@ -1735,6 +1889,7 @@ const server = http.createServer(async (req, res) => {
asn: parseInt(asn),
name: net.name || overview?.holder || "Unknown",
aka: net.aka || "",
org_name: (net.org && net.org.name) ? net.org.name : "",
website: net.website || "",
type: net.info_type || "",
policy: net.policy_general || "",
@ -2221,10 +2376,81 @@ const server = http.createServer(async (req, res) => {
);
});
// ============================================================
// Atlas Probe Cache (for Lia's Atlas Paradise)
// ============================================================
let atlasProbeCache = null;
let atlasProbeFetching = false;
function fetchAllAtlasProbes() {
if (atlasProbeFetching) return Promise.resolve();
atlasProbeFetching = true;
console.log("[ATLAS] Fetching all Atlas probes...");
return new Promise(function(resolve) {
var allAsns = new Set();
var byCountry = {};
var pageCount = 0;
var maxPages = 40;
function fetchPage(pageUrl) {
if (pageCount >= maxPages) return finish();
pageCount++;
fetchJSON(pageUrl).then(function(data) {
if (!data || !data.results) return finish();
data.results.forEach(function(probe) {
var asn4 = probe.asn_v4;
var asn6 = probe.asn_v6;
var cc = probe.country_code || "XX";
if (!byCountry[cc]) byCountry[cc] = { total: 0, connected: 0, asnSet: new Set() };
byCountry[cc].total++;
if (probe.status && probe.status.id === 1) byCountry[cc].connected++;
if (asn4) { allAsns.add(asn4); byCountry[cc].asnSet.add(asn4); }
if (asn6) { allAsns.add(asn6); byCountry[cc].asnSet.add(asn6); }
});
if (data.next) {
fetchPage(data.next);
} else {
finish();
}
}).catch(function() { finish(); });
}
function finish() {
var byCountryOut = {};
Object.keys(byCountry).forEach(function(cc) {
var info = byCountry[cc];
byCountryOut[cc] = { total: info.total, connected: info.connected, asn_count: info.asnSet.size };
});
atlasProbeCache = {
total_probes: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].total; }, 0),
total_connected: Object.keys(byCountry).reduce(function(s, cc) { return s + byCountry[cc].connected; }, 0),
unique_asns_with_probes: allAsns.size,
asns_with_probes: Array.from(allAsns).sort(function(a, b) { return a - b; }),
by_country: byCountryOut,
fetched_at: new Date().toISOString(),
pages_fetched: pageCount,
};
console.log("[ATLAS] Loaded " + allAsns.size + " unique ASNs with probes (" + pageCount + " pages)");
atlasProbeFetching = false;
resolve();
}
fetchPage("https://atlas.ripe.net/api/v2/probes/?page_size=500&status=1&page=1&format=json");
});
}
const PORT = process.env.PORT || 3101;
// Fetch RPKI ASPA feed at startup and refresh every 10 minutes
fetchRpkiAspaFeed().then(() => {
Promise.all([fetchRpkiAspaFeed(), fetchAllAtlasProbes()]).then(() => {
server.listen(PORT, "0.0.0.0", () => {
console.log("PeerCortex v0.4.0 running on http://0.0.0.0:" + PORT);
console.log("bgproutes.io API key: " + (BGPROUTES_API_KEY ? "configured" : "NOT configured"));
@ -2236,3 +2462,8 @@ fetchRpkiAspaFeed().then(() => {
setInterval(() => {
fetchRpkiAspaFeed();
}, 10 * 60 * 1000);
// Refresh Atlas probe cache every hour
setInterval(function() {
fetchAllAtlasProbes();
}, 60 * 60 * 1000);