fix: statusz tables no longer flicker on refresh

Every poll and event rebuilt each table from scratch, and an open bytes
view collapsed to "loading..." and was refetched every five seconds.

- Tables are rewritten only when their markup changes. Clock-dependent
  text (ages, retry countdowns, uptime) is rendered as placeholders and
  ticked in place once a second, so unchanged data means no DOM writes.
- The bytes view is cached per route and set of established sessions, and
  the previous bytes stay visible while new ones load.

Measured with a MutationObserver: zero table rebuilds across 13 s of
polling with a bytes view open; one announcement redraws only the local
and received tables once each, with no "loading..." flash.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Rayhaan JaufeerallyandClaude Fable 5.1 committed 2026-09-19 17:10:42 +02:00
1 parent cc022c3b7b
commit 20cad53b24
1 file changed
+56 -27
+56 -27
View File
@@ -121,7 +121,7 @@ Live updates: <span id="live-text" class="warn">connecting</span><br>
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() };
const state = { status: null, peers: [], local: [], received: [], wellKnown: {}, openWire: new Set(), wire: new Map() };
const DASH = '<span class="dim">-</span>';
// ---- helpers -----------------------------------------------------------
@@ -131,7 +131,19 @@ Live updates: <span id="live-text" class="warn">connecting</span><br>
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 => `<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];
@@ -152,30 +164,30 @@ Live updates: <span id="live-text" class="warn">connecting</span><br>
$('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 = '<tr><td colspan="10" class="dim">No peers configured and none connected.</td></tr>'; return; }
b.innerHTML = state.peers.map(p => {
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 ${fmtDur(p.retry_at_unix - now())}` : '';
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('');
}).join(''));
}
function renderLocal() {
const b = $('local-body'); $('local-count').textContent = state.local.length;
if (!state.local.length) { b.innerHTML = '<tr><td colspan="9" class="dim">Nothing announced yet.</td></tr>'; return; }
const me = myOwner(), asn = state.status ? state.status.local_asn : '?';
b.innerHTML = state.local.map(r => {
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>';
@@ -184,19 +196,19 @@ Live updates: <span id="live-text" class="warn">connecting</span><br>
<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" id="wire-${p}"><span class="dim">loading...</span></td></tr>` : '');
}).join('');
state.openWire.forEach(loadWire);
(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) { b.innerHTML = '<tr><td colspan="9" class="dim">No routes received from peers.</td></tr>'; return; }
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));
b.innerHTML = state.received.map(r => { const s = r.summary; return `<tr class="${localPrefixes.has(r.prefix) ? 'loop' : ''}">
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('');
<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 ------------
@@ -216,21 +228,37 @@ Live updates: <span id="live-text" class="warn">connecting</span><br>
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 : '<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(prefix)}`);
if (!views.length) { cell.innerHTML = '<span class="dim">No established session to send this on.</span>'; return; }
cell.innerHTML = 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;
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 for its name)</span>
<pre class="bytes">${segmentUpdate(v.hex).map(s => `<span class="${s.cls}" title="${esc(s.label)}">${s.hex}</span>`).join(' ')}</pre>`).join('');
} catch (e) { cell.innerHTML = `<span class="bad">${esc(e.message)}</span>`; }
} 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(); }
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: <span id="live-text" class="warn">connecting</span><br>
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: <span id="live-text" class="warn">connecting</span><br>
loadAll().catch(e => logEvent(`<span class="bad">${esc(e.message)}</span>`));
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);
})();
</script>
</body>