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