diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js
index 1d4d21ea..a48f51b8 100644
--- a/frontend/packages/dashboard/src/tabs.js
+++ b/frontend/packages/dashboard/src/tabs.js
@@ -2442,15 +2442,6 @@ 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 = {
@@ -2470,38 +2461,15 @@ 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) {
- // Queue drained — show placeholder and purge cache.
- rebuildQueueRowCache.clear();
- root.replaceChildren(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.'));
+ root.append(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.
@@ -2513,58 +2481,21 @@ 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),
);
-
- // 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);
- }
- 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);
+ ul.append(renderQueueEntry(o, byId, 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();
+ root.append(ul);
}
function renderQueueEntry(entry, _byId, isChild) {
@@ -2585,15 +2516,9 @@ 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. 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.
+ // finished Xs ago for terminal.
if (entry.state === 'queued') {
- li.append(' ', el('span', {
- class: 'rqe-when',
- 'data-rqe-enqueued': String(entry.enqueued_at),
- }, '· queued ' + fmtAgo(entry.enqueued_at)));
+ li.append(' ', el('span', { class: 'rqe-when' }, '· 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', {
@@ -2601,11 +2526,7 @@ window.marked = marked;
'data-rqe-elapsed': String(entry.started_at),
}, '· ' + fmtElapsed(elapsed)));
} else if (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)));
+ li.append(' ', el('span', { class: 'rqe-when' }, '· ' + entry.state + ' ' + fmtAgo(entry.finished_at)));
}
// Reason (truncated; full text on hover).
if (entry.reason) {
@@ -2681,39 +2602,14 @@ 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, now - started);
+ const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - 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