perf(dashboard): keyed rebuild-queue row cache — skip rebuild for unchanged entries
Apply the same fingerprint-cache pattern as the container row cache to
the rebuild-queue list. Maintain rebuildQueueRowCache (Map<id, {el,
fingerprint}>) so renderRebuildQueue can reuse <li> 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.
This commit is contained in:
parent
b15d7a4b5d
commit
029ae69953
1 changed files with 80 additions and 11 deletions
|
|
@ -2442,6 +2442,15 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── rebuild queue ──────────────────────────────────────────────────────
|
// ─── rebuild queue ──────────────────────────────────────────────────────
|
||||||
|
// Keyed row cache for the rebuild-queue list. Maps entry.id → { el, fingerprint }.
|
||||||
|
// Same pattern as containerRowCache: reuse <li> 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
|
// Glyph + verb per QueueKind. Mirrors the labels used in
|
||||||
// hive-c0re::rebuild_queue::QueueKind::as_str.
|
// hive-c0re::rebuild_queue::QueueKind::as_str.
|
||||||
const QUEUE_KIND_GLYPH = {
|
const QUEUE_KIND_GLYPH = {
|
||||||
|
|
@ -2461,15 +2470,38 @@ window.marked = marked;
|
||||||
cancelled: '⊘',
|
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) {
|
function renderRebuildQueue(s) {
|
||||||
const root = $('rebuild-queue-section');
|
const root = $('rebuild-queue-section');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
root.replaceChildren();
|
|
||||||
const queue = s.rebuild_queue || [];
|
const queue = s.rebuild_queue || [];
|
||||||
|
|
||||||
if (!queue.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Index by id for parent lookup.
|
// Index by id for parent lookup.
|
||||||
const byId = new Map(queue.map((e) => [e.id, e]));
|
const byId = new Map(queue.map((e) => [e.id, e]));
|
||||||
// Top-level entries first; children render nested under their parent.
|
// Top-level entries first; children render nested under their parent.
|
||||||
|
|
@ -2481,21 +2513,58 @@ window.marked = marked;
|
||||||
childrenOf.get(e.parent_id).push(e);
|
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.
|
// Children whose parent isn't in the snapshot (history-evicted) still render flat.
|
||||||
const orphans = queue.filter(
|
const orphans = queue.filter(
|
||||||
(e) => e.parent_id != null && !byId.has(e.parent_id),
|
(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 <li>, 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 <ul>; 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) {
|
function renderQueueEntry(entry, _byId, isChild) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue