From 20cad53b24e919a4bc9556edccfaf61e6e63e72a Mon Sep 17 00:00:00 2001 From: Rayhaan Jaufeerally Date: Sat, 19 Sep 2026 17:10:42 +0200 Subject: [PATCH] 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 --- crates/web/static/statusz.html | 83 +++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 27 deletions(-) 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); })();