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:
1 parent
cc022c3b7b
commit
20cad53b24
1 file changed
+56
-27
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"'}[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)}
|
||||
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)}
|
||||
<span class="hh"> header </span> <span class="hl"> lengths </span> <span class="ha"> path attributes </span> <span class="hn"> NLRI </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>
|
||||
|
||||
Reference in new issue
Block a user