From 029ae69953ca0dfa7b3520d732d04bafe4bfdf96 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 12:15:01 +0200 Subject: [PATCH 1/2] =?UTF-8?q?perf(dashboard):=20keyed=20rebuild-queue=20?= =?UTF-8?q?row=20cache=20=E2=80=94=20skip=20rebuild=20for=20unchanged=20en?= =?UTF-8?q?tries?= 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) { From 6f0b17677878ec9f53421cc49cd01949a4f9d4fd Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 12:19:44 +0200 Subject: [PATCH 2/2] feat(dashboard): live-tick queued-age + terminal-age labels in rebuild queue Stamp data-rqe-enqueued / data-rqe-finished / data-rqe-state on queued and terminal rqe-when spans. A 30s ticker updates them in place so 'queued 2m ago' and 'done 5m ago' labels advance as time passes. Complement to the keyed rebuild-queue row cache: rows now persist across snapshots, so without a ticker these static fmtAgo labels would become arbitrarily stale. The running-entry elapsed ticker (data-rqe-elapsed, 1s interval) already handled that state; this fills the gap for queued and terminal states. --- frontend/packages/dashboard/src/tabs.js | 43 ++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 54069c86..1d4d21ea 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -2585,9 +2585,15 @@ window.marked = marked; // Source chip (manual / meta_update / auto_update / crash_recover). li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source)); // Timing: queued Xs ago when pending, elapsed when running, - // finished Xs ago for terminal. + // finished Xs ago for terminal. Queued + terminal stamps use + // data-rqe-enqueued / data-rqe-finished so the 30s ticker below + // can keep them fresh — keyed rows persist across snapshots, so + // without a ticker "queued 2m ago" would never advance. if (entry.state === 'queued') { - li.append(' ', el('span', { class: 'rqe-when' }, '· queued ' + fmtAgo(entry.enqueued_at))); + li.append(' ', el('span', { + class: 'rqe-when', + 'data-rqe-enqueued': String(entry.enqueued_at), + }, '· queued ' + fmtAgo(entry.enqueued_at))); } else if (entry.state === 'running' && entry.started_at) { const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - entry.started_at)); li.append(' ', el('span', { @@ -2595,7 +2601,11 @@ window.marked = marked; 'data-rqe-elapsed': String(entry.started_at), }, '· ' + fmtElapsed(elapsed))); } else if (entry.finished_at) { - li.append(' ', el('span', { class: 'rqe-when' }, '· ' + entry.state + ' ' + fmtAgo(entry.finished_at))); + li.append(' ', el('span', { + class: 'rqe-when', + 'data-rqe-finished': String(entry.finished_at), + 'data-rqe-state': entry.state, + }, '· ' + entry.state + ' ' + fmtAgo(entry.finished_at))); } // Reason (truncated; full text on hover). if (entry.reason) { @@ -2671,14 +2681,39 @@ window.marked = marked; // `renderQueueEntry`; the ticker avoids a full re-render just for the // wall-clock update. setInterval(() => { + const now = Math.floor(Date.now() / 1000); for (const span of document.querySelectorAll('.rqe-when[data-rqe-elapsed]')) { const started = parseInt(span.dataset.rqeElapsed, 10); if (!started) continue; - const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - started)); + const elapsed = Math.max(0, now - started); span.textContent = '· ' + fmtElapsed(elapsed); } + for (const span of document.querySelectorAll('.build-logs-runtime[data-bl-elapsed]')) { + const started = parseInt(span.dataset.blElapsed, 10); + if (!started) continue; + const elapsed = Math.max(0, now - started); + span.textContent = fmtElapsed(elapsed); + } }, 1000); + // 30s ticker for queued-age and terminal-age labels. Keyed rows + // persist across rebuild_queue_changed snapshots, so without this + // "queued 1m ago" / "done 5m ago" labels would never advance. + setInterval(() => { + const now = Math.floor(Date.now() / 1000); + for (const span of document.querySelectorAll('.rqe-when[data-rqe-enqueued]')) { + const enqueued = parseInt(span.dataset.rqeEnqueued, 10); + if (!enqueued) continue; + span.textContent = '· queued ' + fmtAgo(enqueued); + } + for (const span of document.querySelectorAll('.rqe-when[data-rqe-finished]')) { + const finished = parseInt(span.dataset.rqeFinished, 10); + if (!finished) continue; + const state = span.dataset.rqeState || ''; + span.textContent = '· ' + state + ' ' + fmtAgo(finished); + } + }, 30_000); + // ─── reminders ────────────────────────────────────────────────────────── // Reminders aren't part of /api/state (separate sqlite table, separate // mutation cadence). refreshReminders() is called from refreshState() for