PeerCortex/public/index.html

4419 lines
270 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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)">&#9679; 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)">&#9642; 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)">&#9675; 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)">&#9644; 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)">&#9679;</span> IXP</span>
<span><span style="color:var(--cyan)">&#9679;</span> Datacenter/Facility</span>
<span><span style="color:var(--green)">&#9644;</span> Submarine Cable</span>
<span><span style="color:var(--border-light)">&#9675;</span> Global Datacenter (PeeringDB)</span>
<span><span style="color:#f7ae54">&#9644;</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 &amp; 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">&#10003;</span>';
if (status === 'invalid') return '<span class="rpki-invalid" title="RPKI Invalid">&#10007;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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()">&times;</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()">&times;</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()">&times;</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()">&times;</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()">&times;</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()">&times;</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()">&times;</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()">&times;</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 &nbsp;·&nbsp; Tier 1: ' + tier1.length + ' &nbsp;·&nbsp; Transit: ' + transit.length + ' &nbsp;·&nbsp; 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:transparent;border:1px solid var(--border);border-radius:0">';
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="var(--blue)" 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:var(--blue)">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 var(--border)"><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 var(--border);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,'&quot;')})" 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&nbsp;</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>