Status page: tables for the kernel, communities by AS, attributes as text

The kernel section was one run-on line of interfaces and addresses. It is
now a small table of the installation state, and a table of interfaces
with their IPv4, IPv6 and link-local addresses one per line; ports that
are down and have no addresses share one line at the bottom.

Communities ran together into one block that wrapped anywhere. Each AS's
communities are now a line of their own, kept whole so that they copy,
with a hanging indent where a line wraps; pointing at a line names the AS.
Where control communities are on (now in /api/status), those of ours that
ask for something say what as a tooltip.

A rejected route's details were a fold-out that repeated the summary and
then dumped the decoded attributes as JSON. Rejected routes now have the
same columns as received ones, and both have an "other attributes" column
for what the summary had no field for, as text: aggregator, extended
communities (route targets and origins by name), AS4_PATH with its sets in
braces, originator ID, cluster list, and unknown attributes in hex. The
API has them as summary.other; the JSON stays in `attributes`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Opus 5.5 committed 2026-10-05 23:09:02 +02:00
1 parent 89e0c3a3e2
commit 17045a3634
5 files changed
+332 -51

No files matched your search

+112 -44
View File
@@ -30,13 +30,19 @@ table.data > tbody > tr:hover > td { background: #f3f3ff; }
.mono, table.data td.mono { font-family: monospace; }
.ok { color: #080; font-weight: bold; } .bad { color: #c00; font-weight: bold; } .warn { color: #a60; font-weight: bold; }
.dim { color: #666; }
.nowrap { white-space: nowrap; }
table.data td.path { min-width: 12em; }
#event-log { font-family: monospace; font-size: 12px; max-height: 22em; overflow-y: auto; }
#event-log div { white-space: pre-wrap; }
/* 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; }
/* A line that wraps carries on indented, so each AS's line stays apart. */
.comms > div { padding-left: 2em; text-indent: -2em; margin-bottom: 2px; }
.comms { min-width: 30em; }
table.kv th { background: #eef; font-weight: normal; }
table.data tr.dim td { color: #666; }
details { clear: both; margin: 8px 0 0; }
details.refused-detail { margin: 0; }
details.refused-detail summary { cursor: pointer; }
details.refused-detail table td { padding: 1px 8px 1px 0; border: 0; }
details.refused-detail pre { max-height: 20em; overflow: auto; margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
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; }
@@ -102,7 +108,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
</form>
<div class="pager" id="received-pager"></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>local pref</th><th>communities</th><th>large communities</th><th>RPKI</th><th>ASPA</th><th>best</th></tr></thead>
<thead><tr><th>prefix</th><th>from peer</th><th>AS path</th><th>next hop</th><th>origin</th><th>MED</th><th>local pref</th><th>communities</th><th>large communities</th><th>other attributes</th><th>RPKI</th><th>ASPA</th><th>best</th></tr></thead>
<tbody id="received-body"></tbody>
</table>
<div class="pager" id="received-pager-below"></div>
@@ -110,7 +116,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
<h1 id="refused">Rejected routes (<span id="refused-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 that a peer announces again, and that is rejected again, counts again. The latest rejected routes of each peer are kept.</div>
<table class="data">
<thead><tr><th>peer</th><th>prefix</th><th>AS path</th><th>reason</th><th>when</th><th>attributes</th></tr></thead>
<thead><tr><th>peer</th><th>prefix</th><th>AS path</th><th>reason</th><th>when</th><th>next hop</th><th>origin</th><th>MED</th><th>local pref</th><th>communities</th><th>large communities</th><th>other attributes</th></tr></thead>
<tbody id="refused-body"></tbody>
</table>
@@ -155,8 +161,67 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
if (state.status) $('s-uptime').textContent = fmtDur(t - state.status.started_at_unix);
}
const fmtTime = unix => new Date(unix * 1000).toLocaleTimeString();
const commList = (list, named) => list.length ? list.map(c =>
named && WELL_KNOWN[c] ? `<span title="${esc(c)}">${WELL_KNOWN[c]}</span>` : esc(c)).join(' ') : DASH;
const fmtCount = n => Number(n).toLocaleString('en-US');
// 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, fn, arg] = p;
if (admin !== L) return '';
if (fn === 0) return arg ? `not to AS${arg}` : 'to no other network';
if (fn === 1) return `only to AS${arg} (and others named so)`;
if (fn >= 11 && fn <= 13) return `our AS ${TIMES[fn - 10]} more on the path to ${arg ? `AS${arg}` : 'every network'}`;
if (fn === 20) return `not to group ${arg}`;
if (fn === 21) return `only to group ${arg} (and others named so)`;
if (fn >= 31 && fn <= 33) return `our AS ${TIMES[fn - 30]} more on the path to group ${arg}`;
return '';
}
// Communities, one line for each AS that defines them, in the order the
// route has them; each whole, so it can be copied. Pointing at a line
// names its AS. Well-known ones by name, and ours that ask for something
// with what they ask.
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 name = !large && WELL_KNOWN[c];
if (name) return `<span class="comm" title="${esc(c)}">${name}</span>`;
const meaning = controlMeaning(c, large);
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.
const otherList = list => list && list.length
? list.map(a => `<div><span class="dim">${esc(a.name)}</span> <span class="mono">${esc(a.value)}</span></div>`).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>`;
const asPath = p => p.length ? p.map(asn).join(' ') : '<span class="dim">(empty)</span>';
@@ -285,8 +350,8 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
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">${asPath(Array(1 + r.prepend).fill(local))}</td><td>${r.med ?? DASH}</td>
<td class="mono">${commList(r.communities, true)}</td><td class="mono">${commList(r.large_communities)}</td>
<td class="mono path">${asPath(Array(1 + r.prepend).fill(local))}</td><td>${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(''));
}
@@ -301,12 +366,12 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
: `${shown}${st ? `, for ${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, `<tr><td colspan="13" 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>
<td class="mono">${esc(r.prefix)}</td><td class="mono">${r.peer_name ? esc(r.peer_name) + ' ' : ''}${esc(r.peer_addr)} <span class="dim">AS${asn(r.peer_asn)}${r.path_id ? ` path ${r.path_id}` : ''}</span></td><td class="mono">${asPath(s.as_path)}</td>
<td class="mono">${esc(r.prefix)}</td><td class="mono">${r.peer_name ? esc(r.peer_name) + ' ' : ''}${esc(r.peer_addr)} <span class="dim">AS${asn(r.peer_asn)}${r.path_id ? ` path ${r.path_id}` : ''}</span></td><td class="mono path">${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>${s.local_pref ?? DASH}</td>
<td class="mono">${commList(s.communities, true)}</td><td class="mono">${commList(s.large_communities)}</td>
<td>${commList(s.communities)}</td><td>${commList(s.large_communities, true)}</td><td>${otherList(s.other)}</td>
<td>${r.rpki ? `<span class="${RPKI_CLASS[r.rpki] || ''}">${esc(r.rpki)}</span>` : DASH}</td>
<td>${r.aspa ? `<span class="${RPKI_CLASS[r.aspa] || ''}">${esc(r.aspa)}</span>` : DASH}</td><td>${r.best ? '<span class="ok">best</span>' : ''}${r.stale ? ` <span class="warn" title="Kept from a session of the peer's that ended (graceful restart), and not sent again since">stale</span>` : ''}</td></tr>`; }).join(''));
}
@@ -331,32 +396,33 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
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 unresolved = Object.entries(k.unresolved).map(([why, n]) => `${n} ${esc(why)}`).join(', ');
const interfaces = k.interfaces.map(i =>
`<span class="mono">${esc(i.name)}</span> <span class="${i.up ? 'ok' : 'bad'}">${i.up ? 'up' : 'down'}</span>` +
(i.addresses.length ? ` <span class="mono dim">${i.addresses.map(esc).join(' ')}</span>` : '')).join(' &middot; ');
setHTML(b, `<span class="${k.synced ? 'ok' : 'warn'}">${k.synced ? 'In step' : 'Catching up'}</span> with the routing table: ` +
`<b>${k.installed}</b> routes installed <span class="dim">(table ${k.table}, metric ${k.metric}, proto bgp)</span>` +
(k.kept ? `; <span class="warn">${k.kept} kept from before the restart</span>, not announced again yet` : '') +
(unresolved ? `; <span class="warn">not installed:</span> ${unresolved}` : '') +
(k.refused ? `; <span class="bad">${k.refused} refused by the kernel</span>` : '') + '.' +
(k.last_error ? `<br><span class="bad">Last error</span> (${k.errors} in all): ${esc(k.last_error)}` : '') +
(interfaces ? `<br>Interfaces: ${interfaces}` : ''));
}
// Everything a refused route came with, folded away: the fields of the
// received routes' table, then the attributes as decoded.
function refusedDetail(r) {
const s = r.summary;
const field = (name, value) => `<tr><td class="dim">${name}</td><td class="mono">${value}</td></tr>`;
return `<details class="refused-detail"><summary>show</summary><table>` +
field('next hop', s.next_hop ? esc(s.next_hop) : DASH) +
field('origin', s.origin ? esc(s.origin) : DASH) +
field('MED', s.med ?? DASH) +
field('local pref', s.local_pref ?? DASH) +
field('communities', commList(s.communities, true)) +
field('large communities', commList(s.large_communities)) +
`</table><pre class="mono">${esc(r.attributes.map(a => JSON.stringify(a)).join('\n'))}</pre></details>`;
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 ? 'ok' : 'warn'}">${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="warn">${fmtCount(k.kept)}</span>, not announced again yet`) : '') +
(unresolved ? row('not installed', `<span class="warn">${unresolved}</span>`) : '') +
(k.refused ? row('refused by the kernel', `<span class="bad">${fmtCount(k.refused)}</span>`) : '') +
(k.last_error ? row('last error', `<span class="bad">${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><span class="${i.up ? 'ok' : 'bad'}">${i.up ? 'up' : 'down'}</span></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);
}
// For each peer a line that says how many and why, then its latest few.
@@ -366,16 +432,18 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
const total = state.refused.reduce((n, p) => n + p.total, 0);
const announcements = n => `${n} announcement${n === 1 ? '' : 's'}`;
$('refused-count').textContent = total ? `${announcements(total)} from ${state.refused.length} peer${state.refused.length === 1 ? '' : 's'}` : '0';
if (!state.refused.length) { setHTML(b, '<tr><td colspan="6" class="dim">Nothing a peer announced has been rejected.</td></tr>'); return; }
if (!state.refused.length) { setHTML(b, '<tr><td colspan="12" class="dim">Nothing a peer announced has been rejected.</td></tr>'); return; }
setHTML(b, state.refused.map(p => {
const why = Object.entries(p.by_reason).sort((a, c) => c[1] - a[1]).map(([reason, n]) => `${n} ${esc(reason)}`).join(', ');
const more = p.recent.length > REFUSED_SHOWN
? ` <span class="dim">Latest ${REFUSED_SHOWN} shown; <a href="/api/routes/rejected?peer=${encodeURIComponent(p.peer)}">the latest ${p.recent.length} as json</a>.</span>` : '';
return `<tr><td colspan="6"><b>${esc(p.peer)}</b> <span class="dim">${esc(p.peer_addr)}</span>: <span class="bad">${announcements(p.total)} rejected</span> (${why}).${more}</td></tr>` +
p.recent.slice(0, REFUSED_SHOWN).map(r => `<tr><td></td>
return `<tr><td colspan="12"><b>${esc(p.peer)}</b> <span class="dim">${esc(p.peer_addr)}</span>: <span class="bad">${announcements(p.total)} rejected</span> (${why}).${more}</td></tr>` +
p.recent.slice(0, REFUSED_SHOWN).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">${asPath(r.as_path)}</td><td>${esc(r.reason)}</td><td>${age(r.at_unix)} ago</td>
<td>${refusedDetail(r)}</td></tr>`).join('');
<td class="mono path">${asPath(r.as_path)}</td><td>${esc(r.reason)}</td><td class="nowrap">${age(r.at_unix)} ago</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>${s.local_pref ?? DASH}</td>
<td>${commList(s.communities)}</td><td>${commList(s.large_communities, true)}</td><td>${otherList(s.other)}</td></tr>`; }).join('');
}).join(''));
}