perf(dashboard): keyed container row cache — skip rebuild + async fetch for unchanged rows

Maintain a module-level containerRowCache (Map<name, {el, fingerprint}>)
that preserves <li> elements across renderContainers calls. Each row's
fingerprint encodes everything that affects its rendered output:
container running/login/update/reminder state, derived pending/opRunning
labels, tree position (depth, isLast, ancestorIsLast), selection, agent
question counts, and link-base context.

When the fingerprint is unchanged the existing DOM node is reused:
- no replaceChildren wipe for stable rows
- the async dashboard-state fetch (nav strip, ctx badge, status text)
  is skipped — previously-fetched data stays in place
- DOM order is reconciled via insertBefore with zero layout work for
  in-place nodes

Before this change every SSE event (container_changed, rebuild_queue_
changed, transient_set/cleared, question_added/resolved) caused a full
wipe + rebuild of the entire container list, triggering N concurrent
/api/dashboard-state fetches where N is the number of running agents.

After this change only the rows whose state actually changed are
rebuilt; the rest survive intact across re-renders.
This commit is contained in:
iris 2026-06-05 12:09:24 +02:00 committed by mara
commit f011638b5e

View file

@ -156,6 +156,11 @@ window.marked = marked;
// forms' POST → 200 → matching event flips the row without a // forms' POST → 200 → matching event flips the row without a
// snapshot refetch. // snapshot refetch.
const containersState = new Map(); const containersState = new Map();
// Keyed container row cache. Maps agent name → { el: <li>, fingerprint }.
// Allows renderContainers to skip rebuilding rows whose displayed state
// hasn't changed — prevents full-wipe flicker + avoids redundant async
// dashboard-state fetches on every SSE event.
const containerRowCache = new Map();
function syncContainersFromSnapshot(s) { function syncContainersFromSnapshot(s) {
containersState.clear(); containersState.clear();
for (const c of s.containers || []) containersState.set(c.name, c); for (const c of s.containers || []) containersState.set(c.name, c);
@ -563,110 +568,43 @@ window.marked = marked;
return prefix; return prefix;
} }
function renderContainers(s) { // Serialise the visible state of a container row into a stable string
const root = $('containers-section'); // for change-detection. Includes everything that affects what the row
// #containers-section only exists on /index.html. tabs.js is the // renders — container fields, derived pending/selection state, tree
// bundle for that page only (flow.html loads flow.js instead). // position, and link-base context. The async dashboard-state (nav
// Belt-and-suspenders for any future page that adds tabs.js // strip, ctx badge, status text) is intentionally excluded: it
// without a #containers-section — matches the // populates in-place and is preserved when a row is reused.
// no-op-when-target-absent convention the other renderers function containerRowFingerprint(c, node, pending, opRunning, selected,
// (renderTombstones, etc.) follow. askerCount, targetCount, gatewayLinks, hostname) {
if (!root) return; return JSON.stringify({
root.replaceChildren(); running: c.running,
needs_login: c.needs_login,
// Containers come from the derived map (event-driven) rather than needs_update: c.needs_update,
// `s.containers`; `s` still supplies hostname (for the web-ui pending_reminders: c.pending_reminders,
// link) and tombstones/meta_inputs (not event-derived yet). The port: c.port,
// tree builder handles the ordering — we don't pre-sort here. pending,
const containers = Array.from(containersState.values()); opRunning,
const portConflicts = derivePortConflicts(containers); selected,
const anyStale = containers.some((c) => c.needs_update); askerCount,
targetCount,
// Port-hash collisions: rename one of the listed agents and depth: node.depth,
// rebuild. The banner sits above the agent list so it's the isLast: node.isLast,
// first thing the operator sees when something's wedged. ancestorIsLast: node.ancestorIsLast,
if (portConflicts.length) { gatewayLinks,
const banner = el('div', { class: 'port-conflict' }, hostname,
el('strong', {}, '⚠ port collision'), ' — '); });
const groups = portConflicts.map((c) =>
`:${c.port} (${c.agents.join(' + ')})`).join('; ');
banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
root.append(banner);
} }
if (anyStale) { // Build a single container-row <li> from scratch. Extracted so
root.append(form( // renderContainers can call this only for rows whose fingerprint
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL', // changed (keyed cache), skipping the build + async dashboard-state
'rebuild every stale container?', // fetch for stable rows.
{}, { noRefresh: true }, function buildContainerLi(c, node, opts) {
)); const {
} pending, opRunning, selected,
askerCount, targetCount, agentQCount,
if (transientsState.size) { url, containerBase, forgeBase, s,
const ul = el('ul'); } = opts;
const nowUnix = Math.floor(Date.now() / 1000);
for (const [name, t] of transientsState) {
const secs = Math.max(0, nowUnix - t.since_unix);
ul.append(el('li', {},
el('span', { class: 'glyph spinner' }, '◐'), ' ',
el('span', { class: 'agent' }, name), ' ',
el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
el('span', { class: 'meta' }, `nixos-container create + start (${secs}s)`),
));
}
root.append(ul);
}
if (!containers.length && !transientsState.size) {
root.append(el('p', { class: 'empty' }, 'no managed containers'));
return;
}
// Drop stale selections (agent destroyed while selected). Defensive —
// the action bar would otherwise loop POST against a gone agent.
const liveNames = new Set(containers.map((c) => c.name));
for (const n of Array.from(selectionState)) {
if (!liveNames.has(n)) selectionState.delete(n);
}
const hostname = (s && s.hostname) || window.location.hostname;
// When hive-gateway is in front of the dashboard, build same-origin
// `/agent/<name>/` URLs instead of the direct `http://<host>:<port>/`
// TCP fallback — the gateway proxies the prefix to the per-agent
// harness (TCP via `agent-ports.json` or unix-domain via
// `agent-sockets.json`). See
// `docs/web-ui.md::Container row` + `docs/gateway.md::Vhost map`.
const gatewayLinks = !!(s && s.gateway_enabled);
const ul = el('ul', { class: 'containers' });
const tree = buildAgentTree(containers);
// In-flight rebuild / meta-update / destroy ops per agent name —
// see docs/web-ui.md::Container row for the building… badge
// rationale (covers the SYST3M-shows-rebuild-but-SW4RM-shows-stopped
// gap when no operator transient is set).
const inFlight = inFlightOpsByAgent();
for (const node of tree) {
const c = node.container;
const url = gatewayLinks
? `/agent/${encodeURIComponent(c.name)}/`
: `http://${hostname}:${c.port}/`;
// Pending-state derivation + queued-vs-running split — see
// docs/web-ui.md::Container row for the transient → in-flight
// queue priority order and the opRunning rationale.
const transientKind = transientsState.get(c.name)?.kind || null;
const op = !transientKind ? inFlight.get(c.name) : null;
const pending = transientKind
|| (op && (op.state === 'running'
? (op.kind === 'meta_update' ? 'meta-updating'
: op.kind === 'destroy' ? 'destroying'
: op.kind === 'restart' ? 'restarting'
: 'rebuilding')
: (op.kind === 'meta_update' ? 'meta-update queued'
: op.kind === 'destroy' ? 'destroy queued'
: op.kind === 'restart' ? 'restart queued'
: 'rebuild queued')));
const opRunning = transientKind != null
|| (op != null && op.state === 'running');
const selected = selectionState.has(c.name);
const li = el('li', { const li = el('li', {
class: 'container-row' class: 'container-row'
+ (pending ? ' pending' : '') + (pending ? ' pending' : '')
@ -740,15 +678,6 @@ window.marked = marked;
// and must never reach the HTML parser. // and must never reach the HTML parser.
const navStrip = el('span', { class: 'nav-strip' }); const navStrip = el('span', { class: 'nav-strip' });
head.append(navStrip); head.append(navStrip);
// Forge public URL: prefer state.forge_public_url (set by the NixOS
// module when forge.behindGateway=true, e.g.
// "https://forge.pr1ma.darkest.space"), fall back to
// "<hostname>:3000" for gateway-off / local-dev deploys.
const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`;
// Container nav-strip base: gateway prefix or direct TCP.
const containerBase = gatewayLinks
? `/agent/${encodeURIComponent(c.name)}`
: `http://${hostname}:${c.port}`;
if (c.running) { if (c.running) {
// Fetch the lean dashboard-state snapshot from the agent directly. // Fetch the lean dashboard-state snapshot from the agent directly.
// Populates: nav strip links (including the screen link that // Populates: nav strip links (including the screen link that
@ -756,6 +685,8 @@ window.marked = marked;
// ctx-window badge, and self-reported status text. // ctx-window badge, and self-reported status text.
// Fails gracefully when the agent is starting up or the gateway // Fails gracefully when the agent is starting up or the gateway
// is not yet routing to it — badges simply don't appear. // is not yet routing to it — badges simply don't appear.
// Only runs when the row is first built (fingerprint changed) —
// reused rows keep their previously-fetched nav strip + badges.
fetch(`${containerBase}/api/dashboard-state`) fetch(`${containerBase}/api/dashboard-state`)
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.then((ds) => { .then((ds) => {
@ -856,12 +787,7 @@ window.marked = marked;
// answer) or the target (owes a reply). Derived live from // answer) or the target (owes a reply). Derived live from
// questionsState so the badge updates instantly on QuestionAdded / // questionsState so the badge updates instantly on QuestionAdded /
// QuestionResolved without a separate backend field. // QuestionResolved without a separate backend field.
const agentQCount = questionsState.pending.filter(
(q) => q.asker === c.name || q.target === c.name,
).length;
if (agentQCount > 0) { if (agentQCount > 0) {
const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length;
const targetCount = questionsState.pending.filter((q) => q.target === c.name).length;
const parts = []; const parts = [];
if (askerCount > 0) parts.push(`${askerCount} asked`); if (askerCount > 0) parts.push(`${askerCount} asked`);
if (targetCount > 0) parts.push(`${targetCount} to answer`); if (targetCount > 0) parts.push(`${targetCount} to answer`);
@ -881,8 +807,182 @@ window.marked = marked;
// action button. // action button.
li.append(icon, body, buildAgentMenu(c, forgeBase)); li.append(icon, body, buildAgentMenu(c, forgeBase));
ul.append(li); return li;
} }
function renderContainers(s) {
const root = $('containers-section');
// #containers-section only exists on /index.html. tabs.js is the
// bundle for that page only (flow.html loads flow.js instead).
// Belt-and-suspenders for any future page that adds tabs.js
// without a #containers-section — matches the
// no-op-when-target-absent convention the other renderers
// (renderTombstones, etc.) follow.
if (!root) return;
// Containers come from the derived map (event-driven) rather than
// `s.containers`; `s` still supplies hostname (for the web-ui
// link) and tombstones/meta_inputs (not event-derived yet). The
// tree builder handles the ordering — we don't pre-sort here.
const containers = Array.from(containersState.values());
const portConflicts = derivePortConflicts(containers);
const anyStale = containers.some((c) => c.needs_update);
// Preserve the keyed container list across the header-section
// rebuild. We wipe the banners/buttons above the list on every
// render (simple), but recycle <li> elements for unchanged rows
// (keyed) to avoid full-DOM thrash and redundant async fetches.
const existingUl = root.querySelector('ul.containers');
root.replaceChildren();
// Port-hash collisions: rename one of the listed agents and
// rebuild. The banner sits above the agent list so it's the
// first thing the operator sees when something's wedged.
if (portConflicts.length) {
const banner = el('div', { class: 'port-conflict' },
el('strong', {}, '⚠ port collision'), ' — ');
const groups = portConflicts.map((c) =>
`:${c.port} (${c.agents.join(' + ')})`).join('; ');
banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
root.append(banner);
}
if (anyStale) {
root.append(form(
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
'rebuild every stale container?',
{}, { noRefresh: true },
));
}
if (transientsState.size) {
const ul = el('ul');
const nowUnix = Math.floor(Date.now() / 1000);
for (const [name, t] of transientsState) {
const secs = Math.max(0, nowUnix - t.since_unix);
ul.append(el('li', {},
el('span', { class: 'glyph spinner' }, '◐'), ' ',
el('span', { class: 'agent' }, name), ' ',
el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
el('span', { class: 'meta' }, `nixos-container create + start (${secs}s)`),
));
}
root.append(ul);
}
if (!containers.length && !transientsState.size) {
root.append(el('p', { class: 'empty' }, 'no managed containers'));
return;
}
// Drop stale selections (agent destroyed while selected). Defensive —
// the action bar would otherwise loop POST against a gone agent.
const liveNames = new Set(containers.map((c) => c.name));
for (const n of Array.from(selectionState)) {
if (!liveNames.has(n)) selectionState.delete(n);
}
const hostname = (s && s.hostname) || window.location.hostname;
// When hive-gateway is in front of the dashboard, build same-origin
// `/agent/<name>/` URLs instead of the direct `http://<host>:<port>/`
// TCP fallback — the gateway proxies the prefix to the per-agent
// harness (TCP via `agent-ports.json` or unix-domain via
// `agent-sockets.json`). See
// `docs/web-ui.md::Container row` + `docs/gateway.md::Vhost map`.
const gatewayLinks = !!(s && s.gateway_enabled);
// Forge public URL: prefer state.forge_public_url (set by the NixOS
// module when forge.behindGateway=true), fall back to
// "<hostname>:3000" for gateway-off / local-dev deploys.
const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`;
const ul = existingUl ?? el('ul', { class: 'containers' });
const tree = buildAgentTree(containers);
// In-flight rebuild / meta-update / destroy ops per agent name —
// see docs/web-ui.md::Container row for the building… badge
// rationale (covers the SYST3M-shows-rebuild-but-SW4RM-shows-stopped
// gap when no operator transient is set).
const inFlight = inFlightOpsByAgent();
// Build the ordered list of <li> elements, reusing cached rows
// whose displayed state hasn't changed.
const orderedLis = [];
for (const node of tree) {
const c = node.container;
const url = gatewayLinks
? `/agent/${encodeURIComponent(c.name)}/`
: `http://${hostname}:${c.port}/`;
// Container nav-strip base: gateway prefix or direct TCP.
const containerBase = gatewayLinks
? `/agent/${encodeURIComponent(c.name)}`
: `http://${hostname}:${c.port}`;
// Pending-state derivation + queued-vs-running split — see
// docs/web-ui.md::Container row for the transient → in-flight
// queue priority order and the opRunning rationale.
const transientKind = transientsState.get(c.name)?.kind || null;
const op = !transientKind ? inFlight.get(c.name) : null;
const pending = transientKind
|| (op && (op.state === 'running'
? (op.kind === 'meta_update' ? 'meta-updating'
: op.kind === 'destroy' ? 'destroying'
: op.kind === 'restart' ? 'restarting'
: 'rebuilding')
: (op.kind === 'meta_update' ? 'meta-update queued'
: op.kind === 'destroy' ? 'destroy queued'
: op.kind === 'restart' ? 'restart queued'
: 'rebuild queued')));
const opRunning = transientKind != null
|| (op != null && op.state === 'running');
const selected = selectionState.has(c.name);
// Pending questions where this agent is the asker (awaiting an
// answer) or the target (owes a reply). Derived live from
// questionsState so the badge updates instantly on QuestionAdded /
// QuestionResolved without a separate backend field.
const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length;
const targetCount = questionsState.pending.filter((q) => q.target === c.name).length;
const agentQCount = questionsState.pending.filter(
(q) => q.asker === c.name || q.target === c.name,
).length;
const fp = containerRowFingerprint(c, node, pending, opRunning, selected,
askerCount, targetCount, gatewayLinks, hostname);
const cached = containerRowCache.get(c.name);
let li;
if (cached && cached.fingerprint === fp) {
// Row unchanged — reuse the existing DOM node. The async
// dashboard-state (nav strip, ctx badge, status text) stays
// intact from the previous build, avoiding a redundant fetch.
li = cached.el;
} else {
li = buildContainerLi(c, node, {
pending, opRunning, selected,
askerCount, targetCount, agentQCount,
url, containerBase, forgeBase, s,
});
containerRowCache.set(c.name, { el: li, fingerprint: fp });
}
orderedLis.push(li);
}
// Remove cache entries for agents that no longer exist.
for (const [name, entry] of containerRowCache) {
if (!liveNames.has(name)) {
entry.el.remove();
containerRowCache.delete(name);
}
}
// Apply correct DOM order without a full wipe. insertBefore is a
// no-op when the node is already at position i, so stable sections
// of the list cause zero layout work.
for (let i = 0; i < orderedLis.length; i++) {
if (ul.children[i] !== orderedLis[i]) {
ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
}
}
// Trim any excess children (defensive — shouldn't happen after the
// cache-removal pass above, but keeps the ul length exact).
while (ul.children.length > orderedLis.length) ul.lastChild.remove();
root.append(ul); root.append(ul);
renderSelectionBar(containers); renderSelectionBar(containers);
} }