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:
parent
dee5871609
commit
41af8be7f4
@ -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,"&").replace(/</g,"<") + '</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,"&").replace(/</g,"<") + '\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
326
deploy/public/lia.html
Normal 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> · Data from <a href="https://atlas.ripe.net" target="_blank">RIPE Atlas</a> & <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 & PeeringDB | peercortex.org</div></body></html>';
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
w.print();
|
||||
}
|
||||
|
||||
// Boot
|
||||
loadData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
237
deploy/server.js
237
deploy/server.js
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user