Files
ferrix/crates/web/static/statusz.html
T
Rayhaan JaufeerallyandClaude Fable 5.1 eb77090d01 feature: config zippy on statusz; drop prepend options from the UI
- The status page has a collapsible "Configuration" section showing the
  config file as read at startup, with its path. Served by the new
  GET /api/config; RouterInfo::with_config carries the text.
- Removed the AS-path prepends field and the SwissIX prepend presets
  (42476:101..103) from the form to keep the exercise focused. The API
  still accepts `prepend`.
- Docs follow: the handout loses its prepending exercise, the lab README
  its prepend row, and the BIRD config notes that the 42476 namespace is
  still admitted for hand-typed values.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 22:46:07 +02:00

497 lines
31 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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">
<!--
Styled after the classic Google-internal /statusz pages as mirrored by
Vitess (go/vt/servenv/status.go) and golang.org/x/net/trace: plain
sans-serif HTML, default links and form controls, dense bordered tables,
and one full-width light blue (#eef) heading bar per section. Deliberately
no dark theme, no framework, no external resources.
-->
<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; }
h1.top { font-size: 150%; margin-top: 0; }
.lefthand { float: left; width: 70%; }
.righthand { text-align: right; }
a { color: #00c; } a:visited { color: #551a8b; }
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; }
.mono, table.data td.mono { font-family: monospace; }
table.data tr.loop td { background: #ffc; }
table.data tr.mine td.owner { font-weight: bold; }
.ok { color: #080; font-weight: bold; } .bad { color: #c00; font-weight: bold; } .warn { color: #a60; font-weight: bold; }
.dim { color: #666; }
table.form td { padding: 2px 6px 2px 0; }
table.form td:first-child { width: 11em; }
select { max-width: 14em; }
pre.bytes { border: 1px solid #99a; background: #fff; padding: 4px; margin: 4px 0; font-size: 12px; white-space: pre-wrap; word-break: break-all; line-height: 1.6; }
.hh { background: #dde; } .hl { background: #fcc; } .ha { background: #cfc; } .ha2 { background: #ada; } .hn { background: #ffc; }
#event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; }
#event-log div { white-space: pre-wrap; }
pre.bytes span[data-kind] { cursor: help; }
pre.bytes span[data-kind]:hover, pre.bytes span[data-kind]:focus { outline: 1px solid #00c; }
#tip { position: absolute; z-index: 10; max-width: 46em; background: #fff; border: 1px solid #99a; font-size: 12px; }
#tip .tip-title { background: #eef; font-weight: bold; text-align: center; padding: 1px 6px; border-bottom: 1px solid #99a; }
#tip table { border-collapse: collapse; }
#tip td { padding: 1px 6px; vertical-align: top; }
#tip td:first-child { color: #666; white-space: nowrap; }
#tip td.mono { word-break: break-word; }
details { clear: both; margin: 8px 0 0; }
summary { cursor: pointer; color: #00c; }
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; }
</style>
</head>
<body>
<h1 class="top">Status for ferrix_bgp</h1>
<div>
<div class="lefthand">
Started: <span id="s-started">...</span> (up <span id="s-uptime">...</span>)<br>
Version <span id="s-version">...</span><br>
Router ID <b id="s-router-id">...</b>, local AS <b id="s-asn">...</b><br>
</div>
<div class="righthand">
Running on <span id="s-hostname">...</span><br>
View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <a href="/api/peers">peers json</a>, <a href="/api/routes/local">local routes json</a>, <a href="/api/routes/received">received routes json</a><br>
<span id="refresh-ctl"></span><br>
</div>
</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>
<table class="data">
<thead><tr><th>name</th><th>peer</th><th>AS</th><th>state</th><th>opened by</th><th>local address (next hop)</th><th>established</th><th>uptime</th><th>4-byte AS</th><th>detail</th></tr></thead>
<tbody id="peers-body"></tbody>
</table>
<h1 id="announce">Announce a prefix</h1>
<form id="announce-form" autocomplete="off">
<table class="form">
<tr><td><label for="f-owner">Owner (your name):</label></td><td><input id="f-owner" size="20" maxlength="64" required></td>
<td><label for="f-prefix">Prefix:</label></td><td><input id="f-prefix" size="24" required placeholder="2a0d:d741:1::/48"></td></tr>
<tr><td><label for="f-origin">Origin:</label></td><td><select id="f-origin"><option>IGP</option><option>EGP</option><option>INCOMPLETE</option></select></td>
<td><label for="f-med">MED:</label></td><td><input id="f-med" type="number" min="0" max="4294967295" placeholder="none" style="width:8em"></td></tr>
</table>
<table class="form">
<tr><td><label for="f-communities">Communities:</label></td><td><input id="f-communities" size="50" placeholder="65000:100, NO_EXPORT">
<select id="f-wk" aria-label="Add a well-known community"><option value="">+ well-known...</option></select>
<span class="dim">comma separated, ASN:VALUE or a name</span></td></tr>
<tr><td><label for="f-large">Large communities:</label></td><td><input id="f-large" size="50" placeholder="42476:0:6939">
<select id="f-lc" aria-label="Add a large community preset"><option value="">+ SwissIX...</option></select>
<span class="dim">GLOBAL:LOCAL1:LOCAL2</span></td></tr>
<tr><td></td><td><input type="submit" value="Announce"> <input type="button" id="f-clear" value="Clear">
&nbsp; <span id="announce-msg"></span></td></tr>
</table>
</form>
<h1 id="local">Local routes (<span id="local-count">...</span>)</h1>
<table class="data">
<thead><tr><th>prefix</th><th>owner</th><th>origin</th><th>AS path</th><th>MED</th><th>communities</th><th>large communities</th><th>age</th><th>actions</th></tr></thead>
<tbody id="local-body"></tbody>
</table>
<h1 id="received">Received routes, Adj-RIB-In (<span id="received-count">...</span>)</h1>
<div class="dim">A yellow row carries a prefix that is also announced locally: it has travelled through other routers and come back.</div>
<table class="data">
<thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>communities</th><th>large communities</th><th>age</th></tr></thead>
<tbody id="received-body"></tbody>
</table>
<h1 id="events">Event log</h1>
<div id="log-state" class="dim"></div>
<div id="event-log"></div>
<div id="tip" hidden></div>
<address>ferrix_bgp &middot; <a href="/statusz">/statusz</a> <a href="/metricz">/metricz</a> <a href="/api/events">/api/events</a></address>
<script>
(() => {
// Large-community presets offered next to the field. "PEER" is replaced by
// an AS number the user is asked for. Source:
// https://www.swissix.ch/resources/action-bgp-communities/
const LARGE_PRESETS = [
['42476:0:PEER', 'do not send to peer AS'],
['42476:1:PEER', 'send to peer AS'],
['42476:0:0', 'send to nobody'],
['42476:1:0', 'send to all (default)'],
['42476:901:PEER', 'add NO_EXPORT towards peer AS'],
['42476:902:PEER', 'add NO_ADVERTISE towards peer AS'],
];
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const state = { status: null, peers: [], local: [], received: [], wellKnown: {}, openWire: new Set(), wire: new Map() };
const DASH = '<span class="dim">-</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 open
// rows, selections and hover state 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 fmtTime = unix => new Date(unix * 1000).toLocaleTimeString();
const commList = (list, wk) => list.length ? list.map(c => {
const name = wk && state.wellKnown[c];
return name ? `<span title="${esc(c)}">${esc(name)}</span>` : esc(c);
}).join(' ') : DASH;
const asPath = p => p.length ? p.join(' ') : '<span class="dim">(empty)</span>';
const myOwner = () => $('f-owner').value.trim();
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`${url}: ${r.status}`);
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-version').textContent = s.version; $('s-hostname').textContent = s.hostname;
$('s-started').textContent = new Date(s.started_at_unix * 1000).toString().replace(/ \(.*\)$/, '');
tick();
}
const STATE_CLASS = { Established: 'ok', Connecting: 'warn', Backoff: 'bad', Idle: 'dim' };
function renderPeers() {
const b = $('peers-body');
$('peers-count').textContent = `${state.peers.filter(p => p.state === 'Established').length} of ${state.peers.length} established`;
if (!state.peers.length) { setHTML(b, '<tr><td colspan="10" class="dim">No peers configured and none connected.</td></tr>'); return; }
setHTML(b, state.peers.map(p => {
const up = p.state === 'Established';
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '';
return `<tr>
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${p.peer_asn}</td>
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td><td>${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH}</td>
<td class="mono">${up ? esc(p.local_addr) : DASH}</td><td>${up ? fmtTime(p.established_at_unix) : DASH}</td>
<td>${up ? age(p.established_at_unix) : DASH}</td><td>${up ? (p.four_octet_asn ? 'yes' : 'no') : DASH}</td><td>${detail}</td></tr>`;
}).join(''));
}
function renderLocal() {
const b = $('local-body'); $('local-count').textContent = state.local.length;
if (!state.local.length) { setHTML(b, '<tr><td colspan="9" class="dim">Nothing announced yet.</td></tr>'); return; }
const me = myOwner(), asn = state.status ? state.status.local_asn : '?', stale = [];
setHTML(b, state.local.map(r => {
const mine = r.owner === me, open = state.openWire.has(r.prefix), p = esc(r.prefix);
const withdraw = mine ? `<a href="#" data-act="withdraw" data-prefix="${p}">withdraw</a>`
: '<span class="dim" title="only the owner can withdraw">withdraw</span>';
return `<tr class="${mine ? 'mine' : ''}">
<td class="mono">${p}</td><td class="owner">${esc(r.owner)}</td><td>${esc(r.origin)}</td>
<td class="mono">${Array(1 + r.prepend).fill(asn).join(' ')}</td><td>${r.med ?? DASH}</td>
<td class="mono">${commList(r.communities, true)}</td><td class="mono">${commList(r.large_communities)}</td><td>${age(r.announced_at_unix)}</td>
<td><a href="#" data-act="wire" data-prefix="${p}">${open ? 'hide bytes' : 'bytes'}</a> | ${withdraw}</td></tr>` +
(open ? `<tr><td colspan="9">${wireHTML(r, stale)}</td></tr>` : '');
}).join(''));
stale.forEach(loadWire);
}
function renderReceived() {
const b = $('received-body'); $('received-count').textContent = state.received.length;
if (!state.received.length) { setHTML(b, '<tr><td colspan="9" class="dim">No routes received from peers.</td></tr>'); return; }
const localPrefixes = new Set(state.local.map(r => r.prefix));
setHTML(b, state.received.map(r => { const s = r.summary; return `<tr class="${localPrefixes.has(r.prefix) ? 'loop' : ''}">
<td class="mono">${esc(r.prefix)}</td><td class="mono">${esc(r.peer_addr)} <span class="dim">AS${r.peer_asn}</span></td><td class="mono">${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>${s.med ?? DASH}</td>
<td class="mono">${commList(s.communities, true)}</td><td class="mono">${commList(s.large_communities)}</td><td>${age(r.received_at_unix)}</td></tr>`; }).join(''));
}
// ---- wire view: split the UPDATE bytes into labelled fields ------------
const ATTR_NAMES = {1:'ORIGIN',2:'AS_PATH',3:'NEXT_HOP',4:'MED',5:'LOCAL_PREF',6:'ATOMIC_AGGREGATE',7:'AGGREGATOR',8:'COMMUNITIES',14:'MP_REACH_NLRI',15:'MP_UNREACH_NLRI',16:'EXT_COMMUNITIES',32:'LARGE_COMMUNITIES'};
function segmentUpdate(hex) {
const bytes = hex.match(/../g) || [], segs = [];
const take = (n, cls, kind) => { segs.push({ cls, kind, hex: bytes.splice(0, n).join(' ') }); };
take(16, 'hh', 'marker'); take(2, 'hh', 'length'); take(1, 'hh', 'type');
const wlen = parseInt(hex.substr(38, 4), 16); take(2, 'hl', 'wlen'); if (wlen) take(wlen, 'hn', 'withdrawn');
const alen = parseInt(bytes.slice(0, 2).join(''), 16); take(2, 'hl', 'alen');
let rem = alen, i = 0;
while (rem > 0) {
const ext = parseInt(bytes[0], 16) & 0x10;
const hdr = ext ? 4 : 3, len = ext ? parseInt(bytes[2] + bytes[3], 16) : parseInt(bytes[2], 16);
take(hdr + len, i++ % 2 ? 'ha2' : 'ha', 'attr'); rem -= hdr + len;
}
if (bytes.length) take(bytes.length, 'hn', 'nlri');
return segs;
}
// ---- decoding a hovered segment into something readable ----------------
const u = (b, o, n) => { let v = 0; for (let k = 0; k < n; k++) v = v * 256 + b[o + k]; return v; };
const fmtV4 = b => b.slice(0, 4).join('.');
function fmtV6(b) {
const g = []; for (let k = 0; k < 16; k += 2) g.push(((b[k] << 8) | b[k + 1]).toString(16));
let best = -1, bestLen = 0;
for (let k = 0; k < 8; k++) { let n = 0; while (k + n < 8 && g[k + n] === '0') n++; if (n > bestLen && n > 1) { best = k; bestLen = n; } }
if (best < 0) return g.join(':');
return g.slice(0, best).join(':') + '::' + g.slice(best + bestLen).join(':');
}
function prefixList(b, afi) {
const out = []; let o = 0;
while (o < b.length) {
const len = b[o++], n = Math.ceil(len / 8), raw = b.slice(o, o + n); o += n;
const full = raw.concat(Array((afi === 2 ? 16 : 4) - raw.length).fill(0));
out.push((afi === 2 ? fmtV6(full) : fmtV4(full)) + '/' + len);
}
return out;
}
function largeLabel(c) {
for (const [pattern, label] of LARGE_PRESETS) {
const m = c.match(new RegExp('^' + pattern.replace('PEER', '(\\d+)') + '$'));
if (m) return label.replace('peer AS', 'AS' + (m[1] || ''));
}
return null;
}
const AFI = { 1: 'IPv4', 2: 'IPv6' }, SAFI = { 1: 'unicast', 2: 'multicast' };
function decodeAttr(b, w4) {
const flags = b[0], type = b[1], ext = flags & 0x10, hdr = ext ? 4 : 3, len = ext ? u(b, 2, 2) : b[2], v = b.slice(hdr);
const name = ATTR_NAMES[type] || `attribute ${type}`;
const rows = [
['flags', `0x${flags.toString(16)}: ${flags & 0x80 ? 'optional' : 'well-known'}, ${flags & 0x40 ? 'transitive' : 'non-transitive'}${flags & 0x20 ? ', partial' : ''}${ext ? ', 2-byte length' : ''}`],
['type code', `${type}`], ['value length', `${len} bytes`],
];
switch (type) {
case 1: rows.push(['origin', ['IGP (originated inside the AS)', 'EGP', 'INCOMPLETE (learned some other way)'][v[0]] || `unknown ${v[0]}`]); break;
case 2: {
const w = w4 ? 4 : 2; let o = 0, hops = 0;
while (o + 2 <= v.length) {
const st = v[o], n = v[o + 1], asns = []; o += 2;
for (let k = 0; k < n; k++, o += w) asns.push(u(v, o, w));
rows.push([st === 2 ? 'AS_SEQUENCE' : st === 1 ? 'AS_SET' : `segment ${st}`, asns.join(' ')]);
hops += st === 2 ? n : 1;
}
rows.push(['path length', `${hops} (what best-path selection compares)`], ['AS number width', w4 ? '4 bytes (RFC 6793)' : '2 bytes']);
break;
}
case 3: rows.push(['next hop', fmtV4(v)]); break;
case 4: rows.push(['MED', `${u(v, 0, 4)} (lower is preferred, compared between routes from the same neighbour AS)`]); break;
case 5: rows.push(['local preference', `${u(v, 0, 4)} (higher is preferred)`]); break;
case 8: for (let o = 0; o + 4 <= v.length; o += 4) { const c = `${u(v, o, 2)}:${u(v, o + 2, 2)}`; rows.push(['community', c + (state.wellKnown[c] ? ` = ${state.wellKnown[c]}` : '')]); } break;
case 32: for (let o = 0; o + 12 <= v.length; o += 12) { const c = `${u(v, o, 4)}:${u(v, o + 4, 4)}:${u(v, o + 8, 4)}`, l = largeLabel(c); rows.push(['large community', c + (l ? ` = SwissIX: ${l}` : '')]); } break;
case 14: {
const afi = u(v, 0, 2), nhLen = v[3], nh = v.slice(4, 4 + nhLen);
rows.push(['address family', `${AFI[afi] || afi} ${SAFI[v[2]] || v[2]}`]);
if (nhLen === 4) rows.push(['next hop', fmtV4(nh)]);
else if (nhLen === 16) rows.push(['next hop', fmtV6(nh)]);
else if (nhLen === 32) rows.push(['next hop', fmtV6(nh.slice(0, 16))], ['link-local next hop', fmtV6(nh.slice(16))]);
prefixList(v.slice(5 + nhLen), afi).forEach(p => rows.push(['announced prefix', p]));
break;
}
case 15: { const afi = u(v, 0, 2); rows.push(['address family', `${AFI[afi] || afi} ${SAFI[v[2]] || v[2]}`]); prefixList(v.slice(3), afi).forEach(p => rows.push(['withdrawn prefix', p])); break; }
default: rows.push(['value', v.map(x => x.toString(16).padStart(2, '0')).join(' ')]);
}
return { title: `Path attribute: ${name}`, rows };
}
function decodeSegment(kind, b, w4) {
switch (kind) {
case 'marker': return { title: 'Header: marker', rows: [['value', '16 bytes of 0xff'], ['meaning', 'Fixed filler. It once carried authentication data; now it only helps a receiver find message boundaries.']] };
case 'length': return { title: 'Header: message length', rows: [['value', `${u(b, 0, 2)} bytes, including this 19-byte header`], ['limits', '19 to 4096 bytes']] };
case 'type': return { title: 'Header: message type', rows: [['value', `${b[0]} = ${['', 'OPEN', 'UPDATE', 'NOTIFICATION', 'KEEPALIVE', 'ROUTE-REFRESH'][b[0]] || 'unknown'}`]] };
case 'wlen': return { title: 'Withdrawn routes length', rows: [['value', `${u(b, 0, 2)} bytes`], ['meaning', u(b, 0, 2) ? 'IPv4 prefixes being withdrawn follow.' : 'No IPv4 prefixes are withdrawn by this message.']] };
case 'withdrawn': return { title: 'Withdrawn routes', rows: prefixList(b, 1).map(p => ['withdrawn prefix', p]) };
case 'alen': return { title: 'Total path attribute length', rows: [['value', `${u(b, 0, 2)} bytes`], ['meaning', 'The path attributes follow; whatever remains after them is IPv4 NLRI.']] };
case 'nlri': return { title: 'NLRI: announced IPv4 prefixes', rows: prefixList(b, 1).map(p => ['announced prefix', p]).concat([['encoding', 'one length byte, then only as many address bytes as the length needs']]) };
default: return decodeAttr(b, w4);
}
}
function showTip(el) {
const b = el.dataset.hex.split(' ').map(x => parseInt(x, 16));
const d = decodeSegment(el.dataset.kind, b, el.dataset.w4 === '1'), tip = $('tip');
tip.innerHTML = `<div class="tip-title">${esc(d.title)}</div><table>${d.rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td class="mono">${esc(v)}</td></tr>`).join('')}</table>`;
tip.hidden = false;
const rects = el.getClientRects(), first = rects[0], last = rects[rects.length - 1];
const left = Math.max(8, Math.min(first.left, window.innerWidth - tip.offsetWidth - 8));
tip.style.left = `${left + window.scrollX}px`; tip.style.top = `${last.bottom + window.scrollY + 4}px`;
}
const hideTip = () => { $('tip').hidden = true; };
const segOf = e => e.target.closest && e.target.closest('pre.bytes span[data-kind]');
document.addEventListener('mouseover', e => { const el = segOf(e); if (el) showTip(el); });
document.addEventListener('mouseout', e => { if (segOf(e)) hideTip(); });
document.addEventListener('focusin', e => { const el = segOf(e); if (el) showTip(el); });
document.addEventListener('focusout', e => { if (segOf(e)) hideTip(); });
// The bytes for a route depend only on the route itself and on which
// sessions are up, so they are fetched once per such combination and kept.
const wireSig = r => JSON.stringify(r) + '|' + state.peers.filter(p => p.state === 'Established').map(p => p.peer_addr + p.local_addr).join(',');
function wireHTML(route, stale) {
const cached = state.wire.get(route.prefix), sig = wireSig(route);
if (cached && cached.sig === sig) return cached.html;
stale.push(route);
// Keep showing the previous bytes while the new ones load.
return cached ? cached.html : '<span class="dim">loading...</span>';
}
async function loadWire(route) {
const sig = wireSig(route);
const pending = state.wire.get(route.prefix);
if (pending && pending.loading === sig) return;
state.wire.set(route.prefix, { ...(pending || { html: '<span class="dim">loading...</span>' }), loading: sig });
let html;
try {
const views = await getJSON(`/api/routes/local/wire?prefix=${encodeURIComponent(route.prefix)}`);
html = !views.length ? '<span class="dim">No established session to send this on.</span>' :
views.map(v => `UPDATE as sent to <b>${esc(v.peer_addr)}</b> AS${v.peer_asn}, ${v.length} bytes, next hop ${esc(v.next_hop)} &nbsp;
<span class="hh">&nbsp;header&nbsp;</span> <span class="hl">&nbsp;lengths&nbsp;</span> <span class="ha">&nbsp;path attributes&nbsp;</span> <span class="hn">&nbsp;NLRI&nbsp;</span>
<span class="dim">(hover a segment to decode it)</span>
<pre class="bytes">${segmentUpdate(v.hex).map(s => `<span class="${s.cls}" tabindex="0" data-kind="${s.kind}" data-w4="${v.four_octet_asn ? 1 : 0}" data-hex="${s.hex}">${s.hex}</span>`).join(' ')}</pre>`).join('');
} catch (e) { html = `<span class="bad">${esc(e.message)}</span>`; }
state.wire.set(route.prefix, { sig, html });
renderLocal();
}
// ---- data loading ------------------------------------------------------
async function loadStatus() { state.status = await getJSON('/api/status'); renderStatus(); renderLocal(); }
async function loadPeers() { state.peers = await getJSON('/api/peers'); renderPeers(); renderLocal(); }
async function loadLocal() { state.local = await getJSON('/api/routes/local'); renderLocal(); renderReceived(); }
async function loadReceived() { state.received = await getJSON('/api/routes/received'); renderReceived(); }
async function loadAll() { await Promise.all([loadStatus(), loadPeers(), loadLocal(), loadReceived()]); }
// ---- 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 => state.wellKnown[c] || c).map(esc).join(' ');
function describe(ev) {
switch (ev.type) {
case 'session_up': return `<span class="ok">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 'session_down': return `<span class="bad">session DOWN</span> with ${esc(ev.peer_addr)} AS${ev.peer_asn}`;
case 'local_announced': { const r = ev.route; return `announced ${esc(r.prefix)} by ${esc(r.owner)}` +
(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="warn">withdrew</span> ${esc(ev.prefix)}`;
case 'received_announced': return `received ${esc(ev.route.prefix)} from AS${ev.route.peer_asn} path [${ev.route.summary.as_path.join(' ')}]`;
case 'received_withdrawn': return `peer AS${ev.peer_asn} <span class="warn">withdrew</span> ${esc(ev.prefix)}`;
case 'lagged': return `<span class="bad">missed ${ev.missed} events</span> on ${esc(ev.stream)}; reloading`;
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(', ')})`) +
` &middot; <a href="#" id="reload-now">reload now</a>`;
$('reload-now').addEventListener('click', e => { e.preventDefault(); loadAll().catch(console.error); });
}
// ---- form --------------------------------------------------------------
// The reader's own action is not "auto" refresh: show its effect at once,
// and again shortly after for whatever the network sends back.
function reloadAfterAction() { loadAll().catch(console.error); setTimeout(() => loadAll().catch(console.error), 1500); }
const splitList = s => s.split(',').map(x => x.trim()).filter(Boolean);
function setMsg(text, cls) { const m = $('announce-msg'); m.className = cls || ''; m.textContent = text; }
function appendTo(id, value) {
const cur = splitList($(id).value); if (!cur.includes(value)) cur.push(value);
$(id).value = cur.join(', ');
}
$('announce-form').addEventListener('submit', async e => {
e.preventDefault();
const body = {
prefix: $('f-prefix').value.trim(), owner: myOwner(), origin: $('f-origin').value,
communities: splitList($('f-communities').value),
large_communities: splitList($('f-large').value),
};
if ($('f-med').value !== '') body.med = Number($('f-med').value);
try { localStorage.setItem('owner', body.owner); } catch (_) {}
setMsg('announcing...', 'dim');
const r = await fetch('/api/routes/local', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
const up = state.peers.filter(p => p.state === 'Established').length;
if (r.ok) { setMsg(`announced ${j.prefix} to ${up} peer(s)`, 'ok'); reloadAfterAction(); }
else setMsg(`${r.status}: ${j.error || 'request failed'}`, 'bad');
});
$('f-clear').addEventListener('click', () => { ['f-prefix','f-communities','f-large','f-med'].forEach(id => $(id).value = ''); $('f-origin').value = 'IGP'; setMsg(''); });
$('f-wk').addEventListener('change', e => { if (e.target.value) appendTo('f-communities', e.target.value); e.target.value = ''; });
$('f-lc').addEventListener('change', e => {
let v = e.target.value; e.target.value = '';
if (!v) return;
if (v.includes('PEER')) {
const peer = (prompt('Peer AS number?') || '').trim().replace(/^AS/i, '');
if (!/^\d+$/.test(peer)) { if (peer) setMsg(`"${peer}" is not an AS number`, 'bad'); return; }
v = v.replace('PEER', peer);
}
appendTo('f-large', v);
});
$('f-owner').addEventListener('input', renderLocal);
$('local-body').addEventListener('click', async e => {
const link = e.target.closest('a[data-act]'); if (!link) return;
e.preventDefault();
const prefix = link.dataset.prefix;
if (link.dataset.act === 'wire') { state.openWire.has(prefix) ? state.openWire.delete(prefix) : state.openWire.add(prefix); renderLocal(); return; }
if (!confirm(`Withdraw ${prefix}?`)) return;
const r = await fetch('/api/routes/local', { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ prefix, owner: myOwner() }) });
if (!r.ok) { const j = await r.json().catch(() => ({})); setMsg(`${r.status}: ${j.error || 'withdraw failed'}`, 'bad'); }
else { setMsg(`withdrew ${prefix}`, 'warn'); state.openWire.delete(prefix); state.wire.delete(prefix); reloadAfterAction(); }
});
// ---- boot --------------------------------------------------------------
try { $('f-owner').value = localStorage.getItem('owner') || ''; } catch (_) {}
LARGE_PRESETS.forEach(([value, label]) => { const o = document.createElement('option'); o.value = value; o.textContent = `${value.replace('PEER', '<peer-as>')} ${label}`; $('f-lc').appendChild(o); });
getJSON('/api/communities/well-known').then(list => {
list.forEach(c => { state.wellKnown[c.value] = c.name; const o = document.createElement('option'); o.value = c.name; o.textContent = `${c.name} (${c.value})`; $('f-wk').appendChild(o); });
renderLocal(); renderReceived();
}).catch(console.error);
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();
loadAll().catch(e => logEvent(`<span class="bad">${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>