Status page: what moved, what is missing, and a dark room
The attention strip's names lead to the peer's row, which lights up for a moment. The IPv4 and IPv6 sessions of one neighbour (NAME-v4, NAME-v6) are listed together, joined down the side, the name said once in full. A route count that moved by more than 2% between refreshes carries ▲ or ▼ for two minutes; a row whose state or count changed while the tab was in the background carries ● until it is clicked. A compact mode drops the next hop and session columns, and is remembered by the browser. Colours are variables, with a second set for prefers-color-scheme: dark. A new per-peer `expected_routes` says about how many routes a peer sends when all is well. The daemon only passes it to the API; the page lists an established peer with under half of it as needing attention, a minute after the session came up, with the expected count beside the actual one. An established session with next to no routes looked, until now, exactly like one that was fine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
7b6547d3c9
commit
277b973f1a
11 files changed
+305
-57
No files matched your search
+215
-53
@@ -3,13 +3,17 @@
|
||||
<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 (#eef) heading bar per section. Deliberately no dark theme, no
|
||||
framework, no external resources. Everything shown comes from /api/*,
|
||||
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).
|
||||
|
||||
@@ -21,50 +25,79 @@
|
||||
(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>
|
||||
body { font-family: sans-serif; font-size: 13px; color: #000; background: #fff; margin: 8px; }
|
||||
h1 { clear: both; width: 100%; text-align: center; font-size: 120%; background: #eef; margin: 14px 0 6px; padding: 2px 0; }
|
||||
: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: #00c; } a:visited { color: #551a8b; }
|
||||
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 #99a; padding: 2px 6px; text-align: left; vertical-align: top; }
|
||||
table.data th { background: #dde; font-weight: bold; position: sticky; top: 0; z-index: 2; }
|
||||
table.data > tbody > tr:hover > td { background: #f3f3ff; }
|
||||
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: #eef; font-weight: bold; padding: 3px 6px; }
|
||||
table.data tr.best > td { background: #f4faf4; }
|
||||
table.data tr.dim td { color: #666; }
|
||||
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: #dde; }
|
||||
#asn-tip { position: fixed; z-index: 10; max-width: 36em; background: #ffffe8; border: 1px solid #99a; padding: 2px 6px; font-size: 12px; pointer-events: none; }
|
||||
.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: #c00; font-weight: bold; }
|
||||
.amber { color: #a60; font-weight: bold; }
|
||||
.green { color: #080; }
|
||||
.dim { color: #666; }
|
||||
.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 #aab; border-radius: 3px; background: #f6f6fb; color: #223; font-family: sans-serif; font-weight: normal; white-space: nowrap; vertical-align: middle; cursor: help; }
|
||||
.g.red { border-color: #c00; background: #fff0f0; color: #c00; font-weight: bold; }
|
||||
.g.amber { border-color: #a60; background: #fff8e8; color: #a60; }
|
||||
.g.green { border-color: #080; background: #f0fff0; color: #080; }
|
||||
.g.dim { border-color: #ccd; background: #fafafa; color: #666; }
|
||||
.g.mark { background: #eef4ff; border-color: #88a; }
|
||||
.g.ctl { background: #fff4e8; border-color: #c96; }
|
||||
.g.wk { background: #f0f0f0; border-color: #999; font-weight: bold; }
|
||||
.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 #666; cursor: help; }
|
||||
.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: #eef; font-weight: normal; position: static; }
|
||||
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: #eef4ff; border-color: #88a; margin-left: 1px; cursor: help; }
|
||||
.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); }
|
||||
@@ -75,23 +108,40 @@ 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: #112; text-decoration: none; font-size: 13.5px; }
|
||||
table.data td.name a.peer-link:hover { text-decoration: underline; color: #00c; }
|
||||
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: #446; background: #f3f3f8; min-width: 1em; text-align: center; }
|
||||
/* The strip: what needs attention, if anything. */
|
||||
#strip { position: sticky; top: 0; z-index: 3; background: #fff; border: 1px solid #99a; padding: 4px 8px; margin: 6px 0; }
|
||||
.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 #99a; vertical-align: middle; margin: 0 6px; background: #fff; }
|
||||
.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: #00c; }
|
||||
summary { cursor: pointer; color: var(--link); }
|
||||
summary:hover { text-decoration: underline; }
|
||||
#config-text { border: 1px solid #99a; padding: 4px 6px; margin: 4px 0; font-size: 12px; overflow-x: auto; }
|
||||
address { font-size: smaller; margin-top: 2em; color: #444; }
|
||||
#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; }
|
||||
@@ -126,10 +176,12 @@ JSON: <a href="/api/status">status</a>, <a href="/api/peers">peers</a>, <a href=
|
||||
<h1 id="peers">Peers (<span id="peers-count">...</span>)</h1>
|
||||
<div class="toolbar">
|
||||
<label><input type="checkbox" id="peers-problems"> problems only</label>
|
||||
<span class="dim">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>
|
||||
<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>our address (next hop)</th><th>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>
|
||||
<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>
|
||||
|
||||
@@ -353,13 +405,71 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
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;
|
||||
}
|
||||
// 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 ? 1 : 2;
|
||||
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'],
|
||||
@@ -413,41 +523,62 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
$('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;
|
||||
const row = p => {
|
||||
// The IPv4 and IPv6 sessions of one neighbour, named <stem>-v4 and
|
||||
// <stem>-v6, are one neighbour to the reader: listed together, the
|
||||
// name once in full and once quietly, joined down the side.
|
||||
const FAMILY_SUFFIX = /^(.*)-v([46])$/;
|
||||
const row = (p, pair) => {
|
||||
const up = p.state === 'Established';
|
||||
const name = p.name ? `<a href="#received" class="peer-link" data-peer="${esc(p.name)}" title="Look at this peer's routes">${esc(p.name)}</a>` : '<span class="dim">(inbound)</span>';
|
||||
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')}` : '';
|
||||
return `<tr class="${peerRank(p) === 3 ? 'dim' : ''}">
|
||||
<td class="name">${name}</td>
|
||||
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">${up ? esc(p.local_addr) : DASH}</td>
|
||||
<td>${up ? sessionGlyphs(p) : DASH}</td>
|
||||
<td class="num">${up ? fmtCount(p.routes) + stale : 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('');
|
||||
const byName = (a, c) => (a.name || a.peer_addr).localeCompare(c.name || c.peer_addr);
|
||||
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>` + problems.map(row).join('');
|
||||
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>` + members.map(row).join('');
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -455,10 +586,14 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
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 => `<b>${esc(p.name || p.peer_addr)}</b> <span class="dim">(${esc((p.last_error || p.state).replace(/;.*$/, ''))})</span>`).join(', ')}`);
|
||||
const restarting = peers.filter(p => peerRank(p) === 1);
|
||||
if (restarting.length) items.push(`<span class="amber">${restarting.length} restarting or stale:</span> ${restarting.map(p => `<b>${esc(p.name || p.peer_addr)}</b>`).join(', ')}`);
|
||||
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>`);
|
||||
@@ -711,7 +846,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
|
||||
// ---- data loading ------------------------------------------------------
|
||||
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); renderReceived(); renderPeers(); renderStrip(); }
|
||||
async function loadPeers() { state.peers = await getJSON('/api/peers'); 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();
|
||||
@@ -843,6 +978,31 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
$('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.
|
||||
@@ -861,6 +1021,8 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user