Status page: count rejected announcements, and name AS numbers

The "refused" column counted rejected announcements since the start, not
routes, which made two peers that send the same routes look different
when one of them re-sends more often. It is now "rejected announcements",
with the held routes, the number that compares, beside it as "held now".
The page says "rejected" throughout, as the documentation and metrics do.

Rows are highlighted under the pointer, and pointing at an AS number
shows its holder's name, which the browser asks RIPEstat for. The daemon
looks nothing up; the monitoring page says what the browser sends where.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Opus 5.5 committed 2026-10-04 22:43:36 +02:00
1 parent efb2d81a50
commit acecc22f48
2 files changed
+78 -17

No files matched your search

+62 -14
View File
@@ -9,7 +9,9 @@
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/*.
framework, no external resources. 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).
-->
<style>
body { font-family: sans-serif; font-size: 13px; color: #000; background: #fff; margin: 8px; }
@@ -21,6 +23,10 @@ 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; }
table.data > tbody > tr:hover > td { background: #f3f3ff; }
.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; }
.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; }
@@ -53,7 +59,7 @@ 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">originated routes json</a>, <a href="/api/routes/received">received routes json</a>, <a href="/api/routes/rejected">refused routes json</a>, <a href="/api/rpki">rpki json</a>, <a href="/api/kernel">kernel json</a><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">originated routes json</a>, <a href="/api/routes/received">received routes json</a>, <a href="/api/routes/rejected">rejected routes json</a>, <a href="/api/rpki">rpki json</a>, <a href="/api/kernel">kernel json</a><br>
<span id="refresh-ctl"></span><br>
</div>
</div>
@@ -65,7 +71,7 @@ View: <a href="/metricz">variables</a>, <a href="/api/status">status json</a>, <
<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>address families</th><th>routes</th><th>refused</th><th>detail</th></tr></thead>
<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>address families</th><th>routes</th><th title="Announcements rejected by the import policy since the daemon started. A route that is announced again and rejected again counts again.">rejected announcements</th><th>detail</th></tr></thead>
<tbody id="peers-body"></tbody>
</table>
@@ -101,8 +107,8 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
</table>
<div class="pager" id="received-pager-below"></div>
<h1 id="refused">Refused routes (<span id="refused-count">...</span>)</h1>
<div class="dim">Routes that peers announced and their import policies did not take. The counts are since the daemon started; the latest refusals of each peer are kept.</div>
<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>
<tbody id="refused-body"></tbody>
@@ -151,7 +157,9 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
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 asPath = p => p.length ? p.join(' ') : '<span class="dim">(empty)</span>';
// 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>';
async function getJSON(url) {
const r = await fetch(url);
@@ -163,6 +171,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
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();
@@ -186,13 +195,13 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
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}${p.role ? ` <span class="dim" title="${p.role_confirmed ? 'The peer says so too (RFC 9234)' : 'By our configuration; the peer has not said what it is'}">${esc(p.role)}${p.role_confirmed ? ' &check;' : ''}</span>` : ''}</td>
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${asn(p.peer_asn)}${p.role ? ` <span class="dim" title="${p.role_confirmed ? 'The peer says so too (RFC 9234)' : 'By our configuration; the peer has not said what it is'}">${esc(p.role)}${p.role_confirmed ? ' &check;' : ''}</span>` : ''}</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>${up ? esc((p.address_families || []).join(', ')) + addPath(p) + (p.extended_next_hop ? ' &middot; IPv4 with IPv6 next hops' : '') : DASH}</td>
<td>${up ? p.routes : DASH}</td>
<td>${up ? (p.rejected_routes ? `<a class="bad" href="#refused">${p.rejected_routes}</a>` : 0) + (p.held_routes ? ` <span class="dim" title="Refused as RPKI or ASPA invalid, or for a next hop that cannot be reached, and kept aside, to be taken if that changes">(${p.held_routes} held)</span>` : '') : DASH}</td><td>${detail}</td></tr>`;
<td>${up ? (p.rejected_routes ? `<a class="bad" href="#refused">${p.rejected_routes}</a>` : 0) + (p.held_routes ? ` &middot; <span 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">${p.held_routes} held now</span>` : '') : DASH}</td><td>${detail}</td></tr>`;
}).join(''));
renderPeerChoices();
}
@@ -267,10 +276,10 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
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 asn = state.status ? state.status.local_asn : '?';
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">${Array(1 + r.prepend).fill(asn).join(' ')}</td><td>${r.med ?? DASH}</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>${age(r.announced_at_unix)}</td></tr>`).join(''));
}
@@ -289,7 +298,7 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
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>
<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${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">${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>${r.rpki ? `<span class="${RPKI_CLASS[r.rpki] || ''}">${esc(r.rpki)}</span>` : DASH}</td>
@@ -349,13 +358,14 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
function renderRefused() {
const b = $('refused-body');
const total = state.refused.reduce((n, p) => n + p.total, 0);
$('refused-count').textContent = total ? `${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 refused.</td></tr>'); return; }
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; }
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">${p.total} refused</span> (${why}).${more}</td></tr>` +
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>
<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>
@@ -447,6 +457,44 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
$('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;