diff --git a/crates/web/static/statusz.html b/crates/web/static/statusz.html
index e84b2fa..afd9c68 100644
--- a/crates/web/static/statusz.html
+++ b/crates/web/static/statusz.html
@@ -121,7 +121,7 @@ Live updates: connecting
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
- const state = { status: null, peers: [], local: [], received: [], wellKnown: {}, openWire: new Set() };
+ const state = { status: null, peers: [], local: [], received: [], wellKnown: {}, openWire: new Set(), wire: new Map() };
const DASH = '-';
// ---- helpers -----------------------------------------------------------
@@ -131,7 +131,19 @@ Live updates: connecting
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);
- const age = unix => fmtDur(now() - unix);
+ // 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 => ``;
+ const until = unix => ``;
+ 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];
@@ -152,30 +164,30 @@ Live updates: connecting
$('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(/ \(.*\)$/, '');
- $('s-uptime').textContent = fmtDur(now() - s.started_at_unix);
+ 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) { b.innerHTML = '
| No peers configured and none connected. |
'; return; }
- b.innerHTML = state.peers.map(p => {
+ if (!state.peers.length) { setHTML(b, '| No peers configured and none connected. |
'); return; }
+ setHTML(b, state.peers.map(p => {
const up = p.state === 'Established';
- const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${fmtDur(p.retry_at_unix - now())}` : '';
+ const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '';
return `
| ${p.name ? esc(p.name) : '(inbound)'} | ${esc(p.peer_addr)} | ${p.peer_asn} |
${esc(p.state)} | ${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH} |
${up ? esc(p.local_addr) : DASH} | ${up ? fmtTime(p.established_at_unix) : DASH} |
${up ? age(p.established_at_unix) : DASH} | ${up ? (p.four_octet_asn ? 'yes' : 'no') : DASH} | ${detail} |
`;
- }).join('');
+ }).join(''));
}
function renderLocal() {
const b = $('local-body'); $('local-count').textContent = state.local.length;
- if (!state.local.length) { b.innerHTML = '| Nothing announced yet. |
'; return; }
- const me = myOwner(), asn = state.status ? state.status.local_asn : '?';
- b.innerHTML = state.local.map(r => {
+ if (!state.local.length) { setHTML(b, '| Nothing announced yet. |
'); 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 ? `withdraw`
: 'withdraw';
@@ -184,19 +196,19 @@ Live updates: connecting
${Array(1 + r.prepend).fill(asn).join(' ')} | ${r.med ?? DASH} |
${commList(r.communities, true)} | ${commList(r.large_communities)} | ${age(r.announced_at_unix)} |
${open ? 'hide bytes' : 'bytes'} | ${withdraw} | ` +
- (open ? `| loading... |
` : '');
- }).join('');
- state.openWire.forEach(loadWire);
+ (open ? `| ${wireHTML(r, stale)} |
` : '');
+ }).join(''));
+ stale.forEach(loadWire);
}
function renderReceived() {
const b = $('received-body'); $('received-count').textContent = state.received.length;
- if (!state.received.length) { b.innerHTML = '| No routes received from peers. |
'; return; }
+ if (!state.received.length) { setHTML(b, '| No routes received from peers. |
'); return; }
const localPrefixes = new Set(state.local.map(r => r.prefix));
- b.innerHTML = state.received.map(r => { const s = r.summary; return `
+ setHTML(b, state.received.map(r => { const s = r.summary; return `
| ${esc(r.prefix)} | ${esc(r.peer_addr)} AS${r.peer_asn} | ${asPath(s.as_path)} |
${s.next_hop ? esc(s.next_hop) : DASH} | ${s.origin ? esc(s.origin) : DASH} | ${s.med ?? DASH} |
- ${commList(s.communities, true)} | ${commList(s.large_communities)} | ${age(r.received_at_unix)} |
`; }).join('');
+ ${commList(s.communities, true)} | ${commList(s.large_communities)} | ${age(r.received_at_unix)} | `; }).join(''));
}
// ---- wire view: split the UPDATE bytes into labelled fields ------------
@@ -216,21 +228,37 @@ Live updates: connecting
if (bytes.length) take(bytes.length, 'hn', 'NLRI');
return segs;
}
- async function loadWire(prefix) {
- const cell = $(`wire-${prefix}`); if (!cell) return;
+ // 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 : 'loading...';
+ }
+ 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: 'loading...' }), loading: sig });
+ let html;
try {
- const views = await getJSON(`/api/routes/local/wire?prefix=${encodeURIComponent(prefix)}`);
- if (!views.length) { cell.innerHTML = 'No established session to send this on.'; return; }
- cell.innerHTML = views.map(v => `UPDATE as sent to ${esc(v.peer_addr)} AS${v.peer_asn}, ${v.length} bytes, next hop ${esc(v.next_hop)}
+ const views = await getJSON(`/api/routes/local/wire?prefix=${encodeURIComponent(route.prefix)}`);
+ html = !views.length ? 'No established session to send this on.' :
+ views.map(v => `UPDATE as sent to ${esc(v.peer_addr)} AS${v.peer_asn}, ${v.length} bytes, next hop ${esc(v.next_hop)}
header lengths path attributes NLRI
(hover a segment for its name)
${segmentUpdate(v.hex).map(s => `${s.hex}`).join(' ')}`).join('');
- } catch (e) { cell.innerHTML = `${esc(e.message)}`; }
+ } catch (e) { html = `${esc(e.message)}`; }
+ 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(); }
+ 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()]); }
@@ -315,7 +343,7 @@ Live updates: connecting
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); loadLocal().catch(console.error); }
+ else { setMsg(`withdrew ${prefix}`, 'warn'); state.openWire.delete(prefix); state.wire.delete(prefix); loadLocal().catch(console.error); }
});
// ---- boot --------------------------------------------------------------
@@ -328,9 +356,10 @@ Live updates: connecting
loadAll().catch(e => logEvent(`${esc(e.message)}`));
connect();
// Dial-state changes (Connecting, Backoff, ...) carry no event, so poll the
- // peers table; everything else is event-driven and only re-rendered here to
- // keep ages and uptime ticking.
- setInterval(() => { loadPeers().catch(console.error); renderStatus(); renderLocal(); renderReceived(); }, 5000);
+ // peers table; it is only redrawn if something changed. Everything else is
+ // event-driven. Ages, countdowns and uptime tick as plain text.
+ setInterval(() => loadPeers().catch(console.error), 5000);
+ setInterval(tick, 1000);
})();