From 029ae69953ca0dfa7b3520d732d04bafe4bfdf96 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 12:15:01 +0200 Subject: [PATCH] =?UTF-8?q?perf(dashboard):=20keyed=20rebuild-queue=20row?= =?UTF-8?q?=20cache=20=E2=80=94=20skip=20rebuild=20for=20unchanged=20entri?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same fingerprint-cache pattern as the container row cache to the rebuild-queue list. Maintain rebuildQueueRowCache (Map) so renderRebuildQueue can reuse
  • nodes whose state hasn't changed across rebuild_queue_changed snapshots. The rebuild queue emits a full snapshot on every mutation (a single rebuild emits queued → running (+ step changes) → done/failed — each transition is an event). Before this change every event caused a full replaceChildren() wipe of the list. After this change only the row(s) whose fingerprint changed get rebuilt; the rest survive intact. The elapsed-time ticker (data-rqe-elapsed + 1s setInterval) already updates running-entry timestamps in-place, so ticking elapsed seconds don't require a re-render and are excluded from the fingerprint. DOM order is reconciled via insertBefore with no wipe, same as the container cache. --- frontend/packages/dashboard/src/tabs.js | 93 +++++++++++++++++++++---- 1 file changed, 81 insertions(+), 12 deletions(-) diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index a48f51b8..54069c86 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -2442,6 +2442,15 @@ window.marked = marked; } // ─── rebuild queue ────────────────────────────────────────────────────── + // Keyed row cache for the rebuild-queue list. Maps entry.id → { el, fingerprint }. + // Same pattern as containerRowCache: reuse
  • nodes whose state hasn't + // changed rather than replacing the entire list on every snapshot event. + // The elapsed-time ticker (data-rqe-elapsed + 1s setInterval below) already + // updates running-entry timestamps in-place, so started_at doesn't need to + // invalidate — including it in the fingerprint only matters for the initial + // render of a newly-running entry. + const rebuildQueueRowCache = new Map(); + // Glyph + verb per QueueKind. Mirrors the labels used in // hive-c0re::rebuild_queue::QueueKind::as_str. const QUEUE_KIND_GLYPH = { @@ -2461,15 +2470,38 @@ window.marked = marked; cancelled: '⊘', }; + // Fingerprint for a single rebuild-queue row. Everything visible in the + // row except the ticking elapsed seconds (handled by data-rqe-elapsed + // ticker, not by re-rendering). + function rebuildQueueEntryFingerprint(entry, isChild) { + return JSON.stringify({ + state: entry.state, + step: entry.step, + kind: entry.kind, + agent: entry.agent, + source: entry.source, + started_at: entry.started_at, + enqueued_at: entry.enqueued_at, + finished_at: entry.finished_at, + reason: entry.reason, + error: entry.error, + build_log_id: entry.build_log_id, + isChild, + }); + } + function renderRebuildQueue(s) { const root = $('rebuild-queue-section'); if (!root) return; - root.replaceChildren(); const queue = s.rebuild_queue || []; + if (!queue.length) { - root.append(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.')); + // Queue drained — show placeholder and purge cache. + rebuildQueueRowCache.clear(); + root.replaceChildren(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.')); return; } + // Index by id for parent lookup. const byId = new Map(queue.map((e) => [e.id, e])); // Top-level entries first; children render nested under their parent. @@ -2481,21 +2513,58 @@ window.marked = marked; childrenOf.get(e.parent_id).push(e); } } - const ul = el('ul', { class: 'rebuild-queue' }); - for (const top of tops) { - ul.append(renderQueueEntry(top, byId)); - for (const child of childrenOf.get(top.id) || []) { - ul.append(renderQueueEntry(child, byId, true)); - } - } // Children whose parent isn't in the snapshot (history-evicted) still render flat. const orphans = queue.filter( (e) => e.parent_id != null && !byId.has(e.parent_id), ); - for (const o of orphans) { - ul.append(renderQueueEntry(o, byId, true)); + + // Build ordered list of
  • , reusing cached nodes for unchanged entries. + const orderedLis = []; + function addEntry(entry, isChild) { + const fp = rebuildQueueEntryFingerprint(entry, isChild); + const cached = rebuildQueueRowCache.get(entry.id); + let li; + if (cached && cached.fingerprint === fp) { + li = cached.el; + } else { + li = renderQueueEntry(entry, byId, isChild); + rebuildQueueRowCache.set(entry.id, { el: li, fingerprint: fp }); + } + orderedLis.push(li); } - root.append(ul); + for (const top of tops) { + addEntry(top, false); + for (const child of childrenOf.get(top.id) || []) { + addEntry(child, true); + } + } + for (const o of orphans) { + addEntry(o, true); + } + + // Drop cache entries for IDs no longer in the snapshot. + const liveIds = new Set(queue.map((e) => e.id)); + for (const [id, entry] of rebuildQueueRowCache) { + if (!liveIds.has(id)) { + entry.el.remove(); + rebuildQueueRowCache.delete(id); + } + } + + // Get or create the
      ; remove any "empty" placeholder if present. + let ul = root.querySelector('ul.rebuild-queue'); + if (!ul) { + ul = el('ul', { class: 'rebuild-queue' }); + root.replaceChildren(ul); + } + + // Reconcile DOM order without a wipe. + for (let i = 0; i < orderedLis.length; i++) { + if (ul.children[i] !== orderedLis[i]) { + ul.insertBefore(orderedLis[i], ul.children[i] ?? null); + } + } + while (ul.children.length > orderedLis.length) ul.lastChild.remove(); } function renderQueueEntry(entry, _byId, isChild) {