Of <stem>-v6 and <stem>-v4, the IPv6 session is listed first and carries the name in full; IPv4 is under it. The same order in the lookup's list of peers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1048 lines
64 KiB
HTML
1048 lines
64 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="color-scheme" content="light dark">
|
||
<title>Status for ferrix_bgp</title>
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%23eef'/%3E%3Ctext x='8' y='12' font-size='11' font-family='sans-serif' font-weight='bold' fill='%2300c' text-anchor='middle'%3EB%3C/text%3E%3C/svg%3E">
|
||
<!--
|
||
A read-only status page in the manner of the classic /statusz pages: plain
|
||
sans-serif HTML, default links, dense bordered tables, and one full-width
|
||
light blue heading bar per section. No framework and no external
|
||
resources. Colours are variables, with a second set for a dark room
|
||
(prefers-color-scheme): the same grammar on an inverted background, so
|
||
the page can sit beside a terminal without being the brightest thing in
|
||
the room. Everything shown comes from /api/*,
|
||
except the names of AS numbers: pointing at one asks RIPEstat for its
|
||
holder, from the browser (the daemon itself looks nothing up).
|
||
|
||
How colour is used, and not used: what is normal is black on white, and
|
||
draws no attention. Red is for what has failed and needs a hand (a
|
||
session down, the kernel refusing routes). Amber is for what is degraded
|
||
or wants watching (routes held or stale, a peer restarting, RPKI data
|
||
about to expire). Green is for a system that is actively doing its job
|
||
(RPKI synchronised, kernel in step), set small. Grey is for what is
|
||
inactive or expected (a passive peer waiting, a rejection count). What
|
||
repeats on every row is a glyph with a tooltip, not a sentence.
|
||
|
||
The page also keeps a little memory between refreshes, in the browser
|
||
only: the route count of each peer last time, to show a count that moved
|
||
(▲ ▼); and what each peer looked like when the tab was last in the
|
||
foreground, to mark (●) what changed while nobody was looking.
|
||
-->
|
||
<style>
|
||
:root {
|
||
--bg: #fff; --fg: #000; --fg-soft: #223; --dim: #666; --line: #99a; --faint-line: #dde;
|
||
--band: #eef; --head: #dde; --hover: #f3f3ff; --best-bg: #f4faf4; --tip-bg: #ffffe8;
|
||
--link: #00c; --visited: #551a8b; --name: #112;
|
||
--red: #c00; --amber: #a60; --green: #080;
|
||
--badge-bg: #f6f6fb; --badge-line: #aab;
|
||
--badge-red-bg: #fff0f0; --badge-amber-bg: #fff8e8; --badge-green-bg: #f0fff0;
|
||
--badge-dim-bg: #fafafa; --badge-dim-line: #ccd;
|
||
--mark-bg: #eef4ff; --mark-line: #88a; --ctl-bg: #fff4e8; --ctl-line: #c96; --wk-bg: #f0f0f0; --wk-line: #999;
|
||
--rank-fg: #446; --rank-bg: #f3f3f8; --flash: #ffe9a8;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--bg: #16181d; --fg: #e4e4e8; --fg-soft: #d8dae6; --dim: #9a9aa6; --line: #4a4d5c; --faint-line: #2c2f3a;
|
||
--band: #23273a; --head: #2a2e40; --hover: #1e2230; --best-bg: #1a2a1c; --tip-bg: #2c2c22;
|
||
--link: #8db4ff; --visited: #c3a4e8; --name: #f0f0f6;
|
||
--red: #ff6b6b; --amber: #e8a846; --green: #62cf72;
|
||
--badge-bg: #22263a; --badge-line: #4a4d5c;
|
||
--badge-red-bg: #3a1e1e; --badge-amber-bg: #3a2e18; --badge-green-bg: #1a3320;
|
||
--badge-dim-bg: #1c1e26; --badge-dim-line: #3a3d4a;
|
||
--mark-bg: #1f2a44; --mark-line: #5a6aa0; --ctl-bg: #3a2a18; --ctl-line: #a0703a; --wk-bg: #2a2a2e; --wk-line: #666;
|
||
--rank-fg: #b8bce0; --rank-bg: #22263a; --flash: #6a5a20;
|
||
}
|
||
}
|
||
body { font-family: sans-serif; font-size: 13px; color: var(--fg); background: var(--bg); margin: 8px; }
|
||
h1 { clear: both; width: 100%; text-align: center; font-size: 120%; background: var(--band); margin: 14px 0 6px; padding: 2px 0; }
|
||
h1.top { font-size: 150%; margin-top: 0; }
|
||
.lefthand { float: left; width: 60%; }
|
||
.righthand { text-align: right; }
|
||
a { color: var(--link); } a:visited { color: var(--visited); }
|
||
table.data { border-collapse: collapse; margin: 4px 0; }
|
||
table.data th, table.data td { border: 1px solid var(--line); padding: 2px 6px; text-align: left; vertical-align: top; }
|
||
table.data th { background: var(--head); font-weight: bold; position: sticky; top: 0; z-index: 2; }
|
||
table.data > tbody > tr:hover > td { background: var(--hover); }
|
||
table.data td.num, table.data th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
table.data tr.group > td { background: var(--band); font-weight: bold; padding: 3px 6px; }
|
||
table.data tr.best > td { background: var(--best-bg); }
|
||
table.data tr.dim td { color: var(--dim); }
|
||
.asn { cursor: help; }
|
||
.asn:hover { background: var(--head); }
|
||
#asn-tip { position: fixed; z-index: 10; max-width: 36em; background: var(--tip-bg); border: 1px solid var(--line); padding: 2px 6px; font-size: 12px; pointer-events: none; }
|
||
.mono, table.data td.mono { font-family: monospace; }
|
||
/* The colour grammar: see the note at the top. */
|
||
.red { color: var(--red); font-weight: bold; }
|
||
.amber { color: var(--amber); font-weight: bold; }
|
||
.green { color: var(--green); }
|
||
.dim { color: var(--dim); }
|
||
.nowrap { white-space: nowrap; }
|
||
/* Glyphs: small bordered badges for what repeats on every row. */
|
||
.g { display: inline-block; font-size: 10.5px; line-height: 1.2; padding: 0 4px; margin: 0 2px 1px 0; border: 1px solid var(--badge-line); border-radius: 3px; background: var(--badge-bg); color: var(--fg-soft); font-family: sans-serif; font-weight: normal; white-space: nowrap; vertical-align: middle; cursor: help; }
|
||
.g.red { border-color: var(--red); background: var(--badge-red-bg); color: var(--red); font-weight: bold; }
|
||
.g.amber { border-color: var(--amber); background: var(--badge-amber-bg); color: var(--amber); }
|
||
.g.green { border-color: var(--green); background: var(--badge-green-bg); color: var(--green); }
|
||
.g.dim { border-color: var(--badge-dim-line); background: var(--badge-dim-bg); color: var(--dim); }
|
||
.g.mark { background: var(--mark-bg); border-color: var(--mark-line); }
|
||
.g.ctl { background: var(--ctl-bg); border-color: var(--ctl-line); }
|
||
.g.wk { background: var(--wk-bg); border-color: var(--wk-line); font-weight: bold; }
|
||
/* Communities: one line per AS that defines them, each kept whole. */
|
||
.comm { white-space: nowrap; margin-right: 0.7em; }
|
||
.comm[title] { border-bottom: 1px dotted var(--dim); cursor: help; }
|
||
.comms > div { padding-left: 2em; text-indent: -2em; margin-bottom: 2px; }
|
||
.comms { min-width: 26em; }
|
||
table.kv th { background: var(--band); font-weight: normal; position: static; }
|
||
table.data td.path { min-width: 12em; }
|
||
/* An AS repeated along the path (prepended): once, with how many times. */
|
||
.g.x { font-family: monospace; background: var(--mark-bg); border-color: var(--mark-line); margin-left: 1px; cursor: help; }
|
||
/* A cell with more in it than a few lines: clamped until opened. */
|
||
.clamp { max-height: 4.4em; overflow: hidden; }
|
||
.clamp.tall:not(.open) { -webkit-mask-image: linear-gradient(#000 60%, transparent); mask-image: linear-gradient(#000 60%, transparent); }
|
||
.clamp.open { max-height: none; }
|
||
.clamp-more { cursor: pointer; display: none; margin-top: 2px; }
|
||
.tall + .clamp-more { display: inline-block; }
|
||
table.data td.name { white-space: nowrap; }
|
||
/* Peer names are what a row is found by: bold, dark, and not underlined
|
||
until pointed at, so that they stand out from the addresses and numbers
|
||
around them rather than joining the blue of every other link. */
|
||
table.data td.name a.peer-link { font-weight: bold; color: var(--name); text-decoration: none; font-size: 13.5px; }
|
||
table.data td.name a.peer-link:hover { text-decoration: underline; color: var(--link); }
|
||
/* The IPv4 and IPv6 sessions of one neighbour: a bar down the side joins
|
||
them, the line between them fades, and the second says the name quietly. */
|
||
table.data tr.pair-a > td.name, table.data tr.pair-b > td.name { border-left: 3px solid var(--mark-line); }
|
||
table.data tr.pair-a > td { border-bottom-color: var(--faint-line); }
|
||
table.data tr.pair-b > td.name a.peer-link { font-weight: normal; color: var(--dim); }
|
||
/* Where the route is in path selection: the best, or the Nth to take over. */
|
||
.g.rank { font-family: sans-serif; color: var(--rank-fg); background: var(--rank-bg); min-width: 1em; text-align: center; }
|
||
/* A route count that moved since the last refresh, and a row that changed
|
||
while the tab was in the background. */
|
||
.g.trend { font-family: sans-serif; cursor: help; }
|
||
.g.since { color: var(--amber); border-color: var(--amber); background: var(--badge-amber-bg); cursor: pointer; }
|
||
/* The strip: what needs attention, if anything. Its names lead to the row. */
|
||
#strip { position: sticky; top: 0; z-index: 3; background: var(--bg); border: 1px solid var(--line); padding: 4px 8px; margin: 6px 0; }
|
||
#strip .item { display: inline-block; margin-right: 1.5em; white-space: nowrap; }
|
||
#strip a.to-peer { color: inherit; font-weight: bold; text-decoration: none; border-bottom: 1px dotted currentColor; }
|
||
#strip a.to-peer:hover { border-bottom-style: solid; }
|
||
@keyframes flash { from { background: var(--flash); } to { background: transparent; } }
|
||
table.data > tbody > tr.flash > td { animation: flash 1.6s ease-out; }
|
||
/* Compact: the columns a wall screen or a phone can do without. */
|
||
body.compact .c-opt, body.compact .legend { display: none; }
|
||
body.compact table.data th, body.compact table.data td { padding: 1px 4px; }
|
||
/* The breakdown of a peer's rejections, by reason. */
|
||
.bar { display: inline-flex; width: 16em; height: 10px; border: 1px solid var(--line); vertical-align: middle; margin: 0 6px; background: var(--bg); }
|
||
.bar span { display: block; height: 100%; }
|
||
details { clear: both; margin: 8px 0 0; }
|
||
summary { cursor: pointer; color: var(--link); }
|
||
summary:hover { text-decoration: underline; }
|
||
#config-text { border: 1px solid var(--line); padding: 4px 6px; margin: 4px 0; font-size: 12px; overflow-x: auto; }
|
||
address { font-size: smaller; margin-top: 2em; color: var(--dim); }
|
||
form.lookup { margin: 4px 0; }
|
||
form.lookup input[type=text] { font-family: monospace; }
|
||
input, select, button { color-scheme: light dark; }
|
||
.pager { margin: 4px 0; }
|
||
.pager a, .pager span { margin-right: 1em; }
|
||
.toolbar { margin: 4px 0; }
|
||
.toolbar label { margin-right: 1em; }
|
||
#event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; }
|
||
#event-log div { white-space: pre-wrap; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<h1 class="top">Status for ferrix_bgp</h1>
|
||
|
||
<div>
|
||
<div class="lefthand">
|
||
<b id="s-hostname">...</b>: router ID <b id="s-router-id">...</b>, AS <b id="s-asn">...</b>, Ferrix <span id="s-version">...</span><br>
|
||
Started <span id="s-started">...</span>, up <span id="s-uptime">...</span><br>
|
||
<span class="dim">On this page: <a href="#peers">peers</a> · <a href="#rpki">RPKI</a> · <a href="#local">originated</a> · <a href="#kernel">kernel</a> · <a href="#received">received, and looking routes up</a> · <a href="#rejected">rejected</a> · <a href="#events">events</a></span><br>
|
||
</div>
|
||
<div class="righthand">
|
||
<span id="refresh-ctl"></span><br>
|
||
JSON: <a href="/api/status">status</a>, <a href="/api/peers">peers</a>, <a href="/api/routes/local">originated</a>, <a href="/api/routes/received">received</a>, <a href="/api/routes/rejected">rejected</a>, <a href="/api/rpki">rpki</a>, <a href="/api/kernel">kernel</a>; <a href="/metricz">metrics</a><br>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="strip" style="clear: both">...</div>
|
||
|
||
<details id="config-zippy" hidden>
|
||
<summary>Configuration <span class="dim" id="config-path"></span></summary>
|
||
<pre id="config-text"></pre>
|
||
</details>
|
||
|
||
<h1 id="peers">Peers (<span id="peers-count">...</span>)</h1>
|
||
<div class="toolbar">
|
||
<label><input type="checkbox" id="peers-problems"> problems only</label>
|
||
<label title="Fewer columns and tighter rows, for a wall screen or a phone"><input type="checkbox" id="compact"> compact</label>
|
||
<span id="since-ctl"></span>
|
||
<span class="dim legend">Glyphs: <span class="g">v4</span><span class="g">v6</span> address families · <span class="g">GR</span> graceful restart · <span class="g">ENH</span> IPv4 with IPv6 next hops · <span class="g">AP</span> ADD-PATH · <span class="g amber">2B</span> two-octet AS numbers only · <span class="g">↑</span> provider <span class="g">↓</span> customer <span class="g">↔</span> peer <span class="g">RS</span> route server <span class="g">RSC</span> its client <span class="g">iBGP</span> our AS. Point at one for its meaning; click a peer's name to look at its routes.</span>
|
||
</div>
|
||
<table class="data">
|
||
<thead><tr><th>name</th><th>peer</th><th>state</th><th>up for</th><th class="c-opt">our address (next hop)</th><th class="c-opt">session</th><th class="num">routes</th><th class="num" title="Announcements rejected by the import policy since the daemon started, and routes held out of the table as invalid or unreachable now">rejected · held</th><th>detail</th></tr></thead>
|
||
<tbody id="peers-body"></tbody>
|
||
</table>
|
||
|
||
<h1 id="rpki">Route origin and path validation (RPKI)</h1>
|
||
<div id="rpki-body">...</div>
|
||
|
||
<h1 id="local">Originated routes (<span id="local-count">...</span>)</h1>
|
||
<table class="data">
|
||
<thead><tr><th>prefix</th><th>origin</th><th>AS path</th><th class="num">MED</th><th>communities</th><th>large communities</th><th>since</th></tr></thead>
|
||
<tbody id="local-body"></tbody>
|
||
</table>
|
||
|
||
<h1 id="kernel">Kernel routes</h1>
|
||
<div id="kernel-body">...</div>
|
||
|
||
<h1 id="received">Received routes (<span id="received-count">...</span>)</h1>
|
||
<form class="lookup" id="lookup">
|
||
Look up <input type="text" id="lookup-q" size="30" placeholder="prefix, address, AS number, or peer" title="A prefix; an address, to find every prefix that covers it; an AS number, to find every route it originates; or a peer's name">
|
||
<select id="lookup-mode" title="What to find for a prefix">
|
||
<option value="exact">routes for this prefix or address</option>
|
||
<option value="within">this prefix and everything inside it</option>
|
||
</select>
|
||
from <select id="lookup-peer"><option value="">any peer</option></select>
|
||
<label><input type="checkbox" id="lookup-best"> best only</label>
|
||
<button type="submit">look up</button>
|
||
<a href="#" id="lookup-clear">show all</a>
|
||
<span id="lookup-note"></span>
|
||
</form>
|
||
<div class="pager" id="received-pager"></div>
|
||
<table class="data">
|
||
<thead><tr><th></th><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th class="num">MED</th><th class="num">local pref</th><th>communities</th><th>large communities</th><th>attrs</th><th title="Route origin validation (RPKI) and AS path verification (ASPA)">RPKI · ASPA</th></tr></thead>
|
||
<tbody id="received-body"></tbody>
|
||
</table>
|
||
<div class="pager" id="received-pager-below"></div>
|
||
|
||
<h1 id="rejected">Rejected routes (<span id="rejected-count">...</span>)</h1>
|
||
<div class="dim">Routes that peers announced and their import policies did not take. The counts are of announcements since the daemon started: a route announced again and rejected again counts again. Of each peer's latest rejections, those alike are one line, with how many and when the last was.</div>
|
||
<table class="data">
|
||
<thead><tr><th>peer</th><th>prefix</th><th>AS path</th><th>reason</th><th class="num">times</th><th>last</th><th>next hop</th><th>origin</th><th>communities</th><th>large communities</th><th>attrs</th></tr></thead>
|
||
<tbody id="rejected-body"></tbody>
|
||
</table>
|
||
|
||
<h1 id="events">Event log</h1>
|
||
<div id="log-state" class="dim"></div>
|
||
<div id="event-log"></div>
|
||
|
||
<address>ferrix_bgp · <a href="/statusz">/statusz</a> <a href="/metricz">/metricz</a> <a href="/api/events">/api/events</a></address>
|
||
|
||
<script>
|
||
(() => {
|
||
// Well-known standard communities (RFC 1997, RFC 7999, RFC 8326), shown by
|
||
// name with the number as a tooltip.
|
||
const WELL_KNOWN = {
|
||
'65535:0': 'GRACEFUL_SHUTDOWN', '65535:666': 'BLACKHOLE', '65535:65281': 'NO_EXPORT',
|
||
'65535:65282': 'NO_ADVERTISE', '65535:65283': 'NO_EXPORT_SUBCONFED', '65535:65284': 'NOPEER',
|
||
};
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const state = { status: null, peers: [], local: [], received: [], receivedPage: {}, rejected: [], rpki: null, kernel: null };
|
||
const DASH = '<span class="dim">–</span>';
|
||
const fmtCount = n => Number(n).toLocaleString('en-US');
|
||
// A glyph: a small badge with its meaning as the tooltip.
|
||
const glyph = (text, title, cls = '') => `<span class="g ${cls}" title="${esc(title)}">${esc(text)}</span>`;
|
||
|
||
// ---- helpers -----------------------------------------------------------
|
||
const fmtDur = s => {
|
||
s = Math.max(0, Math.floor(s));
|
||
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60), sec = s % 60;
|
||
return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m ${sec}s` : `${sec}s`;
|
||
};
|
||
const now = () => Math.floor(Date.now() / 1000);
|
||
// Tables are only rewritten when their markup actually changes, so a
|
||
// refresh with unchanged data never touches the DOM (no flicker, and
|
||
// selections survive). Anything that changes with the clock is a
|
||
// placeholder that tick() fills in as plain text.
|
||
function setHTML(el, html) { if (el._html !== html) { el.innerHTML = html; el._html = html; tick(); } }
|
||
const age = unix => `<span data-since="${unix}"></span>`;
|
||
const until = unix => `<span data-until="${unix}"></span>`;
|
||
function tick() {
|
||
const t = now();
|
||
document.querySelectorAll('[data-since]').forEach(e => { const v = fmtDur(t - e.dataset.since); if (e.textContent !== v) e.textContent = v; });
|
||
document.querySelectorAll('[data-until]').forEach(e => { const v = fmtDur(e.dataset.until - t); if (e.textContent !== v) e.textContent = v; });
|
||
if (state.status) $('s-uptime').textContent = fmtDur(t - state.status.started_at_unix);
|
||
}
|
||
const fmtWhen = unix => new Date(unix * 1000).toLocaleString();
|
||
|
||
// What a community of ours asks for where control communities are on
|
||
// (control_communities), or ''. They are acted on where a route leaves
|
||
// our AS.
|
||
const TIMES = { 1: 'once', 2: 'twice', 3: 'three times' };
|
||
function controlMeaning(c, large) {
|
||
const s = state.status;
|
||
if (!s || !s.control_communities) return '';
|
||
const L = s.local_asn, p = c.split(':').map(Number);
|
||
if (!large) {
|
||
const [high, low] = p;
|
||
if (high === 0) return low === L ? 'to no other network' : `not to AS${low}`;
|
||
if (high === L) return low === L ? 'to every network' : `only to AS${low} (and others named so)`;
|
||
return '';
|
||
}
|
||
const [admin, func, arg] = p;
|
||
if (admin !== L) return '';
|
||
if (func === 0) return arg ? `not to AS${arg}` : 'to no other network';
|
||
if (func === 1) return `only to AS${arg} (and others named so)`;
|
||
if (func >= 11 && func <= 13) return `our AS ${TIMES[func - 10]} more on the path to ${arg ? `AS${arg}` : 'every network'}`;
|
||
if (func === 20) return `not to group ${arg}`;
|
||
if (func === 21) return `only to group ${arg} (and others named so)`;
|
||
if (func >= 31 && func <= 33) return `our AS ${TIMES[func - 30]} more on the path to group ${arg}`;
|
||
return '';
|
||
}
|
||
|
||
// The configured name for a community (community_names): an exact one
|
||
// first, then a pattern, whose open part is shown after the name.
|
||
function communityName(c) {
|
||
// An older daemon's status has no names.
|
||
const names = (state.status && state.status.community_names) || [];
|
||
const parts = c.split(':');
|
||
let fallback = null;
|
||
for (const entry of names) {
|
||
const pattern = entry.community.split(':');
|
||
if (pattern.length !== parts.length) continue;
|
||
if (entry.community === c) return entry.name;
|
||
if (!fallback && pattern.every((p, i) => p === '*' || p === parts[i])) {
|
||
const open = parts.filter((_, i) => pattern[i] === '*').join(':');
|
||
fallback = `${entry.name} ${open}`;
|
||
}
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
// Communities, one line for each AS that defines them, in the order the
|
||
// route has them. Well-known ones by name; ours that ask for something
|
||
// with what they ask as a tooltip; those with a configured name as a
|
||
// badge with that name, the number as the tooltip. Pointing at a line
|
||
// names its AS.
|
||
function commList(list, large) {
|
||
if (!list.length) return DASH;
|
||
const owners = new Map();
|
||
for (const c of list) {
|
||
const owner = c.split(':')[0];
|
||
if (!owners.has(owner)) owners.set(owner, []);
|
||
owners.get(owner).push(c);
|
||
}
|
||
const local = state.status ? String(state.status.local_asn) : null;
|
||
const lines = [...owners].map(([owner, cs]) => {
|
||
const items = cs.map(c => {
|
||
const wk = !large && WELL_KNOWN[c];
|
||
if (wk) return glyph(wk, c, 'wk');
|
||
const name = communityName(c);
|
||
const meaning = controlMeaning(c, large);
|
||
if (name) return glyph(name, meaning ? `${c}: ${meaning}` : c, meaning ? 'ctl' : 'mark');
|
||
return meaning
|
||
? `<span class="comm" title="${esc(`${meaning}: a control community, acted on where the route leaves AS${state.status.local_asn}`)}">${esc(c)}</span>`
|
||
: `<span class="comm">${esc(c)}</span>`;
|
||
}).join(' ');
|
||
// Not for our own AS, nor for 0 and 65535, which are nobody's.
|
||
const named = owner !== local && owner !== '0' && owner !== '65535';
|
||
return named ? `<div class="asn" data-asn="${esc(owner)}">${items}</div>` : `<div>${items}</div>`;
|
||
});
|
||
return `<div class="comms mono">${lines.join('')}</div>`;
|
||
}
|
||
|
||
// The attributes that have no column of their own, as glyphs.
|
||
const OTHER_GLYPHS = {
|
||
'atomic aggregate': ['AA', 'ATOMIC_AGGREGATE: the route is an aggregate that lost information'],
|
||
'aggregator': ['AGG', 'AGGREGATOR: the AS and router that made the aggregate'],
|
||
'extended communities': ['EXT', 'Extended communities'],
|
||
'AS4_PATH': ['AS4', 'AS4_PATH: the path with four-octet AS numbers, from a two-octet session'],
|
||
'only to customer': ['OTC', 'Only to Customer (RFC 9234): the route came down or across from this AS, and may only go on to customers'],
|
||
'originator ID': ['OID', 'ORIGINATOR_ID: the router of our AS that learned the route from outside'],
|
||
'cluster list': ['CL', 'CLUSTER_LIST: the route reflector clusters the route has passed through'],
|
||
};
|
||
const otherList = list => list && list.length
|
||
? list.map(a => {
|
||
const [text, meaning] = OTHER_GLYPHS[a.name] || [a.name, a.name];
|
||
return glyph(text, `${meaning}: ${a.value}`);
|
||
}).join('')
|
||
: DASH;
|
||
|
||
// An AS number, whose holder's name is shown when it is pointed at.
|
||
const asn = n => `<span class="asn" data-asn="${esc(n)}">${esc(n)}</span>`;
|
||
// A path, with an AS that repeats (prepending) once, and how many times.
|
||
function asPath(p) {
|
||
if (!p.length) return '<span class="dim">(empty)</span>';
|
||
const runs = [];
|
||
for (const n of p) {
|
||
const last = runs[runs.length - 1];
|
||
if (last && last.asn === n) last.times++; else runs.push({ asn: n, times: 1 });
|
||
}
|
||
return runs.map(r => r.times === 1 ? asn(r.asn)
|
||
: `<span class="nowrap">${asn(r.asn)}${glyph(`×${r.times}`, `AS${r.asn} ${r.times} times in a row: prepended ${r.times - 1} times`, 'x')}</span>`).join(' ');
|
||
}
|
||
// Wraps what can run to many lines: shown clamped, with a way to open
|
||
// it, if it does. See markTall.
|
||
const clamp = html => `<div class="clamp">${html}</div><span class="g dim clamp-more">▾ more</span>`;
|
||
// After a render: finds the clamped cells with more than fits.
|
||
function markTall(root) {
|
||
for (const c of root.querySelectorAll('.clamp')) c.classList.toggle('tall', c.scrollHeight > c.clientHeight + 1);
|
||
}
|
||
function toggleClamp(cell) {
|
||
const c = cell.querySelector('.clamp.tall'), more = cell.querySelector('.clamp-more');
|
||
if (!c) return;
|
||
const open = c.classList.toggle('open');
|
||
more.textContent = open ? '▴ less' : '▾ more';
|
||
}
|
||
|
||
async function getJSON(url) {
|
||
const r = await fetch(url);
|
||
if (!r.ok) throw new Error(`${url}: ${r.status} ${await r.text()}`);
|
||
return r.json();
|
||
}
|
||
|
||
// ---- renderers ---------------------------------------------------------
|
||
function renderStatus() {
|
||
const s = state.status; if (!s) return;
|
||
$('s-router-id').textContent = s.router_id; $('s-asn').textContent = s.local_asn;
|
||
$('s-asn').className = 'asn'; $('s-asn').dataset.asn = s.local_asn;
|
||
$('s-version').textContent = s.version; $('s-hostname').textContent = s.hostname;
|
||
$('s-started').textContent = new Date(s.started_at_unix * 1000).toString().replace(/ \(.*\)$/, '');
|
||
tick();
|
||
}
|
||
|
||
// An established peer with under half the routes it is expected to send
|
||
// (expected_routes), once it has had a minute to send them.
|
||
const SETTLING_SECS = 60;
|
||
function belowExpected(p) {
|
||
return p.state === 'Established' && p.expected_routes > 0 && p.routes != null
|
||
&& p.routes < p.expected_routes / 2 && now() - p.established_at_unix > SETTLING_SECS;
|
||
}
|
||
// Peers in name order, except that the IPv6 session of a neighbour
|
||
// (<stem>-v6) comes before its IPv4 one: IPv6 is the network's present,
|
||
// IPv4 what is still carried along.
|
||
const FAMILY_SUFFIX = /^(.*)-v([46])$/;
|
||
function nameOrder(a, c) {
|
||
const [sa, fa] = FAMILY_SUFFIX.exec(a)?.slice(1) || [a, ''];
|
||
const [sc, fc] = FAMILY_SUFFIX.exec(c)?.slice(1) || [c, ''];
|
||
return sa.localeCompare(sc) || fc.localeCompare(fa);
|
||
}
|
||
const byName = (a, c) => nameOrder(a.name || a.peer_addr, c.name || c.peer_addr);
|
||
|
||
// Where a peer stands, for the strip and for sorting: 0 failed, 1
|
||
// degraded, 2 normal, 3 inactive and expected.
|
||
function peerRank(p) {
|
||
if (p.state === 'Backoff' || p.state === 'Connecting') return p.stale_until_unix ? 1 : 0;
|
||
if (p.state === 'Established') return p.stale_routes || belowExpected(p) ? 1 : 2;
|
||
return 3;
|
||
}
|
||
|
||
// ---- what moved: between refreshes, and while nobody was looking --------
|
||
// A route count that moved by more than a little since the last refresh
|
||
// is shown with an arrow for a while: a peer flapping or draining shows
|
||
// here before anything else says so.
|
||
const TREND_MIN_SHARE = 0.02, TREND_MIN_ROUTES = 10, TREND_SHOWN_SECS = 120;
|
||
const trends = new Map();
|
||
function noteTrends(before, after) {
|
||
const was = new Map(before.filter(p => p.name).map(p => [p.name, p]));
|
||
for (const p of after) {
|
||
const old = p.name && was.get(p.name);
|
||
if (!old || p.state !== 'Established' || old.state !== 'Established' || p.routes == null || old.routes == null) continue;
|
||
const moved = p.routes - old.routes;
|
||
if (Math.abs(moved) >= TREND_MIN_ROUTES && Math.abs(moved) / Math.max(old.routes, 1) >= TREND_MIN_SHARE) {
|
||
trends.set(p.name, { from: old.routes, to: p.routes, at: now() });
|
||
}
|
||
}
|
||
}
|
||
function trendGlyph(p) {
|
||
const t = p.name && trends.get(p.name);
|
||
if (!t || now() - t.at > TREND_SHOWN_SECS) return '';
|
||
const up = t.to > t.from, share = Math.round(100 * Math.abs(t.to - t.from) / Math.max(t.from, 1));
|
||
return glyph(up ? '▲' : '▼', `${up ? 'Up' : 'Down'} from ${fmtCount(t.from)} to ${fmtCount(t.to)} (${share}%) ${fmtDur(now() - t.at)} ago`, 'trend amber');
|
||
}
|
||
// What each peer looked like when the tab last went into the background.
|
||
// On coming back, a row whose state changed, or whose routes moved as
|
||
// above, carries a mark until it is clicked.
|
||
let lastLooked = null;
|
||
const glance = () => new Map(state.peers.filter(p => p.name).map(p => [p.name, { state: p.state, routes: p.routes }]));
|
||
function changedSince(p) {
|
||
const then = lastLooked && p.name && lastLooked.get(p.name);
|
||
if (!then) return null;
|
||
if (then.state !== p.state) return `was ${then.state}`;
|
||
if (then.routes != null && p.routes != null) {
|
||
const moved = p.routes - then.routes;
|
||
if (Math.abs(moved) >= TREND_MIN_ROUTES && Math.abs(moved) / Math.max(then.routes, 1) >= TREND_MIN_SHARE) return `had ${fmtCount(then.routes)} routes`;
|
||
}
|
||
return null;
|
||
}
|
||
function sinceGlyph(p) {
|
||
const change = changedSince(p);
|
||
return change ? glyph('●', `Changed since you last looked at this tab: ${change}. Click to clear.`, 'since') : '';
|
||
}
|
||
// Clears the mark on one row, or on all.
|
||
function lookedAt(name) {
|
||
if (!lastLooked) return;
|
||
const seen = glance();
|
||
if (name) { if (seen.has(name)) lastLooked.set(name, seen.get(name)); } else lastLooked = seen;
|
||
renderPeers();
|
||
}
|
||
document.addEventListener('visibilitychange', () => { if (document.hidden) lastLooked = glance(); });
|
||
const ROLE_GLYPH = {
|
||
provider: ['↑', 'provider: we buy transit from it'],
|
||
customer: ['↓', 'customer: it buys transit from us'],
|
||
peer: ['↔', 'peer: we exchange our own and our customers’ routes'],
|
||
'route server': ['RS', 'route server of an Internet exchange'],
|
||
'route server client': ['RSC', 'client of a route server we run'],
|
||
};
|
||
function roleGlyph(p) {
|
||
if (p.peer_asn === (state.status || {}).local_asn) return glyph('iBGP', 'a router of our own AS');
|
||
const r = ROLE_GLYPH[p.role];
|
||
if (!r) return glyph('?', 'no role configured', 'amber');
|
||
const confirmed = p.role_confirmed ? '; the peer says so too (RFC 9234)' : p.state === 'Established' ? '; by our configuration, the peer has not said what it is' : '';
|
||
return glyph(r[0], r[1] + confirmed);
|
||
}
|
||
// The session's capabilities as glyphs.
|
||
function sessionGlyphs(p) {
|
||
const out = [];
|
||
const families = p.address_families || [];
|
||
if (families.some(f => f.startsWith('IPv4'))) out.push(glyph('v4', 'IPv4 unicast'));
|
||
if (families.some(f => f.startsWith('IPv6'))) out.push(glyph('v6', 'IPv6 unicast'));
|
||
if (p.extended_next_hop) out.push(glyph('ENH', 'IPv4 routes go with IPv6 next hops (RFC 8950)'));
|
||
const gr = p.graceful_restart;
|
||
if (gr) out.push(glyph('GR', `Graceful restart (RFC 4724): the peer says a restart takes ${gr.restart_time_secs}s${gr.notification ? '; NOTIFICATIONs other than Hard Reset count as restarts (RFC 8538)' : ''}`));
|
||
const rx = p.add_path_receive || [], tx = p.add_path_send || [];
|
||
if (rx.length || tx.length) out.push(glyph(`AP${rx.length ? '↓' : ''}${tx.length ? '↑' : ''}`, `ADD-PATH (RFC 7911): ${rx.length ? `we receive several paths for ${rx.join(', ')}` : ''}${rx.length && tx.length ? '; ' : ''}${tx.length ? `we send several for ${tx.join(', ')}` : ''}`));
|
||
if (p.four_octet_asn === false) out.push(glyph('2B', 'The peer handles two-octet AS numbers only (no RFC 6793): wide AS numbers go as AS_TRANS', 'amber'));
|
||
if (p.direction) out.push(glyph(p.direction === 'outbound' ? '⇢' : '⇠', p.direction === 'outbound' ? 'the connection was opened by us' : 'the connection was opened by the peer', 'dim'));
|
||
return out.join('');
|
||
}
|
||
const STATE_CLASS = { Established: '', Connecting: 'amber', Backoff: 'red', Idle: 'dim', Passive: 'dim' };
|
||
function peerDetail(p) {
|
||
const parts = [];
|
||
if (p.state !== 'Established' && p.stale_until_unix)
|
||
parts.push(`<span class="amber">restarting</span>: ${fmtCount(p.stale_routes)} routes kept for ${until(p.stale_until_unix)}`);
|
||
if (p.state === 'Backoff') parts.push(`${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}`);
|
||
if (p.state === 'Passive') parts.push(`waiting for the peer to connect${p.last_error ? `; last session: ${esc(p.last_error)}` : ''}`);
|
||
return parts.join('; ');
|
||
}
|
||
const ROLE_ORDER = ['iBGP', 'provider', 'route server', 'peer', 'customer', 'route server client', 'none'];
|
||
function peerGroup(p) {
|
||
if (p.peer_asn === (state.status || {}).local_asn) return 'iBGP';
|
||
return p.role || 'none';
|
||
}
|
||
const GROUP_TITLE = { iBGP: 'Our own routers', provider: 'Providers', 'route server': 'Route servers', peer: 'Peers', customer: 'Customers', 'route server client': 'Route server clients', none: 'Without a role' };
|
||
function renderPeers() {
|
||
const b = $('peers-body');
|
||
const peers = state.peers;
|
||
const up = peers.filter(p => p.state === 'Established').length;
|
||
const down = peers.filter(p => peerRank(p) === 0).length;
|
||
const waiting = peers.filter(p => p.state === 'Passive').length;
|
||
$('peers-count').textContent = `${up} of ${peers.length} established` + (down ? `, ${down} down` : '') + (waiting ? `, ${waiting} waiting` : '');
|
||
if (!peers.length) { setHTML(b, '<tr><td colspan="9" class="dim">No peers configured and none connected.</td></tr>'); return; }
|
||
const problemsOnly = $('peers-problems').checked;
|
||
// The IPv4 and IPv6 sessions of one neighbour, named <stem>-v4 and
|
||
// <stem>-v6, are one neighbour to the reader: listed together, IPv6
|
||
// first, the name once in full and once quietly, joined down the side.
|
||
const row = (p, pair) => {
|
||
const up = p.state === 'Established';
|
||
const family = p.name && FAMILY_SUFFIX.exec(p.name);
|
||
const shown = pair && family ? `${esc(family[1])}<span class="dim">-v${family[2]}</span>` : esc(p.name || '');
|
||
const name = p.name ? `<a href="#received" class="peer-link" data-peer="${esc(p.name)}" title="Look at this peer's routes">${shown}</a>` : '<span class="dim">(inbound)</span>';
|
||
const rejected = up ? `<span class="dim">${fmtCount(p.rejected_routes || 0)}</span>` : DASH;
|
||
const held = up && p.held_routes ? ` · <span class="amber" title="Routes rejected as RPKI or ASPA invalid, or for a next hop that cannot be reached, and kept aside, to be taken if that changes">${fmtCount(p.held_routes)} held</span>` : '';
|
||
const stale = up && p.stale_routes ? ` ${glyph(`${fmtCount(p.stale_routes)} stale`, 'Kept from the peer’s last session, and not sent again yet', 'amber')}` : '';
|
||
const short = belowExpected(p);
|
||
const expected = up && p.expected_routes > 0
|
||
? ` <span class="${short ? 'amber' : 'dim'}" title="${short ? 'Well under' : 'About'} the ${fmtCount(p.expected_routes)} routes this peer is expected to send (expected_routes)">of ~${fmtCount(p.expected_routes)}</span>` : '';
|
||
const classes = [peerRank(p) === 3 ? 'dim' : '', pair || ''].filter(Boolean).join(' ');
|
||
return `<tr class="${classes}" data-peer-row="${esc(p.name || p.peer_addr)}">
|
||
<td class="name">${sinceGlyph(p)}${name}</td>
|
||
<td class="mono">${esc(p.peer_addr)} <span class="dim">AS${asn(p.peer_asn)}</span> ${roleGlyph(p)}</td>
|
||
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td>
|
||
<td class="nowrap">${up ? `<span title="since ${esc(fmtWhen(p.established_at_unix))}">${age(p.established_at_unix)}</span>` : DASH}</td>
|
||
<td class="mono c-opt">${up ? esc(p.local_addr) : DASH}</td>
|
||
<td class="c-opt">${up ? sessionGlyphs(p) : DASH}</td>
|
||
<td class="num">${up ? trendGlyph(p) + fmtCount(p.routes) + expected + stale : DASH}</td>
|
||
<td class="num">${rejected}${held}</td>
|
||
<td>${peerDetail(p)}</td></tr>`;
|
||
};
|
||
// Rows for a sorted list, with the v4/v6 pairs in it marked.
|
||
const rows = list => list.map((p, i) => {
|
||
const stem = p.name && FAMILY_SUFFIX.exec(p.name);
|
||
const sameStem = q => q && q.name && FAMILY_SUFFIX.exec(q.name)?.[1] === stem[1];
|
||
if (!stem) return row(p);
|
||
if (sameStem(list[i + 1])) return row(p, 'pair-a');
|
||
if (sameStem(list[i - 1])) return row(p, 'pair-b');
|
||
return row(p);
|
||
}).join('');
|
||
let html = '';
|
||
// What needs attention first, whoever it is; then everyone by what
|
||
// they are to us.
|
||
const problems = peers.filter(p => peerRank(p) <= 1).sort(byName);
|
||
if (problems.length) {
|
||
html += `<tr class="group"><td colspan="9"><span class="red">Needing attention</span> (${problems.length})</td></tr>` + rows(problems);
|
||
}
|
||
if (!problemsOnly) {
|
||
for (const group of ROLE_ORDER) {
|
||
const members = peers.filter(p => peerRank(p) >= 2 && peerGroup(p) === group).sort(byName);
|
||
if (!members.length) continue;
|
||
html += `<tr class="group"><td colspan="9">${GROUP_TITLE[group]} (${members.length})</td></tr>` + rows(members);
|
||
}
|
||
} else if (!problems.length) {
|
||
html += '<tr><td colspan="9" class="dim">Nothing needs attention.</td></tr>';
|
||
}
|
||
setHTML(b, html);
|
||
const marked = peers.filter(changedSince).length;
|
||
setHTML($('since-ctl'), marked ? `${glyph('●', 'Rows that changed while this tab was in the background', 'since')} <span class="dim">${marked} changed since you last looked ·</span> <a href="#" id="since-clear">clear</a>` : '');
|
||
renderPeerChoices();
|
||
}
|
||
|
||
// The strip: what needs attention, in one line, or that nothing does.
|
||
function renderStrip() {
|
||
const items = [];
|
||
const peers = state.peers, k = state.kernel, r = state.rpki;
|
||
// A name in the strip leads to the peer's row.
|
||
const toPeer = p => `<a href="#peers" class="to-peer" data-peer="${esc(p.name || p.peer_addr)}" title="Go to this peer's row">${esc(p.name || p.peer_addr)}</a>`;
|
||
const down = peers.filter(p => peerRank(p) === 0);
|
||
if (down.length) items.push(`<span class="red">${down.length} session${down.length === 1 ? '' : 's'} down:</span> ${down.map(p => `${toPeer(p)} <span class="dim">(${esc((p.last_error || p.state).replace(/;.*$/, ''))})</span>`).join(', ')}`);
|
||
const short = peers.filter(belowExpected);
|
||
if (short.length) items.push(`<span class="amber">${short.length} sending far fewer routes than expected:</span> ${short.map(p => `${toPeer(p)} <span class="dim">(${fmtCount(p.routes)} of ~${fmtCount(p.expected_routes)})</span>`).join(', ')}`);
|
||
const restarting = peers.filter(p => peerRank(p) === 1 && !belowExpected(p));
|
||
if (restarting.length) items.push(`<span class="amber">${restarting.length} restarting or stale:</span> ${restarting.map(toPeer).join(', ')}`);
|
||
if (k && k.enabled) {
|
||
if (!k.synced) items.push('<span class="amber">kernel catching up</span> with the routing table');
|
||
if (k.refused) items.push(`<span class="red">kernel refused ${fmtCount(k.refused)} routes</span>`);
|
||
const unresolved = Object.values(k.unresolved || {}).reduce((a, n) => a + n, 0);
|
||
if (unresolved) items.push(`<span class="amber">${fmtCount(unresolved)} best routes not installed</span> (next hop unusable)`);
|
||
}
|
||
if (r && r.enabled) {
|
||
if (r.state !== 'synchronised') items.push(`<span class="red">RPKI ${esc(r.state)}</span>${r.last_error ? ` <span class="dim">(${esc(r.last_error)})</span>` : ''}`);
|
||
else if (!r.updated_at_unix) items.push('<span class="amber">RPKI has no data</span>: nothing is refused as invalid');
|
||
else if (r.expires_at_unix && r.expires_at_unix - now() < 3600) items.push(`<span class="amber">RPKI data expires in ${until(r.expires_at_unix)}</span> unless confirmed`);
|
||
}
|
||
const held = peers.reduce((a, p) => a + (p.held_routes || 0), 0);
|
||
const normal = [];
|
||
const up = peers.filter(p => p.state === 'Established').length;
|
||
normal.push(`${up}/${peers.length} sessions up`);
|
||
if (k && k.enabled) normal.push(`${fmtCount(k.installed)} routes in the kernel${k.synced ? ', <span class="green">in step</span>' : ''}`);
|
||
if (r && r.enabled && r.state === 'synchronised' && r.updated_at_unix) normal.push(`RPKI <span class="green">current</span>`);
|
||
if (held) normal.push(`<span class="dim">${fmtCount(held)} routes held</span>`);
|
||
const waiting = peers.filter(p => p.state === 'Passive').length;
|
||
if (waiting) normal.push(`<span class="dim">${waiting} waiting to connect</span>`);
|
||
const head = items.length ? items.map(i => `<span class="item">${i}</span>`).join('') : '<span class="item"><span class="dim">Nothing needs attention.</span></span>';
|
||
setHTML($('strip'), head + `<br><span class="dim">${normal.join(' · ')}</span>`);
|
||
}
|
||
|
||
// ---- looking routes up -------------------------------------------------
|
||
// The query lives in the page's address, so that it survives a reload and
|
||
// auto-refresh, and can be passed on.
|
||
const QUERY_KEYS = ['q', 'mode', 'peer', 'origin', 'best'];
|
||
// Which page of the listing is shown lives there too: the prefix the
|
||
// page comes after, or the one it comes before.
|
||
const PAGE_KEYS = ['after', 'before'];
|
||
// About this many routes to a page. A page holds whole prefixes.
|
||
const PAGE_ROUTES = 100;
|
||
function lookup() {
|
||
const here = new URLSearchParams(location.search);
|
||
return { q: (here.get('q') || '').trim(), mode: here.get('mode') || 'exact', peer: here.get('peer') || '', origin: here.get('origin') || '', best: here.get('best') === '1' };
|
||
}
|
||
function pagePlace() {
|
||
const here = new URLSearchParams(location.search);
|
||
return { after: here.get('after') || '', before: here.get('before') || '' };
|
||
}
|
||
// Goes to another page: {after: prefix}, {before: prefix}, or nothing
|
||
// for the first.
|
||
function setPage(place) {
|
||
const url = new URL(location.href);
|
||
PAGE_KEYS.forEach(k => url.searchParams.delete(k));
|
||
PAGE_KEYS.forEach(k => { if (place && place[k]) url.searchParams.set(k, place[k]); });
|
||
history.replaceState(null, '', url.pathname + url.search + '#received');
|
||
renderRefreshControl();
|
||
loadReceived().catch(console.error);
|
||
}
|
||
function isLookup() { const l = lookup(); return !!(l.q || l.peer || l.origin || l.best); }
|
||
// What to ask the API for: an address finds every prefix that covers it.
|
||
function receivedUrl() {
|
||
const l = lookup(), ask = new URLSearchParams();
|
||
if (l.q) ask.set(l.mode === 'within' ? 'within' : l.q.includes('/') ? 'prefix' : 'address', l.q);
|
||
if (l.peer) ask.set('peer', l.peer);
|
||
if (l.origin) ask.set('origin', l.origin);
|
||
if (l.best) ask.set('best', 'true');
|
||
const place = pagePlace();
|
||
if (place.after) ask.set('after', place.after); else if (place.before) ask.set('before', place.before);
|
||
ask.set('limit', PAGE_ROUTES);
|
||
return `/api/routes/received?${ask}`;
|
||
}
|
||
function renderPeerChoices() {
|
||
const chosen = lookup().peer;
|
||
const names = [...new Set(state.peers.filter(p => p.state === 'Established')
|
||
.map(p => p.name || p.peer_addr.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')))].sort(nameOrder);
|
||
if (chosen && !names.includes(chosen)) names.push(chosen);
|
||
setHTML($('lookup-peer'), '<option value="">any peer</option>' +
|
||
names.map(n => `<option value="${esc(n)}"${n === chosen ? ' selected' : ''}>${esc(n)}</option>`).join(''));
|
||
}
|
||
function showLookup() {
|
||
const l = lookup();
|
||
$('lookup-q').value = l.origin ? `AS${l.origin}` : l.q; $('lookup-mode').value = l.mode; $('lookup-best').checked = l.best;
|
||
renderPeerChoices();
|
||
}
|
||
function setLookup(l) {
|
||
const url = new URL(location.href);
|
||
// Another question starts at its first page.
|
||
QUERY_KEYS.concat(PAGE_KEYS).forEach(k => url.searchParams.delete(k));
|
||
if (l) {
|
||
if (l.q) url.searchParams.set('q', l.q);
|
||
if (l.q && l.mode === 'within') url.searchParams.set('mode', 'within');
|
||
if (l.peer) url.searchParams.set('peer', l.peer);
|
||
if (l.origin) url.searchParams.set('origin', l.origin);
|
||
if (l.best) url.searchParams.set('best', '1');
|
||
}
|
||
history.replaceState(null, '', url.pathname + url.search + '#received');
|
||
showLookup(); renderRefreshControl();
|
||
loadReceived().catch(console.error);
|
||
renderRejected();
|
||
}
|
||
// What was typed is a peer's name if a peer has it: then it is the peer
|
||
// to look at, not a prefix. A number, with or without AS in front, is
|
||
// an AS: the routes it originates.
|
||
function submitLookup() {
|
||
const typed = $('lookup-q').value.trim();
|
||
const isPeer = state.peers.some(p => p.name === typed);
|
||
const asn = /^(?:AS)?(\d+)$/i.exec(typed);
|
||
setLookup({ q: isPeer || asn ? '' : typed, mode: $('lookup-mode').value, peer: isPeer ? typed : $('lookup-peer').value,
|
||
origin: asn ? asn[1] : '', best: $('lookup-best').checked });
|
||
}
|
||
|
||
function renderLocal() {
|
||
const b = $('local-body'); $('local-count').textContent = state.local.length;
|
||
if (!state.local.length) { setHTML(b, '<tr><td colspan="7" class="dim">This router originates no routes.</td></tr>'); return; }
|
||
const local = state.status ? state.status.local_asn : '?';
|
||
setHTML(b, state.local.map(r => `<tr>
|
||
<td class="mono">${esc(r.prefix)}</td><td>${esc(r.origin)}</td>
|
||
<td class="mono path">${asPath(Array(1 + r.prepend).fill(local))}</td><td class="num">${r.med ?? DASH}</td>
|
||
<td>${commList(r.communities)}</td><td>${commList(r.large_communities, true)}</td>
|
||
<td>${age(r.announced_at_unix)}</td></tr>`).join(''));
|
||
}
|
||
|
||
const VALIDITY = {
|
||
valid: ['✓', 'green'], invalid: ['✗', 'red'], 'not found': ['–', 'dim'], unknown: ['–', 'dim'],
|
||
};
|
||
function validity(what, value) {
|
||
if (!value) return `<span class="dim" title="${esc(what)}: not checked">·</span>`;
|
||
const [mark, cls] = VALIDITY[value] || [value, ''];
|
||
return `<span class="${cls}" title="${esc(what)}: ${esc(value)}">${mark}</span>`;
|
||
}
|
||
// Where a route stands in path selection: the best, or the one that
|
||
// would take over were those ranked before it gone.
|
||
const ORDINAL = n => n + (n % 10 === 2 && n % 100 !== 12 ? 'nd' : n % 10 === 3 && n % 100 !== 13 ? 'rd' : 'th');
|
||
function rankGlyph(r) {
|
||
if (r.best) return glyph('★', 'The best route for this prefix: the one chosen, installed and passed on', 'green');
|
||
if (!r.rank) return '';
|
||
return glyph(String(r.rank), `${ORDINAL(r.rank)} in path selection: the route that would be chosen were the ${r.rank - 1} before it gone`, 'rank');
|
||
}
|
||
function renderReceived() {
|
||
const b = $('received-body'), st = state.status, looking = isLookup();
|
||
const page = state.receivedPage, paged = !!(page.next_after || page.prev_before);
|
||
const shown = paged ? `${fmtCount(state.received.length)} on this page` : fmtCount(state.received.length);
|
||
$('received-count').textContent = looking
|
||
? `${shown}${paged ? '' : ' found'}${st ? ` among ${fmtCount(st.received_routes)}` : ''}`
|
||
: paged && st
|
||
? `${fmtCount(st.received_routes)}, for ${fmtCount(st.received_prefixes)} prefixes; ${shown}`
|
||
: `${shown}${st ? `, for ${fmtCount(st.received_prefixes)} prefixes` : ''}`;
|
||
renderPager();
|
||
if (!state.received.length) {
|
||
setHTML(b, `<tr><td colspan="12" class="dim">${looking ? 'No route matches.' : 'No routes received from peers.'}</td></tr>`); return;
|
||
}
|
||
setHTML(b, state.received.map(r => { const s = r.summary; return `<tr class="${r.best ? 'best' : ''}">
|
||
<td class="nowrap">${rankGlyph(r)}${r.stale ? glyph('stale', 'Kept from a session of the peer’s that ended (graceful restart), and not sent again since', 'amber') : ''}</td>
|
||
<td class="mono">${esc(r.prefix)}</td>
|
||
<td class="mono">${r.peer_name ? `<a href="#received" class="peer-link" data-peer="${esc(r.peer_name)}">${esc(r.peer_name)}</a> ` : ''}<span class="dim">${esc(r.peer_addr)} AS${asn(r.peer_asn)}${r.path_id ? ` path ${r.path_id}` : ''}</span></td>
|
||
<td class="mono path">${clamp(asPath(s.as_path))}</td>
|
||
<td class="mono">${s.next_hop ? esc(s.next_hop) : DASH}</td><td>${s.origin ? esc(s.origin) : DASH}</td>
|
||
<td class="num">${s.med ?? DASH}</td><td class="num">${s.local_pref ?? DASH}</td>
|
||
<td>${clamp(commList(s.communities))}</td><td>${clamp(commList(s.large_communities, true))}</td><td>${otherList(s.other)}</td>
|
||
<td class="nowrap">${validity('RPKI', r.rpki)} ${validity('ASPA', r.aspa)}</td></tr>`; }).join(''));
|
||
markTall(b);
|
||
}
|
||
|
||
const RPKI_STATE_CLASS = { synchronised: 'green', synchronising: 'amber', connecting: 'red' };
|
||
function renderRpki() {
|
||
const r = state.rpki, b = $('rpki-body');
|
||
if (!r) return;
|
||
if (!r.enabled) { setHTML(b, '<span class="dim">Not configured: routes are not validated.</span>'); return; }
|
||
const data = r.updated_at_unix
|
||
? `${fmtCount(r.vrps_ipv4)} IPv4 and ${fmtCount(r.vrps_ipv6)} IPv6 VRPs and ${fmtCount(r.aspas)} ASPAs at serial ${r.serial ?? '?'}, confirmed ${age(r.updated_at_unix)} ago` +
|
||
(r.expires_at_unix ? `, dropped in ${until(r.expires_at_unix)} unless confirmed again` : '')
|
||
: '<span class="amber">no data: every route is "not found" and none is refused as invalid</span>';
|
||
setHTML(b, `Cache <span class="mono">${esc(r.cache || '?')}</span>: <span class="${RPKI_STATE_CLASS[r.state] || ''}">${esc(r.state)}</span>` +
|
||
` <span class="dim">(RTR version ${r.protocol_version})</span>. ${data}.` +
|
||
(r.updated_at_unix && r.protocol_version < 2 ? '<br><span class="amber">This cache speaks an RTR version without ASPAs: AS paths are not verified.</span>' : '') +
|
||
(r.last_error ? `<br><span class="red">Last error:</span> ${esc(r.last_error)}` : ''));
|
||
}
|
||
|
||
function renderKernel() {
|
||
const k = state.kernel, b = $('kernel-body');
|
||
if (!k) return;
|
||
if (!k.enabled) { setHTML(b, '<span class="dim">Not configured: the best routes are not put in the kernel, and this machine does not forward by them.</span>'); return; }
|
||
const row = (name, value) => `<tr><th>${name}</th><td>${value}</td></tr>`;
|
||
const unresolved = Object.entries(k.unresolved).map(([why, n]) => `${fmtCount(n)} ${esc(why)}`).join(', ');
|
||
const summary = '<table class="data kv">' +
|
||
row('state', `<span class="${k.synced ? 'green' : 'amber'}">${k.synced ? 'in step' : 'catching up'}</span> with the routing table`) +
|
||
row('routes installed', `<b>${fmtCount(k.installed)}</b>`) +
|
||
row('installed as', `<span class="mono">table ${k.table} metric ${k.metric} proto bgp</span>`) +
|
||
(k.kept ? row('kept from before the restart', `<span class="amber">${fmtCount(k.kept)}</span>, not announced again yet`) : '') +
|
||
(unresolved ? row('not installed', `<span class="amber">${unresolved}</span>`) : '') +
|
||
(k.refused ? row('refused by the kernel', `<span class="red">${fmtCount(k.refused)}</span>`) : '') +
|
||
(k.last_error ? row('last error', `<span class="red">${esc(k.last_error)}</span> (${fmtCount(k.errors)} errors in all)`) : '') +
|
||
'</table>';
|
||
// Interfaces that are up or have addresses get a row each; the others
|
||
// (unused ports, mostly) one line between them.
|
||
const v4 = a => !a.includes(':'), linkLocal = a => /^fe80:/i.test(a);
|
||
const lines = list => list.length ? list.map(esc).join('<br>') : DASH;
|
||
const listed = k.interfaces.filter(i => i.up || i.addresses.length);
|
||
const idle = k.interfaces.filter(i => !i.up && !i.addresses.length);
|
||
const interfaces = !k.interfaces.length ? '' :
|
||
'<table class="data"><thead><tr><th>interface</th><th>state</th><th>IPv4</th><th>IPv6</th><th>IPv6 link-local</th></tr></thead><tbody>' +
|
||
listed.map(i => `<tr${i.up ? '' : ' class="dim"'}><td class="mono">${esc(i.name)}</td>
|
||
<td>${i.up ? glyph('▲ up', 'the interface is up', 'green') : glyph('▼ down', 'the interface is down', 'red')}</td>
|
||
<td class="mono">${lines(i.addresses.filter(v4))}</td>
|
||
<td class="mono">${lines(i.addresses.filter(a => !v4(a) && !linkLocal(a)))}</td>
|
||
<td class="mono dim">${lines(i.addresses.filter(linkLocal))}</td></tr>`).join('') +
|
||
(idle.length ? `<tr class="dim"><td colspan="5">Down and without addresses: <span class="mono">${idle.map(i => esc(i.name)).join(' ')}</span></td></tr>` : '') +
|
||
'</tbody></table>';
|
||
setHTML(b, summary + interfaces);
|
||
}
|
||
|
||
// The breakdown of a peer's rejections by reason: a bar, each reason a
|
||
// segment, with the counts after it.
|
||
const REASON_COLOURS = ['#88a', '#a60', '#6a6', '#c66', '#69c', '#996', '#a6a', '#999'];
|
||
function reasonBar(byReason, total) {
|
||
const reasons = Object.entries(byReason).sort((a, c) => c[1] - a[1]);
|
||
const bar = reasons.map(([reason, n], i) =>
|
||
`<span style="width: ${100 * n / total}%; background: ${REASON_COLOURS[i % REASON_COLOURS.length]}" title="${esc(reason)}: ${fmtCount(n)}"></span>`).join('');
|
||
const legend = reasons.map(([reason, n], i) =>
|
||
`<span class="nowrap"><span style="display: inline-block; width: 8px; height: 8px; background: ${REASON_COLOURS[i % REASON_COLOURS.length]}"></span> ${fmtCount(n)} ${esc(reason)}</span>`).join(', ');
|
||
return `<span class="bar">${bar}</span>${legend}`;
|
||
}
|
||
// For each peer a line that says how many and why, then its latest
|
||
// rejections with those alike as one line.
|
||
const REJECTED_SHOWN = 20;
|
||
function renderRejected() {
|
||
const b = $('rejected-body');
|
||
const chosen = lookup().peer;
|
||
const peers = chosen ? state.rejected.filter(p => p.peer === chosen) : state.rejected;
|
||
const total = state.rejected.reduce((n, p) => n + p.total, 0);
|
||
const announcements = n => `${fmtCount(n)} announcement${n === 1 ? '' : 's'}`;
|
||
$('rejected-count').textContent = total ? `${announcements(total)} from ${state.rejected.length} peer${state.rejected.length === 1 ? '' : 's'}${chosen ? `; showing ${chosen}` : ''}` : '0';
|
||
if (!peers.length) { setHTML(b, `<tr><td colspan="11" class="dim">${chosen ? `Nothing ${esc(chosen)} announced has been rejected.` : 'Nothing a peer announced has been rejected.'}</td></tr>`); return; }
|
||
setHTML(b, peers.map(p => {
|
||
// Alike: the same prefix, for the same reason, by the same path.
|
||
const alike = new Map();
|
||
for (const r of p.recent) {
|
||
const key = `${r.prefix}|${r.reason}|${r.as_path.join(' ')}`;
|
||
const seen = alike.get(key);
|
||
if (seen) { seen.times += 1; seen.first = Math.min(seen.first, r.at_unix); }
|
||
else alike.set(key, { ...r, times: 1, first: r.at_unix });
|
||
}
|
||
const lines = [...alike.values()].sort((a, c) => c.at_unix - a.at_unix).slice(0, REJECTED_SHOWN);
|
||
const more = alike.size > REJECTED_SHOWN
|
||
? ` <span class="dim">${REJECTED_SHOWN} of ${alike.size} shown; <a href="/api/routes/rejected?peer=${encodeURIComponent(p.peer)}">the latest ${p.recent.length} as json</a>.</span>` : '';
|
||
return `<tr class="group"><td colspan="11"><a href="#received" class="peer-link" data-peer="${esc(p.peer)}">${esc(p.peer)}</a> <span class="dim" style="font-weight: normal">${esc(p.peer_addr)}</span>: <span style="font-weight: normal">${announcements(p.total)} rejected</span> ${reasonBar(p.by_reason, p.total)}${more}</td></tr>` +
|
||
lines.map(r => { const s = r.summary; return `<tr><td></td>
|
||
<td class="mono">${esc(r.prefix)}${r.path_id ? ` <span class="dim">path ${r.path_id}</span>` : ''}</td>
|
||
<td class="mono path">${clamp(asPath(r.as_path))}</td><td>${esc(r.reason)}</td>
|
||
<td class="num">${r.times > 1 ? `×${r.times}` : ''}</td>
|
||
<td class="nowrap"><span title="${esc(fmtWhen(r.at_unix))}${r.times > 1 ? `; the first of these ${esc(fmtWhen(r.first))}` : ''}">${age(r.at_unix)} ago</span></td>
|
||
<td class="mono">${s.next_hop ? esc(s.next_hop) : DASH}</td><td>${s.origin ? esc(s.origin) : DASH}</td>
|
||
<td>${clamp(commList(s.communities))}</td><td>${clamp(commList(s.large_communities, true))}</td><td>${otherList(s.other)}</td></tr>`; }).join('');
|
||
}).join(''));
|
||
markTall(b);
|
||
}
|
||
|
||
// ---- data loading ------------------------------------------------------
|
||
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); renderReceived(); renderPeers(); renderStrip(); }
|
||
async function loadPeers() { const fresh = await getJSON('/api/peers'); noteTrends(state.peers, fresh); state.peers = fresh; renderPeers(); renderStrip(); }
|
||
// The way to the pages before and after this one, where there are any.
|
||
function renderPager() {
|
||
const page = state.receivedPage, place = pagePlace();
|
||
const paged = page.next_after || page.prev_before;
|
||
const link = (label, to, there) => there ? `<a href="#" data-page='${esc(JSON.stringify(to))}'>${label}</a>` : `<span class="dim">${label}</span>`;
|
||
const range = state.received.length
|
||
? `<span class="mono">${esc(state.received[0].prefix)} – ${esc(state.received[state.received.length - 1].prefix)}</span>` : '';
|
||
const html = !paged && !place.after && !place.before ? '' :
|
||
link('« first', {}, page.prev_before || (!state.received.length && (place.after || place.before))) +
|
||
link('‹ previous', { before: page.prev_before }, page.prev_before) +
|
||
link('next ›', { after: page.next_after }, page.next_after) + range;
|
||
for (const id of ['received-pager', 'received-pager-below']) setHTML($(id), html);
|
||
}
|
||
|
||
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); }
|
||
async function loadReceived() {
|
||
const note = $('lookup-note');
|
||
try {
|
||
const page = await getJSON(receivedUrl());
|
||
state.received = page.routes; state.receivedPage = page;
|
||
note.textContent = '';
|
||
} catch (e) {
|
||
// A query the server could not make sense of: say what it said.
|
||
state.received = []; state.receivedPage = {};
|
||
note.innerHTML = `<span class="red">${esc(e.message.replace(/^[^:]*: \d+ /, ''))}</span>`;
|
||
}
|
||
renderReceived();
|
||
}
|
||
async function loadRejected() { state.rejected = await getJSON('/api/routes/rejected'); renderRejected(); }
|
||
async function loadRpki() { state.rpki = await getJSON('/api/rpki'); renderRpki(); renderStrip(); }
|
||
async function loadKernel() { state.kernel = await getJSON('/api/kernel'); renderKernel(); renderStrip(); }
|
||
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived(), loadRejected(), loadRpki(), loadKernel()]); }
|
||
|
||
// ---- events ------------------------------------------------------------
|
||
function logEvent(html) {
|
||
const box = $('event-log'), d = document.createElement('div');
|
||
d.innerHTML = `${new Date().toLocaleTimeString()} ${html}`;
|
||
box.prepend(d); while (box.children.length > 200) box.lastChild.remove();
|
||
}
|
||
const names = list => list.map(c => WELL_KNOWN[c] || c).map(esc).join(' ');
|
||
function describe(ev) {
|
||
switch (ev.type) {
|
||
case 'session_up': return `<span class="green">session UP</span> with ${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} (opened by ${ev.peer.direction === 'outbound' ? 'us' : 'peer'})`;
|
||
case 'route_refresh_requested': return `${esc(ev.peer.name || ev.peer.peer_addr)} AS${ev.peer.peer_asn} asked for a route refresh; re-sent its routes`;
|
||
case 'session_down': return `<span class="red">session DOWN</span> with ${esc(ev.peer_addr)} AS${ev.peer_asn}`;
|
||
case 'local_announced': { const r = ev.route; return `originating ${esc(r.prefix)}` +
|
||
(r.communities.length ? ` [${names(r.communities)}]` : '') + (r.large_communities.length ? ` [${names(r.large_communities)}]` : '') +
|
||
(r.prepend ? ` prepend ${r.prepend}` : '') + (r.med != null ? ` med ${r.med}` : ''); }
|
||
case 'local_withdrawn': return `<span class="amber">no longer originating</span> ${esc(ev.prefix)}`;
|
||
case 'best_changed': return `best route for ${esc(ev.route.prefix)} is now from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`;
|
||
case 'best_withdrawn': return `${esc(ev.prefix)} has <span class="amber">no route</span> any more`;
|
||
case 'lagged': return `<span class="red">missed ${ev.missed == null ? '' : ev.missed + ' '}events</span> on ${esc(ev.stream)}`;
|
||
default: return esc(JSON.stringify(ev));
|
||
}
|
||
}
|
||
function connect() {
|
||
const st = $('log-state');
|
||
const es = new EventSource('/api/events');
|
||
es.onopen = () => { st.textContent = 'Live: connected to the event stream.'; };
|
||
es.onerror = () => { st.textContent = 'Reconnecting to the event stream...'; };
|
||
// Events only feed the log. How often the tables reload is decided by
|
||
// ?refresh=, so the page never changes faster than the reader asked for.
|
||
es.addEventListener('bgp', e => logEvent(describe(JSON.parse(e.data))));
|
||
}
|
||
|
||
// ---- auto-refresh, opt-in through ?refresh=<seconds> --------------------
|
||
const refreshParam = parseInt(new URLSearchParams(location.search).get('refresh'), 10);
|
||
const REFRESH = Number.isFinite(refreshParam) && refreshParam >= 1 ? Math.min(refreshParam, 3600) : 0;
|
||
function refreshLink(seconds, text) {
|
||
const url = new URL(location.href);
|
||
if (seconds) url.searchParams.set('refresh', seconds); else url.searchParams.delete('refresh');
|
||
return `<a href="${esc(url.pathname + url.search + url.hash)}">${text}</a>`;
|
||
}
|
||
function renderRefreshControl() {
|
||
$('refresh-ctl').innerHTML = (REFRESH
|
||
? `Auto-refresh: <b>every ${fmtDur(REFRESH)}</b> (${refreshLink(0, 'turn off')})`
|
||
: `Auto-refresh: off (turn on: ${[2, 5, 30].map(n => refreshLink(n, n + 's')).join(', ')})`) +
|
||
` · <a href="#" id="reload-now">reload now</a>`;
|
||
$('reload-now').addEventListener('click', e => { e.preventDefault(); loadAll().catch(console.error); });
|
||
}
|
||
|
||
// ---- AS names ----------------------------------------------------------
|
||
// The holder of an AS number, from RIPEstat, which has every number
|
||
// (reserved ones too) and lets browsers ask it. Asked from here, once per
|
||
// number and page, only for a number that is pointed at for a moment.
|
||
const asNames = new Map();
|
||
function asName(n) {
|
||
if (!asNames.has(n)) {
|
||
asNames.set(n, fetch(`https://stat.ripe.net/data/as-overview/data.json?resource=AS${n}&sourceapp=ferrix_bgp`)
|
||
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
|
||
.then(j => j.data.holder || (j.data.block && j.data.block.desc) || 'no holder registered')
|
||
// Asked again next time: the network may be back.
|
||
.catch(e => { asNames.delete(n); return `name not available (${e.message})`; }));
|
||
}
|
||
return asNames.get(n);
|
||
}
|
||
const tip = document.createElement('div');
|
||
tip.id = 'asn-tip'; tip.hidden = true; document.body.append(tip);
|
||
let pointedAt = null, asking = 0;
|
||
function placeTip(el) {
|
||
const box = el.getBoundingClientRect();
|
||
tip.style.left = `${Math.max(0, Math.min(box.left, innerWidth - tip.offsetWidth - 4))}px`;
|
||
const below = box.bottom + 4;
|
||
tip.style.top = `${below + tip.offsetHeight > innerHeight ? box.top - tip.offsetHeight - 4 : below}px`;
|
||
}
|
||
document.addEventListener('mouseover', e => {
|
||
const el = e.target.closest('.asn');
|
||
if (el === pointedAt) return;
|
||
pointedAt = el; clearTimeout(asking);
|
||
const n = el && el.dataset.asn;
|
||
if (!n || !/^\d+$/.test(n)) { tip.hidden = true; return; }
|
||
// Passing over a path on the way somewhere else asks for nothing.
|
||
asking = setTimeout(() => {
|
||
tip.textContent = `AS${n}: ...`; tip.hidden = false; placeTip(el);
|
||
asName(n).then(name => { if (pointedAt === el) { tip.textContent = `AS${n}: ${name}`; placeTip(el); } });
|
||
}, 150);
|
||
});
|
||
addEventListener('scroll', () => { tip.hidden = true; pointedAt = null; clearTimeout(asking); }, { passive: true });
|
||
|
||
// ---- boot --------------------------------------------------------------
|
||
getJSON('/api/config').then(c => {
|
||
if (!c.text) return;
|
||
$('config-path').textContent = c.path ? `(${c.path}, as read at startup)` : '';
|
||
$('config-text').textContent = c.text;
|
||
$('config-zippy').hidden = false;
|
||
}).catch(console.error);
|
||
renderRefreshControl();
|
||
showLookup();
|
||
$('lookup').addEventListener('submit', e => { e.preventDefault(); submitLookup(); });
|
||
$('lookup-clear').addEventListener('click', e => { e.preventDefault(); setLookup(null); });
|
||
$('peers-problems').addEventListener('change', renderPeers);
|
||
// Compact is remembered in this browser; nothing else is.
|
||
const COMPACT_KEY = 'statusz.compact';
|
||
try { $('compact').checked = localStorage.getItem(COMPACT_KEY) === '1'; } catch (e) { /* no storage: a session's choice only */ }
|
||
const applyCompact = () => document.body.classList.toggle('compact', $('compact').checked);
|
||
applyCompact();
|
||
$('compact').addEventListener('change', () => { applyCompact(); try { localStorage.setItem(COMPACT_KEY, $('compact').checked ? '1' : '0'); } catch (e) { /* as above */ } });
|
||
// A name in the strip goes to the peer's row and lights it for a moment.
|
||
document.addEventListener('click', e => {
|
||
const link = e.target.closest('a.to-peer');
|
||
if (!link) return;
|
||
e.preventDefault();
|
||
$('peers-problems').checked = false; renderPeers();
|
||
const tr = document.querySelector(`tr[data-peer-row="${CSS.escape(link.dataset.peer)}"]`);
|
||
if (!tr) return;
|
||
tr.scrollIntoView({ block: 'center' });
|
||
tr.classList.remove('flash'); void tr.offsetWidth; tr.classList.add('flash');
|
||
tr.addEventListener('animationend', () => tr.classList.remove('flash'), { once: true });
|
||
});
|
||
// The marks of what changed while nobody looked: one goes when its row
|
||
// is clicked, all when asked.
|
||
document.addEventListener('click', e => {
|
||
if (e.target.closest('#since-clear')) { e.preventDefault(); lookedAt(null); return; }
|
||
const tr = e.target.closest('tr[data-peer-row]');
|
||
if (tr && !e.target.closest('a')) lookedAt(tr.dataset.peerRow);
|
||
});
|
||
document.addEventListener('click', e => { if (e.target.closest('.clamp-more')) toggleClamp(e.target.closest('td')); });
|
||
document.addEventListener('dblclick', e => { const td = e.target.closest('td'); if (td && td.querySelector('.clamp')) { toggleClamp(td); getSelection().removeAllRanges(); } });
|
||
// A peer's name anywhere on the page leads to its routes.
|
||
document.addEventListener('click', e => {
|
||
const link = e.target.closest('a.peer-link');
|
||
if (!link) return;
|
||
e.preventDefault();
|
||
const l = lookup();
|
||
setLookup({ q: '', mode: l.mode, peer: link.dataset.peer, best: false });
|
||
$('received').scrollIntoView();
|
||
});
|
||
for (const id of ['received-pager', 'received-pager-below']) {
|
||
$(id).addEventListener('click', e => {
|
||
const to = e.target.closest('a[data-page]');
|
||
if (!to) return;
|
||
e.preventDefault(); setPage(JSON.parse(to.dataset.page));
|
||
});
|
||
}
|
||
// For poking at the page from the console; nothing on the page uses it.
|
||
window.statusz = { state, noteTrends, renderPeers, renderStrip, lookedAt };
|
||
loadAll().catch(e => logEvent(`<span class="red">${esc(e.message)}</span>`));
|
||
if (REFRESH) {
|
||
connect();
|
||
setInterval(() => loadAll().catch(console.error), REFRESH * 1000);
|
||
setInterval(tick, 1000);
|
||
} else {
|
||
// A snapshot: nothing moves until the reader reloads or turns refresh on.
|
||
$('log-state').textContent = 'The event log is live only while auto-refresh is on.';
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|