These were called in doLookup but never defined anywhere, causing: 'Error: renderResilienceScore is not defined' This JS error aborted the entire render pipeline after the lookup completed — meaning WHOIS, health report, ASPA, bgproutes all never loaded because the catch block fired instead. Also added AbortController timeouts to all 5 new feature card loaders.
4419 lines
270 KiB
HTML
4419 lines
270 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>PeerCortex — The ASN News</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,800;0,900;1,400&family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
|
||
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
|
||
:root{
|
||
--bg:#F5F2EC;
|
||
--card:transparent;
|
||
--card-hover:rgba(0,0,0,0.02);
|
||
--border:#C9C3B6;
|
||
--border-light:#9E9589;
|
||
--purple:#B83A1B;
|
||
--blue:#1D4ED8;
|
||
--green:#15803D;
|
||
--orange:#B45309;
|
||
--red:#B91C1C;
|
||
--cyan:#0369A1;
|
||
--yellow:#92400E;
|
||
--white:#1C1917;
|
||
--muted:#57534E;
|
||
--dim:#A8A29E;
|
||
--text:#1C1917;
|
||
--text-dim:#57534E;
|
||
--serif:'Playfair Display',Georgia,serif;
|
||
--body:'Source Serif 4',Georgia,serif;
|
||
--mono:'IBM Plex Mono','Courier New',monospace;
|
||
}
|
||
|
||
body{font-family:var(--body);background:var(--bg);color:var(--text);line-height:1.6;min-height:100vh}
|
||
a{color:var(--blue);text-decoration:none;transition:color .2s}
|
||
a:hover{color:var(--purple)}
|
||
|
||
/* ─── Masthead ─────────────────────────────────────────── */
|
||
.ed-masthead{max-width:1080px;margin:0 auto;padding:1.25rem 2rem 0}
|
||
.ed-masthead-top{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;flex-wrap:wrap;padding-bottom:.75rem}
|
||
.ed-logo{font-family:var(--serif);font-size:2.4rem;font-weight:900;letter-spacing:-.03em;color:var(--text);line-height:1}
|
||
.ed-tagline{font-family:var(--body);font-size:.8rem;font-style:italic;color:var(--muted);letter-spacing:.02em;margin-top:.15rem}
|
||
.ed-logo sup{font-size:.8rem;color:var(--purple);font-family:var(--mono);font-weight:700;vertical-align:super}
|
||
.ed-masthead-meta{font-family:var(--mono);font-size:.65rem;color:var(--muted);text-align:right;line-height:1.6}
|
||
.ed-rule-h{border:none;border-top:2px solid var(--text);margin:0}
|
||
.ed-rule{border:none;border-top:1px solid var(--border);margin:0}
|
||
.ed-nav{display:flex;gap:1.75rem;padding:.6rem 0;flex-wrap:wrap}
|
||
.ed-nav a{font-family:var(--body);font-size:.75rem;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);text-decoration:none}
|
||
.ed-nav a:hover{color:var(--purple)}
|
||
|
||
/* ─── Search band ────────────────────────────────────────── */
|
||
.ed-search-band{background:#1C1917;padding:1.25rem 0;margin:.75rem 0 0}
|
||
.ed-search-inner{max-width:1080px;margin:0 auto;padding:0 2rem;display:flex;gap:1rem;align-items:center}
|
||
.ed-search-label{font-family:var(--mono);font-size:.62rem;font-weight:600;color:#A8A29E;letter-spacing:.1em;text-transform:uppercase;white-space:nowrap}
|
||
.ed-search-band .search-input{flex:1;background:transparent;border:none;border-bottom:2px solid #57534E;padding:.35rem .5rem;font-family:var(--mono);font-size:1rem;color:#F5F2EC;outline:none;border-radius:0}
|
||
.ed-search-band .search-input:focus{border-color:#B83A1B;box-shadow:none}
|
||
.ed-search-band .search-input::placeholder{color:#57534E}
|
||
.ed-search-band .search-btn{background:#B83A1B;border:none;padding:.5rem 1.75rem;font-family:var(--mono);font-size:.72rem;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#fff;cursor:pointer;border-radius:0}
|
||
.ed-search-band .search-btn:hover{background:#9A2F14}
|
||
.ed-search-band .search-btn:disabled{opacity:.5;cursor:not-allowed}
|
||
#searchHistory{max-width:1080px;margin:0 auto;padding:.5rem 2rem 0}
|
||
|
||
/* ─── Meta bar ─────────────────────────────────────────── */
|
||
.ed-metabar-wrap{max-width:1080px;margin:0 auto;padding:.5rem 2rem}
|
||
.meta-bar{font-family:var(--mono);font-size:.65rem;color:var(--dim);display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}
|
||
|
||
/* ─── Main layout ──────────────────────────────────────── */
|
||
.ed-layout{max-width:1080px;margin:0 auto;padding:0 2rem 4rem;display:grid;grid-template-columns:1fr 280px;gap:0 3rem}
|
||
.ed-layout-full{max-width:1080px;margin:0 auto;padding:0 2rem 1rem}
|
||
@media(max-width:800px){.ed-layout{grid-template-columns:1fr}.ed-sidebar{display:none}}
|
||
.ed-main{min-width:0}
|
||
.ed-two-col{display:grid;grid-template-columns:1fr 1fr;gap:0 2rem}
|
||
@media(max-width:600px){.ed-two-col{grid-template-columns:1fr}}
|
||
|
||
/* ─── Footer ───────────────────────────────────────────── */
|
||
.ed-footer{max-width:1080px;margin:2rem auto 0;padding:1.25rem 2rem 2.5rem;border-top:2px solid var(--text);display:flex;flex-wrap:wrap;gap:1rem;justify-content:space-between;align-items:baseline}
|
||
.ed-footer-name{font-family:var(--serif);font-size:1.1rem;font-weight:800}
|
||
.ed-footer-links{display:flex;gap:1.25rem;font-family:var(--body);font-size:.72rem;color:var(--muted)}
|
||
.ed-footer-links a{color:var(--muted);text-decoration:none}
|
||
.ed-footer-links a:hover{color:var(--purple)}
|
||
.ed-footer-copy{width:100%;font-family:var(--mono);font-size:.6rem;color:var(--dim)}
|
||
|
||
/* ─── Cards become section dividers ───────────────────── */
|
||
.card{background:transparent;border:none;border-top:2px solid var(--text);border-radius:0;padding:1.5rem 0;margin-top:2rem;transition:none}
|
||
.card:hover{border-color:var(--text)}
|
||
.card.full{grid-column:unset}
|
||
|
||
.card-title{font-family:var(--body);font-size:.68rem;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}
|
||
.card-title svg{width:16px;height:16px;opacity:.4}
|
||
|
||
/* ─── Tables ───────────────────────────────────────────── */
|
||
.tbl{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:.75rem}
|
||
.tbl thead tr{border-top:2px solid var(--text);border-bottom:1px solid var(--border)}
|
||
.tbl th{text-align:left;font-family:var(--body);font-size:.62rem;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.08em;padding:.4rem .5rem;background:transparent}
|
||
.tbl td{padding:.4rem .5rem;border-bottom:1px solid var(--border);color:var(--text)}
|
||
.tbl tr:hover td{background:rgba(0,0,0,.02)}
|
||
.tbl .asn-link{color:var(--blue);cursor:pointer;font-weight:500}
|
||
.tbl .asn-link:hover{color:var(--purple);text-decoration:underline}
|
||
|
||
/* ─── Stats ────────────────────────────────────────────── */
|
||
.stat-row{display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:1rem}
|
||
.stat{text-align:center}
|
||
.stat-val{font-family:var(--serif);font-size:1.8rem;font-weight:800;color:var(--green);line-height:1.2}
|
||
.stat-val.blue{color:var(--blue)}.stat-val.purple{color:var(--purple)}.stat-val.orange{color:var(--orange)}.stat-val.red{color:var(--red)}.stat-val.cyan{color:var(--cyan)}
|
||
.stat-label{font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;font-family:var(--mono)}
|
||
|
||
/* ─── Big score ────────────────────────────────────────── */
|
||
.big-score{font-family:var(--serif);font-size:4rem;font-weight:800;line-height:1;margin:.5rem 0}
|
||
.big-score.high{color:var(--green)}.big-score.mid{color:var(--orange)}.big-score.low{color:var(--red)}
|
||
|
||
/* ─── Network name ─────────────────────────────────────── */
|
||
.net-name{font-family:var(--serif);font-size:2.4rem;font-weight:800;letter-spacing:-.02em;line-height:1.1;color:var(--text)}
|
||
.net-aka{font-family:var(--body);font-style:italic;color:var(--muted);margin-bottom:.75rem}
|
||
|
||
/* ─── Badges ───────────────────────────────────────────── */
|
||
.badge{font-family:var(--mono);font-size:.62rem;border-radius:0;padding:.15rem .5rem;display:inline-block;margin-right:.4rem;margin-bottom:.3rem;font-weight:500}
|
||
.badge-purple{background:rgba(184,58,27,.08);color:#B83A1B}
|
||
.badge-blue{background:rgba(29,78,216,.08);color:#1D4ED8}
|
||
.badge-green{background:rgba(21,128,61,.08);color:#15803D}
|
||
.badge-orange{background:rgba(180,83,9,.08);color:#B45309}
|
||
.badge-red{background:rgba(185,28,28,.08);color:#B91C1C}
|
||
.badge-cyan{background:rgba(3,105,161,.08);color:#0369A1}
|
||
|
||
/* ─── External links ───────────────────────────────────── */
|
||
.ext-links{display:flex;flex-direction:row;flex-wrap:wrap;gap:.75rem 1.25rem;margin-top:.75rem}
|
||
.ext-link{font-family:var(--body);font-size:.78rem;color:var(--purple);border:none;border-radius:0;padding:0}
|
||
.ext-link::before{content:'→ ';font-family:var(--mono)}
|
||
.ext-link:hover{color:var(--blue)}
|
||
|
||
/* ─── Progress bars ────────────────────────────────────── */
|
||
.progress-wrap{height:4px;background:var(--border);border-radius:0;overflow:hidden;margin:.5rem 0}
|
||
.progress-bar{height:100%;transition:width .5s ease}
|
||
.progress-bar.green{background:var(--green)}.progress-bar.red{background:var(--red)}.progress-bar.orange{background:var(--orange)}.progress-bar.blue{background:var(--blue)}
|
||
.progress-multi{display:flex;height:4px;border-radius:0;overflow:hidden;margin:.5rem 0;background:var(--border)}
|
||
.progress-multi>div{height:100%;transition:width .5s ease}
|
||
|
||
/* ─── Skeleton ─────────────────────────────────────────── */
|
||
.skeleton{background:linear-gradient(90deg,var(--border) 25%,#D6D0C4 50%,var(--border) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:0;height:1rem;margin:.4rem 0}
|
||
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
|
||
.skeleton.h2{height:2rem;width:60%}.skeleton.h3{height:1.2rem;width:40%}.skeleton.wide{width:100%}.skeleton.med{width:70%}
|
||
|
||
/* ─── Hidden ───────────────────────────────────────────── */
|
||
.hidden{display:none !important}
|
||
|
||
/* ─── Scroll wrap ──────────────────────────────────────── */
|
||
.scroll-wrap{max-height:300px;overflow-y:auto}
|
||
.scroll-wrap::-webkit-scrollbar{width:6px}
|
||
.scroll-wrap::-webkit-scrollbar-track{background:transparent}
|
||
.scroll-wrap::-webkit-scrollbar-thumb{background:var(--border);border-radius:0}
|
||
|
||
/* ─── RPKI ─────────────────────────────────────────────── */
|
||
.rpki-valid{color:var(--green)}
|
||
.prov-badge{display:inline-flex;align-items:center;gap:5px;vertical-align:middle;cursor:help}
|
||
.prov-dot{display:inline-block;width:8px;height:8px;border-radius:50%}
|
||
.prov-high{background:var(--green)}
|
||
.prov-medium{background:var(--orange)}
|
||
.prov-experimental{background:transparent;border:1.5px solid var(--dim);width:7px;height:7px}
|
||
.prov-heuristic{background:var(--orange);opacity:0.6}
|
||
.prov-label{font-size:10px;color:var(--dim);font-family:monospace;letter-spacing:.3px}
|
||
.res-bar-wrap{display:flex;align-items:center;gap:8px;margin:4px 0}
|
||
.res-bar-bg{flex:1;height:6px;background:#1e293b;border-radius:3px;overflow:hidden}
|
||
.res-bar-fill{height:100%;border-radius:3px;transition:width .4s ease}
|
||
.res-score-big{font-size:48px;font-weight:800;letter-spacing:-1px;line-height:1}
|
||
.res-score-label{font-size:11px;color:var(--dim);margin-top:2px}
|
||
.leak-detected{color:var(--orange);font-weight:700}
|
||
.leak-clean{color:var(--green)}
|
||
.leak-pattern{background:#1a1f2e;border-radius:6px;padding:8px 12px;margin:6px 0;font-size:12px}
|
||
.leak-pattern-type{font-size:10px;color:var(--dim);font-family:monospace;margin-bottom:3px}.rpki-invalid{color:var(--red)}.rpki-unknown{color:var(--dim)}
|
||
|
||
/* ─── Status ───────────────────────────────────────────── */
|
||
.status-yes{color:var(--green);font-weight:600}
|
||
.status-no{color:var(--red);font-weight:600}
|
||
.status-unknown{color:var(--muted);font-weight:600}
|
||
|
||
/* ─── Section loading spinner ─────────────────────────── */
|
||
.section-loading{text-align:center;padding:1rem;color:var(--muted);font-family:var(--body);font-size:.8rem}
|
||
.section-loading::before{content:'';display:inline-block;width:16px;height:16px;border:2px solid var(--border);border-top-color:var(--purple);border-radius:50%;animation:spin .8s linear infinite;margin-right:.5rem;vertical-align:middle}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
|
||
/* ─── Expand toggle ────────────────────────────────────── */
|
||
.expand-toggle{color:var(--purple);font-family:var(--body);font-size:.78rem;cursor:pointer;margin-top:.5rem;display:inline-block}
|
||
.expand-toggle:hover{text-decoration:underline}
|
||
.expand-body{display:none;margin-top:.5rem}
|
||
.expand-body.open{display:block}
|
||
|
||
/* ─── History badges ───────────────────────────────────── */
|
||
.history-badge{display:inline-block;padding:.25rem .6rem;border-radius:0;font-size:.75rem;font-weight:500;background:transparent;border:1px solid var(--border);color:var(--muted);cursor:pointer;transition:all .2s;font-family:var(--mono);font-size:.7rem}
|
||
.history-badge:hover{border-color:var(--purple);color:var(--purple)}
|
||
.history-clear{font-size:.7rem;color:var(--dim);cursor:pointer;padding:.25rem .5rem}
|
||
.history-clear:hover{color:var(--red)}
|
||
|
||
/* ─── Modal ────────────────────────────────────────────── */
|
||
.modal-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.4);z-index:1000;display:flex;align-items:center;justify-content:center}
|
||
.modal-content{background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1.5rem;max-width:600px;width:90%;max-height:80vh;overflow-y:auto;position:relative}
|
||
.modal-close{position:absolute;top:1rem;right:1rem;background:none;border:none;color:var(--muted);font-size:1.2rem;cursor:pointer;padding:.3rem}
|
||
.modal-close:hover{color:var(--red)}
|
||
.modal-title{font-family:var(--serif);font-size:1rem;font-weight:700;color:var(--purple);margin-bottom:1rem}
|
||
|
||
/* ─── Prefix / IX links ────────────────────────────────── */
|
||
.prefix-link{color:var(--blue);cursor:pointer;font-family:var(--mono);font-size:.8rem}
|
||
.prefix-link:hover{text-decoration:underline;color:var(--purple)}
|
||
.ix-link{color:var(--green);cursor:pointer}
|
||
.ix-link:hover{text-decoration:underline;color:var(--purple)}
|
||
.asn-link{color:var(--blue)}
|
||
|
||
/* ─── ASPA template ────────────────────────────────────── */
|
||
.aspa-template{background:#1C1917;color:#9CA3AF;border:none;border-radius:0;padding:1rem;font-family:var(--mono);font-size:.75rem;white-space:pre-wrap;word-break:break-all;position:relative;margin:.5rem 0}
|
||
.copy-btn{position:absolute;top:.5rem;right:.5rem;background:#2D2926;border:1px solid #3D3530;border-radius:0;padding:.3rem .6rem;font-size:.7rem;color:#9CA3AF;cursor:pointer;transition:all .2s}
|
||
.copy-btn:hover{border-color:#B83A1B;color:#F5F2EC}
|
||
|
||
/* ─── Data quality badge ───────────────────────────────── */
|
||
.dq-badge{display:inline-flex;align-items:center;gap:.35rem;padding:.25rem .65rem;border-radius:0;font-size:.7rem;font-weight:600;cursor:help;position:relative;transition:all .2s;font-family:var(--mono)}
|
||
.dq-badge.high{background:rgba(21,128,61,.08);color:var(--green);border:1px solid rgba(21,128,61,.2)}
|
||
.dq-badge.medium{background:rgba(180,83,9,.08);color:var(--orange);border:1px solid rgba(180,83,9,.2)}
|
||
.dq-badge.low{background:rgba(185,28,28,.08);color:var(--red);border:1px solid rgba(185,28,28,.2)}
|
||
.dq-badge svg{width:12px;height:12px}
|
||
.dq-tooltip{display:none;position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--bg);border:1px solid var(--border);border-radius:0;padding:.75rem;font-size:.7rem;font-weight:400;color:var(--muted);min-width:280px;max-width:360px;z-index:100;box-shadow:0 4px 12px rgba(0,0,0,.12);text-align:left;line-height:1.5}
|
||
.dq-badge:hover .dq-tooltip{display:block}
|
||
.dq-tooltip-row{display:flex;justify-content:space-between;padding:.2rem 0;border-bottom:1px solid var(--border)}
|
||
.dq-tooltip-row:last-child{border-bottom:none}
|
||
.dq-tooltip-label{color:var(--muted)}
|
||
.dq-tooltip-value{font-weight:600}
|
||
.dq-tooltip-value.agree{color:var(--green)}.dq-tooltip-value.warn{color:var(--orange)}.dq-tooltip-value.bad{color:var(--red)}.dq-tooltip-value.na{color:var(--dim)}
|
||
|
||
/* ─── ASPA gauge ───────────────────────────────────────── */
|
||
.aspa-gauge{position:relative;width:140px;height:140px;margin:0 auto .5rem}
|
||
.aspa-gauge svg{width:100%;height:100%;transform:rotate(-90deg)}
|
||
.aspa-gauge-bg{fill:none;stroke:var(--border);stroke-width:10}
|
||
.aspa-gauge-fill{fill:none;stroke-width:10;stroke-linecap:round;transition:stroke-dashoffset .8s ease,stroke .3s}
|
||
.aspa-gauge-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center}
|
||
.aspa-gauge-score{font-family:var(--serif);font-size:2.2rem;font-weight:800;line-height:1;color:var(--text)}
|
||
.aspa-gauge-label{font-size:.65rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;font-family:var(--mono)}
|
||
.aspa-breakdown{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin:1rem 0}
|
||
.aspa-breakdown-item{background:transparent;border:1px solid var(--border);border-radius:0;padding:.75rem}
|
||
.aspa-breakdown-label{font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;margin-bottom:.25rem;font-family:var(--mono)}
|
||
.aspa-breakdown-score{font-size:1.2rem;font-weight:700}
|
||
.aspa-breakdown-bar{height:4px;background:var(--border);border-radius:0;margin-top:.35rem;overflow:hidden}
|
||
.aspa-breakdown-bar>div{height:100%;transition:width .5s ease}
|
||
.valley-alert{background:rgba(185,28,28,.06);border:1px solid rgba(185,28,28,.2);border-radius:0;padding:.75rem;margin:.5rem 0;font-size:.8rem;color:var(--red)}
|
||
.asset-alert{background:rgba(180,83,9,.06);border:1px solid rgba(180,83,9,.2);border-radius:0;padding:.75rem;margin:.5rem 0;font-size:.8rem;color:var(--orange)}
|
||
.path-result-badge{display:inline-block;padding:.15rem .5rem;border-radius:0;font-size:.7rem;font-weight:600;font-family:var(--mono)}
|
||
.path-valid{background:rgba(21,128,61,.08);color:var(--green)}
|
||
.path-invalid{background:rgba(185,28,28,.08);color:var(--red)}
|
||
.path-unknown{background:rgba(87,83,78,.1);color:var(--muted)}
|
||
.hop-detail{font-size:.7rem;color:var(--text-dim);margin-top:.3rem;font-family:var(--mono)}
|
||
.hop-arrow{color:var(--dim);margin:0 .15rem}
|
||
.hop-pp{color:var(--green)}.hop-npp{color:var(--red)}.hop-na{color:var(--muted)}
|
||
.audit-row{display:flex;align-items:center;gap:.5rem;padding:.35rem 0;border-bottom:1px solid var(--border);font-size:.8rem}
|
||
.audit-missing{color:var(--orange)}.audit-extra{color:var(--cyan)}.audit-ok{color:var(--green)}
|
||
|
||
/* ─── Health gauge ─────────────────────────────────────── */
|
||
.health-gauge{position:relative;width:160px;height:160px;margin:0 auto .5rem}
|
||
.health-gauge svg{width:100%;height:100%;transform:rotate(-90deg)}
|
||
.health-gauge-bg{fill:none;stroke:var(--border);stroke-width:12}
|
||
.health-gauge-fill{fill:none;stroke-width:12;stroke-linecap:round;transition:stroke-dashoffset .8s ease,stroke .3s}
|
||
.health-gauge-text{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center}
|
||
.health-gauge-score{font-family:var(--serif);font-weight:800;font-size:2.8rem;line-height:1;color:var(--text)}
|
||
.health-gauge-label{font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;font-family:var(--mono)}
|
||
.health-checks{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:0;margin:1rem 0}
|
||
.health-check-item{display:flex;align-items:center;gap:.5rem;padding:.6rem 0;background:transparent;border:none;border-bottom:1px solid var(--border);border-radius:0;font-size:.8rem;transition:border-bottom-color .2s;position:relative;cursor:pointer}
|
||
.health-check-item:hover{border-bottom-color:var(--text);background:transparent}
|
||
.health-check-icon{font-size:1rem;flex-shrink:0}
|
||
.health-check-name{flex:1;color:var(--text-dim);font-family:var(--body)}
|
||
.health-check-score{font-size:.75rem;font-weight:600;min-width:2rem;text-align:right;font-family:var(--mono)}
|
||
.health-tooltip{display:none;position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);background:var(--bg);border:1px solid var(--border);border-radius:0;padding:12px 16px;min-width:300px;max-width:400px;z-index:1000;box-shadow:0 4px 20px rgba(0,0,0,0.12);font-size:13px;line-height:1.6;color:var(--muted);pointer-events:none;font-family:var(--body)}
|
||
.health-tooltip::after{content:'';position:absolute;top:100%;left:50%;transform:translateX(-50%);border:6px solid transparent;border-top-color:var(--border)}
|
||
.health-tooltip .tt-section{margin-bottom:6px}
|
||
.health-tooltip .tt-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--dim);font-weight:600}
|
||
.health-tooltip .tt-value{color:var(--text)}
|
||
.health-tooltip .tt-fix{color:var(--orange);font-style:italic}
|
||
.health-check-item:hover .health-tooltip{display:block}
|
||
|
||
/* ─── Show more ────────────────────────────────────────── */
|
||
.show-more-btn{font-size:.8rem;color:var(--blue);cursor:pointer;padding:.5rem 0;margin-top:.25rem;transition:color .2s;user-select:none;font-family:var(--body)}
|
||
.show-more-btn:hover{color:var(--purple);text-decoration:underline}
|
||
.sort-toggle{font-size:.7rem;color:var(--muted);cursor:pointer;padding:.2rem .5rem;border:1px solid var(--border);border-radius:0;transition:all .2s;user-select:none;font-family:var(--mono)}
|
||
.sort-toggle:hover{color:var(--blue);border-color:var(--blue)}
|
||
|
||
/* ─── Routing overview ─────────────────────────────────── */
|
||
.routing-stats-row{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.25rem}
|
||
.routing-stat-card{flex:1;min-width:100px;background:transparent;border:none;border-top:1px solid var(--border);border-radius:0;padding:.875rem 1rem;text-align:center}
|
||
.routing-stat-val{font-family:var(--serif);font-size:1.6rem;font-weight:700;line-height:1.2}
|
||
.routing-stat-label{font-size:.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-top:.25rem;font-family:var(--mono)}
|
||
.prop-section{margin-bottom:1rem}
|
||
.prop-label{font-size:.8rem;font-weight:600;color:var(--text-dim);margin-bottom:.4rem;font-family:var(--body)}
|
||
.prop-bar-wrap{display:flex;align-items:center;gap:.75rem}
|
||
.prop-bar{flex:1;height:6px;border-radius:0;background:var(--border);overflow:hidden}
|
||
.prop-fill{height:100%;transition:width 1.2s cubic-bezier(.4,0,.2,1);width:0}
|
||
.prop-fill.green{background:var(--green)}
|
||
.prop-fill.orange{background:var(--orange)}
|
||
.prop-fill.red{background:var(--red)}
|
||
.prop-pct{font-size:.9rem;font-weight:700;min-width:50px;text-align:right;font-family:var(--mono)}
|
||
.prop-detail{font-size:.7rem;color:var(--muted);margin-top:.2rem}
|
||
.prefix-dist{margin-top:1rem}
|
||
.prefix-dist-label{font-size:.8rem;font-weight:600;color:var(--text-dim);margin-bottom:.5rem}
|
||
.prefix-badges{display:flex;flex-wrap:wrap;gap:.4rem}
|
||
.prefix-badge{font-size:.7rem;padding:.2rem .5rem;border-radius:0;background:transparent;border:1px solid var(--border);color:var(--text-dim);font-family:var(--mono)}
|
||
.routing-footer{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:.75rem;margin-top:1rem;padding-top:.75rem;border-top:1px solid var(--border)}
|
||
.routing-footer-left{display:flex;align-items:center;gap:.5rem;font-size:.8rem;color:var(--text-dim)}
|
||
|
||
/* ─── IX traffic stats ─────────────────────────────────── */
|
||
.ix-traffic-stats{display:flex;gap:1rem;flex-wrap:wrap;margin-top:.75rem;padding:.75rem;background:transparent;border:1px solid var(--border);border-radius:0}
|
||
.ix-traffic-stat{text-align:center}
|
||
.ix-traffic-val{font-family:var(--serif);font-size:1.1rem;font-weight:700;color:var(--cyan)}
|
||
.ix-traffic-label{font-size:.65rem;color:var(--muted);text-transform:uppercase;font-family:var(--mono)}
|
||
|
||
/* ─── WHOIS ────────────────────────────────────────────── */
|
||
.whois-grid{display:grid;grid-template-columns:130px 1fr;gap:.3rem .75rem;font-size:.8rem}
|
||
.whois-key{color:var(--muted);font-weight:600;text-align:right;font-family:var(--mono);font-size:.7rem}
|
||
.whois-val{color:var(--text-dim);word-break:break-all;font-family:var(--mono);font-size:.7rem}
|
||
|
||
/* ─── Compare ──────────────────────────────────────────── */
|
||
.compare-box{display:flex;gap:.75rem;align-items:stretch;flex-wrap:wrap}
|
||
.compare-input{background:transparent;border:none;border-bottom:1px solid var(--border);border-radius:0;padding:.35rem .5rem;font-family:var(--mono);font-size:.9rem;color:var(--text);outline:none;width:160px;transition:border-color .2s}
|
||
.compare-input:focus{border-color:var(--purple)}
|
||
.compare-btn{background:transparent;border:1px solid var(--border);border-radius:0;padding:.5rem 1.25rem;font-size:.72rem;font-weight:600;color:var(--purple);cursor:pointer;font-family:var(--mono);transition:all .2s}
|
||
.compare-btn:hover{border-color:var(--purple);background:rgba(184,58,27,.05)}
|
||
.compare-grid{display:grid;grid-template-columns:1fr 1fr;gap:1.5rem}
|
||
.compare-col{background:transparent;border:1px solid var(--border);border-radius:0;padding:1rem}
|
||
.compare-col-title{font-family:var(--body);font-size:.85rem;font-weight:600;margin-bottom:.75rem;display:flex;align-items:center;gap:.5rem}
|
||
.compare-metric{display:flex;justify-content:space-between;padding:.4rem 0;border-bottom:1px solid var(--border);font-size:.8rem}
|
||
.compare-metric-label{color:var(--muted)}
|
||
.compare-metric-val{font-weight:600;font-family:var(--mono)}
|
||
.compare-venn{text-align:center;margin:1rem 0}
|
||
.compare-results{max-width:1080px;margin:0 auto;padding:0 2rem 1rem}
|
||
|
||
/* ─── Provider graph ───────────────────────────────────── */
|
||
.provider-graph{width:100%;max-width:600px;margin:0 auto}
|
||
.provider-graph svg{width:100%;height:auto}
|
||
|
||
/* ─── Flag ─────────────────────────────────────────────── */
|
||
.flag{font-size:1.2rem;margin-right:.3rem}
|
||
|
||
/* ─── MapLibre ─────────────────────────────────────────── */
|
||
.maplibregl-ctrl-attrib{background:rgba(245,242,236,.9)!important;color:var(--muted)!important;font-size:10px!important}
|
||
.maplibregl-ctrl-attrib a{color:var(--blue)!important}
|
||
.pc-popup .maplibregl-popup-content{background:var(--bg);border:1px solid var(--border);border-radius:0;padding:8px 10px;color:var(--text);font-family:var(--body);font-size:12px;box-shadow:0 4px 16px rgba(0,0,0,.15)}
|
||
.pc-popup .maplibregl-popup-tip{border-top-color:var(--bg);border-bottom-color:var(--bg)}
|
||
|
||
/* ─── ASPA alert ───────────────────────────────────────── */
|
||
/* reuse valley-alert / asset-alert styles */
|
||
|
||
|
||
/* Dark Mode */
|
||
body.dark{--bg:#0f0f0f;--card:transparent;--border:#2a2a2a;--border-light:#444;--text:#e8e4dc;--muted:#a09890;--dim:#666;--card-hover:rgba(255,255,255,0.03)}
|
||
body.dark .ed-search-band{background:#111}
|
||
body.dark .ed-search-band .search-input{color:#e8e4dc;border-bottom-color:#a09890}
|
||
body.dark .tbl thead tr{border-top-color:#e8e4dc}
|
||
body.dark .card{border-top-color:#e8e4dc}
|
||
.dark-toggle{position:fixed;bottom:1.25rem;right:1.25rem;background:var(--text);color:var(--bg);border:none;font-family:var(--mono);font-size:.65rem;padding:.4rem .75rem;cursor:pointer;letter-spacing:.06em;z-index:999;opacity:.7;transition:opacity .2s}
|
||
.dark-toggle:hover{opacity:1}
|
||
/* Share dropdown */
|
||
.share-dropdown{position:relative;display:inline-block}
|
||
.share-dropdown-menu{display:none;position:absolute;top:1.6rem;left:0;background:var(--bg);border-top:2px solid var(--text);border-left:1px solid var(--border);border-right:1px solid var(--border);border-bottom:1px solid var(--border);min-width:170px;z-index:9998;box-shadow:0 8px 24px rgba(0,0,0,.1)}
|
||
.share-dropdown-menu.open{display:block}
|
||
.share-dropdown-menu a{display:flex;align-items:center;gap:.6rem;padding:.5rem .85rem;font-family:var(--mono);font-size:.65rem;color:var(--text);text-decoration:none;border-bottom:1px solid var(--border);letter-spacing:.04em;white-space:nowrap}
|
||
.share-dropdown-menu a:last-child{border-bottom:none}
|
||
.share-dropdown-menu a:hover{background:var(--card-hover);color:var(--purple)}
|
||
.share-dropdown-menu a svg{flex-shrink:0;opacity:.7}
|
||
/* Hijack alert badge */
|
||
.hijack-alert{background:#B91C1C;color:#fff;font-family:var(--mono);font-size:.62rem;padding:.15rem .4rem;border-radius:2px}
|
||
.hijack-ok{background:#15803D;color:#fff;font-family:var(--mono);font-size:.62rem;padding:.15rem .4rem;border-radius:2px}
|
||
/* Community badges */
|
||
.comm-rfc{background:#1D4ED8;color:#fff;font-family:var(--mono);font-size:.6rem;padding:.15rem .4rem;border-radius:2px;margin:.1rem}
|
||
.comm-carrier{background:#57534E;color:#fff;font-family:var(--mono);font-size:.6rem;padding:.15rem .4rem;border-radius:2px;margin:.1rem}
|
||
.comm-ixp{background:#0369A1;color:#fff;font-family:var(--mono);font-size:.6rem;padding:.15rem .4rem;border-radius:2px;margin:.1rem}
|
||
.comm-unknown{background:transparent;border:1px solid var(--border);color:var(--muted);font-family:var(--mono);font-size:.6rem;padding:.15rem .4rem;border-radius:2px;margin:.1rem}
|
||
|
||
/* ── Name Search Autocomplete ─────────────────── */
|
||
.search-wrap { position: relative; flex: 1; }
|
||
.autocomplete-list {
|
||
position: absolute;
|
||
top: calc(100% + 2px);
|
||
left: 0; right: 0;
|
||
background: var(--bg);
|
||
border: 1px solid var(--text);
|
||
border-top: none;
|
||
z-index: 500;
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
}
|
||
.autocomplete-item {
|
||
padding: .55rem .75rem;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: .6rem;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.autocomplete-item:hover, .autocomplete-item.selected {
|
||
background: var(--text);
|
||
color: var(--bg);
|
||
}
|
||
.autocomplete-item:hover .ac-meta, .autocomplete-item.selected .ac-meta {
|
||
color: rgba(255,255,255,.6);
|
||
}
|
||
.ac-asn {
|
||
font-family: var(--mono);
|
||
font-size: .68rem;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
color: var(--purple);
|
||
}
|
||
.autocomplete-item:hover .ac-asn, .autocomplete-item.selected .ac-asn {
|
||
color: #f5c4b8;
|
||
}
|
||
.ac-name { font-family: var(--body); font-size: .82rem; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.ac-meta { font-family: var(--mono); font-size: .6rem; color: var(--muted); white-space: nowrap; }
|
||
.ac-source { font-family: var(--mono); font-size: .55rem; color: var(--dim); margin-left: auto; padding-left: .4rem; white-space: nowrap; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<button class="dark-toggle" id="darkToggle" onclick="toggleDark()">◐ DARK</button>
|
||
|
||
<!-- MASTHEAD -->
|
||
<div class="ed-masthead">
|
||
<div class="ed-masthead-top">
|
||
<div>
|
||
<div class="ed-logo">PeerCortex<sup>β</sup></div>
|
||
<div class="ed-tagline">The ASN News</div>
|
||
</div>
|
||
<div class="ed-masthead-meta">The ASN News<br><span style="font-family:var(--mono)">peercortex.org · v0.6.9 · routing intelligence</span></div>
|
||
</div>
|
||
<hr class="ed-rule-h">
|
||
<nav class="ed-nav">
|
||
<a href="https://www.peeringdb.com" target="_blank">PeeringDB</a>
|
||
<a href="https://stat.ripe.net" target="_blank">RIPE Stat</a>
|
||
<a href="https://bgp.he.net" target="_blank">bgp.he.net</a>
|
||
<a href="https://atlas.ripe.net" target="_blank">RIPE Atlas</a>
|
||
<a href="https://www.routeviews.org" target="_blank">Route Views</a>
|
||
<a href="https://bgproutes.io" target="_blank">bgproutes.io</a>
|
||
<a href="https://github.com/renefichtmueller/PeerCortex" target="_blank">GitHub</a>
|
||
<a href="#" onclick="openChangelog();return false">Changelog</a>
|
||
<span class="share-dropdown" id="shareDropdown">
|
||
<a href="#" onclick="toggleShareMenu();return false" id="shareNavLink" title="Share" style="display:flex;align-items:center;gap:.35rem">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||
</a>
|
||
<div class="share-dropdown-menu" id="shareMenu">
|
||
<a href="#" onclick="shareCopy();return false">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||
Copy Link
|
||
</a>
|
||
<a href="#" onclick="shareTwitter();return false">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.737-8.835L1.254 2.25H8.08l4.253 5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||
X / Twitter
|
||
</a>
|
||
<a href="#" onclick="shareLinkedIn();return false">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
||
LinkedIn
|
||
</a>
|
||
<a href="#" onclick="shareFacebook();return false">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||
Facebook
|
||
</a>
|
||
</div>
|
||
</span>
|
||
</nav>
|
||
<hr class="ed-rule">
|
||
</div>
|
||
|
||
<!-- SEARCH BAND -->
|
||
<div class="ed-search-band">
|
||
<div class="ed-search-inner">
|
||
<span class="ed-search-label">ASN / Prefix / Org</span>
|
||
<div class="search-wrap">
|
||
<input type="text" class="search-input" id="asnInput" placeholder="Enter ASN, IP prefix, or network name" value="" autofocus autocomplete="off">
|
||
<div id="autocompleteList" class="autocomplete-list" style="display:none"></div>
|
||
</div>
|
||
<button class="search-btn" id="searchBtn" onclick="doLookup()">Analyse</button>
|
||
</div>
|
||
<div id="searchHistory" style="display:flex;flex-wrap:wrap;gap:.4rem"></div>
|
||
</div>
|
||
|
||
<!-- META BAR -->
|
||
<div class="ed-metabar-wrap"><div class="meta-bar" id="metaBar"></div></div>
|
||
|
||
<!-- LOADING SKELETON -->
|
||
<div class="ed-layout hidden" id="skeleton">
|
||
<div class="ed-main">
|
||
<div class="card"><div class="card-title">Network Overview</div><div class="skeleton h2"></div><div class="skeleton h3"></div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
|
||
<div class="ed-two-col">
|
||
<div class="card"><div class="card-title">Announced Prefixes</div><div class="skeleton h2"></div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
|
||
<div class="card"><div class="card-title">RPKI Compliance</div><div class="skeleton h2"></div><div class="skeleton wide"></div></div>
|
||
</div>
|
||
<div class="card"><div class="card-title">Network Health Report</div><div class="skeleton wide"></div><div class="skeleton med"></div><div class="skeleton wide"></div></div>
|
||
<div class="card"><div class="card-title">RIPE Atlas Probes</div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
|
||
<div class="card"><div class="card-title">ASPA Status</div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
|
||
<div class="card"><div class="card-title">ASPA Deep Analysis</div><div class="skeleton h2"></div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
|
||
<div class="card"><div class="card-title">bgproutes.io</div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
|
||
<div class="card"><div class="card-title">Routing Overview</div><div class="skeleton wide"></div><div class="skeleton med"></div><div class="skeleton wide"></div></div>
|
||
<div class="card"><div class="card-title">AS Neighbours</div><div class="skeleton wide"></div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
|
||
<div class="card"><div class="card-title">IX Presence</div><div class="skeleton wide"></div><div class="skeleton wide"></div></div>
|
||
</div>
|
||
<aside class="ed-sidebar">
|
||
<div class="card"><div class="card-title">WHOIS</div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
|
||
<div class="card"><div class="card-title">Facilities</div><div class="skeleton wide"></div><div class="skeleton med"></div></div>
|
||
<div class="card"><div class="card-title">Quick Compare</div><div class="skeleton med"></div></div>
|
||
</aside>
|
||
</div>
|
||
|
||
<!-- MAIN LAYOUT -->
|
||
<div class="ed-layout hidden" id="dashboard">
|
||
|
||
<main class="ed-main">
|
||
|
||
<!-- Network Overview -->
|
||
<section class="card" id="overviewCard" title="General network information: ASN, organization name, registration date, country, RIR, and type (ISP/Enterprise/etc.)">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||
Network Overview
|
||
</div>
|
||
<div id="overviewContent"></div>
|
||
</section>
|
||
|
||
<!-- Prefixes + RPKI side by side -->
|
||
<div class="ed-two-col">
|
||
<section class="card" id="prefixCard" title="All IP address blocks (IPv4 and IPv6) announced by this AS in the global routing table, with RPKI validation status per prefix">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16v16H4z"/><path d="M4 12h16M12 4v16"/></svg>
|
||
Announced Prefixes
|
||
</div>
|
||
<div id="prefixContent"></div>
|
||
</section>
|
||
|
||
<section class="card" id="rpkiCard" title="RPKI (Resource Public Key Infrastructure) compliance score: percentage of announced prefixes covered by valid Route Origin Authorizations (ROAs)">
|
||
<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>
|
||
RPKI Compliance
|
||
</div>
|
||
<div id="rpkiContent"></div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- Network Health Report -->
|
||
<section class="card" id="healthCard" title="13-point automated routing health check: RPKI coverage, IRR registration, ASPA, prefix hygiene, prefix count, deaggregation, and more">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||
Network Health Report
|
||
</div>
|
||
<div id="healthContent"><div class="section-loading">Running comprehensive validation...</div></div>
|
||
</section>
|
||
|
||
<!-- RIPE Atlas Probes -->
|
||
<section class="card" id="atlasCard" title="RIPE Atlas measurement probes hosted within this AS — used for distributed latency and reachability measurements across the internet">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4M12 18v4M2 12h4M18 12h4"/></svg>
|
||
RIPE Atlas Probes
|
||
</div>
|
||
<div id="atlasContent"></div>
|
||
</section>
|
||
|
||
<!-- ASPA Status -->
|
||
<section class="card" id="aspaCard" title="ASPA (Autonomous System Provider Authorization) status — RFC 9582. Verifies whether this AS has published which providers are authorized to forward its routes, protecting against route leaks">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2v-4M9 21H5a2 2 0 0 1-2-2v-4"/></svg>
|
||
ASPA Status
|
||
</div>
|
||
<div id="aspaContent"><div class="section-loading">Loading ASPA data...</div></div>
|
||
</section>
|
||
|
||
<!-- ASPA Deep Analysis -->
|
||
<section class="card" id="aspaDeepCard" title="Deep ASPA analysis: upstream provider chain verification, customer cone analysis, and BGP path validation against published ASPA objects">
|
||
<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"/><path d="M9 12l2 2 4-4"/></svg>
|
||
ASPA Deep Analysis (RFC-Compliant)
|
||
</div>
|
||
<div id="aspaDeepContent"><div class="section-loading">Loading ASPA deep analysis...</div></div>
|
||
</section>
|
||
|
||
<!-- bgproutes.io -->
|
||
<section class="card" id="bgroutesCard" title="Live BGP routing table data from bgproutes.io — vantage points across the internet showing which prefixes are currently visible in the global DFZ (Default-Free Zone)">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||
bgproutes.io
|
||
</div>
|
||
<div id="bgroutesContent"><div class="section-loading">Loading bgproutes.io data...</div></div>
|
||
</section>
|
||
|
||
<!-- Routing Overview (hidden initially) -->
|
||
<section class="card hidden" id="bgpHeCard" title="BGP routing data from BGP.HE.NET (Hurricane Electric) — additional vantage point showing prefix visibility and AS-PATH diversity">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||
Routing Overview
|
||
</div>
|
||
<div id="bgpHeContent"></div>
|
||
</section>
|
||
|
||
<!-- AS Neighbours -->
|
||
<section class="card" id="neighbourCard" title="Direct BGP neighbors (peers, upstreams, customers) of this AS derived from RIPE Stat routing data — shows the AS topology one hop away">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||
AS Neighbours
|
||
</div>
|
||
<div id="neighbourContent"></div>
|
||
</section>
|
||
|
||
<!-- IX Presence -->
|
||
<section class="card" id="ixCard" title="Internet Exchange Points where this AS maintains a presence, sourced from PeeringDB — includes IX name, city, speed, and IPv4/IPv6 peering addresses">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="1" width="22" height="22" rx="2"/><path d="M7 1v22M17 1v22M1 12h22M1 7h22M1 17h22"/></svg>
|
||
IX Presence
|
||
</div>
|
||
<div id="ixContent"></div>
|
||
</section>
|
||
|
||
<!-- Facilities -->
|
||
<section class="card" id="facCard" title="Physical colocation facilities where this network is present, sourced from PeeringDB — data centers and carrier hotels where interconnection can be arranged">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21h18M3 7v14M21 7v14M6 21V10M10 21V10M14 21V10M18 21V10M12 7l9-4H3l9 4z"/></svg>
|
||
Facilities
|
||
</div>
|
||
<div id="facContent"></div>
|
||
</section>
|
||
|
||
<!-- Global Infrastructure Map -->
|
||
<section class="card" id="mapCard" style="display:none">
|
||
<div class="card-title" style="cursor:pointer" onclick="toggleExpand(this)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="10" r="3"/><path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 10-16 0c0 3 2.7 7 8 11.7z"/></svg>
|
||
Global Infrastructure Map
|
||
</div>
|
||
<div class="expand-body">
|
||
<div id="mapLayerBar" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px;align-items:center">
|
||
<span style="font-size:.7rem;color:var(--muted);margin-right:2px;text-transform:uppercase;letter-spacing:.05em;font-family:var(--mono)">Layers:</span>
|
||
<button class="map-layer-btn active" id="layerBtnPops" onclick="toggleMapLayer('pops',this)" style="padding:3px 10px;font-size:.72rem;border-radius:0;border:1px solid var(--cyan);color:var(--cyan);background:rgba(3,105,161,.08);cursor:pointer;font-family:var(--mono)">● ASN PoPs</button>
|
||
<button class="map-layer-btn" id="layerBtnCables" onclick="toggleMapLayer('cables',this)" style="padding:3px 10px;font-size:.72rem;border-radius:0;border:1px solid var(--border);color:var(--muted);background:transparent;cursor:pointer;font-family:var(--mono)">▪ Submarine Cables</button>
|
||
<button class="map-layer-btn" id="layerBtnGlobalFacs" onclick="toggleMapLayer('globalFacs',this)" style="padding:3px 10px;font-size:.72rem;border-radius:0;border:1px solid var(--border);color:var(--muted);background:transparent;cursor:pointer;font-family:var(--mono)">○ Global Datacenters</button>
|
||
<button class="map-layer-btn" id="layerBtnTelecoms" onclick="toggleMapLayer('telecoms',this)" style="padding:3px 10px;font-size:.72rem;border-radius:0;border:1px solid var(--border);color:var(--muted);background:transparent;cursor:pointer;font-family:var(--mono)">▬ OIM Telecoms</button>
|
||
<span id="mapLoadingIndicator" style="font-size:.7rem;color:var(--muted);margin-left:4px;display:none;font-family:var(--mono)">Loading...</span>
|
||
</div>
|
||
<div style="display:flex;gap:0;height:500px">
|
||
<div id="mapSidePanel" style="width:220px;flex-shrink:0;background:#111;border-right:1px solid #333;overflow-y:auto;padding:14px 12px;font-family:var(--mono);font-size:.75rem;color:#8892a4;display:flex;flex-direction:column;gap:4px">
|
||
<div style="color:#4a5568;font-size:.65rem;text-transform:uppercase;letter-spacing:.08em;margin-bottom:8px">Click point for details</div>
|
||
<div id="mapSidePanelContent" style="flex:1"></div>
|
||
</div>
|
||
<div id="networkMap" style="flex:1;background:#1C1917;position:relative"></div>
|
||
</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-top:8px;font-size:.7rem;color:var(--muted);font-family:var(--mono)">
|
||
<span><span style="color:var(--orange)">●</span> IXP</span>
|
||
<span><span style="color:var(--cyan)">●</span> Datacenter/Facility</span>
|
||
<span><span style="color:var(--green)">▬</span> Submarine Cable</span>
|
||
<span><span style="color:var(--border-light)">○</span> Global Datacenter (PeeringDB)</span>
|
||
<span><span style="color:#f7ae54">▬</span> OIM Fiber/Telecoms</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Provider Relationship Graph (hidden) -->
|
||
<section class="card hidden" id="providerGraphCard" title="Visual graph of upstream providers and downstream customers in the BGP topology — shows the routing hierarchy around this AS">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="5" r="3"/><circle cx="5" cy="19" r="3"/><circle cx="19" cy="19" r="3"/><line x1="12" y1="8" x2="5" y2="16"/><line x1="12" y1="8" x2="19" y2="16"/></svg>
|
||
Provider Relationship Graph
|
||
</div>
|
||
<div id="providerGraphContent"></div>
|
||
</section>
|
||
|
||
<!-- ASPA Change Alert (hidden) -->
|
||
<section class="card hidden" id="aspaAlertCard" title="ASPA violation alerts: route announcements that violate published ASPA policies, indicating potential route leaks or misconfigurations">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||
ASPA Change Tracking
|
||
</div>
|
||
<div id="aspaAlertContent"></div>
|
||
</section>
|
||
|
||
<!-- Peering Recommendations (hidden) -->
|
||
<section class="card hidden" id="peeringRecCard" title="Peering recommendations based on common IX presence — networks that share IXPs with this AS and have an open peering policy, ranked by overlap">
|
||
<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;font-family:var(--body)">Analyzing IX overlap with top networks...</div></div>
|
||
</section>
|
||
|
||
<!-- BGP Community Decoder -->
|
||
<section class="card hidden" id="commCard" title="BGP Community decoder — interprets the numeric community tags (e.g. 65000:100) attached to this AS's routes, showing their meaning (traffic engineering, blackhole, geographic markers, etc.)">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-3l-4 4z"/></svg>
|
||
BGP Community Decoder
|
||
</div>
|
||
<div id="commContent"></div>
|
||
</section>
|
||
|
||
<!-- IRR Audit -->
|
||
<section class="card hidden" id="irrCard" title="IRR (Internet Routing Registry) audit — checks whether announced prefixes are registered in IRR databases (RIPE, ARIN, APNIC, etc.) as route objects, which is required for many ISPs to accept the routes">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
||
IRR Audit
|
||
</div>
|
||
<div id="irrContent"></div>
|
||
</section>
|
||
|
||
<!-- RPKI Time Machine -->
|
||
<section class="card hidden" id="rpkiHistCard" title="Routing history from RIPE Stat — shows all IP prefixes this AS has announced over the past 90 days, with first seen and last seen timestamps">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||
RPKI Time Machine <span style="font-size:.6rem;color:var(--dim);font-family:var(--mono);margin-left:.5rem">90 days</span>
|
||
</div>
|
||
<div id="rpkiHistContent"></div>
|
||
</section>
|
||
|
||
<!-- AS-PATH Visualizer -->
|
||
<section class="card hidden" id="aspathCard" title="AS-PATH visualizer — shows the actual BGP paths to this AS's prefixes as observed by RIPE RIS route collectors, revealing upstream transit providers and peering paths">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
|
||
AS-PATH Visualizer
|
||
</div>
|
||
<div id="aspathContent"></div>
|
||
</section>
|
||
|
||
<!-- Looking Glass -->
|
||
<section class="card hidden" id="lgCard" title="Looking Glass — query RIPE RIS route collectors for live BGP table entries for any prefix, showing AS-PATH, next-hop, and community attributes from multiple vantage points">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||
Looking Glass <span style="font-size:.6rem;color:var(--dim);font-family:var(--mono);margin-left:.5rem">via RIPE RIS</span>
|
||
</div>
|
||
<div id="lgInput" style="margin-bottom:.75rem;display:flex;gap:.5rem">
|
||
<input type="text" id="lgPrefixInput" placeholder="Prefix (e.g. 1.1.1.0/24)" style="flex:1;background:transparent;border:none;border-bottom:1px solid var(--border);font-family:var(--mono);font-size:.8rem;color:var(--text);padding:.25rem;outline:none">
|
||
<button onclick="doLookingGlass()" style="background:var(--text);color:var(--bg);border:none;font-family:var(--mono);font-size:.65rem;padding:.3rem .8rem;cursor:pointer;letter-spacing:.06em">QUERY</button>
|
||
</div>
|
||
<div id="lgContent"></div>
|
||
</section>
|
||
|
||
<!-- BGP Hijack Alert -->
|
||
<section class="card hidden" id="hijackCard" title="BGP Hijack Monitor — detects unauthorized announcements of this AS's prefixes by other networks. Subscribe to receive alerts when unexpected origins are detected for your prefixes">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||
BGP Hijack Monitor
|
||
</div>
|
||
<div id="hijackContent"></div>
|
||
</section>
|
||
|
||
<!-- Prefix Changes -->
|
||
<section class="card hidden" id="pfxChangesCard" title="Prefix Changes — BGP announcements, withdrawals, origin-ASN changes, RPKI status issues, and live RIS stream for a custom time window">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||
Prefix Changes
|
||
</div>
|
||
<!-- Time range picker -->
|
||
<div id="pfxTimeRange" style="display:flex;flex-wrap:wrap;gap:.4rem;align-items:center;margin-bottom:.75rem">
|
||
<span style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-right:.2rem">RANGE:</span>
|
||
<button class="pfx-preset active" onclick="pfxSetPreset(1)" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .6rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer">1h</button>
|
||
<button class="pfx-preset" onclick="pfxSetPreset(6)" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .6rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer">6h</button>
|
||
<button class="pfx-preset" onclick="pfxSetPreset(24)" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .6rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer">24h</button>
|
||
<button class="pfx-preset" onclick="pfxSetPreset(168)" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .6rem;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer">7d</button>
|
||
<span style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-left:.4rem">CUSTOM:</span>
|
||
<input type="datetime-local" id="pfxFrom" style="font-family:var(--mono);font-size:.65rem;background:transparent;border:1px solid var(--border);color:var(--text);padding:.2rem .4rem;outline:none">
|
||
<span style="font-family:var(--mono);font-size:.65rem;color:var(--dim)">→</span>
|
||
<input type="datetime-local" id="pfxTo" style="font-family:var(--mono);font-size:.65rem;background:transparent;border:1px solid var(--border);color:var(--text);padding:.2rem .4rem;outline:none">
|
||
<button onclick="pfxLoadCustom()" style="font-family:var(--mono);font-size:.65rem;padding:.2rem .7rem;background:var(--text);color:var(--bg);border:none;cursor:pointer;letter-spacing:.05em">LOAD</button>
|
||
</div>
|
||
<!-- Tabs -->
|
||
<div style="display:flex;gap:0;margin-bottom:.75rem;border-bottom:1px solid var(--border)">
|
||
<button class="pfx-tab active" onclick="pfxTab('ann')" id="pfxTabAnn" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid var(--text);background:transparent;color:var(--text);cursor:pointer">📢 Announced <span id="pfxCntAnn" style="color:var(--dim)"></span></button>
|
||
<button class="pfx-tab" onclick="pfxTab('wd')" id="pfxTabWd" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid transparent;background:transparent;color:var(--dim);cursor:pointer">📤 Withdrawn <span id="pfxCntWd" style="color:var(--dim)"></span></button>
|
||
<button class="pfx-tab" onclick="pfxTab('orig')" id="pfxTabOrig" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid transparent;background:transparent;color:var(--dim);cursor:pointer">🔄 Origin Changes <span id="pfxCntOrig" style="color:var(--dim)"></span></button>
|
||
<button class="pfx-tab" onclick="pfxTab('rpki')" id="pfxTabRpki" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid transparent;background:transparent;color:var(--dim);cursor:pointer">🛡 RPKI Issues <span id="pfxCntRpki" style="color:var(--dim)"></span></button>
|
||
<button class="pfx-tab" onclick="pfxTab('live')" id="pfxTabLive" style="font-family:var(--mono);font-size:.65rem;padding:.35rem .7rem;border:none;border-bottom:2px solid transparent;background:transparent;color:var(--dim);cursor:pointer">🔴 Live</button>
|
||
</div>
|
||
<div id="pfxContent" style="font-family:var(--mono);font-size:.75rem"></div>
|
||
</section>
|
||
|
||
<!-- AS-SET Expander -->
|
||
<section class="card hidden" id="assetCard" title="AS-SET expander — recursively resolves an IRR AS-SET (e.g. AS-EXAMPLE) to the full list of member ASNs. Useful for validating import/export filters in router configs">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||
AS-SET Expander
|
||
</div>
|
||
<div style="margin-bottom:.75rem;display:flex;gap:.5rem">
|
||
<input type="text" id="assetInput" placeholder="e.g. AS-FLEXOPTIX" style="flex:1;background:transparent;border:none;border-bottom:1px solid var(--border);font-family:var(--mono);font-size:.8rem;color:var(--text);padding:.25rem;outline:none">
|
||
<button onclick="doAssetExpand()" style="background:var(--text);color:var(--bg);border:none;font-family:var(--mono);font-size:.65rem;padding:.3rem .8rem;cursor:pointer;letter-spacing:.06em">EXPAND</button>
|
||
</div>
|
||
<div id="assetContent"></div>
|
||
</section>
|
||
|
||
<!-- IXP Peering Matrix -->
|
||
<section class="card hidden" id="ixMatrixCard" title="IXP member list — shows all networks connected to the selected Internet Exchange Point, with their port speed and peering policy (Open/Selective/Bilateral). Select different IXPs using the buttons above">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||
IXP Member List
|
||
</div>
|
||
<div id="ixMatrixContent"></div>
|
||
</section>
|
||
|
||
<!-- Integrated Sources of Trust (hidden) -->
|
||
<section class="card hidden" id="sourcesCard" title="All data sources integrated into PeerCortex — PeeringDB, RIPE Stat, RIPE NCC, bgproutes.io, Cloudflare RPKI, RIPE Atlas, IRR databases, and more">
|
||
<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:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||
<div style="width:36px;height:36px;border-radius:0;background:rgba(21,128,61,.08);border:1px solid rgba(21,128,61,.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);font-family:var(--body)">PeeringDB</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">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);font-family:var(--mono)">peeringdb.com</a></div>
|
||
</div>
|
||
|
||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||
<div style="width:36px;height:36px;border-radius:0;background:rgba(29,78,216,.08);border:1px solid rgba(29,78,216,.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);font-family:var(--body)">RIPE Stat</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">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);font-family:var(--mono)">stat.ripe.net</a></div>
|
||
</div>
|
||
|
||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||
<div style="width:36px;height:36px;border-radius:0;background:rgba(184,58,27,.08);border:1px solid rgba(184,58,27,.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);font-family:var(--body)">RPKI / ROA Validation</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">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);font-family:var(--mono)">rpki.cloudflare.com</a></div>
|
||
</div>
|
||
|
||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||
<div style="width:36px;height:36px;border-radius:0;background:rgba(180,83,9,.08);border:1px solid rgba(180,83,9,.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);font-family:var(--body)">ASPA (RFC 9582)</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">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);font-family:var(--mono)">IETF Draft-14</a></div>
|
||
</div>
|
||
|
||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||
<div style="width:36px;height:36px;border-radius:0;background:rgba(3,105,161,.08);border:1px solid rgba(3,105,161,.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);font-family:var(--body)">RIPE Atlas</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">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);font-family:var(--mono)">atlas.ripe.net</a></div>
|
||
</div>
|
||
|
||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||
<div style="width:36px;height:36px;border-radius:0;background:rgba(146,64,14,.08);border:1px solid rgba(146,64,14,.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);font-family:var(--body)">bgproutes.io</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">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);font-family:var(--mono)">bgproutes.io</a></div>
|
||
</div>
|
||
|
||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||
<div style="width:36px;height:36px;border-radius:0;background:rgba(21,128,61,.08);border:1px solid rgba(21,128,61,.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);font-family:var(--body)">NLNOG IRR Explorer</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">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);font-family:var(--mono)">irrexplorer.nlnog.net</a></div>
|
||
</div>
|
||
|
||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||
<div style="width:36px;height:36px;border-radius:0;background:rgba(185,28,28,.08);border:1px solid rgba(185,28,28,.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);font-family:var(--body)">MANRS Observatory</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">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);font-family:var(--mono)">observatory.manrs.org</a></div>
|
||
</div>
|
||
|
||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||
<div style="width:36px;height:36px;border-radius:0;background:rgba(28,25,23,.06);border:1px solid rgba(28,25,23,.15);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(--text);font-family:var(--body)">bgp.he.net</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">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);font-family:var(--mono)">bgp.he.net</a></div>
|
||
</div>
|
||
|
||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||
<div style="width:36px;height:36px;border-radius:0;background:rgba(28,25,23,.06);border:1px solid rgba(28,25,23,.15);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(--text);font-family:var(--body)">Team Cymru Bogon Reference</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">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);font-family:var(--mono)">team-cymru.com</a></div>
|
||
</div>
|
||
|
||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||
<div style="width:36px;height:36px;border-radius:0;background:rgba(29,78,216,.08);border:1px solid rgba(29,78,216,.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);font-family:var(--body)">RIPE DB / IRR</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">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);font-family:var(--mono)">apps.db.ripe.net</a></div>
|
||
</div>
|
||
|
||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:0;padding:1rem;display:flex;gap:.75rem;align-items:flex-start">
|
||
<div style="width:36px;height:36px;border-radius:0;background:rgba(3,105,161,.08);border:1px solid rgba(3,105,161,.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);font-family:var(--body)">Route Views / RIPE RIS</div><div style="font-size:.7rem;color:var(--muted);margin-top:.15rem;font-family:var(--body)">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);font-family:var(--mono)">routeviews.org</a></div>
|
||
</div>
|
||
|
||
</div>
|
||
<div style="margin-top:1rem;font-size:.7rem;color:var(--dim);font-family:var(--mono)">All data is queried in real-time from authoritative sources. No data is stored or cached beyond 5 minutes.</div>
|
||
</div>
|
||
</section>
|
||
|
||
</main>
|
||
|
||
<!-- SIDEBAR -->
|
||
<aside class="ed-sidebar">
|
||
|
||
<!-- WHOIS -->
|
||
<section class="card" id="whoisCard" title="WHOIS registration data from the Regional Internet Registry (RIR) — official contact info, abuse contacts, registration date, and resource holder details">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||
WHOIS
|
||
</div>
|
||
<div id="whoisContent"><div class="section-loading">Loading WHOIS data...</div></div>
|
||
</section>
|
||
|
||
<!-- Contacts & Registration -->
|
||
<section class="card hidden" id="contactsCard" title="Points of Contact and registration data from PeeringDB and RDAP — includes names, roles, and registration dates. Named individuals may be relevant B2B leads.">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||
Contacts & Registration
|
||
</div>
|
||
<div id="contactsContent"></div>
|
||
</section>
|
||
|
||
<!-- Data Sources Timing -->
|
||
<section class="card" id="resilienceCard" style="display:none">
|
||
<div class="card-title">Resilience Score <span id="resilienceProvBadge" style="float:right"></span></div>
|
||
<div id="resilienceContent"></div>
|
||
</section>
|
||
<section class="card" id="routeLeakCard" style="display:none">
|
||
<div class="card-title">Route Leak Detection <span id="routeLeakProvBadge" style="float:right"></span></div>
|
||
<div id="routeLeakContent"></div>
|
||
</section>
|
||
<section class="card hidden" id="sourceTimingCard" title="Response time for each data source queried during this ASN lookup — useful for debugging and understanding data freshness">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||
Data Sources
|
||
</div>
|
||
<div id="sourceTimingContent"></div>
|
||
</section>
|
||
|
||
<!-- Quick Compare -->
|
||
<section class="card" id="compareCard" title="Side-by-side comparison of two ASNs — compare RPKI compliance, prefix counts, IX presence, ASPA status, and peering policies between any two networks">
|
||
<div class="card-title">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 20V10M12 20V4M6 20v-6"/></svg>
|
||
Quick Compare
|
||
</div>
|
||
<div id="compareContent">
|
||
<p style="font-size:.8rem;color:var(--muted);margin-bottom:.75rem;font-family:var(--body)">Compare IX, facility, upstream overlap and RPKI coverage with another network.</p>
|
||
<div class="compare-box">
|
||
<input type="text" class="compare-input" id="compareAsn" placeholder="Second ASN">
|
||
<button class="compare-btn" onclick="doCompare()">Compare</button>
|
||
</div>
|
||
<div id="compareResults" style="margin-top:1rem"></div>
|
||
</div>
|
||
</section>
|
||
|
||
</aside>
|
||
|
||
</div><!-- /ed-layout -->
|
||
|
||
<!-- Full Compare Results -->
|
||
<div class="ed-layout-full hidden" id="fullComparePanel"></div>
|
||
|
||
<!-- FOOTER -->
|
||
<footer class="ed-footer">
|
||
<div class="ed-footer-name">PeerCortex — The ASN News</div>
|
||
<nav class="ed-footer-links">
|
||
<a href="https://www.peeringdb.com" target="_blank">PeeringDB</a>
|
||
<a href="https://stat.ripe.net" target="_blank">RIPE Stat</a>
|
||
<a href="https://atlas.ripe.net" target="_blank">RIPE Atlas</a>
|
||
<a href="https://bgproutes.io" target="_blank">bgproutes.io</a>
|
||
<a href="https://github.com/renefichtmueller/PeerCortex" target="_blank">GitHub</a>
|
||
</nav>
|
||
<div class="ed-footer-copy">v0.6.9 · Open Source · MIT License · Data powered by PeeringDB · RIPE Stat · RIPE Atlas · Route Views · bgp.he.net · bgproutes.io · RIPE DB · Cloudflare RPKI</div>
|
||
<div class="ed-footer-copy" style="margin-top:.4rem;opacity:.45" id="visitor-count"></div>
|
||
</footer>
|
||
|
||
<script>
|
||
const $ = id => document.getElementById(id);
|
||
let currentAsn = null;
|
||
let currentLookupData = null;
|
||
|
||
function countryFlag(code) {
|
||
if (!code || code.length !== 2) return '';
|
||
const c = code.toUpperCase();
|
||
return String.fromCodePoint(...[...c].map(ch => 0x1F1E6 + ch.charCodeAt(0) - 65));
|
||
}
|
||
|
||
function fmtSpeed(mbps) {
|
||
if (!mbps || mbps === 0) return '0 Gbps';
|
||
if (mbps >= 1000) return (mbps / 1000).toFixed(0) + ' Gbps';
|
||
return mbps + ' Mbps';
|
||
}
|
||
|
||
function rpkiIcon(status) {
|
||
if (status === 'valid') return '<span class="rpki-valid" title="RPKI Valid">✓</span>';
|
||
if (status === 'invalid') return '<span class="rpki-invalid" title="RPKI Invalid">✗</span>';
|
||
return '<span class="rpki-unknown" title="Not Found">?</span>';
|
||
}
|
||
|
||
function pct(n, total) {
|
||
if (!total) return 0;
|
||
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>';
|
||
}
|
||
|
||
function lookupAsn(asn) {
|
||
$('asnInput').value = asn;
|
||
doLookup();
|
||
}
|
||
|
||
function copyToClipboard(text, btn) {
|
||
navigator.clipboard.writeText(text).then(function() {
|
||
var orig = btn.textContent;
|
||
btn.textContent = 'Copied!';
|
||
setTimeout(function() { btn.textContent = orig; }, 1500);
|
||
});
|
||
}
|
||
|
||
function renderAuditList(containerId, list, sortBy, type) {
|
||
var sorted = list.slice();
|
||
if (sortBy === 'asn') sorted.sort(function(a, b) { return a.asn - b.asn; });
|
||
else if (sortBy === 'frequency') sorted.sort(function(a, b) { return (b.frequency || 0) - (a.frequency || 0); });
|
||
else if (sortBy === 'name') sorted.sort(function(a, b) { return (a.name || '').localeCompare(b.name || ''); });
|
||
|
||
var labelId = type === 'missing' ? 'missingSortLabel' : 'extraSortLabel';
|
||
var labelEl = document.getElementById(labelId);
|
||
if (labelEl) labelEl.textContent = sortBy === 'asn' ? 'by ASN' : sortBy === 'frequency' ? 'by frequency' : 'by name';
|
||
|
||
var LIMIT = 5;
|
||
var h = '';
|
||
sorted.slice(0, LIMIT).forEach(function(item) {
|
||
h += '<div class="audit-row audit-' + type + '">';
|
||
h += '<span style="font-weight:600">' + asnLink(item.asn) + '</span>';
|
||
if (item.name && item.name !== 'AS' + item.asn) h += ' <span style="color:var(--muted)">' + escHtml(item.name) + '</span>';
|
||
if (type === 'missing') h += ' <span class="badge badge-orange">seen in ' + (item.frequency_pct || 0) + '% of paths (' + (item.frequency || 0) + ')</span>';
|
||
else h += ' <span class="badge badge-cyan">not seen in any path</span>';
|
||
h += '</div>';
|
||
});
|
||
if (sorted.length > LIMIT) {
|
||
var moreId = containerId + 'More';
|
||
h += '<div id="' + moreId + '" style="display:none">';
|
||
sorted.slice(LIMIT).forEach(function(item) {
|
||
h += '<div class="audit-row audit-' + type + '">';
|
||
h += '<span style="font-weight:600">' + asnLink(item.asn) + '</span>';
|
||
if (item.name && item.name !== 'AS' + item.asn) h += ' <span style="color:var(--muted)">' + escHtml(item.name) + '</span>';
|
||
if (type === 'missing') h += ' <span class="badge badge-orange">seen in ' + (item.frequency_pct || 0) + '% of paths (' + (item.frequency || 0) + ')</span>';
|
||
else h += ' <span class="badge badge-cyan">not seen in any path</span>';
|
||
h += '</div>';
|
||
});
|
||
h += '</div>';
|
||
var remaining = sorted.length - LIMIT;
|
||
h += '<div class="show-more-btn" onclick="var el=document.getElementById(\'' + moreId + '\');if(el.style.display===\'none\'){el.style.display=\'block\';this.textContent=\'Hide ' + remaining + ' entries\';}else{el.style.display=\'none\';this.textContent=\'Show ' + remaining + ' more...\';}">Show ' + remaining + ' more...</div>';
|
||
}
|
||
document.getElementById(containerId).innerHTML = h;
|
||
}
|
||
|
||
function renderMetaBar(d) {
|
||
var bar = $('metaBar');
|
||
// Clear previous content
|
||
while (bar.firstChild) bar.removeChild(bar.firstChild);
|
||
|
||
// Meta text span
|
||
var metaSpan = document.createElement('span');
|
||
metaSpan.textContent = 'Query: ' + d.meta.query + ' | Duration: ' + d.meta.duration_ms + 'ms | RPKI checked: ' + d.meta.rpki_prefixes_checked + '/' + d.meta.total_prefixes + ' prefixes | ' + d.meta.timestamp;
|
||
bar.appendChild(metaSpan);
|
||
|
||
// Data Quality badge
|
||
var dq = d.data_quality;
|
||
if (dq) {
|
||
var conf = dq.overall_confidence || 'high';
|
||
var badge = document.createElement('span');
|
||
badge.className = 'dq-badge ' + conf;
|
||
|
||
// Shield icon
|
||
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
svg.setAttribute('viewBox', '0 0 24 24');
|
||
svg.setAttribute('fill', 'none');
|
||
svg.setAttribute('stroke', 'currentColor');
|
||
svg.setAttribute('stroke-width', '2');
|
||
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
path.setAttribute('d', 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z');
|
||
svg.appendChild(path);
|
||
badge.appendChild(svg);
|
||
|
||
var label = document.createElement('span');
|
||
var confLabel = conf === 'high' ? 'High Confidence' : conf === 'medium' ? 'Medium Confidence' : 'Low Confidence';
|
||
label.textContent = confLabel;
|
||
badge.appendChild(label);
|
||
|
||
// Tooltip
|
||
var tooltip = document.createElement('div');
|
||
tooltip.className = 'dq-tooltip';
|
||
|
||
var title = document.createElement('div');
|
||
title.style.cssText = 'font-weight:600;color:var(--white);margin-bottom:.5rem;font-size:.75rem';
|
||
title.textContent = 'Data Quality Report';
|
||
tooltip.appendChild(title);
|
||
|
||
var sources = document.createElement('div');
|
||
sources.style.cssText = 'margin-bottom:.5rem;color:var(--dim);font-size:.65rem';
|
||
sources.textContent = 'Sources Queried: ' + (dq.sources_queried || []).join(', ');
|
||
tooltip.appendChild(sources);
|
||
|
||
// Cross-Checks header
|
||
var checksHeader = document.createElement('div');
|
||
checksHeader.style.cssText = 'font-weight:600;color:var(--white);margin-bottom:.3rem;margin-top:.3rem;font-size:.7rem';
|
||
checksHeader.textContent = 'Cross-Checks';
|
||
tooltip.appendChild(checksHeader);
|
||
|
||
var checks = dq.cross_checks || {};
|
||
var checkNames = [['rpki', 'RPKI Validation'], ['prefixes', 'Prefix Count'], ['neighbours', 'Neighbours']];
|
||
var totalAgreement = 0;
|
||
var totalChecks = 0;
|
||
for (var ci = 0; ci < checkNames.length; ci++) {
|
||
var key = checkNames[ci][0];
|
||
var name = checkNames[ci][1];
|
||
var check = checks[key] || {};
|
||
var row = document.createElement('div');
|
||
row.className = 'dq-tooltip-row';
|
||
|
||
var rowLabel = document.createElement('span');
|
||
rowLabel.className = 'dq-tooltip-label';
|
||
rowLabel.textContent = name + ' (' + (check.sources || 0) + ' sources)';
|
||
row.appendChild(rowLabel);
|
||
|
||
var rowVal = document.createElement('span');
|
||
var pct = check.agreement_pct;
|
||
if (pct == null) {
|
||
rowVal.className = 'dq-tooltip-value na';
|
||
rowVal.textContent = 'N/A';
|
||
} else {
|
||
rowVal.className = 'dq-tooltip-value ' + (pct > 90 ? 'agree' : pct >= 70 ? 'warn' : 'bad');
|
||
rowVal.textContent = 'Agreement: ' + pct + '%';
|
||
totalAgreement += pct;
|
||
totalChecks++;
|
||
}
|
||
row.appendChild(rowVal);
|
||
tooltip.appendChild(row);
|
||
}
|
||
|
||
// Disagreements Found detail
|
||
var rpkiCheck = checks.rpki || {};
|
||
if (rpkiCheck.disagreements && rpkiCheck.disagreements.length > 0) {
|
||
var disDiv = document.createElement('div');
|
||
disDiv.style.cssText = 'margin-top:.4rem;font-size:.6rem;color:var(--orange)';
|
||
disDiv.textContent = 'Disagreements Found: ' + rpkiCheck.disagreements.map(function(dd) { return dd.prefix; }).join(', ');
|
||
tooltip.appendChild(disDiv);
|
||
}
|
||
|
||
// Prefix note
|
||
var pfxCheck = checks.prefixes || {};
|
||
if (pfxCheck.note) {
|
||
var noteDiv = document.createElement('div');
|
||
noteDiv.style.cssText = 'margin-top:.3rem;font-size:.6rem;color:var(--dim)';
|
||
noteDiv.textContent = pfxCheck.note;
|
||
tooltip.appendChild(noteDiv);
|
||
}
|
||
|
||
// Overall note
|
||
var avgAgreement = totalChecks > 0 ? totalAgreement / totalChecks : 100;
|
||
var overallNote = document.createElement('div');
|
||
overallNote.style.cssText = 'margin-top:.4rem;font-size:.6rem;font-style:italic;color:var(--muted)';
|
||
if (avgAgreement > 90) {
|
||
overallNote.textContent = 'All sources agree';
|
||
overallNote.style.color = 'var(--green)';
|
||
} else if (avgAgreement >= 70) {
|
||
overallNote.textContent = 'Minor discrepancies between sources';
|
||
overallNote.style.color = 'var(--orange)';
|
||
} else {
|
||
overallNote.textContent = 'Significant data disagreements detected';
|
||
overallNote.style.color = 'var(--red)';
|
||
}
|
||
tooltip.appendChild(overallNote);
|
||
|
||
badge.appendChild(tooltip);
|
||
bar.appendChild(badge);
|
||
}
|
||
}
|
||
|
||
async function doLookup() {
|
||
const raw = $('asnInput').value.trim().replace(/[^0-9]/g, '');
|
||
if (!raw) return;
|
||
currentAsn = raw;
|
||
|
||
$('searchBtn').disabled = true;
|
||
$('searchBtn').textContent = 'Loading...';
|
||
$('dashboard').classList.add('hidden');
|
||
$('skeleton').classList.remove('hidden');
|
||
$('metaBar').textContent = '';
|
||
|
||
const lookupCtrl = new AbortController();
|
||
const lookupTimer = setTimeout(() => lookupCtrl.abort(), 15000);
|
||
try {
|
||
const resp = await fetch('/api/lookup?asn=' + raw, { signal: lookupCtrl.signal });
|
||
clearTimeout(lookupTimer);
|
||
const d = await resp.json();
|
||
|
||
if (d.error) {
|
||
$('skeleton').classList.add('hidden');
|
||
$('metaBar').textContent = 'Error: ' + d.error;
|
||
return;
|
||
}
|
||
|
||
currentLookupData = d;
|
||
renderDashboard(d);
|
||
$('skeleton').classList.add('hidden');
|
||
$('dashboard').classList.remove('hidden');
|
||
renderMetaBar(d);
|
||
|
||
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, d);
|
||
window._lastLookupData = d;
|
||
renderContacts(d);
|
||
renderSourceTiming(d);
|
||
renderResilienceScore(d.resilience_score);
|
||
renderRouteLeak(d.route_leak);
|
||
|
||
// Load ASPA and bgproutes.io data asynchronously
|
||
loadOverviewEnrichment(raw, d.network ? d.network.name : '', d.network ? d.network.website : '');
|
||
loadHealthReport(raw);
|
||
loadAspaData(raw);
|
||
loadAspaVerifyData(raw);
|
||
loadBgroutesData(raw);
|
||
loadWhoisData(raw);
|
||
// v0.6.1 new features
|
||
loadNewFeatures(raw);
|
||
} catch (e) {
|
||
clearTimeout(lookupTimer);
|
||
$('skeleton').classList.add('hidden');
|
||
$('metaBar').textContent = e.name === 'AbortError' ? 'Lookup timed out — try again' : 'Error: ' + e.message;
|
||
} finally {
|
||
$('searchBtn').disabled = false;
|
||
$('searchBtn').textContent = 'Lookup';
|
||
}
|
||
}
|
||
|
||
async function loadAspaData(asn) {
|
||
$('aspaContent').innerHTML = '<div class="section-loading">Loading ASPA data...</div>';
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), 15000);
|
||
try {
|
||
const resp = await fetch('/api/aspa?asn=' + asn, { signal: ctrl.signal });
|
||
clearTimeout(timer);
|
||
if (!resp.ok) { $('aspaContent').textContent = 'ASPA data unavailable (server ' + resp.status + ')'; renderProviderGraphFromLookupFallback(asn); return; }
|
||
var text = await resp.text();
|
||
if (!text || text[0] === '<') { $('aspaContent').textContent = 'ASPA data unavailable (timeout). Provider data shown from lookup.'; renderProviderGraphFromLookupFallback(asn); return; }
|
||
const d = JSON.parse(text);
|
||
if (d.error) { $('aspaContent').textContent = 'ASPA check failed: ' + d.error; renderProviderGraphFromLookupFallback(asn); return; }
|
||
renderAspa(d);
|
||
} catch (e) {
|
||
clearTimeout(timer);
|
||
$('aspaContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">ASPA data temporarily unavailable</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>';
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), 12000);
|
||
try {
|
||
const resp = await fetch('/api/bgproutes?asn=' + asn, { signal: ctrl.signal });
|
||
clearTimeout(timer);
|
||
const d = await resp.json();
|
||
if (d.error) {
|
||
$('bgroutesContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">bgproutes.io data temporarily unavailable</div>';
|
||
return;
|
||
}
|
||
renderBgroutes(d);
|
||
} catch (e) {
|
||
clearTimeout(timer);
|
||
$('bgroutesContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">bgproutes.io data temporarily unavailable</div>';
|
||
}
|
||
}
|
||
|
||
function renderAspa(d) {
|
||
let h = '';
|
||
|
||
// ASPA status
|
||
h += '<div style="display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:1rem">';
|
||
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">ASPA Object</div>';
|
||
if (d.aspa_object_exists) {
|
||
h += '<div class="status-yes">Found in RPKI</div>';
|
||
} else {
|
||
h += '<div class="status-no">Not Found</div>';
|
||
}
|
||
h += '</div>';
|
||
if (d.aspa_object_exists && d.aspa_declared_providers && d.aspa_declared_providers.length > 0) {
|
||
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">RPKI-Declared Providers</div>';
|
||
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--green)">' + d.aspa_declared_count + '</div>';
|
||
h += '</div>';
|
||
}
|
||
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">Detected Providers</div>';
|
||
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--blue)">' + d.provider_count + '</div>';
|
||
h += '</div>';
|
||
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">BGP Paths Analyzed</div>';
|
||
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--cyan)">' + (d.path_analysis ? d.path_analysis.total_paths_seen : 0) + '</div>';
|
||
h += '</div>';
|
||
h += '</div>';
|
||
|
||
// Detected providers (collapsible after 10)
|
||
if (d.detected_providers && d.detected_providers.length > 0) {
|
||
var provLimit = 10;
|
||
var provList = d.detected_providers.slice().sort(function(a, b) { return a.asn - b.asn; });
|
||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--orange);margin:.75rem 0 .4rem">Detected Upstream Providers (' + provList.length + ')</div>';
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.25rem">';
|
||
provList.slice(0, provLimit).forEach(function(p) {
|
||
var nameStr = (p.name && p.name !== 'AS' + p.asn) ? ' ' + escHtml(p.name) : '';
|
||
h += '<span class="badge badge-orange">' + asnLink(p.asn) + nameStr + '</span>';
|
||
});
|
||
h += '</div>';
|
||
if (provList.length > provLimit) {
|
||
var provMoreId = 'provMore' + Date.now();
|
||
h += '<div id="' + provMoreId + '" style="display:none;flex-wrap:wrap;gap:.3rem;margin-bottom:.25rem">';
|
||
provList.slice(provLimit).forEach(function(p) {
|
||
var nameStr = (p.name && p.name !== 'AS' + p.asn) ? ' ' + escHtml(p.name) : '';
|
||
h += '<span class="badge badge-orange">' + asnLink(p.asn) + nameStr + '</span>';
|
||
});
|
||
h += '</div>';
|
||
h += '<div class="show-more-btn" onclick="var el=document.getElementById(\'' + provMoreId + '\');if(el.style.display===\'none\'){el.style.display=\'flex\';this.textContent=\'Hide ' + (provList.length - provLimit) + ' providers\';}else{el.style.display=\'none\';this.textContent=\'Show ' + (provList.length - provLimit) + ' more providers...\';}">Show ' + (provList.length - provLimit) + ' more providers...</div>';
|
||
}
|
||
}
|
||
|
||
// RPKI-declared providers (when ASPA object exists)
|
||
if (d.aspa_object_exists && d.aspa_declared_providers && d.aspa_declared_providers.length > 0) {
|
||
var declaredList = d.aspa_declared_providers.slice().sort(function(a, b) { return a.asn - b.asn; });
|
||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--green);margin:.75rem 0 .4rem">RPKI-Declared Providers (' + declaredList.length + ')</div>';
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.25rem">';
|
||
declaredList.forEach(function(p) {
|
||
var label = p.asn === 0 ? 'AS0 (Tier-1 / No Provider)' : asnLink(p.asn);
|
||
h += '<span class="badge badge-green">' + label + '</span>';
|
||
});
|
||
h += '</div>';
|
||
}
|
||
|
||
// Recommended ASPA template (scrollable, max 200px)
|
||
if (d.recommended_aspa) {
|
||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--cyan);margin:.75rem 0 .4rem">Recommended ASPA Object</div>';
|
||
h += '<div class="aspa-template" id="aspaTemplate" style="max-height:200px;overflow-y:auto">' + escHtml(d.recommended_aspa);
|
||
h += '<button class="copy-btn" onclick="copyToClipboard(document.getElementById(\'aspaTemplate\').innerText, this)">Copy</button>';
|
||
h += '</div>';
|
||
}
|
||
|
||
// Sample path analysis
|
||
if (d.path_analysis && d.path_analysis.sample_paths && d.path_analysis.sample_paths.length > 0) {
|
||
h += '<div class="expand-toggle" onclick="toggleExpand(this)">Show sample BGP paths (' + d.path_analysis.sample_paths.length + ')</div>';
|
||
h += '<div class="expand-body"><div class="scroll-wrap"><table class="tbl"><thead><tr><th>RRC</th><th>Prefix</th><th>AS Path</th><th>Provider</th></tr></thead><tbody>';
|
||
d.path_analysis.sample_paths.forEach(function(p) {
|
||
h += '<tr><td>' + escHtml(p.rrc || '') + '</td>';
|
||
h += '<td style="font-family:monospace;font-size:.75rem">' + escHtml(p.prefix || '') + '</td>';
|
||
h += '<td style="font-family:monospace;font-size:.7rem">' + escHtml(p.path || '') + '</td>';
|
||
h += '<td>' + (p.detected_provider ? '<span class="badge badge-green">' + escHtml(p.detected_provider) + '</span>' : '-') + '</td></tr>';
|
||
});
|
||
h += '</tbody></table></div></div>';
|
||
}
|
||
|
||
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.5rem">Analyzed in ' + (d.meta ? d.meta.duration_ms : '?') + 'ms</div>';
|
||
$('aspaContent').innerHTML = h;
|
||
|
||
// Feature 8: ASPA Change Alerting
|
||
if (d.detected_providers && currentAsn) {
|
||
checkAspaChanges(currentAsn, d.detected_providers);
|
||
}
|
||
|
||
// Feature 9: Provider Relationship Graph
|
||
if (d.detected_providers && d.detected_providers.length > 0 && currentAsn) {
|
||
renderProviderGraph(currentAsn, d.detected_providers);
|
||
}
|
||
}
|
||
|
||
function renderBgroutes(d) {
|
||
let h = '';
|
||
|
||
h += '<div style="display:flex;gap:2rem;flex-wrap:wrap;margin-bottom:1rem">';
|
||
|
||
// Vantage points
|
||
if (d.vantage_points) {
|
||
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">Vantage Points</div>';
|
||
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--green)">' + (d.vantage_points.count || 0) + '</div>';
|
||
h += '</div>';
|
||
}
|
||
|
||
// Route data status
|
||
if (d.routes) {
|
||
h += '<div><div style="font-size:.75rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.25rem">Route Data</div>';
|
||
if (d.routes.status === 'unavailable') {
|
||
h += '<div style="font-size:.85rem;color:var(--orange)">' + escHtml(d.routes.message) + '</div>';
|
||
} else {
|
||
h += '<div style="font-size:1.5rem;font-weight:700;color:var(--blue)">' + (d.routes.count || 0) + ' routes</div>';
|
||
}
|
||
h += '</div>';
|
||
}
|
||
|
||
h += '</div>';
|
||
|
||
// VP list
|
||
if (d.vantage_points && d.vantage_points.list && d.vantage_points.list.length > 0) {
|
||
h += '<div class="expand-toggle" onclick="toggleExpand(this)">Show vantage points (' + d.vantage_points.list.length + ')</div>';
|
||
h += '<div class="expand-body"><div class="scroll-wrap" style="max-height:200px"><table class="tbl"><thead><tr><th>ID</th><th>Name</th><th>Location</th><th>ASN</th></tr></thead><tbody>';
|
||
d.vantage_points.list.forEach(function(vp) {
|
||
h += '<tr><td>' + escHtml(vp.id || vp.vp_id || '') + '</td>';
|
||
h += '<td>' + escHtml(vp.name || vp.description || '') + '</td>';
|
||
h += '<td>' + escHtml(vp.location || vp.city || vp.country || '') + '</td>';
|
||
h += '<td>' + escHtml(vp.asn || vp.peer_asn || '') + '</td></tr>';
|
||
});
|
||
h += '</tbody></table></div></div>';
|
||
}
|
||
|
||
// Route samples
|
||
if (d.routes && d.routes.sample && d.routes.sample.length > 0) {
|
||
if (d.routes.vp_used) {
|
||
h += '<div style="font-size:.75rem;color:var(--muted);margin-bottom:.5rem">VP: ' + escHtml(d.routes.vp_used.org || '') + ' (' + escHtml(d.routes.vp_used.country || '') + ', ID ' + (d.routes.vp_used.id || '') + ')</div>';
|
||
}
|
||
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 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(rov) + '</span></td>';
|
||
h += '<td><span class="badge ' + aspaBadge + '">' + escHtml(aspa) + '</span></td></tr>';
|
||
});
|
||
h += '</tbody></table></div></div>';
|
||
}
|
||
|
||
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.5rem">Queried in ' + (d.meta ? d.meta.duration_ms : '?') + 'ms</div>';
|
||
$('bgroutesContent').innerHTML = h;
|
||
}
|
||
|
||
function renderDashboard(d) {
|
||
const n = d.network;
|
||
const p = d.prefixes;
|
||
const r = d.rpki;
|
||
const nb = d.neighbours;
|
||
const ix = d.ix_presence;
|
||
const fac = d.facilities;
|
||
|
||
// Overview
|
||
let ov = '<div class="net-name">' + escHtml(n.name) + ' <span style="color:var(--muted);font-size:1rem">AS' + n.asn + '</span></div>';
|
||
if (n.aka) ov += '<div class="net-aka">AKA: ' + escHtml(n.aka) + '</div>';
|
||
if (n.country || n.city) {
|
||
var geo = '';
|
||
if (n.country) geo += '<span class="flag">' + countryFlag(n.country) + '</span>';
|
||
if (n.city) geo += '<span style="font-family:var(--mono);font-size:.72rem;color:var(--muted);margin-left:.3rem">' + escHtml(n.city) + '</span>';
|
||
ov += geo;
|
||
}
|
||
if (n.rir) ov += '<span class="badge badge-cyan">' + escHtml(n.rir) + '</span>';
|
||
if (n.type) ov += '<span class="badge badge-purple">' + escHtml(n.type) + '</span>';
|
||
if (n.policy) ov += '<span class="badge badge-blue">' + escHtml(n.policy) + '</span>';
|
||
if (n.ratio) ov += '<span class="badge badge-green">' + escHtml(n.ratio) + '</span>';
|
||
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.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>';
|
||
|
||
// Enrichment placeholder — filled async by loadOverviewEnrichment()
|
||
ov += '<div id="overviewEnrich" style="margin-top:.6rem"></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>';
|
||
ov += '<a class="ext-link" href="https://bgp.he.net/AS' + n.asn + '" target="_blank">bgp.he.net</a>';
|
||
ov += '<a class="ext-link" href="https://stat.ripe.net/AS' + n.asn + '" target="_blank">RIPE Stat</a>';
|
||
ov += '<a class="ext-link" href="https://www.routeviews.org/routeviews/index.php/prefix/?asn=' + n.asn + '" target="_blank">Route Views</a>';
|
||
ov += '<a class="ext-link" href="https://bgproutes.io/search/AS' + n.asn + '" target="_blank">bgproutes.io</a>';
|
||
if (n.looking_glass) ov += '<a class="ext-link" href="' + escAttr(n.looking_glass) + '" target="_blank">Looking Glass</a>';
|
||
if (n.route_server) ov += '<a class="ext-link" href="' + escAttr(n.route_server) + '" target="_blank">Route Server</a>';
|
||
ov += '<a class="ext-link" href="#" onclick="exportRawJson(event)" title="Download full lookup data as JSON">⬇ Raw JSON</a>';
|
||
ov += '</div>';
|
||
$('overviewContent').innerHTML = ov;
|
||
|
||
// Prefixes
|
||
let px = '<div class="stat-row">';
|
||
px += '<div class="stat"><div class="stat-val blue">' + p.total + '</div><div class="stat-label">Total</div></div>';
|
||
px += '<div class="stat"><div class="stat-val green">' + p.ipv4 + '</div><div class="stat-label">IPv4</div></div>';
|
||
px += '<div class="stat"><div class="stat-val purple">' + p.ipv6 + '</div><div class="stat-label">IPv6</div></div>';
|
||
px += '</div>';
|
||
|
||
const v4pct = pct(p.ipv4, p.total);
|
||
px += '<div style="font-size:.7rem;color:var(--muted)">IPv4 ' + v4pct + '% / IPv6 ' + (100 - v4pct) + '%</div>';
|
||
px += '<div class="progress-multi"><div style="width:' + v4pct + '%;background:var(--green)"></div><div style="width:' + (100 - v4pct) + '%;background:var(--purple)"></div></div>';
|
||
|
||
if (p.list && p.list.length > 0) {
|
||
px += '<div class="expand-toggle" onclick="toggleExpand(this)">Show all ' + p.list.length + ' prefixes</div>';
|
||
px += '<div class="expand-body"><div class="scroll-wrap"><table class="tbl"><thead><tr><th>Prefix</th><th>RPKI</th></tr></thead><tbody>';
|
||
const rpkiMap = {};
|
||
(r.details || []).forEach(function(rd) { rpkiMap[rd.prefix] = rd.status; });
|
||
p.list.forEach(function(pfx) {
|
||
const st = rpkiMap[pfx] || 'not_checked';
|
||
px += '<tr><td><span class="prefix-link" onclick="showPrefixDetail(\'' + escAttr(pfx) + '\')">' + escHtml(pfx) + '</span></td><td>' + rpkiIcon(st) + '</td></tr>';
|
||
});
|
||
px += '</tbody></table></div></div>';
|
||
}
|
||
$('prefixContent').innerHTML = px;
|
||
|
||
// RPKI
|
||
let rk = '';
|
||
const scoreClass = r.coverage_percent >= 90 ? 'high' : r.coverage_percent >= 70 ? 'mid' : 'low';
|
||
rk += '<div class="big-score ' + scoreClass + '">' + r.coverage_percent + '%</div>';
|
||
rk += '<div style="font-size:.8rem;color:var(--muted);margin-bottom:.5rem">RPKI Coverage (' + r.checked + ' prefixes checked)</div>';
|
||
|
||
const vpct = pct(r.valid, r.checked);
|
||
const ipct = pct(r.invalid, r.checked);
|
||
const npct = 100 - vpct - ipct;
|
||
rk += '<div class="progress-multi">';
|
||
rk += '<div style="width:' + vpct + '%;background:var(--green)" title="Valid ' + vpct + '%"></div>';
|
||
rk += '<div style="width:' + ipct + '%;background:var(--red)" title="Invalid ' + ipct + '%"></div>';
|
||
rk += '<div style="width:' + npct + '%;background:var(--dim)" title="Not Found ' + npct + '%"></div>';
|
||
rk += '</div>';
|
||
|
||
rk += '<div style="display:flex;gap:1.5rem;margin-top:.5rem;font-size:.8rem">';
|
||
rk += '<div><span style="color:var(--green);font-weight:600">' + r.valid + '</span> <span style="color:var(--muted)">valid</span></div>';
|
||
rk += '<div><span style="color:var(--red);font-weight:600">' + r.invalid + '</span> <span style="color:var(--muted)">invalid</span></div>';
|
||
rk += '<div><span style="color:var(--dim);font-weight:600">' + r.not_found + '</span> <span style="color:var(--muted)">not found</span></div>';
|
||
rk += '</div>';
|
||
|
||
// Expandable per-prefix RPKI details
|
||
if (r.details && r.details.length > 0) {
|
||
rk += '<div class="expand-toggle" onclick="toggleExpand(this)">Show per-prefix RPKI details (' + r.details.length + ')</div>';
|
||
rk += '<div class="expand-body"><div class="scroll-wrap"><table class="tbl"><thead><tr><th>Prefix</th><th>Status</th><th>ROAs</th></tr></thead><tbody>';
|
||
r.details.forEach(function(rd) {
|
||
var icon = rpkiIcon(rd.status);
|
||
var statusText = rd.status === 'valid' ? '<span style="color:var(--green)">Valid</span>' : rd.status === 'invalid' ? '<span style="color:var(--red)">Invalid</span>' : '<span style="color:var(--muted)">Not Found</span>';
|
||
rk += '<tr><td style="font-family:monospace;font-size:.75rem">' + escHtml(rd.prefix) + '</td>';
|
||
rk += '<td>' + icon + ' ' + statusText + '</td>';
|
||
rk += '<td>' + (rd.validating_roas || 0) + '</td></tr>';
|
||
});
|
||
rk += '</tbody></table></div></div>';
|
||
}
|
||
$('rpkiContent').innerHTML = rk;
|
||
|
||
// Atlas Probes
|
||
renderAtlas(d.atlas);
|
||
|
||
// Neighbours
|
||
let ne = '<div class="stat-row">';
|
||
ne += '<div class="stat"><div class="stat-val">' + nb.total + '</div><div class="stat-label">Total</div></div>';
|
||
ne += '<div class="stat"><div class="stat-val orange">' + nb.upstream_count + '</div><div class="stat-label">Upstreams</div></div>';
|
||
ne += '<div class="stat"><div class="stat-val cyan">' + nb.peer_count + '</div><div class="stat-label">Peers</div></div>';
|
||
ne += '<div class="stat"><div class="stat-val blue">' + nb.downstream_count + '</div><div class="stat-label">Downstreams</div></div>';
|
||
ne += '</div>';
|
||
|
||
ne += '<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem">'; // 3 columns for upstream/downstream/peer
|
||
|
||
ne += '<div><div style="font-size:.75rem;font-weight:600;color:var(--orange);margin-bottom:.4rem">Top Upstreams</div>';
|
||
ne += '<div class="scroll-wrap"><table class="tbl"><thead><tr><th>ASN</th><th>Name</th><th>Power</th></tr></thead><tbody>';
|
||
(nb.upstreams || []).slice(0, 10).forEach(function(u) {
|
||
var nameDisplay = (u.name && u.name !== 'AS' + u.asn) ? escHtml(u.name) : '';
|
||
ne += '<tr><td>' + asnLink(u.asn) + '</td><td>' + nameDisplay + '</td><td>' + (u.power || '-') + '</td></tr>';
|
||
});
|
||
ne += '</tbody></table></div></div>';
|
||
|
||
ne += '<div><div style="font-size:.75rem;font-weight:600;color:var(--blue);margin-bottom:.4rem">Top Downstreams</div>';
|
||
ne += '<div class="scroll-wrap"><table class="tbl"><thead><tr><th>ASN</th><th>Name</th><th>Power</th></tr></thead><tbody>';
|
||
(nb.downstreams || []).slice(0, 10).forEach(function(u) {
|
||
var nameDisplay = (u.name && u.name !== 'AS' + u.asn) ? escHtml(u.name) : '';
|
||
ne += '<tr><td>' + asnLink(u.asn) + '</td><td>' + nameDisplay + '</td><td>' + (u.power || '-') + '</td></tr>';
|
||
});
|
||
ne += '</tbody></table></div></div>';
|
||
|
||
ne += '<div><div style="font-size:.75rem;font-weight:600;color:var(--cyan);margin-bottom:.4rem">Top Peers</div>';
|
||
ne += '<div class="scroll-wrap"><table class="tbl"><thead><tr><th>ASN</th><th>Name</th><th>Power</th></tr></thead><tbody>';
|
||
(nb.peers || []).slice(0, 10).forEach(function(u) {
|
||
var nameDisplay = (u.name && u.name !== 'AS' + u.asn) ? escHtml(u.name) : '';
|
||
ne += '<tr><td>' + asnLink(u.asn) + '</td><td>' + nameDisplay + '</td><td>' + (u.power || '-') + '</td></tr>';
|
||
});
|
||
ne += '</tbody></table></div></div>';
|
||
|
||
ne += '</div>';
|
||
$('neighbourContent').innerHTML = ne;
|
||
|
||
// IX Presence
|
||
let ixh = '<div class="stat-row">';
|
||
ixh += '<div class="stat"><div class="stat-val green">' + ix.total_connections + '</div><div class="stat-label">Connections</div></div>';
|
||
ixh += '<div class="stat"><div class="stat-val purple">' + ix.unique_ixps + '</div><div class="stat-label">Unique IXPs</div></div>';
|
||
// Total capacity across all IX ports
|
||
var totalMbps = (ix.connections || []).reduce(function(s, c) { return s + (c.speed_mbps || 0); }, 0);
|
||
var capLabel = totalMbps >= 1000000 ? (totalMbps/1000000).toFixed(2) + ' Tbps' : totalMbps >= 1000 ? (totalMbps/1000).toFixed(0) + ' Gbps' : totalMbps + ' Mbps';
|
||
ixh += '<div class="stat"><div class="stat-val cyan">' + capLabel + '</div><div class="stat-label">IX Capacity</div></div>';
|
||
ixh += '</div>';
|
||
|
||
if (ix.connections && ix.connections.length > 0) {
|
||
ixh += '<div class="scroll-wrap"><table class="tbl"><thead><tr><th>IX Name</th><th>City</th><th>Speed</th><th>RS</th><th>IPv4</th><th>IPv6</th></tr></thead><tbody>';
|
||
ix.connections.forEach(function(c) {
|
||
const rsIcon = c.is_rs_peer
|
||
? '<span title="Connected to Route Server" style="color:var(--green);font-weight:700">✓ RS</span>'
|
||
: '<span style="color:var(--dim)">—</span>';
|
||
ixh += '<tr><td><span class="ix-link" onclick="showIXDetail(' + (c.ix_id || 0) + ', \'' + escAttr(c.ix_name) + '\')">' + escHtml(c.ix_name) + '</span></td>';
|
||
ixh += '<td style="font-size:.75rem;color:var(--muted)">' + escHtml(c.city || '') + '</td>';
|
||
ixh += '<td>' + fmtSpeed(c.speed_mbps) + '</td>';
|
||
ixh += '<td style="text-align:center">' + rsIcon + '</td>';
|
||
ixh += '<td style="font-family:monospace;font-size:.75rem">' + (c.ipv4 || '-') + '</td>';
|
||
ixh += '<td style="font-family:monospace;font-size:.75rem">' + (c.ipv6 || '-') + '</td></tr>';
|
||
});
|
||
ixh += '</tbody></table></div>';
|
||
}
|
||
// Feature 26: IX traffic stats
|
||
ixh += renderIxTrafficStats(ix.connections);
|
||
$('ixContent').innerHTML = ixh;
|
||
|
||
// Facilities
|
||
let fh = '<div class="stat-row"><div class="stat"><div class="stat-val blue">' + fac.total + '</div><div class="stat-label">Facilities</div></div></div>';
|
||
if (fac.list && fac.list.length > 0) {
|
||
fh += '<div class="scroll-wrap"><table class="tbl"><thead><tr><th>Facility</th><th>City</th><th>Country</th></tr></thead><tbody>';
|
||
fac.list.forEach(function(f) {
|
||
const fUrl = f.fac_id ? 'https://www.peeringdb.com/fac/' + f.fac_id : '#';
|
||
fh += '<tr><td><a href="' + fUrl + '" target="_blank">' + escHtml(f.name) + '</a></td>';
|
||
fh += '<td>' + escHtml(f.city) + '</td>';
|
||
fh += '<td>' + countryFlag(f.country) + ' ' + escHtml(f.country) + '</td></tr>';
|
||
});
|
||
fh += '</tbody></table></div>';
|
||
}
|
||
$('facContent').innerHTML = fh;
|
||
|
||
// Network Footprint Map
|
||
renderNetworkMap(d);
|
||
|
||
// Feature 24: Render bgp.he.net data
|
||
renderRoutingOverview(d.bgp_he_net, d.routing);
|
||
}
|
||
|
||
// Note: innerHTML usage above is the existing pattern in this codebase; all user-facing
|
||
// strings are escaped via escHtml/escAttr before insertion.
|
||
|
||
// ============================================================
|
||
// Global Infrastructure Map (MapLibre GL)
|
||
// Layers: ASN PoPs | Submarine Cables | Global Datacenters | OIM Telecoms
|
||
// ============================================================
|
||
var _pcMap = null;
|
||
var _pcMapData = null; // current ASN data
|
||
var _mapLayers = { pops: true, cables: false, globalFacs: false, telecoms: false };
|
||
var _cablesLoaded = false;
|
||
var _globalFacsLoaded = false;
|
||
var _telecomsLoaded = false;
|
||
|
||
function toggleMapLayer(layer, btn) {
|
||
_mapLayers[layer] = !_mapLayers[layer];
|
||
var active = _mapLayers[layer];
|
||
btn.style.border = active ? '1px solid ' + _layerColor(layer) : '1px solid #4a5568';
|
||
btn.style.color = active ? _layerColor(layer) : 'var(--muted)';
|
||
btn.style.background = active ? 'rgba(' + _layerRgb(layer) + ',.12)' : 'transparent';
|
||
|
||
if (!_pcMap) return;
|
||
if (layer === 'pops') {
|
||
['pops-fac', 'pops-ix'].forEach(function(id) {
|
||
if (_pcMap.getLayer(id)) _pcMap.setLayoutProperty(id, 'visibility', active ? 'visible' : 'none');
|
||
});
|
||
} else if (layer === 'cables') {
|
||
if (active && !_cablesLoaded) { _loadCables(); return; }
|
||
['cables-line', 'cables-glow'].forEach(function(id) {
|
||
if (_pcMap.getLayer(id)) _pcMap.setLayoutProperty(id, 'visibility', active ? 'visible' : 'none');
|
||
});
|
||
} else if (layer === 'globalFacs') {
|
||
if (active && !_globalFacsLoaded) { _loadGlobalFacs(); return; }
|
||
if (_pcMap.getLayer('global-facs')) _pcMap.setLayoutProperty('global-facs', 'visibility', active ? 'visible' : 'none');
|
||
} else if (layer === 'telecoms') {
|
||
if (active && !_telecomsLoaded) { _loadTelecoms(); return; }
|
||
['oim-telecoms-line', 'oim-telecoms-dc'].forEach(function(id) {
|
||
if (_pcMap.getLayer(id)) _pcMap.setLayoutProperty(id, 'visibility', active ? 'visible' : 'none');
|
||
});
|
||
}
|
||
}
|
||
|
||
function _layerColor(l) {
|
||
if (l === 'pops') return '#7dcfff';
|
||
if (l === 'cables') return '#9ece6a';
|
||
if (l === 'globalFacs') return '#bb9af7';
|
||
return '#f7ae54'; // telecoms
|
||
}
|
||
function _layerRgb(l) {
|
||
if (l === 'pops') return '125,207,255';
|
||
if (l === 'cables') return '158,206,106';
|
||
if (l === 'globalFacs') return '187,154,247';
|
||
return '247,174,84'; // telecoms
|
||
}
|
||
|
||
function _showMapLoader(show) {
|
||
var el = document.getElementById('mapLoadingIndicator');
|
||
if (el) el.style.display = show ? 'inline' : 'none';
|
||
}
|
||
|
||
function _showMapPanel(html) {
|
||
var el = document.getElementById('mapSidePanelContent');
|
||
if (el) el.innerHTML = html;
|
||
}
|
||
|
||
function _mapPanelItem(label, color, title, subtitle) {
|
||
return '<div style="border-top:1px solid #222;padding-top:10px;margin-top:6px">' +
|
||
'<div style="font-size:.6rem;text-transform:uppercase;letter-spacing:.08em;color:' + color + ';margin-bottom:5px">' + label + '</div>' +
|
||
'<div style="font-size:.82rem;font-weight:700;color:#e2e8f0;line-height:1.35;word-break:break-word">' + title + '</div>' +
|
||
(subtitle ? '<div style="font-size:.7rem;color:#6b7280;margin-top:4px;line-height:1.3">' + subtitle + '</div>' : '') +
|
||
'</div>';
|
||
}
|
||
|
||
function _loadCables() {
|
||
_showMapLoader(true);
|
||
fetch('/api/submarine-cables').then(function(r) { return r.json(); }).then(function(geo) {
|
||
if (!_pcMap) return;
|
||
_cablesLoaded = true;
|
||
_showMapLoader(false);
|
||
if (_pcMap.getSource('cables')) {
|
||
['cables-glow','cables-line'].forEach(function(id) {
|
||
if (_pcMap.getLayer(id)) _pcMap.setLayoutProperty(id, 'visibility', 'visible');
|
||
});
|
||
return;
|
||
}
|
||
var before = _pcMap.getLayer('pops-fac') ? 'pops-fac' : undefined;
|
||
_pcMap.addSource('cables', { type: 'geojson', data: geo });
|
||
_pcMap.addLayer({
|
||
id: 'cables-glow', type: 'line', source: 'cables',
|
||
layout: { 'line-cap': 'round', visibility: 'visible' },
|
||
paint: { 'line-color': '#9ece6a', 'line-width': 4, 'line-opacity': 0.12 }
|
||
}, before);
|
||
_pcMap.addLayer({
|
||
id: 'cables-line', type: 'line', source: 'cables',
|
||
layout: { 'line-cap': 'round', visibility: 'visible' },
|
||
paint: { 'line-color': '#9ece6a', 'line-width': 1.5, 'line-opacity': 0.7 }
|
||
}, before);
|
||
_pcMap.on('click', 'cables-line', function(e) {
|
||
var props = e.features[0].properties;
|
||
_showMapPanel(_mapPanelItem('Submarine Cable', '#9ece6a',
|
||
escHtml(props.name || 'Unnamed Cable'),
|
||
props.color ? 'Color code: ' + escHtml(props.color) : null
|
||
));
|
||
});
|
||
_pcMap.on('mouseenter', 'cables-line', function() { _pcMap.getCanvas().style.cursor = 'pointer'; });
|
||
_pcMap.on('mouseleave', 'cables-line', function() { _pcMap.getCanvas().style.cursor = ''; });
|
||
}).catch(function() { _showMapLoader(false); _cablesLoaded = false; });
|
||
}
|
||
|
||
function _loadGlobalFacs() {
|
||
_showMapLoader(true);
|
||
fetch('/api/global-infra').then(function(r) { return r.json(); }).then(function(data) {
|
||
if (!_pcMap) return;
|
||
_globalFacsLoaded = true;
|
||
_showMapLoader(false);
|
||
if (_pcMap.getSource('global-facs')) return;
|
||
var features = (data.facs || []).map(function(f) {
|
||
return { type: 'Feature', geometry: { type: 'Point', coordinates: [f.lng, f.lat] },
|
||
properties: { name: f.name, city: f.city, country: f.country } };
|
||
});
|
||
var before = _pcMap.getLayer('pops-fac') ? 'pops-fac' : undefined;
|
||
_pcMap.addSource('global-facs', { type: 'geojson', data: { type: 'FeatureCollection', features: features } });
|
||
_pcMap.addLayer({
|
||
id: 'global-facs', type: 'circle', source: 'global-facs',
|
||
layout: { visibility: 'visible' },
|
||
paint: { 'circle-radius': 3, 'circle-color': '#bb9af7', 'circle-opacity': 0.65, 'circle-stroke-width': 0.5, 'circle-stroke-color': '#bb9af7' }
|
||
}, before);
|
||
_pcMap.on('click', 'global-facs', function(e) {
|
||
var p = e.features[0].properties;
|
||
_showMapPanel(_mapPanelItem('Datacenter (PeeringDB)', '#bb9af7',
|
||
escHtml(p.name || 'Unnamed'),
|
||
[(p.city || ''), (p.country || '')].filter(Boolean).join(', ') || null
|
||
));
|
||
});
|
||
_pcMap.on('mouseenter', 'global-facs', function() { _pcMap.getCanvas().style.cursor = 'pointer'; });
|
||
_pcMap.on('mouseleave', 'global-facs', function() { _pcMap.getCanvas().style.cursor = ''; });
|
||
}).catch(function() { _showMapLoader(false); _globalFacsLoaded = false; });
|
||
}
|
||
|
||
function _loadTelecoms() {
|
||
if (!_pcMap) return;
|
||
_telecomsLoaded = true;
|
||
_showMapLoader(true);
|
||
|
||
// Add OpenInfraMap telecoms vector source
|
||
if (!_pcMap.getSource('oim-telecoms')) {
|
||
_pcMap.addSource('oim-telecoms', {
|
||
type: 'vector',
|
||
tiles: ['https://openinframap.org/map/telecoms/{z}/{x}/{y}.pbf'],
|
||
maxzoom: 17,
|
||
attribution: '<a href="https://openinframap.org/copyright" target="_blank">Open Infrastructure Map</a>'
|
||
});
|
||
}
|
||
|
||
var before = _pcMap.getLayer('pops-fac') ? 'pops-fac' : undefined;
|
||
|
||
try {
|
||
// Glasfaser-Leitungen (solid, gut sichtbar ab Zoom 2)
|
||
if (!_pcMap.getLayer('oim-telecoms-line')) {
|
||
_pcMap.addLayer({
|
||
id: 'oim-telecoms-line',
|
||
type: 'line',
|
||
source: 'oim-telecoms',
|
||
'source-layer': 'telecoms_communication_line',
|
||
minzoom: 2,
|
||
layout: { visibility: 'visible', 'line-cap': 'round', 'line-join': 'round' },
|
||
paint: {
|
||
'line-color': '#f7ae54',
|
||
'line-width': ['interpolate', ['linear'], ['zoom'], 2, 1, 6, 1.5, 10, 2.5, 14, 4],
|
||
'line-opacity': 0.8
|
||
}
|
||
}, before);
|
||
}
|
||
} catch(e) { console.warn('OIM telecoms line layer error:', e); }
|
||
|
||
try {
|
||
// Datacenters als Kreise (ab Zoom 4)
|
||
if (!_pcMap.getLayer('oim-telecoms-dc')) {
|
||
_pcMap.addLayer({
|
||
id: 'oim-telecoms-dc',
|
||
type: 'circle',
|
||
source: 'oim-telecoms',
|
||
'source-layer': 'telecoms_data_center_point',
|
||
minzoom: 4,
|
||
layout: { visibility: 'visible' },
|
||
paint: {
|
||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 8, 5, 12, 8],
|
||
'circle-color': '#f7ae54',
|
||
'circle-opacity': 0.85,
|
||
'circle-stroke-width': 1.5,
|
||
'circle-stroke-color': '#1a1a1a',
|
||
'circle-stroke-opacity': 0.6
|
||
}
|
||
}, before);
|
||
}
|
||
} catch(e) { console.warn('OIM telecoms dc layer error:', e); }
|
||
|
||
if (_pcMap.getLayer('oim-telecoms-line')) {
|
||
_pcMap.on('click', 'oim-telecoms-line', function(e) {
|
||
var p = e.features[0].properties;
|
||
_showMapPanel(_mapPanelItem('OIM Glasfaser-Leitung', '#f7ae54',
|
||
escHtml(p.name || p.operator || 'Unnamed cable'),
|
||
[p.type, p.location].filter(Boolean).join(' · ') || null
|
||
));
|
||
});
|
||
_pcMap.on('mouseenter', 'oim-telecoms-line', function() { _pcMap.getCanvas().style.cursor = 'pointer'; });
|
||
_pcMap.on('mouseleave', 'oim-telecoms-line', function() { _pcMap.getCanvas().style.cursor = ''; });
|
||
}
|
||
|
||
if (_pcMap.getLayer('oim-telecoms-dc')) {
|
||
|
||
_pcMap.on('click', 'oim-telecoms-dc', function(e) {
|
||
var p = e.features[0].properties;
|
||
_showMapPanel(_mapPanelItem('OIM Telecoms', '#f7ae54',
|
||
escHtml(p.name || p.operator || 'Unnamed facility'),
|
||
p.type ? p.type.replace(/_/g,' ') : null
|
||
));
|
||
});
|
||
_pcMap.on('mouseenter', 'oim-telecoms-dc', function() { _pcMap.getCanvas().style.cursor = 'pointer'; });
|
||
_pcMap.on('mouseleave', 'oim-telecoms-dc', function() { _pcMap.getCanvas().style.cursor = ''; });
|
||
}
|
||
|
||
_showMapLoader(false);
|
||
}
|
||
|
||
function renderNetworkMap(d) {
|
||
var mapCard = document.getElementById('mapCard');
|
||
var mapDiv = document.getElementById('networkMap');
|
||
if (!mapCard || !mapDiv || typeof maplibregl === 'undefined') return;
|
||
|
||
_pcMapData = d;
|
||
|
||
// Build ASN PoP GeoJSON
|
||
var popFeatures = [];
|
||
var facs = (d.facilities && d.facilities.list) || [];
|
||
facs.forEach(function(f) {
|
||
if (f.latitude && f.longitude) {
|
||
popFeatures.push({ type: 'Feature',
|
||
geometry: { type: 'Point', coordinates: [+f.longitude, +f.latitude] },
|
||
properties: { type: 'fac', name: f.name, detail: (f.city || '') + (f.country ? ', ' + f.country : '') }
|
||
});
|
||
}
|
||
});
|
||
var ixLocs = d.ix_locations || [];
|
||
var ixConns = (d.ix_presence && d.ix_presence.connections) || [];
|
||
var ixSpeedMap = {};
|
||
ixConns.forEach(function(c) { if (c.ix_id) ixSpeedMap[c.ix_id] = (ixSpeedMap[c.ix_id] || 0) + (c.speed_mbps || 0); });
|
||
ixLocs.forEach(function(ix) {
|
||
if (ix.latitude && ix.longitude) {
|
||
var spd = ixSpeedMap[ix.ix_id] || 0;
|
||
popFeatures.push({ type: 'Feature',
|
||
geometry: { type: 'Point', coordinates: [+ix.longitude, +ix.latitude] },
|
||
properties: { type: 'ix', name: ix.name, detail: (ix.city || '') + (spd ? ' | ' + fmtSpeed(spd) : '') }
|
||
});
|
||
}
|
||
});
|
||
|
||
if (popFeatures.length === 0) { mapCard.style.display = 'none'; return; }
|
||
mapCard.style.display = 'block';
|
||
|
||
// Destroy previous map
|
||
if (_pcMap) { _pcMap.remove(); _pcMap = null; _cablesLoaded = false; _globalFacsLoaded = false; _telecomsLoaded = false; }
|
||
|
||
setTimeout(function() {
|
||
_pcMap = new maplibregl.Map({
|
||
container: 'networkMap',
|
||
style: 'https://tiles.openfreemap.org/styles/dark',
|
||
center: [10, 20],
|
||
zoom: 2,
|
||
attributionControl: false,
|
||
});
|
||
|
||
_pcMap.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
||
|
||
_pcMap.on('load', function() {
|
||
var popGeo = { type: 'FeatureCollection', features: popFeatures };
|
||
|
||
_pcMap.addSource('pops', { type: 'geojson', data: popGeo });
|
||
|
||
// Facility circles
|
||
_pcMap.addLayer({
|
||
id: 'pops-fac', type: 'circle', source: 'pops',
|
||
filter: ['==', ['get', 'type'], 'fac'],
|
||
paint: { 'circle-radius': 7, 'circle-color': '#7dcfff', 'circle-opacity': 0.85, 'circle-stroke-width': 1, 'circle-stroke-color': '#7dcfff', 'circle-stroke-opacity': 0.5 }
|
||
});
|
||
|
||
// IXP circles
|
||
_pcMap.addLayer({
|
||
id: 'pops-ix', type: 'circle', source: 'pops',
|
||
filter: ['==', ['get', 'type'], 'ix'],
|
||
paint: { 'circle-radius': 6, 'circle-color': '#ff9e64', 'circle-opacity': 0.85, 'circle-stroke-width': 1, 'circle-stroke-color': '#ff9e64', 'circle-stroke-opacity': 0.5 }
|
||
});
|
||
|
||
// Popups for PoPs
|
||
['pops-fac', 'pops-ix'].forEach(function(layerId) {
|
||
_pcMap.on('click', layerId, function(e) {
|
||
var p = e.features[0].properties;
|
||
var color = p.type === 'ix' ? '#ff9e64' : '#7dcfff';
|
||
var label = p.type === 'ix' ? 'IXP' : 'Datacenter';
|
||
_showMapPanel(_mapPanelItem(label, color,
|
||
escHtml(p.name || ''),
|
||
p.detail ? escHtml(p.detail) : null
|
||
));
|
||
});
|
||
_pcMap.on('mouseenter', layerId, function() { _pcMap.getCanvas().style.cursor = 'pointer'; });
|
||
_pcMap.on('mouseleave', layerId, function() { _pcMap.getCanvas().style.cursor = ''; });
|
||
});
|
||
|
||
// Fit to PoP bounds
|
||
var lngs = popFeatures.map(function(f) { return f.geometry.coordinates[0]; });
|
||
var lats = popFeatures.map(function(f) { return f.geometry.coordinates[1]; });
|
||
var bounds = [[Math.min.apply(null,lngs)-2, Math.min.apply(null,lats)-2], [Math.max.apply(null,lngs)+2, Math.max.apply(null,lats)+2]];
|
||
_pcMap.fitBounds(bounds, { padding: 40, maxZoom: 6, duration: 800 });
|
||
|
||
// Restore active overlay layers
|
||
if (_mapLayers.cables && !_cablesLoaded) _loadCables();
|
||
if (_mapLayers.globalFacs && !_globalFacsLoaded) _loadGlobalFacs();
|
||
if (_mapLayers.telecoms && !_telecomsLoaded) _loadTelecoms();
|
||
});
|
||
}, 50);
|
||
}
|
||
|
||
function renderAtlas(atlas) {
|
||
if (!atlas) {
|
||
$('atlasContent').innerHTML = '<div style="font-size:.85rem;color:var(--muted)">No RIPE Atlas data available.</div>';
|
||
return;
|
||
}
|
||
|
||
var h = '';
|
||
|
||
// Summary badges
|
||
h += '<div class="stat-row">';
|
||
h += '<div class="stat"><div class="stat-val blue">' + atlas.total_probes + '</div><div class="stat-label">Total Probes</div></div>';
|
||
h += '<div class="stat"><div class="stat-val green">' + atlas.connected + '</div><div class="stat-label">Connected</div></div>';
|
||
h += '<div class="stat"><div class="stat-val red">' + atlas.disconnected + '</div><div class="stat-label">Disconnected</div></div>';
|
||
h += '<div class="stat"><div class="stat-val orange">' + atlas.anchors + '</div><div class="stat-label">Anchors</div></div>';
|
||
h += '</div>';
|
||
|
||
if (atlas.total_probes > 0) {
|
||
// Connection ratio bar
|
||
var connPct = pct(atlas.connected, atlas.total_probes);
|
||
h += '<div style="font-size:.7rem;color:var(--muted)">Connected ' + connPct + '% / Disconnected ' + (100 - connPct) + '%</div>';
|
||
h += '<div class="progress-multi"><div style="width:' + connPct + '%;background:var(--green)"></div><div style="width:' + (100 - connPct) + '%;background:var(--red)"></div></div>';
|
||
}
|
||
|
||
if (atlas.probes && atlas.probes.length > 0) {
|
||
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 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(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>';
|
||
h += '<td style="font-size:.75rem">' + escHtml(p.description) + '</td>';
|
||
h += '</tr>';
|
||
});
|
||
h += '</tbody></table></div></div>';
|
||
} else if (atlas.total_probes === 0) {
|
||
h += '<div style="margin-top:.75rem;font-size:.85rem;color:var(--muted)">No RIPE Atlas probes found for this network. <a href="https://atlas.ripe.net/get-involved/become-a-host/" target="_blank" style="color:var(--blue)">Host a probe?</a></div>';
|
||
}
|
||
|
||
$('atlasContent').innerHTML = h;
|
||
}
|
||
|
||
async function doCompare() {
|
||
if (!currentAsn) return;
|
||
const raw2 = $('compareAsn').value.trim().replace(/[^0-9]/g, '');
|
||
if (!raw2) return;
|
||
|
||
$('compareResults').innerHTML = '<div class="skeleton wide"></div><div class="skeleton med"></div>';
|
||
doFullCompare();
|
||
|
||
try {
|
||
const resp = await fetch('/api/compare?asn1=' + currentAsn + '&asn2=' + raw2);
|
||
const d = await resp.json();
|
||
|
||
let h = '<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">';
|
||
h += '<div style="text-align:center"><div class="stat-val blue">' + escHtml(d.asn1.name) + '</div><div class="stat-label">AS' + d.asn1.asn + ' (' + d.asn1.ix_count + ' IXPs)</div></div>';
|
||
h += '<div style="text-align:center"><div class="stat-val purple">' + escHtml(d.asn2.name) + '</div><div class="stat-label">AS' + d.asn2.asn + ' (' + d.asn2.ix_count + ' IXPs)</div></div>';
|
||
h += '</div>';
|
||
|
||
// RPKI comparison
|
||
if (d.rpki_comparison) {
|
||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--purple);margin:.75rem 0 .4rem">RPKI Coverage Comparison</div>';
|
||
h += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:.75rem">';
|
||
|
||
var s1class = d.rpki_comparison.asn1_coverage >= 90 ? 'green' : d.rpki_comparison.asn1_coverage >= 70 ? 'orange' : 'red';
|
||
var s2class = d.rpki_comparison.asn2_coverage >= 90 ? 'green' : d.rpki_comparison.asn2_coverage >= 70 ? 'orange' : 'red';
|
||
h += '<div style="text-align:center"><div style="font-size:2rem;font-weight:700;color:var(--' + s1class + ')">' + d.rpki_comparison.asn1_coverage + '%</div><div style="font-size:.7rem;color:var(--muted)">AS' + d.asn1.asn + ' (' + d.rpki_comparison.asn1_checked + ' checked)</div></div>';
|
||
h += '<div style="text-align:center"><div style="font-size:2rem;font-weight:700;color:var(--' + s2class + ')">' + d.rpki_comparison.asn2_coverage + '%</div><div style="font-size:.7rem;color:var(--muted)">AS' + d.asn2.asn + ' (' + d.rpki_comparison.asn2_checked + ' checked)</div></div>';
|
||
h += '</div>';
|
||
if (d.rpki_comparison.better !== 'equal') {
|
||
h += '<div style="text-align:center;font-size:.8rem;color:var(--green);margin-bottom:.5rem">' + escHtml(d.rpki_comparison.better) + ' has better RPKI coverage</div>';
|
||
}
|
||
}
|
||
|
||
// Common upstreams
|
||
if (d.common_upstreams && d.common_upstreams.length > 0) {
|
||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--yellow);margin:.5rem 0">Common Upstreams (' + d.common_upstreams.length + ')</div>';
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.5rem">';
|
||
d.common_upstreams.forEach(function(u) { var nameStr = (u.name && u.name !== 'AS' + u.asn) ? ' ' + escHtml(u.name) : ''; h += '<span class="badge badge-orange">' + asnLink(u.asn) + nameStr + '</span>'; });
|
||
h += '</div>';
|
||
} else if (d.common_upstreams) {
|
||
h += '<div style="font-size:.8rem;color:var(--muted);margin:.5rem 0">No common upstreams detected</div>';
|
||
}
|
||
|
||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--green);margin:.5rem 0">Common IXPs (' + d.common_ixps.length + ')</div>';
|
||
if (d.common_ixps.length > 0) {
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem">';
|
||
d.common_ixps.forEach(function(ix) { h += '<span class="badge badge-green">' + escHtml(ix.name) + '</span>'; });
|
||
h += '</div>';
|
||
} else {
|
||
h += '<div style="font-size:.8rem;color:var(--muted)">No common IXPs</div>';
|
||
}
|
||
|
||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--orange);margin:.5rem 0">Only AS' + d.asn1.asn + ' (' + d.only_asn1_ixps.length + ')</div>';
|
||
if (d.only_asn1_ixps.length > 0) {
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem">';
|
||
d.only_asn1_ixps.slice(0, 15).forEach(function(ix) { h += '<span class="badge badge-orange">' + escHtml(ix.name) + '</span>'; });
|
||
if (d.only_asn1_ixps.length > 15) h += '<span class="badge badge-orange">+' + (d.only_asn1_ixps.length - 15) + ' more</span>';
|
||
h += '</div>';
|
||
}
|
||
|
||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--blue);margin:.5rem 0">Only AS' + d.asn2.asn + ' (' + d.only_asn2_ixps.length + ')</div>';
|
||
if (d.only_asn2_ixps.length > 0) {
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem">';
|
||
d.only_asn2_ixps.slice(0, 15).forEach(function(ix) { h += '<span class="badge badge-blue">' + escHtml(ix.name) + '</span>'; });
|
||
if (d.only_asn2_ixps.length > 15) h += '<span class="badge badge-blue">+' + (d.only_asn2_ixps.length - 15) + ' more</span>';
|
||
h += '</div>';
|
||
}
|
||
|
||
if (d.common_facilities && d.common_facilities.length > 0) {
|
||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--cyan);margin:.5rem 0">Common Facilities (' + d.common_facilities.length + ')</div>';
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem">';
|
||
d.common_facilities.forEach(function(f) { h += '<span class="badge badge-cyan">' + escHtml(f.name) + '</span>'; });
|
||
h += '</div>';
|
||
}
|
||
|
||
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.5rem">Compared in ' + d.meta.duration_ms + 'ms</div>';
|
||
$('compareResults').innerHTML = h;
|
||
} catch (e) {
|
||
$('compareResults').innerHTML = '<div style="color:var(--red);font-size:.8rem">Compare failed: ' + escHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
function toggleExpand(el) {
|
||
const body = el.nextElementSibling;
|
||
const isOpen = body.classList.contains('open');
|
||
body.classList.toggle('open');
|
||
el.textContent = isOpen ? el.textContent.replace('Hide', 'Show') : el.textContent.replace('Show', 'Hide');
|
||
}
|
||
|
||
function escHtml(s) {
|
||
if (!s) return '';
|
||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
function escAttr(s) {
|
||
return escHtml(s);
|
||
}
|
||
|
||
|
||
async function loadAspaVerifyData(asn) {
|
||
$('aspaDeepContent').innerHTML = '<div class="section-loading">Running RFC-compliant ASPA verification...</div>';
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), 20000);
|
||
try {
|
||
const resp = await fetch('/api/aspa/verify?asn=' + asn, { signal: ctrl.signal });
|
||
clearTimeout(timer);
|
||
if (!resp.ok) { $('aspaDeepContent').textContent = 'ASPA verification unavailable (server ' + resp.status + ')'; return; }
|
||
var text = await resp.text();
|
||
if (!text || text[0] === '<') { $('aspaDeepContent').textContent = 'ASPA verification unavailable (timeout for large ASNs)'; return; }
|
||
const d = JSON.parse(text);
|
||
if (d.error) { $('aspaDeepContent').textContent = 'ASPA verification failed: ' + d.error; return; }
|
||
renderAspaDeep(d);
|
||
} catch (e) {
|
||
clearTimeout(timer);
|
||
$('aspaDeepContent').textContent = 'ASPA verification temporarily unavailable';
|
||
}
|
||
}
|
||
|
||
function renderAspaDeep(d) {
|
||
var h = '';
|
||
var score = d.readiness_score || { total: 0, breakdown: {} };
|
||
var pv = d.path_verification || {};
|
||
var audit = d.provider_audit || {};
|
||
|
||
// === READINESS SCORE GAUGE ===
|
||
h += '<div style="display:grid;grid-template-columns:200px 1fr;gap:2rem;align-items:start;margin-bottom:1.5rem">';
|
||
|
||
// Gauge
|
||
var scoreColor = score.total >= 75 ? 'var(--green)' : score.total >= 50 ? 'var(--orange)' : 'var(--red)';
|
||
var circumference = 2 * Math.PI * 54;
|
||
var offset = circumference - (score.total / 100) * circumference;
|
||
h += '<div>';
|
||
h += '<div class="aspa-gauge">';
|
||
h += '<svg viewBox="0 0 120 120"><circle class="aspa-gauge-bg" cx="60" cy="60" r="54"/>';
|
||
h += '<circle class="aspa-gauge-fill" cx="60" cy="60" r="54" stroke="' + scoreColor + '" stroke-dasharray="' + circumference.toFixed(1) + '" stroke-dashoffset="' + offset.toFixed(1) + '"/></svg>';
|
||
h += '<div class="aspa-gauge-text"><div class="aspa-gauge-score" style="color:' + scoreColor + '">' + score.total + '</div><div class="aspa-gauge-label">Readiness</div></div>';
|
||
h += '</div>';
|
||
|
||
// ASPA object status below gauge
|
||
h += '<div style="text-align:center;margin-top:.5rem">';
|
||
if (d.aspa_object_exists) {
|
||
h += '<span class="badge badge-green">ASPA Object Found</span>';
|
||
} else {
|
||
h += '<span class="badge badge-red">No ASPA Object</span>';
|
||
}
|
||
h += '</div>';
|
||
h += '</div>';
|
||
|
||
// Breakdown grid
|
||
h += '<div>';
|
||
h += '<div class="aspa-breakdown">';
|
||
|
||
var bd = score.breakdown || {};
|
||
var items = [
|
||
{ key: 'roa_coverage', label: 'ROA Coverage', suffix: '%' },
|
||
{ key: 'aspa_object', label: 'ASPA Object', suffix: '' },
|
||
{ key: 'provider_completeness', label: 'Provider Match', suffix: '%' },
|
||
{ key: 'path_validation', label: 'Path Validation', suffix: '%' },
|
||
];
|
||
|
||
items.forEach(function(item) {
|
||
var b = bd[item.key] || { score: 0, max: 25, value: 0 };
|
||
var pctFill = (b.score / b.max * 100).toFixed(0);
|
||
var color = pctFill >= 80 ? 'var(--green)' : pctFill >= 50 ? 'var(--orange)' : 'var(--red)';
|
||
var displayVal = item.key === 'aspa_object' ? (b.value ? 'Yes' : 'No') : b.value + item.suffix;
|
||
h += '<div class="aspa-breakdown-item">';
|
||
h += '<div class="aspa-breakdown-label">' + item.label + '</div>';
|
||
h += '<div class="aspa-breakdown-score" style="color:' + color + '">' + b.score + '<span style="color:var(--dim);font-size:.8rem">/' + b.max + '</span></div>';
|
||
h += '<div style="font-size:.7rem;color:var(--muted)">' + displayVal + '</div>';
|
||
h += '<div class="aspa-breakdown-bar"><div style="width:' + pctFill + '%;background:' + color + '"></div></div>';
|
||
h += '</div>';
|
||
});
|
||
|
||
h += '</div>';
|
||
h += '</div>';
|
||
h += '</div>';
|
||
|
||
// === PATH VERIFICATION SUMMARY ===
|
||
h += '<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:1rem;padding:1rem;background:var(--bg);border-radius:8px;border:1px solid var(--border)">';
|
||
h += '<div class="stat"><div class="stat-val green">' + (pv.valid || 0) + '</div><div class="stat-label">Valid Paths</div></div>';
|
||
h += '<div class="stat"><div class="stat-val red">' + (pv.invalid || 0) + '</div><div class="stat-label">Invalid Paths</div></div>';
|
||
h += '<div class="stat"><div class="stat-val">' + (pv.unknown || 0) + '</div><div class="stat-label">Unknown Paths</div></div>';
|
||
h += '<div class="stat"><div class="stat-val orange">' + (pv.as_set_flagged || 0) + '</div><div class="stat-label">AS_SET Flagged</div></div>';
|
||
h += '<div class="stat"><div class="stat-val red">' + (pv.valley_detected || 0) + '</div><div class="stat-label">Valley Detected</div></div>';
|
||
h += '<div class="stat"><div class="stat-val cyan">' + (pv.total || 0) + '</div><div class="stat-label">Total Analyzed</div></div>';
|
||
h += '</div>';
|
||
|
||
// Valid percentage bar
|
||
if (pv.total > 0) {
|
||
var vPct = pct(pv.valid, pv.total);
|
||
var iPct = pct(pv.invalid, pv.total);
|
||
var uPct = 100 - vPct - iPct;
|
||
h += '<div style="font-size:.7rem;color:var(--muted);margin-bottom:.25rem">Path Validation: ' + vPct + '% valid, ' + iPct + '% invalid, ' + uPct + '% unknown</div>';
|
||
h += '<div class="progress-multi"><div style="width:' + vPct + '%;background:var(--green)"></div><div style="width:' + iPct + '%;background:var(--red)"></div><div style="width:' + uPct + '%;background:var(--dim)"></div></div>';
|
||
}
|
||
|
||
// === AS_SET WARNINGS ===
|
||
if (pv.as_set_flagged > 0) {
|
||
h += '<div class="asset-alert"><strong>AS_SET Deprecation Warning:</strong> ' + pv.as_set_flagged + ' path(s) contain AS_SET segments. AS_SET is deprecated per RFC 6472 and these paths are automatically marked Invalid per ASPA RFC.</div>';
|
||
// Show AS_SET paths detail
|
||
var asSetPaths = (pv.results || []).filter(function(r) { return r.has_as_set; });
|
||
if (asSetPaths.length > 0) {
|
||
h += '<div class="expand-toggle" onclick="toggleExpand(this)">Show AS_SET paths (' + asSetPaths.length + ')</div>';
|
||
h += '<div class="expand-body"><div class="scroll-wrap" style="max-height:200px"><table class="tbl"><thead><tr><th>Prefix</th><th>AS Path</th><th>RRC</th></tr></thead><tbody>';
|
||
asSetPaths.forEach(function(p) {
|
||
h += '<tr><td style="font-family:monospace;font-size:.7rem">' + escHtml(p.prefix || '') + '</td>';
|
||
h += '<td style="font-family:monospace;font-size:.65rem;color:var(--orange)">' + escHtml(p.path || '') + '</td>';
|
||
h += '<td>' + escHtml(p.rrc || '') + '</td></tr>';
|
||
});
|
||
h += '</tbody></table></div></div>';
|
||
}
|
||
}
|
||
|
||
// === VALLEY DETECTION ALERTS ===
|
||
if (pv.valley_detected > 0) {
|
||
var valleyPaths = (pv.results || []).filter(function(r) { return r.valleys && r.valleys.length > 0; });
|
||
h += '<div class="valley-alert"><strong>Valley Detection:</strong> ' + pv.valley_detected + ' path(s) show potential route leaks (up-down-up pattern).</div>';
|
||
if (valleyPaths.length > 0) {
|
||
h += '<div class="expand-toggle" onclick="toggleExpand(this)">Show valley details (' + valleyPaths.length + ' paths)</div>';
|
||
h += '<div class="expand-body"><div class="scroll-wrap" style="max-height:200px">';
|
||
valleyPaths.forEach(function(vp) {
|
||
vp.valleys.forEach(function(v) {
|
||
h += '<div style="padding:.4rem 0;border-bottom:1px solid var(--border);font-size:.75rem">';
|
||
h += '<span style="color:var(--red);font-weight:600">Valley:</span> ' + escHtml(v.description);
|
||
h += '<br><span style="color:var(--muted)">Path segment: ' + v.path_segment.join(' -> ') + '</span>';
|
||
h += '</div>';
|
||
});
|
||
});
|
||
h += '</div></div>';
|
||
}
|
||
}
|
||
|
||
// === PROVIDER AUDIT ===
|
||
h += '<div style="margin-top:1.25rem"><div style="font-size:.85rem;font-weight:600;color:var(--purple);margin-bottom:.5rem">Provider Audit</div>';
|
||
h += '<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:.75rem">';
|
||
h += '<div><span style="font-size:.7rem;color:var(--muted);text-transform:uppercase">Detected</span><div style="font-size:1.3rem;font-weight:700;color:var(--blue)">' + (audit.detected_count || 0) + '</div></div>';
|
||
h += '<div><span style="font-size:.7rem;color:var(--muted);text-transform:uppercase">Declared</span><div style="font-size:1.3rem;font-weight:700;color:var(--cyan)">' + (audit.declared_count || 0) + '</div></div>';
|
||
h += '<div><span style="font-size:.7rem;color:var(--muted);text-transform:uppercase">Completeness</span><div style="font-size:1.3rem;font-weight:700;color:' + ((audit.completeness_pct || 0) >= 80 ? 'var(--green)' : 'var(--orange)') + '">' + (audit.completeness_pct || 0) + '%</div></div>';
|
||
h += '</div>';
|
||
|
||
// Missing from ASPA (collapsible, sortable)
|
||
if (audit.missing_from_aspa && audit.missing_from_aspa.length > 0) {
|
||
window._auditMissing = audit.missing_from_aspa;
|
||
window._auditMissingSort = 'frequency';
|
||
h += '<div style="display:flex;align-items:center;gap:.75rem;margin:.5rem 0 .3rem">';
|
||
h += '<span style="font-size:.8rem;font-weight:600;color:var(--orange)">Missing from ASPA Declaration (' + audit.missing_from_aspa.length + ')</span>';
|
||
h += '<span class="sort-toggle" onclick="window._auditMissingSort=window._auditMissingSort===\'frequency\'?\'asn\':\'frequency\';renderAuditList(\'missingListEl\',window._auditMissing,window._auditMissingSort,\'missing\')" title="Toggle sort order">Sort: <span id="missingSortLabel">by frequency</span> ↕</span>';
|
||
h += '</div>';
|
||
h += '<div id="missingListEl"></div>';
|
||
}
|
||
|
||
// Extra in ASPA (collapsible, sortable)
|
||
if (audit.extra_in_aspa && audit.extra_in_aspa.length > 0) {
|
||
window._auditExtra = audit.extra_in_aspa;
|
||
window._auditExtraSort = 'asn';
|
||
h += '<div style="display:flex;align-items:center;gap:.75rem;margin:.5rem 0 .3rem">';
|
||
h += '<span style="font-size:.8rem;font-weight:600;color:var(--cyan)">Extra in ASPA (not seen in paths) (' + audit.extra_in_aspa.length + ')</span>';
|
||
h += '<span class="sort-toggle" onclick="window._auditExtraSort=window._auditExtraSort===\'asn\'?\'name\':\'asn\';renderAuditList(\'extraListEl\',window._auditExtra,window._auditExtraSort,\'extra\')" title="Toggle sort order">Sort: <span id="extraSortLabel">by ASN</span> ↕</span>';
|
||
h += '</div>';
|
||
h += '<div id="extraListEl"></div>';
|
||
}
|
||
|
||
if ((!audit.missing_from_aspa || audit.missing_from_aspa.length === 0) && (!audit.extra_in_aspa || audit.extra_in_aspa.length === 0)) {
|
||
h += '<div class="audit-row audit-ok">Provider declarations match observed BGP paths</div>';
|
||
}
|
||
h += '</div>';
|
||
|
||
// === DETECTED PROVIDERS WITH FREQUENCY ===
|
||
if (d.detected_providers && d.detected_providers.length > 0) {
|
||
h += '<div style="margin-top:1.25rem"><div style="font-size:.85rem;font-weight:600;color:var(--blue);margin-bottom:.5rem">Detected Providers (by frequency)</div>';
|
||
h += '<div class="scroll-wrap" style="max-height:200px"><table class="tbl"><thead><tr><th>Provider</th><th>Name</th><th>Seen in Paths</th><th>Frequency</th></tr></thead><tbody>';
|
||
var sortedProviders = (d.detected_providers || []).slice().sort(function(a, b) { return (b.frequency || 0) - (a.frequency || 0) || a.asn - b.asn; });
|
||
sortedProviders.forEach(function(p) {
|
||
h += '<tr><td>' + asnLink(p.asn) + '</td>';
|
||
var provName = (p.name && p.name !== 'AS' + p.asn) ? escHtml(p.name) : '';
|
||
h += '<td>' + provName + '</td>';
|
||
h += '<td>' + (p.frequency || 0) + '</td>';
|
||
h += '<td><div style="display:flex;align-items:center;gap:.5rem"><div class="progress-wrap" style="flex:1;margin:0"><div class="progress-bar green" style="width:' + (p.frequency_pct || 0) + '%"></div></div><span style="font-size:.7rem;color:var(--muted)">' + (p.frequency_pct || 0) + '%</span></div></td>';
|
||
h += '</tr>';
|
||
});
|
||
h += '</tbody></table></div></div>';
|
||
}
|
||
|
||
// === PATH VERIFICATION DETAILS ===
|
||
if (pv.results && pv.results.length > 0) {
|
||
h += '<div style="margin-top:1.25rem">';
|
||
h += '<div class="expand-toggle" onclick="toggleExpand(this)">Show path verification details (' + pv.results.length + ' paths)</div>';
|
||
h += '<div class="expand-body"><div class="scroll-wrap" style="max-height:400px"><table class="tbl"><thead><tr><th>RRC</th><th>Prefix</th><th>Path</th><th>Upstream</th><th>Downstream</th><th>Overall</th><th style="min-width:120px">Hop Detail</th></tr></thead><tbody>';
|
||
|
||
pv.results.forEach(function(r) {
|
||
var oBadge = r.overall === 'Valid' ? 'path-valid' : r.overall === 'Invalid' ? 'path-invalid' : 'path-unknown';
|
||
var uBadge = r.upstream_verification.result === 'Valid' ? 'path-valid' : r.upstream_verification.result === 'Invalid' ? 'path-invalid' : 'path-unknown';
|
||
var dBadge = r.downstream_verification.result === 'Valid' ? 'path-valid' : r.downstream_verification.result === 'Invalid' ? 'path-invalid' : 'path-unknown';
|
||
|
||
h += '<tr>';
|
||
h += '<td style="font-size:.7rem">' + escHtml(r.rrc || '') + '</td>';
|
||
h += '<td style="font-family:monospace;font-size:.7rem">' + escHtml(r.prefix || '') + '</td>';
|
||
h += '<td style="font-family:monospace;font-size:.65rem;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + escAttr(r.collapsed_path || r.path || '') + '">' + escHtml(r.collapsed_path || r.path || '') + '</td>';
|
||
h += '<td><span class="path-result-badge ' + uBadge + '">' + r.upstream_verification.result + '</span></td>';
|
||
h += '<td><span class="path-result-badge ' + dBadge + '">' + r.downstream_verification.result + '</span></td>';
|
||
h += '<td><span class="path-result-badge ' + oBadge + '">' + r.overall + '</span>';
|
||
if (r.has_as_set) h += '<br><span class="badge badge-orange" style="font-size:.6rem;margin-top:.2rem">AS_SET</span>';
|
||
if (r.valleys && r.valleys.length > 0) h += '<br><span class="badge badge-red" style="font-size:.6rem;margin-top:.2rem">VALLEY</span>';
|
||
h += '</td>';
|
||
|
||
// Hop detail
|
||
h += '<td class="hop-detail">';
|
||
var hops = r.upstream_verification.hops || [];
|
||
if (hops.length > 0) {
|
||
hops.forEach(function(hop, idx) {
|
||
var cls = hop.result === 'ProviderPlus' ? 'hop-pp' : hop.result === 'NotProviderPlus' ? 'hop-npp' : 'hop-na';
|
||
if (idx > 0) h += '<span class="hop-arrow">,</span>';
|
||
h += '<span class="' + cls + '" title="AS' + hop.from + '->AS' + hop.to + ': ' + hop.result + '">' + hop.result.charAt(0) + '</span>';
|
||
});
|
||
} else {
|
||
h += '<span style="color:var(--dim)">-</span>';
|
||
}
|
||
h += '</td>';
|
||
h += '</tr>';
|
||
});
|
||
|
||
h += '</tbody></table></div>';
|
||
h += '<div style="font-size:.65rem;color:var(--dim);margin-top:.5rem">Hop legend: <span class="hop-pp">P</span>=ProviderPlus, <span class="hop-npp">N</span>=NotProviderPlus, <span class="hop-na">N</span>=NoAttestation</div>';
|
||
h += '</div></div>';
|
||
}
|
||
|
||
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.75rem">ASPA verification completed in ' + (d.meta ? d.meta.duration_ms : '?') + 'ms | ' + (d.meta ? d.meta.paths_analyzed : '?') + ' of ' + (d.meta ? d.meta.total_paths_seen : '?') + ' paths analyzed</div>';
|
||
$('aspaDeepContent').innerHTML = h;
|
||
|
||
// Initial render of sortable audit lists
|
||
if (window._auditMissing && window._auditMissing.length > 0) {
|
||
renderAuditList('missingListEl', window._auditMissing, 'frequency', 'missing');
|
||
}
|
||
if (window._auditExtra && window._auditExtra.length > 0) {
|
||
renderAuditList('extraListEl', window._auditExtra, 'asn', 'extra');
|
||
}
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// Feature 5: Search History (localStorage)
|
||
// ============================================================
|
||
function loadSearchHistory() {
|
||
try {
|
||
return JSON.parse(localStorage.getItem('peercortex_history') || '[]');
|
||
} catch(e) { return []; }
|
||
}
|
||
|
||
function saveToHistory(asn, name) {
|
||
var history = loadSearchHistory();
|
||
// Remove existing entry for this ASN
|
||
history = history.filter(function(h) { return h.asn !== asn; });
|
||
// Add to front
|
||
history.unshift({ asn: asn, name: name || 'AS' + asn, ts: Date.now() });
|
||
// Keep only 10
|
||
history = history.slice(0, 10);
|
||
localStorage.setItem('peercortex_history', JSON.stringify(history));
|
||
renderSearchHistory();
|
||
}
|
||
|
||
function renderSearchHistory() {
|
||
var history = loadSearchHistory();
|
||
var el = $('searchHistory');
|
||
if (!el || history.length === 0) { if(el) el.innerHTML = ''; return; }
|
||
var h = '<span style="font-size:.7rem;color:var(--dim);margin-right:.3rem">Recent:</span>';
|
||
history.forEach(function(item) {
|
||
h += '<span class="history-badge" onclick="lookupAsn(' + item.asn + ')" title="' + escAttr(item.name) + '">AS' + item.asn + '</span>';
|
||
});
|
||
h += '<span class="history-clear" onclick="clearHistory()" title="Clear history">x clear</span>';
|
||
el.innerHTML = h;
|
||
}
|
||
|
||
function clearHistory() {
|
||
localStorage.removeItem('peercortex_history');
|
||
renderSearchHistory();
|
||
}
|
||
|
||
// ============================================================
|
||
// Feature 3: Prefix Detail View (modal)
|
||
// ============================================================
|
||
async function showPrefixDetail(prefix) {
|
||
// Create modal
|
||
var overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.onclick = function(e) { if (e.target === overlay) document.body.removeChild(overlay); };
|
||
var modal = document.createElement('div');
|
||
modal.className = 'modal-content';
|
||
modal.innerHTML = '<button class="modal-close" onclick="this.closest(\'\'.modal-overlay\'\').remove()">×</button>' +
|
||
'<div class="modal-title">Prefix Detail: ' + escHtml(prefix) + '</div>' +
|
||
'<div class="section-loading">Loading prefix details...</div>';
|
||
overlay.appendChild(modal);
|
||
document.body.appendChild(overlay);
|
||
|
||
try {
|
||
var resp = await fetch('/api/prefix/detail?prefix=' + encodeURIComponent(prefix));
|
||
var d = await resp.json();
|
||
if (d.error) {
|
||
modal.innerHTML = '<button class="modal-close" onclick="this.closest(\'.modal-overlay\').remove()">×</button>' +
|
||
'<div class="modal-title">Prefix Detail: ' + escHtml(prefix) + '</div>' +
|
||
'<div style="color:var(--red)">' + escHtml(d.error) + '</div>';
|
||
return;
|
||
}
|
||
|
||
var h = '<button class="modal-close" onclick="this.closest(\'.modal-overlay\').remove()">×</button>';
|
||
h += '<div class="modal-title">Prefix Detail: ' + escHtml(prefix) + '</div>';
|
||
|
||
// Origins
|
||
if (d.origins && d.origins.length > 0) {
|
||
h += '<div style="margin-bottom:.75rem"><span style="font-size:.75rem;color:var(--muted);text-transform:uppercase">Origin AS:</span> ';
|
||
d.origins.forEach(function(o) { h += asnLink(o.asn) + ' '; });
|
||
h += '</div>';
|
||
}
|
||
|
||
// RPKI
|
||
h += '<div style="margin-bottom:.75rem"><span style="font-size:.75rem;color:var(--muted);text-transform:uppercase">RPKI Status:</span> ';
|
||
var rpkiCls = d.rpki.status === 'valid' ? 'badge-green' : d.rpki.status === 'invalid' ? 'badge-red' : 'badge-orange';
|
||
h += '<span class="badge ' + rpkiCls + '">' + escHtml(d.rpki.status) + '</span>';
|
||
h += ' <span style="font-size:.75rem;color:var(--muted)">(' + d.rpki.validating_roas + ' ROAs)</span>';
|
||
h += '</div>';
|
||
|
||
// IRR
|
||
h += '<div style="margin-bottom:.75rem"><span style="font-size:.75rem;color:var(--muted);text-transform:uppercase">IRR Status:</span> ';
|
||
h += '<span class="badge ' + (d.irr_status === 'found' ? 'badge-green' : 'badge-orange') + '">' + escHtml(d.irr_status) + '</span></div>';
|
||
|
||
// Visibility
|
||
h += '<div style="margin-bottom:.75rem"><span style="font-size:.75rem;color:var(--muted);text-transform:uppercase">Visibility:</span> ';
|
||
h += '<span style="font-weight:600;color:var(--blue)">' + (d.visibility.ris_peers_seeing || 0) + '</span>';
|
||
h += ' <span style="color:var(--muted)">/ ' + (d.visibility.total_probes || 0) + ' RIS peers</span></div>';
|
||
|
||
// First seen
|
||
if (d.first_seen) {
|
||
h += '<div style="margin-bottom:.75rem"><span style="font-size:.75rem;color:var(--muted);text-transform:uppercase">First Seen:</span> ';
|
||
h += '<span style="color:var(--text-dim)">' + escHtml(d.first_seen) + '</span></div>';
|
||
}
|
||
|
||
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.75rem">Queried in ' + d.meta.duration_ms + 'ms</div>';
|
||
modal.innerHTML = h;
|
||
} catch(e) {
|
||
modal.innerHTML = '<button class="modal-close" onclick="this.closest(\'.modal-overlay\').remove()">×</button>' +
|
||
'<div class="modal-title">Prefix Detail</div>' +
|
||
'<div style="color:var(--red)">Error: ' + escHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Feature 4: IX Detail View (modal)
|
||
// ============================================================
|
||
async function showIXDetail(ixId, ixName) {
|
||
var overlay = document.createElement('div');
|
||
overlay.className = 'modal-overlay';
|
||
overlay.onclick = function(e) { if (e.target === overlay) document.body.removeChild(overlay); };
|
||
var modal = document.createElement('div');
|
||
modal.className = 'modal-content';
|
||
modal.innerHTML = '<button class="modal-close" onclick="this.closest(\'.modal-overlay\').remove()">×</button>' +
|
||
'<div class="modal-title">IX Detail: ' + escHtml(ixName || 'IX ' + ixId) + '</div>' +
|
||
'<div class="section-loading">Loading IX details...</div>';
|
||
overlay.appendChild(modal);
|
||
document.body.appendChild(overlay);
|
||
|
||
try {
|
||
var resp = await fetch('/api/ix/detail?ix_id=' + ixId);
|
||
var d = await resp.json();
|
||
if (d.error) {
|
||
modal.innerHTML = '<button class="modal-close" onclick="this.closest(\'.modal-overlay\').remove()">×</button>' +
|
||
'<div class="modal-title">IX Detail</div><div style="color:var(--red)">' + escHtml(d.error) + '</div>';
|
||
return;
|
||
}
|
||
|
||
var h = '<button class="modal-close" onclick="this.closest(\'.modal-overlay\').remove()">×</button>';
|
||
h += '<div class="modal-title">' + escHtml(d.ix.name) + '</div>';
|
||
|
||
if (d.ix.city || d.ix.country) {
|
||
h += '<div style="margin-bottom:.5rem;font-size:.85rem;color:var(--muted)">' + escHtml(d.ix.city) + (d.ix.country ? ', ' + escHtml(d.ix.country) : '') + '</div>';
|
||
}
|
||
|
||
h += '<div style="display:flex;gap:1.5rem;margin-bottom:1rem;flex-wrap:wrap">';
|
||
h += '<div class="stat"><div class="stat-val green">' + d.total_members + '</div><div class="stat-label">Total Members</div></div>';
|
||
h += '</div>';
|
||
|
||
if (d.ix.website) {
|
||
h += '<div style="margin-bottom:.5rem"><a href="' + escAttr(d.ix.website) + '" target="_blank" style="font-size:.8rem">' + escHtml(d.ix.website) + '</a></div>';
|
||
}
|
||
h += '<div style="margin-bottom:1rem"><a href="' + escAttr(d.ix.peeringdb_url) + '" target="_blank" class="ext-link" style="font-size:.8rem">View on PeeringDB</a></div>';
|
||
|
||
// Top members by speed
|
||
if (d.top_members_by_speed && d.top_members_by_speed.length > 0) {
|
||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--purple);margin-bottom:.5rem">Top Members by Speed</div>';
|
||
h += '<div class="scroll-wrap" style="max-height:300px"><table class="tbl"><thead><tr><th>ASN</th><th>Name</th><th>Speed</th></tr></thead><tbody>';
|
||
d.top_members_by_speed.forEach(function(m) {
|
||
h += '<tr><td>' + asnLink(m.asn) + '</td><td>' + escHtml(m.name) + '</td><td>' + escHtml(m.speed_display) + '</td></tr>';
|
||
});
|
||
h += '</tbody></table></div>';
|
||
}
|
||
|
||
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.75rem">Queried in ' + d.meta.duration_ms + 'ms</div>';
|
||
modal.innerHTML = h;
|
||
} catch(e) {
|
||
modal.innerHTML = '<button class="modal-close" onclick="this.closest(\'.modal-overlay\').remove()">×</button>' +
|
||
'<div class="modal-title">IX Detail</div><div style="color:var(--red)">Error: ' + escHtml(e.message) + '</div>';
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Feature 8: ASPA Change Alerting
|
||
// ============================================================
|
||
function checkAspaChanges(asn, currentProviders) {
|
||
var key = 'peercortex_aspa_' + asn;
|
||
var prev = null;
|
||
try { prev = JSON.parse(localStorage.getItem(key)); } catch(e) {}
|
||
|
||
var card = $('aspaAlertCard');
|
||
if (!card) return;
|
||
card.classList.remove('hidden');
|
||
|
||
var h = '';
|
||
var now = new Date().toISOString();
|
||
|
||
if (prev && prev.providers) {
|
||
var prevSet = new Set(prev.providers.map(function(p) { return p.asn; }));
|
||
var currSet = new Set(currentProviders.map(function(p) { return p.asn; }));
|
||
|
||
var added = currentProviders.filter(function(p) { return !prevSet.has(p.asn); });
|
||
var removed = prev.providers.filter(function(p) { return !currSet.has(p.asn); });
|
||
|
||
h += '<div style="font-size:.8rem;color:var(--muted);margin-bottom:.75rem">Last checked: ' + escHtml(prev.timestamp || 'unknown') + '</div>';
|
||
h += '<div style="font-size:.8rem;color:var(--muted);margin-bottom:.75rem">Current check: ' + escHtml(now) + '</div>';
|
||
|
||
if (added.length === 0 && removed.length === 0) {
|
||
h += '<div style="font-size:.85rem;color:var(--green);font-weight:600">No provider changes detected since last check.</div>';
|
||
} else {
|
||
if (added.length > 0) {
|
||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--green);margin:.5rem 0 .3rem">New Providers Detected (' + added.length + ')</div>';
|
||
added.forEach(function(p) {
|
||
h += '<div class="audit-row" style="color:var(--green)">+ ' + asnLink(p.asn) + ' ' + escHtml(p.name || '') + '</div>';
|
||
});
|
||
}
|
||
if (removed.length > 0) {
|
||
h += '<div style="font-size:.8rem;font-weight:600;color:var(--red);margin:.5rem 0 .3rem">Removed Providers (' + removed.length + ')</div>';
|
||
removed.forEach(function(p) {
|
||
h += '<div class="audit-row" style="color:var(--red)">- ' + asnLink(p.asn) + ' ' + escHtml(p.name || '') + '</div>';
|
||
});
|
||
}
|
||
}
|
||
} else {
|
||
h += '<div style="font-size:.8rem;color:var(--muted);margin-bottom:.5rem">First check: ' + escHtml(now) + '</div>';
|
||
h += '<div style="font-size:.85rem;color:var(--muted)">No previous data. Provider snapshot saved for future comparison.</div>';
|
||
}
|
||
|
||
// Save current state
|
||
localStorage.setItem(key, JSON.stringify({
|
||
providers: currentProviders,
|
||
timestamp: now
|
||
}));
|
||
|
||
$('aspaAlertContent').innerHTML = h;
|
||
}
|
||
|
||
// ============================================================
|
||
// Feature 9: Provider Relationship Graph (SVG)
|
||
// ============================================================
|
||
function renderProviderGraph(asn, providers) {
|
||
var graphCard = $('providerGraphCard');
|
||
if (!providers || providers.length === 0) { graphCard.classList.add('hidden'); return; }
|
||
graphCard.classList.remove('hidden');
|
||
|
||
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); });
|
||
|
||
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:#fff;border:1.5px solid ' + color + '55;border-radius:6px;padding:.6rem .85rem;display:flex;align-items:center;gap:.65rem;transition:all .15s;box-shadow:0 1px 3px rgba(0,0,0,.06)" onmouseenter="this.style.transform=\'translateY(-1px)\';this.style.borderColor=\'' + color + '\';this.style.boxShadow=\'0 3px 8px rgba(0,0,0,.1)\'" onmouseleave="this.style.transform=\'none\';this.style.borderColor=\'' + color + '55\';this.style.boxShadow=\'0 1px 3px rgba(0,0,0,.06)\'">' +
|
||
'<div style="width:34px;height:34px;border-radius:50%;background:' + color + '20;border:2px solid ' + color + ';display:flex;align-items:center;justify-content:center;flex-shrink:0"><span style="font-size:.58rem;font-weight:800;color:' + color + '">' + label + '</span></div>' +
|
||
'<div style="flex:1;min-width:0"><div style="font-weight:700;font-size:.85rem;color:#1a202c;letter-spacing:.01em">AS' + p.asn + '</div><div style="font-size:.72rem;color:#4a5568;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:.1rem">' + n + '</div></div>' +
|
||
(f ? '<div style="font-size:.72rem;font-weight:700;color:' + color + ';padding:.15rem .45rem;border-radius:4px;background:' + color + '18;flex-shrink:0;border:1px solid ' + color + '40">' + f + '</div>' : '') +
|
||
'</div>';
|
||
}
|
||
|
||
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>';
|
||
|
||
function section(items, color, title, label, limit) {
|
||
if (!items.length) return '';
|
||
var s = '<div style="margin-bottom:1.5rem"><div style="font-size:.7rem;font-weight:800;color:' + color + ';text-transform:uppercase;letter-spacing:.1em;margin-bottom:.6rem;padding-bottom:.4rem;border-bottom:1.5px solid ' + color + '30;display:flex;align-items:center;gap:.5rem"><span style="width:8px;height:8px;border-radius:50%;background:' + color + ';flex-shrink:0"></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:.72rem;color:#64748b;text-align:center;margin-top:1rem;padding-top:.75rem;border-top:1px solid var(--border)">' + providers.length + ' providers total · Tier 1: ' + tier1.length + ' · Transit: ' + transit.length + ' · IX/Peer: ' + peers.length + '</div>';
|
||
|
||
$('providerGraphContent').innerHTML = h;
|
||
}
|
||
|
||
|
||
// ============================================================
|
||
// Feature 2: Full Compare UI
|
||
// ============================================================
|
||
async function doFullCompare() {
|
||
if (!currentAsn) return;
|
||
var raw2 = $('compareAsn').value.trim().replace(/[^0-9]/g, '');
|
||
if (!raw2) return;
|
||
|
||
var panel = $('fullComparePanel');
|
||
panel.classList.remove('hidden');
|
||
panel.innerHTML = '<div class="card full"><div class="section-loading">Loading full comparison...</div></div>';
|
||
|
||
try {
|
||
var resp = await fetch('/api/compare?asn1=' + currentAsn + '&asn2=' + raw2);
|
||
var d = await resp.json();
|
||
if (d.error) {
|
||
panel.innerHTML = '<div class="card full"><div style="color:var(--red)">' + escHtml(d.error) + '</div></div>';
|
||
return;
|
||
}
|
||
renderFullCompare(d);
|
||
} catch(e) {
|
||
panel.innerHTML = '<div class="card full"><div style="color:var(--red)">Compare failed: ' + escHtml(e.message) + '</div></div>';
|
||
}
|
||
}
|
||
|
||
function renderFullCompare(d) {
|
||
var panel = $('fullComparePanel');
|
||
var h = '';
|
||
|
||
// Header card with both networks
|
||
h += '<div class="card full">';
|
||
h += '<div class="card-title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 20V10M12 20V4M6 20v-6"/></svg> Network Comparison</div>';
|
||
|
||
// Side-by-side metrics
|
||
h += '<div class="compare-grid">';
|
||
|
||
// ASN1 column
|
||
h += '<div class="compare-col">';
|
||
h += '<div class="compare-col-title"><span style="color:var(--blue);font-size:1.2rem;font-weight:700">' + escHtml(d.asn1.name) + '</span></div>';
|
||
h += '<div style="font-size:.85rem;color:var(--muted);margin-bottom:.75rem">AS' + d.asn1.asn + '</div>';
|
||
h += '<div class="compare-metric"><span class="compare-metric-label">IXPs</span><span class="compare-metric-val" style="color:var(--blue)">' + d.asn1.ix_count + '</span></div>';
|
||
h += '<div class="compare-metric"><span class="compare-metric-label">Facilities</span><span class="compare-metric-val" style="color:var(--blue)">' + d.asn1.fac_count + '</span></div>';
|
||
h += '<div class="compare-metric"><span class="compare-metric-label">Upstreams</span><span class="compare-metric-val" style="color:var(--blue)">' + d.asn1.upstream_count + '</span></div>';
|
||
var r1cls = d.asn1.rpki_coverage >= 90 ? 'green' : d.asn1.rpki_coverage >= 70 ? 'orange' : 'red';
|
||
h += '<div class="compare-metric"><span class="compare-metric-label">RPKI Coverage</span><span class="compare-metric-val" style="color:var(--' + r1cls + ')">' + d.asn1.rpki_coverage + '%</span></div>';
|
||
h += '</div>';
|
||
|
||
// ASN2 column
|
||
h += '<div class="compare-col">';
|
||
h += '<div class="compare-col-title"><span style="color:var(--purple);font-size:1.2rem;font-weight:700">' + escHtml(d.asn2.name) + '</span></div>';
|
||
h += '<div style="font-size:.85rem;color:var(--muted);margin-bottom:.75rem">AS' + d.asn2.asn + '</div>';
|
||
h += '<div class="compare-metric"><span class="compare-metric-label">IXPs</span><span class="compare-metric-val" style="color:var(--purple)">' + d.asn2.ix_count + '</span></div>';
|
||
h += '<div class="compare-metric"><span class="compare-metric-label">Facilities</span><span class="compare-metric-val" style="color:var(--purple)">' + d.asn2.fac_count + '</span></div>';
|
||
h += '<div class="compare-metric"><span class="compare-metric-label">Upstreams</span><span class="compare-metric-val" style="color:var(--purple)">' + d.asn2.upstream_count + '</span></div>';
|
||
var r2cls = d.asn2.rpki_coverage >= 90 ? 'green' : d.asn2.rpki_coverage >= 70 ? 'orange' : 'red';
|
||
h += '<div class="compare-metric"><span class="compare-metric-label">RPKI Coverage</span><span class="compare-metric-val" style="color:var(--' + r2cls + ')">' + d.asn2.rpki_coverage + '%</span></div>';
|
||
h += '</div>';
|
||
h += '</div>'; // end compare-grid
|
||
|
||
// Common IXPs
|
||
h += '<div style="margin-top:1.5rem">';
|
||
h += '<div style="font-size:.85rem;font-weight:600;color:var(--green);margin-bottom:.5rem">Common IXPs (' + d.common_ixps.length + ')</div>';
|
||
if (d.common_ixps.length > 0) {
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.75rem">';
|
||
d.common_ixps.forEach(function(ix) { h += '<span class="badge badge-green">' + escHtml(ix.name) + '</span>'; });
|
||
h += '</div>';
|
||
} else {
|
||
h += '<div style="font-size:.8rem;color:var(--muted);margin-bottom:.75rem">No common IXPs</div>';
|
||
}
|
||
|
||
// Only in ASN1
|
||
h += '<div style="font-size:.85rem;font-weight:600;color:var(--blue);margin-bottom:.5rem">Only in AS' + d.asn1.asn + ' (' + d.only_asn1_ixps.length + ')</div>';
|
||
if (d.only_asn1_ixps.length > 0) {
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.75rem">';
|
||
d.only_asn1_ixps.slice(0, 30).forEach(function(ix) { h += '<span class="badge badge-blue">' + escHtml(ix.name) + '</span>'; });
|
||
if (d.only_asn1_ixps.length > 30) h += '<span class="badge badge-blue">+' + (d.only_asn1_ixps.length - 30) + ' more</span>';
|
||
h += '</div>';
|
||
}
|
||
|
||
// Only in ASN2
|
||
h += '<div style="font-size:.85rem;font-weight:600;color:var(--purple);margin-bottom:.5rem">Only in AS' + d.asn2.asn + ' (' + d.only_asn2_ixps.length + ')</div>';
|
||
if (d.only_asn2_ixps.length > 0) {
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.75rem">';
|
||
d.only_asn2_ixps.slice(0, 30).forEach(function(ix) { h += '<span class="badge badge-purple">' + escHtml(ix.name) + '</span>'; });
|
||
if (d.only_asn2_ixps.length > 30) h += '<span class="badge badge-purple">+' + (d.only_asn2_ixps.length - 30) + ' more</span>';
|
||
h += '</div>';
|
||
}
|
||
|
||
// Common upstreams
|
||
if (d.common_upstreams && d.common_upstreams.length > 0) {
|
||
h += '<div style="font-size:.85rem;font-weight:600;color:var(--orange);margin-bottom:.5rem">Common Upstreams (' + d.common_upstreams.length + ')</div>';
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.75rem">';
|
||
d.common_upstreams.forEach(function(u) { h += '<span class="badge badge-orange">' + asnLink(u.asn) + ' ' + escHtml(u.name || '') + '</span>'; });
|
||
h += '</div>';
|
||
}
|
||
|
||
// Common facilities
|
||
if (d.common_facilities && d.common_facilities.length > 0) {
|
||
h += '<div style="font-size:.85rem;font-weight:600;color:var(--cyan);margin-bottom:.5rem">Common Facilities (' + d.common_facilities.length + ')</div>';
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.3rem;margin-bottom:.75rem">';
|
||
d.common_facilities.forEach(function(f) { h += '<span class="badge badge-cyan">' + escHtml(f.name) + '</span>'; });
|
||
h += '</div>';
|
||
}
|
||
|
||
h += '</div>';
|
||
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.75rem">Compared in ' + d.meta.duration_ms + 'ms</div>';
|
||
h += '</div>'; // end card
|
||
|
||
panel.innerHTML = h;
|
||
// Scroll into view
|
||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
|
||
|
||
|
||
|
||
// ============================================================
|
||
// Feature 24: Routing Overview (enhanced bgp.he.net + RIPE visibility)
|
||
// ============================================================
|
||
function renderRoutingOverview(heData, routing) {
|
||
var hasHe = heData && (heData.peer_count || heData.prefixes_v4 || heData.prefixes_v6 || heData.country);
|
||
var hasRouting = routing && (routing.ipv4_prefixes > 0 || routing.ipv6_prefixes > 0);
|
||
if (!hasHe && !hasRouting) { $('bgpHeCard').classList.add('hidden'); return; }
|
||
$('bgpHeCard').classList.remove('hidden');
|
||
|
||
var r = routing || {};
|
||
var he = heData || {};
|
||
var v4p = r.ipv4_prefixes || he.prefixes_v4 || 0;
|
||
var v6p = r.ipv6_prefixes || he.prefixes_v6 || 0;
|
||
var peers = he.peer_count || 0;
|
||
var h = '';
|
||
|
||
// Top stat cards
|
||
h += '<div class="routing-stats-row">';
|
||
h += '<div class="routing-stat-card"><div class="routing-stat-val" style="color:var(--green)">' + v4p + '</div><div class="routing-stat-label">IPv4 Prefixes</div></div>';
|
||
h += '<div class="routing-stat-card"><div class="routing-stat-val" style="color:var(--purple)">' + v6p + '</div><div class="routing-stat-label">IPv6 Prefixes</div></div>';
|
||
if (peers > 0) {
|
||
h += '<div class="routing-stat-card"><div class="routing-stat-val" style="color:var(--cyan)">' + peers + '</div><div class="routing-stat-label">Observed Peers</div></div>';
|
||
}
|
||
h += '</div>';
|
||
|
||
// Propagation bars
|
||
function propColor(pct) { return pct >= 90 ? 'green' : pct >= 70 ? 'orange' : 'red'; }
|
||
function propBar(label, pct, totalPeers, pfxCount) {
|
||
if (pfxCount <= 0) return '';
|
||
var seenBy = Math.round(pct / 100 * totalPeers);
|
||
var barId = 'prop-' + label.replace(/\s/g,'').toLowerCase() + '-' + Math.random().toString(36).substr(2,5);
|
||
var s = '<div class="prop-section">';
|
||
s += '<div class="prop-label">' + label + '</div>';
|
||
s += '<div class="prop-bar-wrap">';
|
||
s += '<div class="prop-bar"><div class="prop-fill ' + propColor(pct) + '" id="' + barId + '" data-width="' + Math.min(pct, 100) + '%"></div></div>';
|
||
s += '<div class="prop-pct" style="color:var(--' + propColor(pct) + ')">' + pct.toFixed(1) + '%</div>';
|
||
s += '</div>';
|
||
if (totalPeers > 0) {
|
||
s += '<div class="prop-detail">Seen by ' + seenBy + ' of ' + totalPeers + ' RIS peers</div>';
|
||
}
|
||
s += '</div>';
|
||
return s;
|
||
}
|
||
|
||
if (r.ipv4_visibility_avg > 0 || r.ipv6_visibility_avg > 0) {
|
||
h += propBar('IPv4 Route Propagation', r.ipv4_visibility_avg || 0, r.total_ris_peers_v4 || 0, v4p);
|
||
h += propBar('IPv6 Route Propagation', r.ipv6_visibility_avg || 0, r.total_ris_peers_v6 || 0, v6p);
|
||
}
|
||
|
||
// Prefix distribution
|
||
var psv4 = (r.prefix_sizes_v4 || []);
|
||
var psv6 = (r.prefix_sizes_v6 || []);
|
||
if (psv4.length > 0 || psv6.length > 0) {
|
||
h += '<div class="prefix-dist">';
|
||
h += '<div class="prefix-dist-label">Prefix Distribution</div>';
|
||
h += '<div class="prefix-badges">';
|
||
if (psv4.length > 0) {
|
||
psv4.forEach(function(p) {
|
||
h += '<span class="prefix-badge" style="border-color:var(--green);color:var(--green)">/' + p.size + ' \u00d7' + p.count + '</span>';
|
||
});
|
||
}
|
||
if (psv6.length > 0) {
|
||
psv6.forEach(function(p) {
|
||
h += '<span class="prefix-badge" style="border-color:var(--purple);color:var(--purple)">/' + p.size + ' \u00d7' + p.count + '</span>';
|
||
});
|
||
}
|
||
h += '</div></div>';
|
||
}
|
||
|
||
// Footer: country + links
|
||
h += '<div class="routing-footer">';
|
||
h += '<div class="routing-footer-left">';
|
||
if (he.country) {
|
||
h += '<span>' + escHtml(he.country) + '</span>';
|
||
}
|
||
if (he.irr_record) {
|
||
h += '<span style="color:var(--dim)">|</span><span style="color:var(--muted)">IRR: ' + escHtml(he.irr_record) + '</span>';
|
||
}
|
||
if (he.looking_glass) {
|
||
h += '<span style="color:var(--dim)">|</span><a class="ext-link" href="' + escAttr(he.looking_glass) + '" target="_blank">Looking Glass</a>';
|
||
}
|
||
h += '</div>';
|
||
if (he.source_url) {
|
||
h += '<a class="ext-link" href="' + escAttr(he.source_url) + '" target="_blank">View on bgp.he.net \u2192</a>';
|
||
}
|
||
h += '</div>';
|
||
|
||
$('bgpHeContent').innerHTML = h;
|
||
|
||
// Animate propagation bars after render
|
||
setTimeout(function() {
|
||
var fills = document.querySelectorAll('.prop-fill[data-width]');
|
||
fills.forEach(function(el) { el.style.width = el.getAttribute('data-width'); });
|
||
}, 50);
|
||
}
|
||
|
||
// Keep backward-compat alias
|
||
function renderBgpHeNet(data) {
|
||
renderRoutingOverview(data, null);
|
||
}
|
||
|
||
// ============================================================
|
||
// Feature 26: IX Traffic Stats
|
||
// ============================================================
|
||
function renderIxTrafficStats(ixConnections) {
|
||
var totalSpeed = (ixConnections || []).reduce(function(sum, c) { return sum + (c.speed_mbps || 0); }, 0);
|
||
var decixConns = (ixConnections || []).filter(function(c) { return (c.ix_name || '').toLowerCase().indexOf('de-cix') >= 0; });
|
||
var h = '';
|
||
if (totalSpeed > 0 || decixConns.length > 0) {
|
||
h += '<div class="ix-traffic-stats">';
|
||
h += '<div class="ix-traffic-stat"><div class="ix-traffic-val">' + fmtSpeed(totalSpeed) + '</div><div class="ix-traffic-label">Total IX Capacity</div></div>';
|
||
if (decixConns.length > 0) {
|
||
var decixSpeed = decixConns.reduce(function(sum, c) { return sum + (c.speed_mbps || 0); }, 0);
|
||
h += '<div class="ix-traffic-stat"><div class="ix-traffic-val">' + decixConns.length + '</div><div class="ix-traffic-label">DE-CIX Ports</div></div>';
|
||
h += '<div class="ix-traffic-stat"><div class="ix-traffic-val">' + fmtSpeed(decixSpeed) + '</div><div class="ix-traffic-label">DE-CIX Capacity</div></div>';
|
||
}
|
||
var ixByName = {};
|
||
(ixConnections || []).forEach(function(c) {
|
||
var name = c.ix_name || 'Unknown';
|
||
if (!ixByName[name]) ixByName[name] = { name: name, total_speed: 0, ports: 0 };
|
||
ixByName[name].total_speed += c.speed_mbps || 0;
|
||
ixByName[name].ports++;
|
||
});
|
||
var topIx = Object.values(ixByName).sort(function(a, b) { return b.total_speed - a.total_speed; }).slice(0, 5);
|
||
if (topIx.length > 0) {
|
||
h += '<div class="ix-traffic-stat" style="flex:1;min-width:200px"><div class="ix-traffic-label" style="text-align:left;margin-bottom:.3rem">Top IXPs by Capacity</div>';
|
||
topIx.forEach(function(ix) {
|
||
var pctOfTotal = totalSpeed > 0 ? Math.round((ix.total_speed / totalSpeed) * 100) : 0;
|
||
h += '<div style="display:flex;align-items:center;gap:.5rem;font-size:.75rem;margin-bottom:.2rem">';
|
||
h += '<span style="color:var(--text-dim);min-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escHtml(ix.name) + '</span>';
|
||
h += '<div class="progress-wrap" style="flex:1;margin:0;height:6px"><div class="progress-bar blue" style="width:' + pctOfTotal + '%"></div></div>';
|
||
h += '<span style="color:var(--muted);min-width:60px;text-align:right">' + fmtSpeed(ix.total_speed) + '</span>';
|
||
h += '</div>';
|
||
});
|
||
h += '</div>';
|
||
}
|
||
h += '</div>';
|
||
}
|
||
return h;
|
||
}
|
||
|
||
// ============================================================
|
||
// Feature 27: WHOIS rendering
|
||
// ============================================================
|
||
async function loadWhoisData(asn) {
|
||
$('whoisContent').innerHTML = '<div class="section-loading">Loading WHOIS data...</div>';
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), 10000);
|
||
try {
|
||
var resp = await fetch('/api/whois?resource=AS' + asn, { signal: ctrl.signal });
|
||
clearTimeout(timer);
|
||
if (!resp.ok) { $('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">WHOIS unavailable (server ' + resp.status + ')</div>'; return; }
|
||
var text = await resp.text();
|
||
if (!text || text[0] === '<') { $('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">WHOIS temporarily unavailable</div>'; return; }
|
||
var d = JSON.parse(text);
|
||
if (d.error) { $('whoisContent').innerHTML = '<div style="color:var(--orange);font-size:.85rem">WHOIS: ' + escHtml(d.error) + '</div>'; return; }
|
||
renderWhois(d);
|
||
} catch (e) {
|
||
clearTimeout(timer);
|
||
$('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">WHOIS temporarily unavailable</div>';
|
||
}
|
||
}
|
||
|
||
function renderWhois(d) {
|
||
var h = '';
|
||
var data = d.data;
|
||
if (!data) { $('whoisContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">No WHOIS data found for ' + escHtml(d.resource) + '</div>'; return; }
|
||
if (Array.isArray(data)) { if (data.length === 0) { $('whoisContent').innerHTML = '<div style="color:var(--muted)">No results.</div>'; return; } data = data[0]; }
|
||
h += '<div style="font-size:.8rem;color:var(--muted);margin-bottom:.75rem">Source: RIPE DB | Type: ' + escHtml(d.type || 'unknown') + '</div>';
|
||
h += '<div class="expand-toggle" onclick="toggleExpand(this)">Show WHOIS details</div>';
|
||
h += '<div class="expand-body"><div class="whois-grid">';
|
||
var fields = [
|
||
['aut-num / inetnum', data.aut_num || data.inetnum || ''],
|
||
['AS Name / Netname', data.as_name || data.netname || ''],
|
||
['Description', (data.descr || []).join(', ')],
|
||
['Organisation', data.org || ''],
|
||
['Country', data.country || ''],
|
||
['Admin-C', (data.admin_c || []).join(', ')],
|
||
['Tech-C', (data.tech_c || []).join(', ')],
|
||
['Maintained By', (data.mnt_by || []).join(', ')],
|
||
['Status', data.status || ''],
|
||
['Created', data.created || ''],
|
||
['Last Modified', data.last_modified || ''],
|
||
['Source', data.source || ''],
|
||
];
|
||
fields.forEach(function(f) {
|
||
if (f[1]) { h += '<div class="whois-key">' + escHtml(f[0]) + '</div><div class="whois-val">' + escHtml(f[1]) + '</div>'; }
|
||
});
|
||
h += '</div>';
|
||
if (data.import && data.import.length > 0) {
|
||
h += '<div style="margin-top:.75rem;font-size:.8rem;font-weight:600;color:var(--orange)">Import Policy</div>';
|
||
h += '<div style="font-family:monospace;font-size:.7rem;color:var(--text-dim);max-height:150px;overflow-y:auto">';
|
||
data.import.forEach(function(imp) { h += escHtml(imp) + '<br>'; });
|
||
h += '</div>';
|
||
}
|
||
if (data.export && data.export.length > 0) {
|
||
h += '<div style="margin-top:.5rem;font-size:.8rem;font-weight:600;color:var(--cyan)">Export Policy</div>';
|
||
h += '<div style="font-family:monospace;font-size:.7rem;color:var(--text-dim);max-height:150px;overflow-y:auto">';
|
||
data.export.forEach(function(exp) { h += escHtml(exp) + '<br>'; });
|
||
h += '</div>';
|
||
}
|
||
if (data.remarks && data.remarks.length > 0) {
|
||
h += '<div style="margin-top:.5rem;font-size:.8rem;font-weight:600;color:var(--muted)">Remarks</div>';
|
||
h += '<div style="font-family:monospace;font-size:.7rem;color:var(--dim);max-height:150px;overflow-y:auto">';
|
||
data.remarks.forEach(function(r) { h += escHtml(r) + '<br>'; });
|
||
h += '</div>';
|
||
}
|
||
h += '</div>';
|
||
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.5rem">Queried in ' + (d.meta ? d.meta.duration_ms : '?') + 'ms</div>';
|
||
$('whoisContent').innerHTML = h;
|
||
}
|
||
|
||
|
||
$('asnInput').addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter') doLookup();
|
||
});
|
||
|
||
(function() {
|
||
renderSearchHistory();
|
||
const params = new URLSearchParams(window.location.search);
|
||
const asn = params.get('asn');
|
||
if (asn) {
|
||
$('asnInput').value = asn;
|
||
doLookup();
|
||
}
|
||
})();
|
||
|
||
async function loadOverviewEnrichment(asn, name, website) {
|
||
var el = document.getElementById('overviewEnrich');
|
||
if (!el) return;
|
||
try {
|
||
var url = '/api/enrich?asn=' + asn + '&name=' + encodeURIComponent(name || '');
|
||
if (website) url += '&website=' + encodeURIComponent(website);
|
||
var resp = await fetch(url);
|
||
if (!resp.ok) return;
|
||
var text = await resp.text();
|
||
if (!text || text[0] === '<') return;
|
||
var d = JSON.parse(text);
|
||
if (!d.description) return;
|
||
var h = '<div style="margin-top:.5rem;padding:.6rem .75rem;background:rgba(0,0,0,.03);border-left:3px solid var(--border);font-size:.82rem;color:var(--text-dim);line-height:1.6;border-radius:0 4px 4px 0">';
|
||
h += '<span style="font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);display:block;margin-bottom:.25rem">About</span>';
|
||
h += escHtml(d.description);
|
||
if (d.wiki_url) h += ' <a href="' + escAttr(d.wiki_url) + '" target="_blank" style="font-size:.72rem;color:var(--blue);white-space:nowrap">Wikipedia →</a>';
|
||
h += '</div>';
|
||
el.innerHTML = h;
|
||
} catch(e) { /* silently fail */ }
|
||
}
|
||
|
||
async function loadHealthReport(asn) {
|
||
$('healthContent').innerHTML = '<div class="section-loading">Running comprehensive validation (13 checks)...</div>';
|
||
const ctrl = new AbortController();
|
||
const timer = setTimeout(() => ctrl.abort(), 20000);
|
||
try {
|
||
var resp = await fetch('/api/validate?asn=' + asn, { signal: ctrl.signal });
|
||
clearTimeout(timer);
|
||
if (!resp.ok) { $('healthContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">Health report unavailable (server ' + resp.status + ')</div>'; return; }
|
||
var text = await resp.text();
|
||
if (!text || text[0] === '<') { $('healthContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">Health report temporarily unavailable</div>'; return; }
|
||
var d = JSON.parse(text);
|
||
if (d.error) {
|
||
$('healthContent').innerHTML = '<div style="color:var(--red);font-size:.85rem">Validation failed: ' + escHtml(d.error) + '</div>';
|
||
return;
|
||
}
|
||
renderHealthReport(d);
|
||
} catch (e) {
|
||
clearTimeout(timer);
|
||
$('healthContent').innerHTML = '<div style="color:var(--muted);font-size:.85rem">Health report temporarily unavailable</div>';
|
||
}
|
||
}
|
||
|
||
|
||
function buildHealthTooltip(key, v, info) {
|
||
var t = '';
|
||
var isFail = v.status === 'fail' || v.status === 'warning';
|
||
|
||
// What was checked
|
||
t += '<div class="tt-section"><span class="tt-label">Checked: </span><span class="tt-value">';
|
||
|
||
if (key === 'bogon') {
|
||
t += 'Scanned ' + (v.total_prefixes_checked || 0) + ' prefixes against RFC 1918/5737/6598 bogon ranges and reserved ASN lists.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">';
|
||
if (v.bogon_prefixes && v.bogon_prefixes.length > 0) {
|
||
t += v.bogon_prefixes.length + ' bogon prefix(es) found: ' + v.bogon_prefixes.map(function(b){return b.prefix}).join(', ');
|
||
} else { t += 'No bogon prefixes detected.'; }
|
||
if (v.bogon_asns_in_paths && v.bogon_asns_in_paths.length > 0) { t += ' Bogon ASNs in paths: ' + v.bogon_asns_in_paths.join(', '); }
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Why it matters: </span><span class="tt-value">Bogon announcements indicate misconfiguration or prefix hijacking.</span></div>';
|
||
if (isFail) t += '<div class="tt-section tt-fix">Fix: Remove bogon prefixes from BGP announcements and add prefix filters.</div>';
|
||
|
||
} else if (key === 'rpki_completeness') {
|
||
t += (v.with_roa || 0) + ' of ' + (v.total_checked || 0) + ' prefixes have valid ROAs (' + (v.coverage_pct || 0) + '% coverage).';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">';
|
||
if (v.over_specific && v.over_specific.length > 0) { t += 'Over-specific prefixes (/25+): ' + v.over_specific.join(', ') + '. '; }
|
||
t += (v.coverage_pct || 0) + '% ROA coverage.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Why it matters: </span><span class="tt-value">ROAs protect against origin hijacking by validating the authorized origin AS for each prefix.</span></div>';
|
||
if (isFail) t += '<div class="tt-section tt-fix">Fix: Create ROAs in your RIR portal for all announced prefixes with correct origin ASN and max-length.</div>';
|
||
|
||
} else if (key === 'resource_cert') {
|
||
t += 'Checked RPKI CA and ROA existence for announced prefixes.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">';
|
||
t += (v.roa_count || 0) + ' ROA(s) found across ' + (v.checked || 0) + ' prefixes \u2014 RPKI CA is ' + (v.has_roas ? 'active' : 'missing') + '.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Why it matters: </span><span class="tt-value">Without an RPKI CA, no origin validation protection exists for your prefixes.</span></div>';
|
||
if (isFail) t += '<div class="tt-section tt-fix">Fix: Set up an RPKI CA through your RIR (RIPE, ARIN, APNIC) and publish ROAs for all prefixes.</div>';
|
||
|
||
} else if (key === 'blocklist') {
|
||
t += 'Scanned ' + (v.checked || 0) + ' prefixes against Spamhaus DROP/EDROP and other blocklists.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">';
|
||
if (v.listed_prefixes && v.listed_prefixes.length > 0) {
|
||
t += v.listed_prefixes.length + ' prefix(es) listed: ';
|
||
v.listed_prefixes.forEach(function(lp) { t += lp.prefix + ' (' + (lp.sources||[]).join(', ') + ') '; });
|
||
} else { t += 'No blocklist entries found.'; }
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Why it matters: </span><span class="tt-value">Blocklisted prefixes indicate abuse, compromise, or hijacking and cause traffic to be rejected.</span></div>';
|
||
if (isFail) t += '<div class="tt-section tt-fix">Fix: Investigate abuse sources, contact the blocklist operator for delisting, and implement BCP38 source validation.</div>';
|
||
|
||
} else if (key === 'irr') {
|
||
t += 'Compared BGP-observed origins with IRR route objects for ' + (v.total_entries || 0) + ' entries.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">';
|
||
t += (v.total_entries || 0) + ' IRR entries checked, ' + (v.mismatch_count || 0) + ' mismatch(es).';
|
||
if (v.mismatches && v.mismatches.length > 0) { t += ' Mismatched: ' + v.mismatches.slice(0,3).map(function(m){return m.prefix}).join(', '); }
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Why it matters: </span><span class="tt-value">IRR objects are used by peers for automated prefix filtering \u2014 mismatches may cause routes to be rejected.</span></div>';
|
||
if (isFail) t += '<div class="tt-section tt-fix">Fix: Create or update route/route6 objects in the appropriate IRR database (RIPE, RADB, etc.).</div>';
|
||
|
||
} else if (key === 'abuse_contact') {
|
||
t += 'Verified abuse contact email in RIR database and checked MX record validity.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">';
|
||
t += 'Contacts: ' + (v.contacts && v.contacts.length > 0 ? v.contacts.join(', ') : 'none') + '. Valid email: ' + (v.has_valid_email ? 'Yes' : 'No') + '.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Why it matters: </span><span class="tt-value">A valid abuse contact is required by RIPE policy ripe-786 and essential for incident response.</span></div>';
|
||
if (isFail) t += '<div class="tt-section tt-fix">Fix: Update the abuse-c attribute on your aut-num object with a valid, monitored email address with working MX records.</div>';
|
||
|
||
} else if (key === 'manrs') {
|
||
t += 'Checked MANRS (Mutually Agreed Norms for Routing Security) participation status.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">';
|
||
t += 'Participant: ' + (v.participant ? 'Yes' : 'No');
|
||
if (v.score !== undefined) t += ', Conformance score: ' + v.score + '%';
|
||
t += '.</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Why it matters: </span><span class="tt-value">MANRS participation is a trust signal demonstrating commitment to routing security best practices.</span></div>';
|
||
if (isFail) t += '<div class="tt-section tt-fix">Fix: Join MANRS at manrs.org \u2014 implement filtering, anti-spoofing, coordination, and global validation.</div>';
|
||
|
||
} else if (key === 'visibility') {
|
||
t += 'Measured prefix visibility across ' + (v.total_rrcs || 0) + ' RIPE RIS route collectors.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">';
|
||
t += 'Seen by ' + (v.seen_by || 0) + ' of ' + (v.total_rrcs || 0) + ' RRCs (' + (v.visibility_score || 0) + '%). Origin changes: ' + (v.origin_changes || 0) + '.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Why it matters: </span><span class="tt-value">Low BGP visibility means routing problems, poor reachability, or possible prefix hijacking.</span></div>';
|
||
if (isFail) t += '<div class="tt-section tt-fix">Fix: Verify BGP sessions are up, check prefix filters at upstreams, and ensure prefixes are not too specific (/25+).</div>';
|
||
|
||
} else if (key === 'rdns') {
|
||
t += 'Checked reverse DNS (PTR) delegation for announced prefixes.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">';
|
||
t += (v.coverage_pct || 0) + '% rDNS coverage across ' + (v.checked || 0) + ' prefixes checked.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Why it matters: </span><span class="tt-value">Missing reverse DNS causes email rejection, breaks traceroute readability, and indicates poor network hygiene.</span></div>';
|
||
if (isFail) t += '<div class="tt-section tt-fix">Fix: Set up rDNS delegations in your RIR portal and configure PTR records on your authoritative DNS servers.</div>';
|
||
|
||
} else if (key === 'rpsl') {
|
||
t += 'Looked up aut-num object in IRR for routing policy declarations.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">';
|
||
t += 'aut-num: ' + (v.exists ? 'Exists' : 'Missing');
|
||
if (v.exists) { t += '. Import policy: ' + (v.has_import ? 'Yes' : 'No') + ', Export policy: ' + (v.has_export ? 'Yes' : 'No'); }
|
||
t += '.</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Why it matters: </span><span class="tt-value">Well-maintained IRR aut-num objects enable automated peering setup and prefix filtering.</span></div>';
|
||
if (isFail) t += '<div class="tt-section tt-fix">Fix: Create an aut-num object with import/export/mp-import/mp-export policies in your RIR\'s IRR database.</div>';
|
||
|
||
} else if (key === 'ix_route_server') {
|
||
t += 'Checked route server participation across ' + (v.total_ix_connections || 0) + ' IX connections.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">';
|
||
t += (v.rs_peer_count || 0) + ' of ' + (v.total_ix_connections || 0) + ' IX connections use route servers (' + (v.rs_peer_pct || 0) + '%).';
|
||
if (v.message) t += ' ' + v.message;
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Why it matters: </span><span class="tt-value">Route server participation means RPKI-based filtering is applied, improving routing security.</span></div>';
|
||
if (isFail) t += '<div class="tt-section tt-fix">Fix: Enable route server peering at your IXPs \u2014 most IXPs offer this for free with RPKI filtering.</div>';
|
||
|
||
} else if (key === 'communities') {
|
||
t += 'Analyzed BGP community usage across ' + (v.total_updates || 0) + ' BGP updates.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">';
|
||
t += (v.unique_communities || 0) + ' unique communities found.';
|
||
if (v.well_known_detected && v.well_known_detected.length > 0) {
|
||
t += ' Well-known: ' + v.well_known_detected.map(function(c){return c.community + ' (' + c.well_known + ')'}).join(', ') + '.';
|
||
}
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Why it matters: </span><span class="tt-value">BGP communities reveal routing policy sophistication and enable traffic engineering control.</span></div>';
|
||
if (isFail) t += '<div class="tt-section tt-fix">Fix: Implement BGP community tagging for traffic engineering and use well-known communities (blackhole, no-export) where appropriate.</div>';
|
||
|
||
} else if (key === 'geolocation') {
|
||
t += 'Compared prefix geolocation data with PeeringDB facility locations.';
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">';
|
||
t += 'Geo countries: ' + ((v.geo_countries || []).join(', ') || 'unknown');
|
||
if (v.pdb_facility_countries && v.pdb_facility_countries.length > 0) { t += '. PDB facilities: ' + v.pdb_facility_countries.join(', '); }
|
||
if (v.country_mismatches && v.country_mismatches.length > 0) { t += '. Mismatches: ' + v.country_mismatches.join(', '); }
|
||
t += '.</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Why it matters: </span><span class="tt-value">Geolocation mismatches between announced prefixes and facility locations can indicate prefix hijacking.</span></div>';
|
||
if (isFail) t += '<div class="tt-section tt-fix">Fix: Update geofeed data, verify PeeringDB facility records, and check for unauthorized prefix announcements.</div>';
|
||
|
||
} else {
|
||
t += info.desc;
|
||
t += '</span></div>';
|
||
t += '<div class="tt-section"><span class="tt-label">Result: </span><span class="tt-value">' + (v.message || v.status || 'Unknown') + '</span></div>';
|
||
}
|
||
|
||
return t;
|
||
}
|
||
|
||
function renderHealthReport(d) {
|
||
var h = '';
|
||
var score = d.health_score || 0;
|
||
var scoreColor = score >= 80 ? 'var(--green)' : score >= 60 ? 'var(--orange)' : 'var(--red)';
|
||
var circumference = 2 * Math.PI * 54;
|
||
var offset = circumference - (score / 100) * circumference;
|
||
|
||
h += '<div style="display:grid;grid-template-columns:200px 1fr;gap:2rem;align-items:start;margin-bottom:1.5rem">';
|
||
|
||
// Gauge
|
||
h += '<div>';
|
||
h += '<div class="health-gauge">';
|
||
h += '<svg viewBox="0 0 120 120"><circle class="health-gauge-bg" cx="60" cy="60" r="54"/>';
|
||
h += '<circle class="health-gauge-fill" cx="60" cy="60" r="54" stroke="' + scoreColor + '" stroke-dasharray="' + circumference.toFixed(1) + '" stroke-dashoffset="' + offset.toFixed(1) + '"/></svg>';
|
||
h += '<div class="health-gauge-text"><div class="health-gauge-score" style="color:' + scoreColor + '">' + score + '</div><div class="health-gauge-label">Health Score</div></div>';
|
||
h += '</div>';
|
||
h += '<div style="text-align:center;font-size:.8rem;color:var(--muted);margin-top:.5rem">' + escHtml(d.name || '') + '</div>';
|
||
h += '</div>';
|
||
|
||
// Checks grid
|
||
h += '<div><div class="health-checks">';
|
||
|
||
var checkLabels = {
|
||
bogon: { label: "Bogon Detection", desc: "RFC 1918, RFC 5737, CGN & reserved ASNs" },
|
||
irr: { label: "IRR Validation", desc: "BGP vs IRR origin consistency" },
|
||
rpki_completeness: { label: "RPKI ROA Coverage", desc: "ROA coverage and over-specific prefixes" },
|
||
abuse_contact: { label: "Abuse Contact", desc: "Valid abuse email in RIR database" },
|
||
blocklist: { label: "Blocklist Check", desc: "Spamhaus DROP and blocklist status" },
|
||
manrs: { label: "MANRS Compliance", desc: "MANRS participation and conformance" },
|
||
rdns: { label: "Reverse DNS", desc: "rDNS delegation for prefixes" },
|
||
visibility: { label: "BGP Visibility", desc: "Route visibility across RIS collectors" },
|
||
communities: { label: "BGP Communities", desc: "Well-known community usage" },
|
||
geolocation: { label: "Geolocation", desc: "Geo vs PeeringDB facility verification" },
|
||
rpsl: { label: "IRR Object", desc: "aut-num with routing policy" },
|
||
ix_route_server: { label: "IX Route Servers", desc: "Route server peering participation" },
|
||
resource_cert: { label: "Resource Cert", desc: "RPKI CA / ROA existence" }
|
||
};
|
||
|
||
var checkOrder = ["bogon","rpki_completeness","resource_cert","blocklist","irr","abuse_contact","manrs","visibility","rdns","rpsl","ix_route_server","communities","geolocation"];
|
||
var validations = d.validations || {};
|
||
|
||
checkOrder.forEach(function(key) {
|
||
var v = validations[key];
|
||
if (!v) return;
|
||
var info = checkLabels[key] || { label: key, desc: "" };
|
||
var icon = v.status === "pass" ? "\u2705" : v.status === "warning" ? "\u26A0\uFE0F" : v.status === "fail" ? "\u274C" : "\u2753";
|
||
var clr = v.status === "pass" ? "var(--green)" : v.status === "warning" ? "var(--orange)" : v.status === "fail" ? "var(--red)" : "var(--muted)";
|
||
var txt = v.status === "pass" ? "Pass" : v.status === "warning" ? "Warn" : v.status === "fail" ? "Fail" : "Err";
|
||
var tt = buildHealthTooltip(key, v, info);
|
||
h += '<div class="health-check-item">';
|
||
h += '<span class="health-check-icon">' + icon + '</span>';
|
||
h += '<span class="health-check-name">' + info.label + '</span>';
|
||
h += '<span class="health-check-score" style="color:' + clr + '">' + txt + '</span>';
|
||
h += '<div class="health-tooltip">' + tt + '</div>';
|
||
h += '</div>';
|
||
});
|
||
|
||
h += '</div></div></div>';
|
||
|
||
// === DATA ACCURACY SECTION ===
|
||
h += '<div style="margin:1.5rem 0;padding:1rem;background:rgba(122,162,247,.06);border:1px solid rgba(122,162,247,.15);border-radius:10px">';
|
||
h += '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.75rem"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#7aa2f7" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>';
|
||
h += '<span style="font-size:.85rem;font-weight:600;color:#7aa2f7">Score Breakdown — Why ' + score + '/100?</span></div>';
|
||
|
||
// Score calculation table
|
||
h += '<table style="width:100%;font-size:.78rem;border-collapse:collapse">';
|
||
h += '<tr style="color:var(--muted);border-bottom:1px solid var(--border)"><th style="text-align:left;padding:.3rem .5rem">Check</th><th style="width:60px;text-align:center;padding:.3rem">Weight</th><th style="width:60px;text-align:center;padding:.3rem">Earned</th><th style="text-align:left;padding:.3rem .5rem">Reason</th></tr>';
|
||
|
||
var weightMap = { bogon: 15, irr: 10, rpki_completeness: 15, abuse_contact: 5, blocklist: 15, manrs: 5, rdns: 5, visibility: 10, rpsl: 5, ix_route_server: 5, resource_cert: 10 };
|
||
var totalW = 0, totalE = 0;
|
||
checkOrder.forEach(function(key) {
|
||
var v = validations[key];
|
||
if (!v) return;
|
||
var w = weightMap[key] || 0;
|
||
if (w === 0) return; // communities, geolocation not scored
|
||
var info = checkLabels[key] || { label: key };
|
||
var earned = 0;
|
||
var reason = '';
|
||
if (v.status === 'info') {
|
||
reason = '<span style="color:var(--muted)">Excluded — unable to verify (API requires authentication)</span>';
|
||
// info checks excluded from scoring
|
||
} else {
|
||
if (v.status === 'pass') { earned = w; reason = '<span style="color:var(--green)">Full points</span>'; }
|
||
else if (v.status === 'warning') { earned = Math.round(w * 0.5); reason = '<span style="color:var(--orange)">' + escHtml(v.note || v.message || 'Partial compliance') + '</span>'; }
|
||
else { earned = 0; reason = '<span style="color:var(--red)">' + escHtml(v.note || v.message || 'Check failed') + '</span>'; }
|
||
totalW += w;
|
||
totalE += earned;
|
||
}
|
||
var statusIcon = v.status === 'pass' ? '✅' : v.status === 'warning' ? '⚠️' : v.status === 'fail' ? '❌' : 'ℹ️';
|
||
h += '<tr style="border-bottom:1px solid rgba(255,255,255,.03)"><td style="padding:.35rem .5rem">' + statusIcon + ' ' + info.label + '</td>';
|
||
h += '<td style="text-align:center;padding:.35rem;color:var(--muted)">' + (v.status === 'info' ? '—' : w) + '</td>';
|
||
h += '<td style="text-align:center;padding:.35rem;font-weight:600;color:' + (earned === w ? 'var(--green)' : earned > 0 ? 'var(--orange)' : v.status === 'info' ? 'var(--muted)' : 'var(--red)') + '">' + (v.status === 'info' ? '—' : earned) + '</td>';
|
||
h += '<td style="padding:.35rem .5rem;font-size:.72rem">' + reason + '</td></tr>';
|
||
});
|
||
|
||
var calcScore = totalW > 0 ? Math.round((totalE / totalW) * 100) : 0;
|
||
h += '<tr style="border-top:2px solid var(--border);font-weight:700"><td style="padding:.4rem .5rem">Total</td>';
|
||
h += '<td style="text-align:center;padding:.4rem">' + totalW + '</td>';
|
||
h += '<td style="text-align:center;padding:.4rem;color:' + scoreColor + '">' + totalE + '</td>';
|
||
h += '<td style="padding:.4rem .5rem;color:' + scoreColor + '">' + calcScore + '/100 = ' + totalE + '/' + totalW + ' weighted points</td></tr>';
|
||
h += '</table>';
|
||
|
||
// Data source note
|
||
h += '<div style="margin-top:.75rem;padding-top:.6rem;border-top:1px solid rgba(255,255,255,.05);font-size:.72rem;color:var(--muted)">';
|
||
h += '<strong>Data Sources:</strong> PeeringDB (profile, IX, facilities), RIPE Stat (prefixes, neighbours, visibility, RPKI), ';
|
||
h += 'RIPE Atlas (probes), Cloudflare RPKI (ROA + ASPA), MANRS Observatory, RIPE DB (IRR objects).<br>';
|
||
h += '<strong>Scoring:</strong> Each check has a weight reflecting its importance to routing security. ';
|
||
h += '"Pass" = full weight, "Warning" = 50%, "Fail" = 0%, "Info" = excluded (unable to verify). ';
|
||
h += 'Score = earned / total_weight × 100. Checks marked "info" (e.g., MANRS when API is unavailable) are excluded from the denominator to avoid unfair penalties.';
|
||
h += '</div></div>';
|
||
|
||
// Expandable details
|
||
h += '<div class="expand-toggle" onclick="toggleExpand(this)">Show detailed validation results</div>';
|
||
h += '<div class="expand-body">';
|
||
|
||
checkOrder.forEach(function(key) {
|
||
var v = validations[key];
|
||
if (!v) return;
|
||
var info = checkLabels[key] || { label: key, desc: "" };
|
||
var icon = v.status === "pass" ? "\u2705" : v.status === "warning" ? "\u26A0\uFE0F" : v.status === "fail" ? "\u274C" : "\u2753";
|
||
var bc = v.status === "pass" ? "var(--green)" : v.status === "warning" ? "var(--orange)" : v.status === "fail" ? "var(--red)" : "var(--border)";
|
||
|
||
h += '<div style="padding:.75rem;margin-bottom:.5rem;background:var(--bg);border:1px solid var(--border);border-left:3px solid ' + bc + ';border-radius:8px">';
|
||
h += '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.4rem"><span>' + icon + '</span><strong style="font-size:.85rem">' + info.label + '</strong><span style="font-size:.7rem;color:var(--muted);margin-left:auto">' + info.desc + '</span></div>';
|
||
h += '<div style="font-size:.8rem;color:var(--text-dim)">';
|
||
|
||
if (key === "bogon") {
|
||
h += 'Prefixes checked: ' + (v.total_prefixes_checked || 0);
|
||
if (v.bogon_prefixes && v.bogon_prefixes.length > 0) {
|
||
h += '<div style="color:var(--red);margin-top:.3rem">Bogon prefixes:</div>';
|
||
v.bogon_prefixes.forEach(function(b) { h += '<div style="font-family:monospace;font-size:.75rem;margin-left:1rem">' + escHtml(b.prefix) + ' - ' + escHtml(b.reason) + '</div>'; });
|
||
} else { h += '<div style="color:var(--green)">No bogon prefixes</div>'; }
|
||
if (v.bogon_asns_in_paths && v.bogon_asns_in_paths.length > 0) { h += '<div style="color:var(--red)">Bogon ASNs: ' + v.bogon_asns_in_paths.join(', ') + '</div>'; }
|
||
} else if (key === "irr") {
|
||
h += 'IRR entries: ' + (v.total_entries || 0) + ', Mismatches: ' + (v.mismatch_count || 0);
|
||
if (v.mismatches && v.mismatches.length > 0) { v.mismatches.slice(0,5).forEach(function(m) { h += '<div style="font-family:monospace;font-size:.75rem;margin:.2rem 0">' + escHtml(m.prefix) + '</div>'; }); }
|
||
} else if (key === "rpki_completeness") {
|
||
var cc = (v.coverage_pct||0) >= 90 ? 'var(--green)' : 'var(--orange)';
|
||
h += 'Coverage: <strong style="color:' + cc + '">' + (v.coverage_pct||0) + '%</strong> (' + (v.with_roa||0) + '/' + (v.total_checked||0) + ')';
|
||
if (v.over_specific && v.over_specific.length > 0) { h += '<div style="color:var(--orange)">Over-specific (/25+): ' + v.over_specific.join(', ') + '</div>'; }
|
||
} else if (key === "abuse_contact") {
|
||
h += 'Contacts: ' + (v.contacts ? v.contacts.join(', ') : 'none');
|
||
h += '<br>Valid email: ' + (v.has_valid_email ? '<span style="color:var(--green)">Yes</span>' : '<span style="color:var(--red)">No</span>');
|
||
} else if (key === "blocklist") {
|
||
h += 'Checked: ' + (v.checked || 0);
|
||
if (v.listed_prefixes && v.listed_prefixes.length > 0) { h += '<div style="color:var(--red)">Listed:</div>'; v.listed_prefixes.forEach(function(lp) { h += '<div style="font-family:monospace;font-size:.75rem;margin-left:1rem">' + escHtml(lp.prefix) + ' on: ' + (lp.sources||[]).join(', ') + '</div>'; }); }
|
||
else { h += '<div style="color:var(--green)">No blocklist entries</div>'; }
|
||
} else if (key === "manrs") {
|
||
h += 'Participant: ' + (v.participant ? '<span style="color:var(--green)">Yes</span>' : '<span style="color:var(--orange)">No</span>');
|
||
if (v.score !== undefined) { h += ', Score: ' + v.score; }
|
||
} else if (key === "rdns") {
|
||
h += 'Coverage: ' + (v.coverage_pct || 0) + '% (' + (v.checked || 0) + ' checked)';
|
||
} else if (key === "visibility") {
|
||
h += 'Score: ' + (v.visibility_score || 0) + '%, Seen by ' + (v.seen_by || 0) + '/' + (v.total_rrcs || 0) + ' RRCs, Origin changes: ' + (v.origin_changes || 0);
|
||
} else if (key === "communities") {
|
||
h += 'Updates: ' + (v.total_updates || 0) + ', Unique communities: ' + (v.unique_communities || 0);
|
||
if (v.well_known_detected && v.well_known_detected.length > 0) { h += '<br>Well-known: '; v.well_known_detected.forEach(function(c) { h += '<span class="badge badge-orange">' + escHtml(c.community) + ' (' + c.well_known + ')</span> '; }); }
|
||
} else if (key === "geolocation") {
|
||
h += 'Geo countries: ' + (v.geo_countries || []).join(', ');
|
||
if (v.pdb_facility_countries && v.pdb_facility_countries.length > 0) { h += '<br>PDB countries: ' + v.pdb_facility_countries.join(', '); }
|
||
if (v.country_mismatches && v.country_mismatches.length > 0) { h += '<br><span style="color:var(--orange)">Mismatches: ' + v.country_mismatches.join(', ') + '</span>'; }
|
||
} else if (key === "rpsl") {
|
||
h += 'aut-num: ' + (v.exists ? '<span style="color:var(--green)">Exists</span>' : '<span style="color:var(--red)">Missing</span>');
|
||
if (v.exists) { h += ', Import: ' + (v.has_import ? 'Yes' : 'No') + ', Export: ' + (v.has_export ? 'Yes' : 'No'); }
|
||
} else if (key === "ix_route_server") {
|
||
h += 'IX connections: ' + (v.total_ix_connections || 0) + ', RS peers: ' + (v.rs_peer_count || 0) + ' (' + (v.rs_peer_pct || 0) + '%)';
|
||
if (v.message) { h += '<br><span style="color:var(--muted)">' + escHtml(v.message) + '</span>'; }
|
||
} else if (key === "resource_cert") {
|
||
h += 'ROAs: ' + (v.has_roas ? '<span style="color:var(--green)">Yes</span>' : '<span style="color:var(--red)">No RPKI CA</span>') + ' (' + (v.roa_count || 0) + '/' + (v.checked || 0) + ')';
|
||
} else {
|
||
h += escHtml(JSON.stringify(v).substring(0, 200));
|
||
}
|
||
|
||
h += '</div></div>';
|
||
});
|
||
|
||
h += '</div>';
|
||
h += '<div style="font-size:.7rem;color:var(--dim);margin-top:.75rem">Validation completed in ' + (d.meta ? d.meta.duration_ms : '?') + 'ms | ' + (d.meta ? d.meta.total_prefixes : '?') + ' prefixes, ' + (d.meta ? d.meta.prefixes_sampled : '?') + ' sampled</div>';
|
||
$('healthContent').innerHTML = h;
|
||
}
|
||
|
||
|
||
function loadPeeringRecommendations(asn, ixConnections, lookupData) {
|
||
if (!ixConnections || ixConnections.length === 0) return;
|
||
lookupData = lookupData || {};
|
||
$('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; });
|
||
|
||
// Get existing BGP neighbours (to filter out already-established peering)
|
||
var existingPeers = new Set();
|
||
var nb = lookupData.neighbours || {};
|
||
(nb.upstreams || []).forEach(function(n) { existingPeers.add(n.asn); });
|
||
(nb.downstreams || []).forEach(function(n) { existingPeers.add(n.asn); });
|
||
(nb.peers || []).forEach(function(n) { existingPeers.add(n.asn); });
|
||
|
||
// 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 via lightweight quick-ix endpoint (1h cached)
|
||
Promise.all(topNets.map(function(targetAsn) {
|
||
return fetch('/api/quick-ix?asn=' + targetAsn).then(function(r) { return r.json(); }).then(function(d) {
|
||
var name = d.name || ('AS' + targetAsn);
|
||
var theirIx = d.ix_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); });
|
||
results.sort(function(a, b) { return b.common_ixps.length - a.common_ixps.length; });
|
||
|
||
// Split into 3 categories: established, potential new, no shared IXP
|
||
var established = results.filter(function(r) { return r.common_ixps.length > 0 && existingPeers.has(r.asn); });
|
||
var potential = results.filter(function(r) { return r.common_ixps.length > 0 && !existingPeers.has(r.asn); });
|
||
var without = results.filter(function(r) { return r.common_ixps.length === 0; });
|
||
|
||
var h = '';
|
||
|
||
// NEW PEERING OPPORTUNITIES (not yet peering, shared IXPs exist)
|
||
if (potential.length > 0) {
|
||
h += '<div style="font-size:.7rem;font-weight:700;color:var(--green);text-transform:uppercase;letter-spacing:.08em;margin-bottom:.5rem">\uD83D\uDE80 New Peering Opportunities (' + potential.length + ')</div>';
|
||
h += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:.5rem;margin-bottom:1rem">';
|
||
potential.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>';
|
||
}
|
||
|
||
// ALREADY ESTABLISHED (peering exists + shared IXPs)
|
||
if (established.length > 0) {
|
||
h += '<div style="font-size:.7rem;font-weight:700;color:#7aa2f7;text-transform:uppercase;letter-spacing:.08em;margin-bottom:.5rem">\u2705 Already Peering (' + established.length + ')</div>';
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:1rem">';
|
||
established.forEach(function(r) {
|
||
h += '<span onclick="lookupAsn(' + r.asn + ')" style="cursor:pointer;font-size:.7rem;padding:.25rem .5rem;border-radius:6px;background:rgba(122,162,247,.08);border:1px solid rgba(122,162,247,.15);color:#7aa2f7">' + escHtml(r.name) + ' (AS' + r.asn + ') — ' + r.common_ixps.length + ' IXPs</span>';
|
||
});
|
||
h += '</div>';
|
||
}
|
||
|
||
// NO SHARED IXP
|
||
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) {
|
||
var alreadyPeer = existingPeers.has(r.asn) ? ' \u2714 peered via transit' : '';
|
||
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 + ')' + alreadyPeer + '</span>';
|
||
});
|
||
h += '</div>';
|
||
}
|
||
|
||
if (potential.length === 0 && established.length > 0) {
|
||
h += '<div style="margin-top:.75rem;padding:.5rem;background:rgba(156,206,106,.06);border:1px solid rgba(156,206,106,.1);border-radius:8px;font-size:.75rem;color:var(--green);text-align:center">\u2705 Already peering with all top networks at shared IXPs</div>';
|
||
}
|
||
|
||
h += '<div style="font-size:.65rem;color:var(--dim);margin-top:.75rem;text-align:center">Compared with top 20 global networks — existing peering detected via BGP neighbour data</div>';
|
||
$('peeringRecContent').innerHTML = h;
|
||
});
|
||
}
|
||
|
||
// ── Terminal Feedback Widget ──────────────────────────────────────────────────
|
||
var termOpen = false;
|
||
var termStep = 0;
|
||
var termData = { category:'', message:'', name:'' };
|
||
|
||
// Safe DOM builder: parses only <span style="...">text</span> from trusted template strings.
|
||
// User-supplied text is always passed via textContent — never interpreted as HTML.
|
||
function termPrint(template, fallbackColor) {
|
||
var out = document.getElementById('termOutput');
|
||
var line = document.createElement('div');
|
||
if (fallbackColor) line.style.color = fallbackColor;
|
||
// Split on <span> tags from our own templates
|
||
var parts = template.split(/(<span[^>]*>[^<]*<\/span>)/g);
|
||
parts.forEach(function(part) {
|
||
var m = part.match(/^<span([^>]*)>(.*?)<\/span>$/);
|
||
if (m) {
|
||
var span = document.createElement('span');
|
||
var sm = m[1].match(/style="([^"]*)"/);
|
||
if (sm) span.style.cssText = sm[1];
|
||
span.textContent = m[2]; // text content, never HTML
|
||
line.appendChild(span);
|
||
} else if (part) {
|
||
line.appendChild(document.createTextNode(part));
|
||
}
|
||
});
|
||
out.appendChild(line);
|
||
out.scrollTop = out.scrollHeight;
|
||
}
|
||
|
||
function termClear() { document.getElementById('termOutput').innerHTML = ''; }
|
||
|
||
function toggleTerm() {
|
||
var p = document.getElementById('termPanel');
|
||
termOpen = !termOpen;
|
||
p.style.display = termOpen ? 'flex' : 'none';
|
||
if (termOpen) { termBoot(); setTimeout(function(){ document.getElementById('termInput').focus(); }, 80); }
|
||
}
|
||
function closeTerm() {
|
||
termOpen = false;
|
||
document.getElementById('termPanel').style.display = 'none';
|
||
}
|
||
// Auto-open terminal on page load with 75% opacity
|
||
window.addEventListener('load', function() {
|
||
setTimeout(function() {
|
||
if (!termOpen) { toggleTerm(); }
|
||
}, 900);
|
||
});
|
||
|
||
function termBoot() {
|
||
termClear();
|
||
termStep = 0;
|
||
termData = { category:'', message:'', name:'' };
|
||
document.getElementById('termPrompt').textContent = '›';
|
||
var G = 'color:var(--purple)', DIM = 'color:var(--dim)', MUT = 'color:var(--muted)';
|
||
var lines = [
|
||
'<span style="' + DIM + '">────────────────────────────────────────────</span>',
|
||
' <span style="' + G + ';font-weight:600">PeerCortex Feedback Terminal</span> <span style="' + MUT + '">v0.6.9</span>',
|
||
'<span style="' + DIM + '">────────────────────────────────────────────</span>',
|
||
'',
|
||
'Got feedback? A bug? A wild idea?',
|
||
'This goes straight to the editorial team.',
|
||
''
|
||
];
|
||
var delay = 0;
|
||
lines.forEach(function(l) { setTimeout(function(){ termPrint(l); }, delay); delay += 35; });
|
||
// Auto-start wizard after boot sequence
|
||
var autoDelay = lines.length * 35 + 350;
|
||
setTimeout(function() {
|
||
if (termStep === 0) { termStartWizard(); }
|
||
}, autoDelay);
|
||
}
|
||
|
||
function termStartWizard() {
|
||
var G = 'color:var(--purple)';
|
||
termStep = 1;
|
||
document.getElementById('termPrompt').textContent = '›';
|
||
termPrint('');
|
||
termPrint('Select category:');
|
||
termPrint(' <span style="' + G + '">1</span> Bug Report');
|
||
termPrint(' <span style="' + G + '">2</span> Feature Request');
|
||
termPrint(' <span style="' + G + '">3</span> Design Feedback');
|
||
termPrint(' <span style="' + G + '">4</span> General');
|
||
termPrint('');
|
||
}
|
||
|
||
function termKeydown(e) {
|
||
if (e.key !== 'Enter') return;
|
||
var inp = document.getElementById('termInput');
|
||
var val = inp.value.trim();
|
||
inp.value = '';
|
||
var prompt = document.getElementById('termPrompt').textContent;
|
||
var G = 'color:var(--purple)', Y = 'color:var(--text)', MUT = 'color:var(--muted)';
|
||
// Echo: prompt + user text (built with DOM — no injection risk)
|
||
(function(){
|
||
var out = document.getElementById('termOutput');
|
||
var d = document.createElement('div');
|
||
var sp = document.createElement('span');
|
||
sp.style.color = 'var(--dim)';
|
||
sp.textContent = prompt;
|
||
d.appendChild(sp);
|
||
d.appendChild(document.createTextNode(' ' + val));
|
||
out.appendChild(d);
|
||
out.scrollTop = out.scrollHeight;
|
||
})();
|
||
|
||
if (termStep === 0) {
|
||
var cmd = val.toLowerCase();
|
||
if (cmd === 'send') { termStartWizard(); }
|
||
else if (cmd === 'help') { termBoot(); }
|
||
else if (cmd === 'clear') { termClear(); }
|
||
else if (cmd !== '') {
|
||
var out2 = document.getElementById('termOutput');
|
||
var err = document.createElement('div');
|
||
err.style.color = 'var(--red)';
|
||
err.textContent = 'bash: ' + val + ': command not found';
|
||
out2.appendChild(err);
|
||
out2.scrollTop = out2.scrollHeight;
|
||
}
|
||
} else if (termStep === 1) {
|
||
var cats = {'1':'Bug Report','2':'Feature Request','3':'Design Feedback','4':'General'};
|
||
if (cats[val]) {
|
||
termData.category = cats[val];
|
||
termPrint('');
|
||
termPrint('<span style="' + G + '">✓</span> Category: <span style="' + Y + '">' + cats[val] + '</span>');
|
||
termPrint('');
|
||
termPrint('Describe the issue or idea:');
|
||
termStep = 2;
|
||
document.getElementById('termPrompt').textContent = '›';
|
||
} else { termPrint('Enter 1, 2, 3, or 4.', 'var(--orange)'); }
|
||
|
||
} else if (termStep === 2) {
|
||
if (val.length < 5) {
|
||
termPrint('Too short — write at least a few words.', 'var(--orange)');
|
||
} else {
|
||
termData.message = val;
|
||
termPrint('');
|
||
termPrint('<span style="' + G + '">✓</span> Message recorded.');
|
||
termPrint('');
|
||
termPrint('Your name or handle: <span style="' + MUT + '">(press Enter to stay Anonymous)</span>');
|
||
termStep = 3;
|
||
}
|
||
|
||
} else if (termStep === 3) {
|
||
termData.name = val || 'Anonymous';
|
||
termStep = 0;
|
||
document.getElementById('termPrompt').textContent = '›';
|
||
termPrint('');
|
||
termPrint('<span style="' + G + '">Transmitting report...</span>');
|
||
fetch('/api/feedback', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ category: termData.category, message: termData.message, name: termData.name, asn: currentAsn || null })
|
||
}).then(function(r){ return r.json(); }).then(function(d){
|
||
if (d.ok) {
|
||
termPrint('');
|
||
termPrint('<span style="' + G + '">████████████████████</span> 100%');
|
||
termPrint('');
|
||
(function(){
|
||
var out3 = document.getElementById('termOutput');
|
||
var ok = document.createElement('div');
|
||
ok.style.color = 'var(--green)';
|
||
ok.textContent = '✓ Feedback transmitted. Thank you, ' + termData.name + '.';
|
||
out3.appendChild(ok);
|
||
var sub = document.createElement('div');
|
||
sub.style.color = 'var(--muted)';
|
||
sub.textContent = 'Your report helps improve PeerCortex.';
|
||
out3.appendChild(sub);
|
||
out3.scrollTop = out3.scrollHeight;
|
||
})();
|
||
termPrint('');
|
||
termPrint('Type <span style="' + G + '">send</span> for another report.');
|
||
} else {
|
||
(function(){
|
||
var out4 = document.getElementById('termOutput');
|
||
var er = document.createElement('div');
|
||
er.style.color = 'var(--red)';
|
||
er.textContent = 'Error: ' + (d.error || 'unknown');
|
||
out4.appendChild(er); out4.scrollTop = out4.scrollHeight;
|
||
})();
|
||
}
|
||
}).catch(function(){
|
||
(function(){
|
||
var out5 = document.getElementById('termOutput');
|
||
var ne = document.createElement('div');
|
||
ne.style.color = 'var(--red)';
|
||
ne.textContent = 'Network error — please try again.';
|
||
out5.appendChild(ne); out5.scrollTop = out5.scrollHeight;
|
||
})();
|
||
});
|
||
}
|
||
}
|
||
|
||
// Unique visitor counter
|
||
fetch('/api/visitors').then(r=>r.json()).then(d=>{
|
||
const el = document.getElementById('visitor-count');
|
||
if(el && d.visitors) el.textContent = d.visitors.toLocaleString() + ' UV';
|
||
}).catch(()=>{});
|
||
|
||
// ── Dark Mode ─────────────────────────────────────────────────
|
||
function toggleDark() {
|
||
const dark = document.body.classList.toggle('dark');
|
||
localStorage.setItem('pc_dark', dark ? '1' : '0');
|
||
document.getElementById('darkToggle').textContent = dark ? '◑ LIGHT' : '◐ DARK';
|
||
}
|
||
(function(){
|
||
if (localStorage.getItem('pc_dark') === '1') {
|
||
document.body.classList.add('dark');
|
||
const btn = document.getElementById('darkToggle');
|
||
if (btn) btn.textContent = '◑ LIGHT';
|
||
}
|
||
})();
|
||
|
||
// ── Share ──────────────────────────────────────────────────────
|
||
function getShareUrl() {
|
||
return currentAsn ? (location.origin + '/?asn=' + currentAsn) : location.href;
|
||
}
|
||
function getShareText() {
|
||
return currentAsn ? 'AS' + currentAsn + ' BGP analysis on PeerCortex — routing intelligence' : 'PeerCortex — BGP & routing intelligence';
|
||
}
|
||
function toggleShareMenu() {
|
||
const menu = document.getElementById('shareMenu');
|
||
menu.classList.toggle('open');
|
||
// Close on outside click
|
||
setTimeout(() => document.addEventListener('click', function close(e) {
|
||
if (!document.getElementById('shareDropdown').contains(e.target)) {
|
||
menu.classList.remove('open');
|
||
document.removeEventListener('click', close);
|
||
}
|
||
}), 10);
|
||
}
|
||
function shareCopy() {
|
||
const url = getShareUrl();
|
||
const el = document.getElementById('shareNavLink');
|
||
document.getElementById('shareMenu').classList.remove('open');
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(url).then(() => {
|
||
const orig = el.textContent; el.textContent = 'Copied!'; el.style.color = 'var(--green)';
|
||
setTimeout(() => { el.textContent = orig; el.style.color = ''; }, 2000);
|
||
}).catch(() => prompt('Link:', url));
|
||
} else { prompt('Link:', url); }
|
||
}
|
||
function shareTwitter() {
|
||
document.getElementById('shareMenu').classList.remove('open');
|
||
const u = 'https://twitter.com/intent/tweet?text=' + encodeURIComponent(getShareText()) + '&url=' + encodeURIComponent(getShareUrl());
|
||
window.open(u, '_blank', 'width=550,height=420');
|
||
}
|
||
function shareLinkedIn() {
|
||
document.getElementById('shareMenu').classList.remove('open');
|
||
const u = 'https://www.linkedin.com/sharing/share-offsite/?url=' + encodeURIComponent(getShareUrl());
|
||
window.open(u, '_blank', 'width=600,height=500');
|
||
}
|
||
function shareFacebook() {
|
||
document.getElementById('shareMenu').classList.remove('open');
|
||
const u = 'https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(getShareUrl());
|
||
window.open(u, '_blank', 'width=580,height=400');
|
||
}
|
||
// Auto-load from URL param
|
||
(function(){
|
||
const p = new URLSearchParams(location.search);
|
||
const asn = p.get('asn');
|
||
if (asn) {
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
const inp = document.getElementById('asnInput');
|
||
if (inp) { inp.value = asn; doLookup(); }
|
||
});
|
||
}
|
||
})();
|
||
|
||
// ── BGP Community Decoder ─────────────────────────────────────
|
||
async function loadCommunities(asn) {
|
||
const card = document.getElementById('commCard');
|
||
const content = document.getElementById('commContent');
|
||
card.classList.remove('hidden');
|
||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Decoding communities…</span>';
|
||
const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 8000);
|
||
try {
|
||
const r = await fetch('/api/communities?asn=' + asn, { signal: ctrl.signal });
|
||
const d = await r.json();
|
||
if (!d.communities || !d.communities.length) {
|
||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">No communities found for this ASN.</span>';
|
||
return;
|
||
}
|
||
const typeColor = { rfc:'comm-rfc', carrier:'comm-carrier', ixp:'comm-ixp' };
|
||
let html = '<div style="display:flex;flex-wrap:wrap;gap:.25rem;margin-bottom:.75rem">';
|
||
for (const c of d.communities) {
|
||
if (c.known) {
|
||
const cls = typeColor[c.known.type] || 'comm-carrier';
|
||
html += `<span class="${cls}" title="${c.known.desc}">${c.raw} · ${c.known.name}</span>`;
|
||
} else {
|
||
html += `<span class="comm-unknown">${c.raw}</span>`;
|
||
}
|
||
}
|
||
html += '</div>';
|
||
const known = d.communities.filter(c => c.known);
|
||
if (known.length) {
|
||
html += '<table class="tbl"><thead><tr><th>Community</th><th>Name</th><th>Description</th><th>Type</th></tr></thead><tbody>';
|
||
for (const c of known) {
|
||
html += `<tr><td style="font-family:var(--mono)">${c.raw}</td><td>${c.known.name}</td><td style="color:var(--muted)">${c.known.desc}</td><td><span class="${typeColor[c.known.type]||'comm-carrier'}">${c.known.type}</span></td></tr>`;
|
||
}
|
||
html += '</tbody></table>';
|
||
}
|
||
content.innerHTML = html;
|
||
} catch(e) {
|
||
content.innerHTML = `<span style="color:var(--red);font-family:var(--mono);font-size:.75rem">Error: ${e.message}</span>`;
|
||
}
|
||
}
|
||
|
||
// ── IRR Audit ─────────────────────────────────────────────────
|
||
async function loadIrrAudit(asn) {
|
||
const card = document.getElementById('irrCard');
|
||
const content = document.getElementById('irrContent');
|
||
card.classList.remove('hidden');
|
||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Checking IRR registration via NLNOG IRR Explorer…</span>';
|
||
const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 8000);
|
||
try {
|
||
const r = await fetch('/api/irr-audit?asn=' + asn, { signal: ctrl.signal });
|
||
const d = await r.json();
|
||
const pct = d.score || 0;
|
||
const color = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--orange)' : 'var(--red)';
|
||
let html = `<div style="display:flex;align-items:baseline;gap:.5rem;margin-bottom:.75rem" title="Percentage of announced prefixes with valid IRR route objects">
|
||
<span style="font-size:1.8rem;font-weight:700;font-family:var(--mono);color:${color}">${pct}%</span>
|
||
<span style="font-family:var(--mono);font-size:.7rem;color:var(--muted)">${d.irr_routes.length}/${d.actual_prefixes.length} prefixes in IRR · ${d.source||'IRR'}</span>
|
||
</div>`;
|
||
if (d.summary) {
|
||
html += '<div style="display:flex;gap:.4rem;margin-bottom:.75rem;flex-wrap:wrap">';
|
||
if (d.summary.good) html += `<span style="font-family:var(--mono);font-size:.62rem;background:rgba(21,128,61,.1);color:var(--green);border:1px solid rgba(21,128,61,.2);padding:.15rem .4rem" title="Prefixes fully registered in IRR with valid RPKI">✓ ${d.summary.good} clean</span>`;
|
||
if (d.summary.warning) html += `<span style="font-family:var(--mono);font-size:.62rem;background:rgba(180,83,9,.1);color:var(--orange);border:1px solid rgba(180,83,9,.2);padding:.15rem .4rem" title="Prefixes with partial or inconsistent IRR/RPKI state">⚠ ${d.summary.warning} warning</span>`;
|
||
if (d.summary.error) html += `<span style="font-family:var(--mono);font-size:.62rem;background:rgba(185,28,28,.1);color:var(--red);border:1px solid rgba(185,28,28,.2);padding:.15rem .4rem" title="Prefixes missing IRR registration or with RPKI invalids">✗ ${d.summary.error} error</span>`;
|
||
html += '</div>';
|
||
}
|
||
if (d.details && d.details.length) {
|
||
html += '<table class="tbl"><thead><tr><th title="IP prefix announced by this AS">Prefix</th><th title="IRR databases containing a route object for this prefix">IRR Sources</th><th title="RPKI Route Origin Authorization validation status">RPKI</th><th title="Overall assessment">Status</th></tr></thead><tbody>';
|
||
for (const det of d.details) {
|
||
const catColor = det.category === 'success' ? 'var(--green)' : det.category === 'warning' ? 'var(--orange)' : 'var(--red)';
|
||
const catIcon = det.category === 'success' ? '✓' : det.category === 'warning' ? '⚠' : '✗';
|
||
const rpkiColor = det.rpki_status === 'VALID' ? 'var(--green)' : det.rpki_status === 'INVALID' ? 'var(--red)' : 'var(--muted)';
|
||
const msg = det.messages && det.messages.length ? det.messages.join(' / ') : det.category;
|
||
html += `<tr title="${msg}">
|
||
<td style="font-family:var(--mono)">${det.prefix}</td>
|
||
<td style="font-family:var(--mono);font-size:.65rem">${det.irr_sources.length ? det.irr_sources.join(', ') : '<span style=\'color:var(--red)\'>none</span>'}</td>
|
||
<td style="font-family:var(--mono);font-size:.65rem;color:${rpkiColor}">${det.rpki_status||'—'}</td>
|
||
<td><span style="font-family:var(--mono);font-size:.65rem;color:${catColor}">${catIcon} ${det.category}</span></td>
|
||
</tr>`;
|
||
}
|
||
html += '</tbody></table>';
|
||
}
|
||
if (d.in_bgp_not_irr && d.in_bgp_not_irr.length) {
|
||
html += `<div style="margin-top:.5rem"><div style="font-family:var(--mono);font-size:.65rem;color:var(--red);margin-bottom:.25rem" title="Announced in BGP but missing IRR route object — many ISPs will filter these routes">MISSING IN IRR (${d.in_bgp_not_irr.length})</div>`;
|
||
html += d.in_bgp_not_irr.map(p => `<div style="font-family:var(--mono);font-size:.7rem;border-left:2px solid var(--red);padding-left:.4rem;margin-bottom:.15rem">${p}</div>`).join('');
|
||
html += '</div>';
|
||
}
|
||
content.innerHTML = html;
|
||
} catch(e) {
|
||
content.innerHTML = `<span style="color:var(--red);font-family:var(--mono);font-size:.75rem">Error: ${e.message}</span>`;
|
||
}
|
||
}
|
||
|
||
// ── RPKI Time Machine ──────────────────────────────────────────
|
||
async function loadRpkiHistory(asn) {
|
||
const card = document.getElementById('rpkiHistCard');
|
||
const content = document.getElementById('rpkiHistContent');
|
||
card.classList.remove('hidden');
|
||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Loading routing history…</span>';
|
||
const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 8000);
|
||
try {
|
||
const r = await fetch('/api/rpki-history?asn=' + asn, { signal: ctrl.signal });
|
||
const d = await r.json();
|
||
if (!d.prefixes || !d.prefixes.length) {
|
||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">No routing history data available for this ASN.</span>';
|
||
return;
|
||
}
|
||
let html = `<div style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-bottom:.75rem" title="Routing history from RIPE Stat, last 90 days">${d.prefixes.length} announced prefixes · source: ${d.source||'RIPE Stat'}</div>`;
|
||
html += '<table class="tbl"><thead><tr><th title="IP prefix announced by this AS">Prefix</th><th title="First time this prefix was seen">First Seen</th><th title="Most recent time this prefix was seen">Last Seen</th><th title="Number of BGP peers that saw this prefix">Peers</th></tr></thead><tbody>';
|
||
for (const p of d.prefixes.slice(0, 30)) {
|
||
const first = p.timelines && p.timelines[0] && p.timelines[0].starttime ? p.timelines[0].starttime.slice(0,10) : '—';
|
||
const last = p.timelines && p.timelines[p.timelines.length-1] && p.timelines[p.timelines.length-1].endtime ? p.timelines[p.timelines.length-1].endtime.slice(0,10) : '—';
|
||
html += `<tr title="Prefix: ${p.prefix}"><td style="font-family:var(--mono)">${p.prefix}</td><td style="font-family:var(--mono);font-size:.65rem;color:var(--muted)">${first}</td><td style="font-family:var(--mono);font-size:.65rem;color:var(--muted)">${last}</td><td style="font-family:var(--mono);font-size:.65rem">${p.timelines ? p.timelines.length : '—'}</td></tr>`;
|
||
}
|
||
html += '</tbody></table>';
|
||
if (d.prefixes.length > 30) html += `<div style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-top:.5rem">Showing 30 of ${d.prefixes.length} prefixes</div>`;
|
||
content.innerHTML = html;
|
||
} catch(e) {
|
||
content.innerHTML = `<span style="color:var(--red);font-family:var(--mono);font-size:.75rem">Error: ${e.message}</span>`;
|
||
}
|
||
}
|
||
|
||
// ── AS-PATH Visualizer ─────────────────────────────────────────
|
||
async function loadAspath(asn) {
|
||
const card = document.getElementById('aspathCard');
|
||
const content = document.getElementById('aspathContent');
|
||
card.classList.remove('hidden');
|
||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Loading AS-PATH data…</span>';
|
||
const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 10000);
|
||
try {
|
||
const r = await fetch('/api/aspath?asn=' + asn, { signal: ctrl.signal });
|
||
const d = await r.json();
|
||
const paths = d && d.paths || [];
|
||
if (!paths.length) { content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">No AS-PATH data available for this ASN.</span>'; return; }
|
||
let html = '<div style="overflow-x:auto">';
|
||
for (const u of paths) {
|
||
const hops = (u.path || '').split(/\s+/).filter(Boolean);
|
||
html += `<div style="margin-bottom:.6rem" title="Prefix: ${u.prefix||''} · RRC: ${u.rrc||''} · Peer: ${u.peer_asn||''}"><div style="font-family:var(--mono);font-size:.6rem;color:var(--dim);margin-bottom:.2rem">${u.prefix || ''} <span style="color:var(--muted)">via ${u.rrc||''}</span></div><div style="display:flex;align-items:center;flex-wrap:wrap;gap:0">`;
|
||
for (let i = 0; i < hops.length; i++) {
|
||
const isOrigin = i === hops.length - 1;
|
||
html += `<span title="AS${hops[i]}" style="font-family:var(--mono);font-size:.68rem;background:${isOrigin?'var(--purple)':'transparent'};color:${isOrigin?'#fff':'var(--text)'};border:1px solid ${isOrigin?'var(--purple)':'var(--border)'};padding:.15rem .4rem;white-space:nowrap">AS${hops[i]}</span>`;
|
||
if (i < hops.length - 1) html += '<span style="color:var(--dim);font-size:.7rem;padding:0 .1rem">→</span>';
|
||
}
|
||
html += '</div></div>';
|
||
}
|
||
html += '</div>';
|
||
const src = d.source ? `<div style="font-family:var(--mono);font-size:.6rem;color:var(--dim);margin-top:.5rem">${d.source} · ${paths.length} paths shown</div>` : '';
|
||
content.innerHTML = html + src;
|
||
} catch(e) {
|
||
content.innerHTML = `<span style="color:var(--red);font-family:var(--mono);font-size:.75rem">Error: ${e.message}</span>`;
|
||
}
|
||
}
|
||
|
||
// ── Looking Glass ─────────────────────────────────────────────
|
||
async function doLookingGlass() {
|
||
const prefix = document.getElementById('lgPrefixInput').value.trim();
|
||
if (!prefix) return;
|
||
const content = document.getElementById('lgContent');
|
||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Querying RIPE RIS…</span>';
|
||
try {
|
||
const r = await fetch('/api/looking-glass?prefix=' + encodeURIComponent(prefix));
|
||
const d = await r.json();
|
||
if (!d.rrcs || !d.rrcs.length) { content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">No results.</span>'; return; }
|
||
let html = `<div style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-bottom:.75rem">${d.total_rrcs} RRC peers — showing ${d.rrcs.length}</div>`;
|
||
html += '<table class="tbl"><thead><tr><th>RRC</th><th>Location</th><th>AS Path</th><th>Next Hop</th></tr></thead><tbody>';
|
||
for (const rrc of d.rrcs) {
|
||
for (const p of rrc.peers) {
|
||
const path = (p.as_path||'').split(' ').map(a => `AS${a}`).join(' → ');
|
||
html += `<tr><td style="font-family:var(--mono)">${rrc.rrc}</td><td style="color:var(--muted)">${rrc.location||''}</td><td style="font-family:var(--mono);font-size:.65rem">${path}</td><td style="font-family:var(--mono);font-size:.65rem">${p.next_hop||''}</td></tr>`;
|
||
}
|
||
}
|
||
html += '</tbody></table>';
|
||
content.innerHTML = html;
|
||
} catch(e) {
|
||
content.innerHTML = `<span style="color:var(--red);font-family:var(--mono);font-size:.75rem">Error: ${e.message}</span>`;
|
||
}
|
||
}
|
||
|
||
// ── AS-SET Expander ────────────────────────────────────────────
|
||
async function doAssetExpand() {
|
||
const setName = document.getElementById('assetInput').value.trim().toUpperCase();
|
||
if (!setName) return;
|
||
const content = document.getElementById('assetContent');
|
||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Expanding recursively…</span>';
|
||
try {
|
||
const r = await fetch('/api/asset-expand?set=' + encodeURIComponent(setName));
|
||
const d = await r.json();
|
||
if (d.error) { content.innerHTML = `<span style="color:var(--red);font-family:var(--mono);font-size:.75rem">${d.error}</span>`; return; }
|
||
let html = `<div style="margin-bottom:.5rem;font-family:var(--mono);font-size:.75rem"><strong>${d.set}</strong> → <strong>${d.count}</strong> ASNs`;
|
||
if (d.sub_sets && d.sub_sets.length) html += ` (via: ${d.sub_sets.join(', ')})`;
|
||
html += '</div>';
|
||
html += '<div style="display:flex;flex-wrap:wrap;gap:.25rem">' + d.asns.slice(0,100).map(a => `<span style="font-family:var(--mono);font-size:.68rem;border:1px solid var(--border);padding:.1rem .3rem">${a}</span>`).join('') + '</div>';
|
||
if (d.count > 100) html += `<div style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-top:.5rem">…and ${d.count - 100} more</div>`;
|
||
content.innerHTML = html;
|
||
} catch(e) {
|
||
content.innerHTML = `<span style="color:var(--red);font-family:var(--mono);font-size:.75rem">Error: ${e.message}</span>`;
|
||
}
|
||
}
|
||
|
||
// ── IXP Peering Matrix ────────────────────────────────────────
|
||
async function loadIxPicker(ixList) {
|
||
// ixList: array of { ix_id, name, city } from PeeringDB
|
||
const card = document.getElementById('ixMatrixCard');
|
||
const content = document.getElementById('ixMatrixContent');
|
||
card.classList.remove('hidden');
|
||
if (!ixList || !ixList.length) {
|
||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">No IXP memberships found for this ASN.</span>';
|
||
return;
|
||
}
|
||
// Build picker buttons
|
||
let picker = '<div style="display:flex;flex-wrap:wrap;gap:.35rem;margin-bottom:.75rem" title="All IXPs where this AS is a member — click to view members">';
|
||
for (const ix of ixList) {
|
||
picker += `<button onclick="loadIxMatrix(${ix.ix_id||ix.id}, ${JSON.stringify(ix.name||'').replace(/"/g,'"')})" style="background:var(--bg);border:1px solid var(--border);font-family:var(--mono);font-size:.62rem;padding:.2rem .5rem;cursor:pointer;color:var(--text)" title="${ix.name||''} · ${ix.city||''}">${ix.name||'IX '+ix.ix_id}</button>`;
|
||
}
|
||
picker += '</div><div id="ixMatrixData"><span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Select an IXP above to view its member list.</span></div>';
|
||
content.innerHTML = picker;
|
||
// Auto-load the first IXP
|
||
if (ixList[0]) {
|
||
const first = ixList[0];
|
||
loadIxMatrix(first.ix_id || first.id, first.name || '');
|
||
}
|
||
}
|
||
|
||
async function loadIxMatrix(ixId, ixName) {
|
||
const dataEl = document.getElementById('ixMatrixData') || document.getElementById('ixMatrixContent');
|
||
if (!dataEl) return;
|
||
dataEl.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Loading member list…</span>';
|
||
try {
|
||
const r = await fetch('/api/ix-matrix?ix_id=' + ixId);
|
||
const d = await r.json();
|
||
if (!d.members || !d.members.length) { dataEl.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">No members found.</span>'; return; }
|
||
const open = d.members.filter(m => m.policy === 'Open').length;
|
||
let html = `<div style="font-family:var(--mono);font-size:.7rem;color:var(--muted);margin-bottom:.75rem" title="${d.ix_name||ixName} — ${d.member_count} members, ${open} with open peering policy">${d.ix_name||ixName} · ${d.ix_city||''} · ${d.member_count} members · ${open} open peering</div>`;
|
||
html += '<table class="tbl"><thead><tr><th title="Autonomous System Number">ASN</th><th title="Network name">Network</th><th title="Port speed at the IX">Speed</th><th title="Peering policy">Policy</th></tr></thead><tbody>';
|
||
for (const m of d.members.slice(0, 50)) {
|
||
html += `<tr title="${m.name||''} · AS${m.asn} · ${m.speed?((m.speed>=1000?(m.speed/1000)+'G':m.speed+'M')):'unknown speed'} · ${m.policy||''}"><td style="font-family:var(--mono)">AS${m.asn}</td><td>${m.name||''}</td><td style="font-family:var(--mono)">${m.speed ? (m.speed >= 1000 ? (m.speed/1000)+'G' : m.speed+'M') : ''}</td><td><span style="font-family:var(--mono);font-size:.6rem;color:${m.policy==='Open'?'var(--green)':'var(--muted)'}">${m.policy||''}</span></td></tr>`;
|
||
}
|
||
html += '</tbody></table>';
|
||
if (d.member_count > 50) html += `<div style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-top:.5rem">Showing 50 of ${d.member_count} members</div>`;
|
||
dataEl.innerHTML = html;
|
||
} catch(e) {
|
||
dataEl.innerHTML = `<span style="color:var(--red);font-family:var(--mono);font-size:.75rem">Error: ${e.message}</span>`;
|
||
}
|
||
}
|
||
|
||
// ── BGP Hijack Monitor ────────────────────────────────────────
|
||
async function loadHijackMonitor(asn) {
|
||
const card = document.getElementById('hijackCard');
|
||
const content = document.getElementById('hijackContent');
|
||
card.classList.remove('hidden');
|
||
content.innerHTML = '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">Checking hijack status…</span>';
|
||
const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 8000);
|
||
try {
|
||
const r = await fetch('/api/hijack-alerts?asn=' + asn, { signal: ctrl.signal });
|
||
const d = await r.json();
|
||
let html = '';
|
||
if (!d.monitoring) {
|
||
html += `<div style="margin-bottom:.75rem;font-family:var(--body);font-size:.82rem;color:var(--muted)">AS${asn} is not yet monitored. Activate to detect unexpected prefix announcements.</div>`;
|
||
html += `<button onclick="subscribeHijack('${asn}')" style="background:var(--text);color:var(--bg);border:none;font-family:var(--mono);font-size:.65rem;padding:.4rem 1rem;cursor:pointer;letter-spacing:.06em">ACTIVATE MONITORING</button>`;
|
||
} else {
|
||
html += `<div style="margin-bottom:.5rem"><span class="hijack-ok">✓ MONITORED</span> <span style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-left:.5rem">${d.prefix_count} prefixes tracked · checked every 30 min</span></div>`;
|
||
}
|
||
if (d.alerts && d.alerts.length) {
|
||
html += `<div style="margin-top:.75rem"><div style="font-family:var(--mono);font-size:.65rem;color:var(--red);font-weight:600;margin-bottom:.4rem">ALERTS (${d.alerts.length})</div>`;
|
||
for (const a of d.alerts.slice(-5).reverse()) {
|
||
html += `<div style="border:1px solid rgba(185,28,28,.3);padding:.5rem;margin-bottom:.4rem;font-family:var(--mono);font-size:.7rem">
|
||
<div style="color:var(--red)">${a.msg}</div>
|
||
<div style="color:var(--dim);margin-top:.2rem">${new Date(a.ts).toLocaleString()}</div>
|
||
${a.unexpected.length ? `<div style="margin-top:.25rem">Unexpected: ${a.unexpected.join(', ')}</div>` : ''}
|
||
${a.missing.length ? `<div>Missing: ${a.missing.join(', ')}</div>` : ''}
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
} else if (d.monitoring) {
|
||
html += '<div style="font-family:var(--mono);font-size:.72rem;color:var(--green);margin-top:.5rem">✓ No hijack alerts detected</div>';
|
||
}
|
||
content.innerHTML = html;
|
||
} catch(e) {
|
||
content.innerHTML = `<span style="color:var(--red);font-family:var(--mono);font-size:.75rem">Error: ${e.message}</span>`;
|
||
}
|
||
}
|
||
|
||
async function subscribeHijack(asn) {
|
||
try {
|
||
const r = await fetch('/api/hijack-subscribe', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ asn }) });
|
||
const d = await r.json();
|
||
if (d.ok) loadHijackMonitor(asn);
|
||
} catch(e) { alert('Error: ' + e.message); }
|
||
}
|
||
|
||
// ── Hook into existing doLookup to load new features ─────────
|
||
const _origDoLookup = typeof doLookup === 'function' ? doLookup : null;
|
||
function loadNewFeatures(asn) {
|
||
loadCommunities(asn);
|
||
loadIrrAudit(asn);
|
||
loadRpkiHistory(asn);
|
||
loadAspath(asn);
|
||
loadHijackMonitor(asn);
|
||
// init time range to last 1h on first load, then load prefix changes
|
||
if (!document.getElementById('pfxFrom').value) {
|
||
const to = new Date(); const from = new Date(Date.now() - 3600000);
|
||
document.getElementById('pfxFrom').value = from.toISOString().slice(0,16);
|
||
document.getElementById('pfxTo').value = to.toISOString().slice(0,16);
|
||
}
|
||
pfxLoad(asn);
|
||
// IXP picker: read from ix_presence.connections (the actual API response structure)
|
||
setTimeout(() => {
|
||
const raw = currentLookupData || {};
|
||
const conns = (raw.ix_presence && raw.ix_presence.connections) || [];
|
||
// Deduplicate by ix_id (one entry per unique IXP)
|
||
const seen = new Set();
|
||
const ixList = conns
|
||
.filter(c => c.ix_id && !seen.has(c.ix_id) && seen.add(c.ix_id))
|
||
.map(c => ({ ix_id: c.ix_id, name: c.ix_name, city: c.city }));
|
||
loadIxPicker(ixList);
|
||
}, 300);
|
||
}
|
||
// Patch the dashboard render to trigger new features
|
||
const _origShowDashboard = typeof showDashboard === 'function' ? showDashboard : null;
|
||
|
||
// Visitor counter
|
||
fetch('/api/visitors').then(r=>r.json()).then(d=>{
|
||
const el = document.getElementById('visitor-count');
|
||
if(el && d.visitors) el.textContent = d.visitors.toLocaleString() + ' UV';
|
||
}).catch(()=>{});
|
||
|
||
|
||
// ── Prefix Changes ─────────────────────────────────────────────
|
||
let pfxCurrentAsn = null;
|
||
let pfxCurrentData = null;
|
||
let pfxActiveTab = 'ann';
|
||
let pfxLiveWs = null;
|
||
let pfxLiveLines = [];
|
||
|
||
function pfxSetPreset(hours) {
|
||
document.querySelectorAll('.pfx-preset').forEach(b => b.style.borderColor = 'var(--border)');
|
||
event.target.style.borderColor = 'var(--text)';
|
||
const to = new Date();
|
||
const from = new Date(Date.now() - hours * 3600000);
|
||
document.getElementById('pfxFrom').value = from.toISOString().slice(0,16);
|
||
document.getElementById('pfxTo').value = to.toISOString().slice(0,16);
|
||
if (pfxCurrentAsn) pfxLoad(pfxCurrentAsn);
|
||
}
|
||
|
||
function pfxLoadCustom() {
|
||
document.querySelectorAll('.pfx-preset').forEach(b => b.style.borderColor = 'var(--border)');
|
||
if (pfxCurrentAsn) pfxLoad(pfxCurrentAsn);
|
||
}
|
||
|
||
async function pfxLoad(asn) {
|
||
pfxCurrentAsn = asn;
|
||
document.getElementById('pfxChangesCard').classList.remove('hidden');
|
||
const el = document.getElementById('pfxContent');
|
||
el.textContent = 'Loading…';
|
||
el.style.color = 'var(--dim)';
|
||
|
||
const from = document.getElementById('pfxFrom').value;
|
||
const to = document.getElementById('pfxTo').value;
|
||
let url = '/api/prefix-changes?asn=' + encodeURIComponent(asn);
|
||
if (from && to) url += '&from=' + encodeURIComponent(new Date(from).toISOString()) + '&to=' + encodeURIComponent(new Date(to).toISOString());
|
||
|
||
try {
|
||
const resp = await fetch(url);
|
||
const d = await resp.json();
|
||
pfxCurrentData = d;
|
||
el.style.color = '';
|
||
document.getElementById('pfxCntAnn').textContent = d.summary ? '(' + d.summary.announcements + ')' : '';
|
||
document.getElementById('pfxCntWd').textContent = d.summary ? '(' + d.summary.withdrawals + ')' : '';
|
||
document.getElementById('pfxCntOrig').textContent = d.summary ? '(' + d.summary.origin_changes + ')' : '';
|
||
document.getElementById('pfxCntRpki').textContent = d.summary ? '(' + d.summary.rpki_issues + ')' : '';
|
||
pfxRender();
|
||
} catch(e) {
|
||
el.textContent = 'Error: ' + e.message;
|
||
el.style.color = 'var(--red)';
|
||
}
|
||
}
|
||
|
||
function pfxTab(name) {
|
||
pfxActiveTab = name;
|
||
document.querySelectorAll('.pfx-tab').forEach(b => { b.style.color = 'var(--dim)'; b.style.borderBottomColor = 'transparent'; });
|
||
const key = 'pfxTab' + name.charAt(0).toUpperCase() + name.slice(1);
|
||
const btn = document.getElementById(key);
|
||
if (btn) { btn.style.color = 'var(--text)'; btn.style.borderBottomColor = 'var(--text)'; }
|
||
if (name === 'live') { pfxRenderLive(pfxCurrentAsn); return; }
|
||
if (pfxLiveWs) { pfxLiveWs.close(); pfxLiveWs = null; }
|
||
pfxRender();
|
||
}
|
||
|
||
function pfxRender() {
|
||
const el = document.getElementById('pfxContent');
|
||
if (!pfxCurrentData) return;
|
||
const d = pfxCurrentData;
|
||
if (pfxActiveTab === 'ann') pfxRenderTable(el, d.announcements || [], ['Timestamp','Prefix','Origin AS','RPKI'], pfxRowAnn);
|
||
if (pfxActiveTab === 'wd') pfxRenderTable(el, d.withdrawals || [], ['Timestamp','Prefix','Peer'], pfxRowWd);
|
||
if (pfxActiveTab === 'orig') pfxRenderTable(el, d.origin_changes || [], ['Timestamp','Prefix','From AS','To AS'], pfxRowOrig);
|
||
if (pfxActiveTab === 'rpki') pfxRenderTable(el, d.rpki_issues || [], ['Timestamp','Prefix','Origin AS','Status'], pfxRowRpki);
|
||
}
|
||
|
||
function pfxRowAnn(r) { return [pfxTs(r.timestamp), escHtml(r.prefix||''), 'AS'+(r.origin|0), pfxRpkiBadge(r.rpki_status)]; }
|
||
function pfxRowWd(r) { return [pfxTs(r.timestamp), escHtml(r.prefix||''), escHtml(r.peer||'')]; }
|
||
function pfxRowOrig(r) { return [pfxTs(r.timestamp), escHtml(r.prefix||''), '<span style="color:var(--red)">AS'+(r.from_origin|0)+'</span>', '<span style="color:var(--green)">AS'+(r.to_origin|0)+'</span>']; }
|
||
function pfxRowRpki(r) { return [pfxTs(r.timestamp), escHtml(r.prefix||''), 'AS'+(r.origin|0), pfxRpkiBadge(r.rpki_status)]; }
|
||
|
||
function pfxRenderTable(el, rows, headers, rowFn) {
|
||
el.textContent = '';
|
||
if (!rows.length) { el.textContent = 'No data in this time range.'; el.style.color = 'var(--dim)'; return; }
|
||
el.style.color = '';
|
||
const wrap = document.createElement('div'); wrap.style.overflowX = 'auto';
|
||
const tbl = document.createElement('table');
|
||
tbl.style.cssText = 'width:100%;border-collapse:collapse;font-size:.72rem';
|
||
const thead = tbl.createTHead(); const hrow = thead.insertRow();
|
||
headers.forEach(h => {
|
||
const th = document.createElement('th');
|
||
th.textContent = h;
|
||
th.style.cssText = 'text-align:left;padding:.3rem .5rem;border-bottom:1px solid var(--border);color:var(--dim);white-space:nowrap';
|
||
hrow.appendChild(th);
|
||
});
|
||
const tbody = tbl.createTBody();
|
||
rows.slice(0, 200).forEach((r, i) => {
|
||
const tr = tbody.insertRow();
|
||
tr.style.background = i % 2 ? 'rgba(255,255,255,.02)' : 'transparent';
|
||
rowFn(r).forEach(cell => {
|
||
const td = tr.insertCell();
|
||
td.style.cssText = 'padding:.25rem .5rem;border-bottom:1px solid rgba(255,255,255,.04);white-space:nowrap';
|
||
td.innerHTML = cell; // cell values: escHtml() for external data, static color spans only
|
||
});
|
||
});
|
||
wrap.appendChild(tbl);
|
||
el.appendChild(wrap);
|
||
if (rows.length > 200) {
|
||
const note = document.createElement('div');
|
||
note.textContent = 'Showing 200 of ' + rows.length + ' entries.';
|
||
note.style.cssText = 'color:var(--dim);font-size:.7rem;margin-top:.5rem';
|
||
el.appendChild(note);
|
||
}
|
||
}
|
||
|
||
function pfxRenderLive(asn) {
|
||
if (!asn) return;
|
||
if (pfxLiveWs) pfxLiveWs.close();
|
||
pfxLiveLines = [];
|
||
const el = document.getElementById('pfxContent');
|
||
el.textContent = '';
|
||
const statusDiv = document.createElement('div');
|
||
statusDiv.style.cssText = 'color:var(--green);margin-bottom:.5rem;font-size:.75rem';
|
||
statusDiv.textContent = '● Connecting to RIPE RIS Live…';
|
||
const log = document.createElement('div');
|
||
log.id = 'pfxLiveLog';
|
||
log.style.cssText = 'font-size:.7rem;line-height:1.6;max-height:400px;overflow-y:auto';
|
||
el.appendChild(statusDiv);
|
||
el.appendChild(log);
|
||
try {
|
||
pfxLiveWs = new WebSocket('wss://ris-live.ripe.net/v1/ws/');
|
||
pfxLiveWs.onopen = () => {
|
||
pfxLiveWs.send(JSON.stringify({ type:'ris_subscribe', data:{ type:'UPDATE', path: String(asn) + '$', 'more-specific': true } }));
|
||
statusDiv.textContent = '';
|
||
statusDiv.appendChild(document.createTextNode('● Live — AS' + asn + ' (RIPE RIS) '));
|
||
const stop = document.createElement('button');
|
||
stop.textContent = 'STOP';
|
||
stop.style.cssText = 'margin-left:.5rem;font-family:var(--mono);font-size:.6rem;border:1px solid var(--border);background:transparent;color:var(--dim);cursor:pointer;padding:.1rem .4rem';
|
||
stop.onclick = () => { if (pfxLiveWs) pfxLiveWs.close(); };
|
||
statusDiv.appendChild(stop);
|
||
};
|
||
pfxLiveWs.onmessage = (ev) => {
|
||
try {
|
||
const msg = JSON.parse(ev.data);
|
||
if (msg.type !== 'ris_message') return;
|
||
const d = msg.data; if (!d) return;
|
||
const ts = escHtml(new Date((d.timestamp||0)*1000).toISOString().slice(11,19));
|
||
const peer = escHtml(String(d.peer||''));
|
||
(d.announcements||[]).forEach(a => pfxLivePush('<span style="color:var(--green)">ANN</span> '+ts+' <b>'+escHtml(String(a.prefix||''))+'</b> peer:'+peer));
|
||
(d.withdrawals||[]).forEach(w => pfxLivePush('<span style="color:var(--red)">WD </span> '+ts+' <b>'+escHtml(String(w.prefix||''))+'</b> peer:'+peer));
|
||
} catch(_) {}
|
||
};
|
||
pfxLiveWs.onclose = () => { statusDiv.textContent = '○ Disconnected'; };
|
||
pfxLiveWs.onerror = () => { statusDiv.textContent = 'WebSocket error — RIPE RIS Live unreachable.'; statusDiv.style.color = 'var(--red)'; };
|
||
} catch(e) { statusDiv.textContent = 'Error: ' + e.message; statusDiv.style.color = 'var(--red)'; }
|
||
}
|
||
|
||
function pfxLivePush(line) {
|
||
pfxLiveLines.unshift(line);
|
||
if (pfxLiveLines.length > 200) pfxLiveLines.pop();
|
||
const log = document.getElementById('pfxLiveLog');
|
||
if (log) log.innerHTML = pfxLiveLines.map(l => '<div>' + l + '</div>').join('');
|
||
}
|
||
|
||
function pfxTs(ts) { return ts ? escHtml(String(ts).replace('T',' ').slice(0,19)) : '—'; }
|
||
function pfxRpkiBadge(s) {
|
||
if (s === 'valid') return '<span style="color:var(--green)">✓ valid</span>';
|
||
if (s === 'invalid') return '<span style="color:var(--red)">✗ invalid</span>';
|
||
return '<span style="color:var(--dim)">? unknown</span>';
|
||
}
|
||
|
||
// ── Contacts & Registration ────────────────────────────────────
|
||
function renderContacts(d) {
|
||
const card = document.getElementById('contactsCard');
|
||
const el = document.getElementById('contactsContent');
|
||
if (!card || !el) return;
|
||
|
||
const contacts = d.contacts || [];
|
||
const reg = d.registration || {};
|
||
const n = d.network || {};
|
||
|
||
let h = '';
|
||
|
||
// Registration metadata
|
||
if (reg.rir || reg.created || reg.handle) {
|
||
h += '<div style="display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:.75rem">';
|
||
if (reg.handle) h += '<span class="badge badge-cyan">' + escHtml(reg.handle) + '</span>';
|
||
if (reg.rir) h += '<span class="badge badge-purple">RIR: ' + escHtml(reg.rir) + '</span>';
|
||
if (reg.created) h += '<span class="badge badge-blue">Registered: ' + escHtml(reg.created) + '</span>';
|
||
if (reg.last_modified) h += '<span class="badge badge-orange">Updated: ' + escHtml(reg.last_modified) + '</span>';
|
||
if (n.peeringdb_created) h += '<span class="badge badge-green">PeeringDB: ' + escHtml(n.peeringdb_created) + '</span>';
|
||
h += '</div>';
|
||
}
|
||
|
||
if (contacts.length === 0) {
|
||
// Show registration-only view
|
||
if (!reg.rir && !reg.created) {
|
||
h += '<span style="font-family:var(--mono);font-size:.75rem;color:var(--dim)">No contact data available.</span>';
|
||
}
|
||
el.innerHTML = h;
|
||
card.classList.remove('hidden');
|
||
return;
|
||
}
|
||
|
||
h += '<div class="scroll-wrap"><table class="tbl"><thead><tr><th>Role</th><th>Name</th><th>Email</th><th></th></tr></thead><tbody>';
|
||
contacts.forEach(function(c) {
|
||
const hasName = c.name && c.name.trim() && c.name !== c.email;
|
||
const hasEmail = c.email && c.email.trim() && c.email !== 'unlisted';
|
||
// Lead badge: named individual (not just "NOC" or generic org name) + accessible
|
||
const isLead = hasName && hasEmail && c.visible !== 'Private' && /[A-Z][a-z]+ [A-Z]/.test(c.name);
|
||
const leadBadge = isLead
|
||
? ' <span title="Potential B2B lead — named contact with public email" style="background:var(--orange);color:#fff;font-size:.55rem;font-family:var(--mono);padding:.1rem .35rem;border-radius:2px;vertical-align:middle;cursor:default">LEAD</span>'
|
||
: '';
|
||
h += '<tr>';
|
||
h += '<td style="font-size:.72rem;color:var(--muted)">' + escHtml(c.role || '') + '</td>';
|
||
h += '<td style="font-size:.8rem">' + escHtml(c.name || '') + leadBadge + '</td>';
|
||
h += '<td style="font-family:monospace;font-size:.72rem">';
|
||
if (hasEmail && c.visible !== 'Private') {
|
||
h += '<a href="mailto:' + escAttr(c.email) + '" style="color:var(--blue)">' + escHtml(c.email) + '</a>';
|
||
} else if (c.email) {
|
||
h += '<span style="color:var(--dim)">' + (c.visible === 'Private' ? '(private)' : escHtml(c.email)) + '</span>';
|
||
}
|
||
h += '</td>';
|
||
h += '<td>';
|
||
if (c.url) h += '<a href="' + escAttr(c.url) + '" target="_blank" style="font-size:.7rem;color:var(--blue)">PeeringDB ↗</a>';
|
||
h += '</td>';
|
||
h += '</tr>';
|
||
});
|
||
h += '</tbody></table></div>';
|
||
|
||
if (contacts.some(c => /[A-Z][a-z]+ [A-Z]/.test(c.name) && c.email && c.visible !== 'Private')) {
|
||
h += '<div style="margin-top:.5rem;font-size:.65rem;color:var(--muted);font-family:var(--mono)">LEAD badge = named individual with public contact. Useful for outreach via CRM.</div>';
|
||
}
|
||
|
||
el.innerHTML = h;
|
||
card.classList.remove('hidden');
|
||
}
|
||
|
||
// ── Resilience Score ───────────────────────────────────────────
|
||
function renderResilienceScore(rs) {
|
||
const card = document.getElementById('resilienceCard');
|
||
const el = document.getElementById('resilienceContent');
|
||
if (!card || !el || !rs) return;
|
||
card.style.display = '';
|
||
const score = rs.score || 0;
|
||
const color = score >= 7 ? 'var(--green)' : score >= 4 ? 'var(--orange)' : 'var(--red)';
|
||
const bd = rs.breakdown || {};
|
||
const labels = { transit_diversity: 'Transit Diversity', peering_breadth: 'Peering Breadth', ixp_presence: 'IXP Presence', path_redundancy: 'Path Redundancy' };
|
||
let h = '<div style="display:flex;align-items:baseline;gap:.5rem;margin-bottom:.75rem">';
|
||
h += '<span style="font-size:2rem;font-weight:700;font-family:var(--mono);color:' + color + '">' + score.toFixed(1) + '</span>';
|
||
h += '<span style="font-size:.75rem;color:var(--muted);font-family:var(--mono)">/10</span></div>';
|
||
h += '<div style="display:flex;flex-direction:column;gap:.35rem">';
|
||
Object.keys(bd).forEach(function(k) {
|
||
const item = bd[k];
|
||
const pct = Math.round((item.raw || 0) * 10);
|
||
const c = pct >= 70 ? 'var(--green)' : pct >= 40 ? 'var(--orange)' : 'var(--red)';
|
||
h += '<div style="display:grid;grid-template-columns:130px 1fr 45px;align-items:center;gap:.5rem">';
|
||
h += '<span style="font-family:var(--mono);font-size:.68rem;color:var(--muted)">' + (labels[k] || k) + '</span>';
|
||
h += '<div style="height:5px;background:var(--border);border-radius:3px"><div style="height:5px;width:' + pct + '%;background:' + c + ';border-radius:3px"></div></div>';
|
||
h += '<span style="font-family:var(--mono);font-size:.68rem;color:' + c + ';text-align:right">' + (item.raw || 0) + '/10</span>';
|
||
h += '</div>';
|
||
});
|
||
h += '</div>';
|
||
if (rs._provenance) {
|
||
const prov = rs._provenance;
|
||
const badge = document.getElementById('resilienceProvBadge');
|
||
if (badge) badge.innerHTML = '<span style="font-family:var(--mono);font-size:.6rem;color:var(--dim)" title="' + escHtml(prov.note || '') + '">' + escHtml(prov.confidence || '') + ' · ' + escHtml(prov.validation || '') + '</span>';
|
||
}
|
||
el.innerHTML = h;
|
||
}
|
||
|
||
// ── Route Leak Detection ───────────────────────────────────────
|
||
function renderRouteLeak(rl) {
|
||
const card = document.getElementById('routeLeakCard');
|
||
const el = document.getElementById('routeLeakContent');
|
||
if (!card || !el || !rl) return;
|
||
card.style.display = '';
|
||
const detected = rl.detected;
|
||
const color = detected ? 'var(--red)' : 'var(--green)';
|
||
let h = '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem">';
|
||
h += '<span style="font-family:var(--mono);font-size:.85rem;font-weight:700;color:' + color + '">' + (detected ? '⚠ LEAK DETECTED' : '✓ No Leaks Detected') + '</span></div>';
|
||
if (rl.patterns && rl.patterns.length) {
|
||
h += '<div style="font-family:var(--mono);font-size:.7rem;color:var(--muted);margin-bottom:.4rem">Patterns:</div>';
|
||
rl.patterns.forEach(function(p) {
|
||
h += '<div style="font-family:var(--mono);font-size:.68rem;color:var(--red);padding:.2rem 0">' + escHtml(String(p)) + '</div>';
|
||
});
|
||
}
|
||
h += '<div style="font-family:var(--mono);font-size:.65rem;color:var(--dim);margin-top:.4rem">';
|
||
h += 'Tier-1 upstreams: ' + (rl.tier1_upstream_count || 0) + ' · Tier-1 downstreams: ' + (rl.tier1_downstream_count || 0);
|
||
h += '</div>';
|
||
if (rl._provenance) {
|
||
const prov = rl._provenance;
|
||
const badge = document.getElementById('routeLeakProvBadge');
|
||
if (badge) badge.innerHTML = '<span style="font-family:var(--mono);font-size:.6rem;color:var(--dim)" title="' + escHtml(prov.note || '') + '">' + escHtml(prov.confidence || '') + ' · ' + escHtml(prov.validation || '') + '</span>';
|
||
}
|
||
el.innerHTML = h;
|
||
}
|
||
|
||
// ── Data Sources Timing ────────────────────────────────────────
|
||
function renderSourceTiming(d) {
|
||
const card = document.getElementById('sourceTimingCard');
|
||
const el = document.getElementById('sourceTimingContent');
|
||
if (!card || !el) return;
|
||
|
||
const timing = d.source_timing || {};
|
||
const keys = Object.keys(timing);
|
||
if (keys.length === 0) { card.classList.add('hidden'); return; }
|
||
|
||
const maxMs = Math.max(...keys.map(k => timing[k] || 0), 1);
|
||
let h = '<div style="display:flex;flex-direction:column;gap:.35rem">';
|
||
keys.forEach(function(src) {
|
||
const ms = timing[src];
|
||
const ok = ms !== null;
|
||
const pct = ok ? Math.round((ms / maxMs) * 100) : 100;
|
||
const color = !ok ? 'var(--red)' : ms < 500 ? 'var(--green)' : ms < 2000 ? 'var(--orange)' : 'var(--red)';
|
||
h += '<div style="display:grid;grid-template-columns:140px 1fr 55px;align-items:center;gap:.5rem">';
|
||
h += '<span style="font-family:var(--mono);font-size:.68rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="' + escHtml(src) + '">' + escHtml(src) + '</span>';
|
||
h += '<div style="height:6px;background:var(--border);border-radius:3px"><div style="height:6px;width:' + pct + '%;background:' + color + ';border-radius:3px"></div></div>';
|
||
h += '<span style="font-family:var(--mono);font-size:.68rem;color:' + color + ';text-align:right">' + (ok ? ms + 'ms' : 'ERR') + '</span>';
|
||
h += '</div>';
|
||
});
|
||
h += '</div>';
|
||
h += '<div style="margin-top:.5rem;font-size:.62rem;color:var(--dim);font-family:var(--mono)">Total lookup: ' + (d.meta && d.meta.duration_ms ? d.meta.duration_ms + 'ms' : '—') + '</div>';
|
||
|
||
el.innerHTML = h;
|
||
card.classList.remove('hidden');
|
||
}
|
||
|
||
// ── Raw JSON Export ────────────────────────────────────────────
|
||
function exportRawJson(e) {
|
||
e.preventDefault();
|
||
if (!window._lastLookupData) { alert('No lookup data available.'); return; }
|
||
const blob = new Blob([JSON.stringify(window._lastLookupData, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'peercortex-AS' + (window._lastLookupData.network && window._lastLookupData.network.asn || 'unknown') + '.json';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
// ── Changelog Overlay ──────────────────────────────────────────
|
||
async function openChangelog() {
|
||
const overlay = document.getElementById('changelogOverlay');
|
||
overlay.style.display = 'flex';
|
||
document.body.style.overflow = 'hidden';
|
||
const box = document.getElementById('changelogContent');
|
||
// Always reload if empty
|
||
if (box.dataset.loaded === '1') return;
|
||
box.innerHTML = '<div style="padding:2rem 0;font-family:var(--mono);font-size:.72rem;color:var(--dim)">Loading release history…</div>';
|
||
try {
|
||
const r = await fetch('/changelog-data?t=' + Date.now());
|
||
const entries = await r.json();
|
||
if (!entries || !entries.length) {
|
||
box.innerHTML = '<div style="font-family:var(--mono);font-size:.75rem;color:var(--red);padding:1rem 0">No changelog entries found.</div>';
|
||
return;
|
||
}
|
||
let html = '';
|
||
for (const e of entries) {
|
||
html += '<div style="border-top:2px solid var(--text);padding:1.25rem 0 .5rem;margin-top:1.5rem">';
|
||
html += '<div style="display:flex;align-items:baseline;gap:1rem;margin-bottom:.75rem">' +
|
||
'<span style="font-family:var(--serif);font-size:1.3rem;font-weight:900">v' + e.version + '</span>' +
|
||
'<span style="font-family:var(--mono);font-size:.6rem;color:var(--dim);letter-spacing:.08em">' + e.date + '</span>' +
|
||
'</div>';
|
||
for (const section of e.sections) {
|
||
html += '<div style="font-family:var(--body);font-size:.62rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin:.75rem 0 .3rem">' + section.name + '</div>';
|
||
for (const item of section.items) {
|
||
const formatted = item.replace(/\*\*(.+?)\*\*/g, '<strong style="color:var(--purple)">$1</strong>');
|
||
html += '<div style="font-family:var(--body);font-size:.82rem;line-height:1.6;padding:.2rem 0 .2rem .75rem;border-left:2px solid var(--border);margin:.25rem 0;color:var(--text)">· ' + formatted + '</div>';
|
||
}
|
||
}
|
||
html += '</div>';
|
||
}
|
||
box.innerHTML = html;
|
||
box.dataset.loaded = '1';
|
||
} catch(e) {
|
||
box.innerHTML = '<div style="font-family:var(--mono);font-size:.75rem;color:var(--red);padding:1rem 0">Error loading changelog: ' + e.message + '</div>';
|
||
}
|
||
}
|
||
function closeChangelog() {
|
||
document.getElementById('changelogOverlay').style.display = 'none';
|
||
document.body.style.overflow = '';
|
||
}
|
||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeChangelog(); });
|
||
</script>
|
||
|
||
<!-- ─── Terminal Feedback Trigger Button ────────────────────────────── -->
|
||
<button id="termBtn" onclick="toggleTerm()" title="Feedback"
|
||
onmouseover="this.style.opacity='1'"
|
||
onmouseout="this.style.opacity='0.35'"
|
||
style="position:fixed;bottom:3.5rem;right:1.25rem;opacity:0.35;background:var(--text);color:var(--bg);border:none;font-family:var(--mono);font-size:.6rem;font-weight:600;letter-spacing:.1em;text-transform:uppercase;padding:.45rem .8rem;cursor:pointer;z-index:9999;transition:opacity .3s ease">Feedback</button>
|
||
|
||
<!-- ─── Feedback Panel ────────────────────────────────────────────── -->
|
||
<div id="termPanel"
|
||
onmouseover="this.style.opacity='1'"
|
||
onmouseout="this.style.opacity='0.35'"
|
||
style="display:none;opacity:0.35;transition:opacity .4s ease;position:fixed;bottom:6rem;right:1.25rem;width:280px;background:var(--bg);border-top:2px solid var(--text);border-left:1px solid var(--border);border-right:1px solid var(--border);border-bottom:1px solid var(--border);z-index:10000;flex-direction:column;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,.12)">
|
||
<!-- Title bar -->
|
||
<div style="padding:.5rem .85rem;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);flex-shrink:0">
|
||
<span style="font-family:var(--mono);font-size:.6rem;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)">Feedback</span>
|
||
<span onclick="closeTerm()" style="font-family:var(--mono);font-size:.65rem;color:var(--dim);cursor:pointer;line-height:1" title="Close">✕</span>
|
||
</div>
|
||
<!-- Output area -->
|
||
<div id="termOutput" style="flex:1;overflow-y:auto;padding:.75rem .85rem;color:var(--text);font-family:var(--mono);font-size:.72rem;line-height:1.7;min-height:220px;max-height:300px;word-break:break-word"></div>
|
||
<!-- Input line -->
|
||
<div style="display:flex;align-items:center;padding:.4rem .85rem;border-top:1px solid var(--border);gap:.5rem;flex-shrink:0">
|
||
<span id="termPrompt" style="color:var(--muted);font-family:var(--mono);font-size:.7rem;white-space:nowrap;flex-shrink:0">›</span>
|
||
<input id="termInput" type="text" autocomplete="off" spellcheck="false" onkeydown="termKeydown(event)"
|
||
style="flex:1;background:transparent;border:none;outline:none;color:var(--text);font-family:var(--mono);font-size:.72rem;caret-color:var(--purple)">
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- Changelog Overlay -->
|
||
<div id="changelogOverlay" style="display:none;position:fixed;inset:0;background:rgba(28,25,23,.6);z-index:9999;align-items:flex-start;justify-content:center;padding:2rem 1rem;overflow-y:auto;backdrop-filter:blur(2px)">
|
||
<div style="background:var(--bg);max-width:780px;width:100%;border-top:3px solid var(--text);position:relative">
|
||
<!-- Header -->
|
||
<div style="padding:1.5rem 2rem 1rem;border-bottom:1px solid var(--border);display:flex;align-items:baseline;justify-content:space-between">
|
||
<div>
|
||
<div style="font-family:var(--serif);font-size:1.6rem;font-weight:900;letter-spacing:-.02em;line-height:1">PeerCortex</div>
|
||
<div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:.1em;margin-top:.2rem">RELEASE HISTORY</div>
|
||
</div>
|
||
<button onclick="closeChangelog()" style="background:none;border:none;color:var(--muted);cursor:pointer;font-family:var(--mono);font-size:.65rem;letter-spacing:.08em;text-transform:uppercase;padding:.3rem .5rem">Close ✕</button>
|
||
</div>
|
||
<!-- Content -->
|
||
<div id="changelogContent" style="padding:0 2rem 2rem;max-height:72vh;overflow-y:auto"></div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|