From 47d2f766c9d4de7662356590c6050c911c7e5792 Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 23 May 2026 11:57:16 +0200 Subject: [PATCH] rebuild_queue: dashboard panel + snapshot field + SSE event wireup --- hive-c0re/assets/app.js | 133 +++++++++++++++++++++++++++++++++ hive-c0re/assets/dashboard.css | 55 ++++++++++++++ hive-c0re/assets/index.html | 7 ++ hive-c0re/src/dashboard.rs | 7 ++ 4 files changed, 202 insertions(+) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 7d14054..53012bd 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -493,6 +493,22 @@ renderMetaInputs({ meta_inputs: metaInputsState }); } + // Derived rebuild queue state — cold-loaded from + // `/api/state.rebuild_queue`, then mutated live by the + // `rebuild_queue_changed` snapshot event. Same shape as the meta- + // inputs panel (full snapshot per change, no diff). + let rebuildQueueState = []; + function syncRebuildQueueFromSnapshot(s) { + rebuildQueueState = (s.rebuild_queue || []).slice(); + } + function applyRebuildQueueChanged(ev) { + rebuildQueueState = (ev.queue || []).slice(); + renderRebuildQueueFromState(); + } + function renderRebuildQueueFromState() { + renderRebuildQueue({ rebuild_queue: rebuildQueueState }); + } + // Derived transient state — cold-loaded from /api/state.transients, // then mutated live by `transient_set` / `transient_cleared`. Keyed // by agent name so add/remove are O(1). `since_unix` is wall-clock so @@ -1653,6 +1669,119 @@ return s.length <= n ? s : s.slice(0, n - 1) + '…'; } + // ─── rebuild queue ────────────────────────────────────────────────────── + // Glyph + verb per QueueKind. Mirrors the labels used in + // hive-c0re::rebuild_queue::QueueKind::as_str. + const QUEUE_KIND_GLYPH = { + rebuild: '↻', + meta_update: '◆', + spawn: '✨', + destroy: '🗑', + }; + const QUEUE_STATE_GLYPH = { + queued: '⏸', + running: '▶', + done: '✔', + failed: '✖', + cancelled: '⊘', + }; + + function renderRebuildQueue(s) { + const root = $('rebuild-queue-section'); + if (!root) return; + root.innerHTML = ''; + const queue = s.rebuild_queue || []; + if (!queue.length) { + 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. + const tops = queue.filter((e) => e.parent_id == null); + const childrenOf = new Map(); + for (const e of queue) { + if (e.parent_id != null) { + if (!childrenOf.has(e.parent_id)) childrenOf.set(e.parent_id, []); + 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)); + } + root.append(ul); + } + + function renderQueueEntry(entry, _byId, isChild) { + const li = el('li', { + class: 'rebuild-queue-entry rqe-' + entry.state, + 'data-id': String(entry.id), + }); + if (isChild) li.classList.add('rqe-child'); + // State glyph + kind + agent. + li.append( + el('span', { class: 'rqe-state', title: entry.state }, QUEUE_STATE_GLYPH[entry.state] || '?'), + ' ', + el('span', { class: 'rqe-kind', title: entry.kind }, + (QUEUE_KIND_GLYPH[entry.kind] || '?') + ' ' + entry.kind), + ' ', + el('code', { class: 'rqe-agent' }, entry.agent), + ); + // 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. + if (entry.state === 'queued') { + 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', { + class: 'rqe-when', + '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))); + } + // Reason (truncated; full text on hover). + if (entry.reason) { + const r = entry.reason.split('\n')[0]; + li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60))); + } + // Error block, when failed. + if (entry.error) { + li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200))); + } + return li; + } + + function fmtElapsed(secs) { + if (secs < 60) return secs + 's running'; + if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's running'; + return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm running'; + } + + // Tick once per second to refresh "running Xs" badges in place + // (mirrors the question-TTL ticker pattern from #335). + setInterval(() => { + 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)); + span.textContent = '· ' + fmtElapsed(elapsed); + } + }, 1000); + // ─── reminders ────────────────────────────────────────────────────────── // Reminders aren't part of /api/state (separate sqlite table, separate // mutation cadence). Refresh fires alongside refreshState() so a @@ -1764,6 +1893,7 @@ 'inbox-section', 'approvals-section', 'meta-inputs-section', + 'rebuild-queue-section', 'reminders-section', ]; //
sections that should survive a refresh need a stable @@ -1830,6 +1960,7 @@ syncContainersFromSnapshot(s); syncTombstonesFromSnapshot(s); syncMetaInputsFromSnapshot(s); + syncRebuildQueueFromSnapshot(s); renderContainers(s); renderTombstones(s); // Sync the derived approvals + questions stores from the @@ -1842,6 +1973,7 @@ syncApprovalsFromSnapshot(s); renderApprovals(); renderMetaInputs(s); + renderRebuildQueue(s); refreshReminders(); restoreOpenDetails(openDetails); notifyDeltas(s); @@ -1963,6 +2095,7 @@ tombstones_changed: (ev) => { applyTombstonesChanged(ev); }, meta_inputs_changed: (ev) => { applyMetaInputsChanged(ev); }, meta_update_running: (ev) => { applyMetaUpdateRunning(ev); }, + rebuild_queue_changed: (ev) => { applyRebuildQueueChanged(ev); }, }, // Both history backfill and live frames flow through here, so the // inbox section ends up populated correctly on first paint and diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 248f523..5f0e8c2 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -521,6 +521,61 @@ code { font-size: 0.85em; animation: badge-pulse 1.6s ease-in-out infinite; } +/* ─── rebuild queue panel ──────────────────────────────────────────────── */ +.rebuild-queue { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.2em; +} +.rebuild-queue-entry { + padding: 0.3em 0.6em; + border: 1px solid var(--border); + background: rgba(24, 24, 37, 0.6); + font-size: 0.9em; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.4em; +} +.rebuild-queue-entry.rqe-child { margin-left: 1.6em; border-color: var(--purple-dim); } +.rebuild-queue-entry.rqe-running { + border-color: var(--purple); + background: rgba(203, 166, 247, 0.12); + animation: badge-pulse 1.6s ease-in-out infinite; +} +.rebuild-queue-entry.rqe-failed { border-color: var(--red); color: var(--red); } +.rebuild-queue-entry.rqe-cancelled { opacity: 0.6; } +.rebuild-queue-entry.rqe-done { opacity: 0.7; color: var(--green); } +.rqe-state { font-weight: bold; min-width: 1.2em; text-align: center; } +.rqe-kind { color: var(--cyan); } +.rqe-agent { color: var(--amber); font-weight: bold; } +.rqe-source { + font-size: 0.75em; + padding: 0.05em 0.45em; + border-radius: 0.7em; + border: 1px solid var(--border); + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} +.rqe-source-manual { color: var(--cyan); border-color: var(--cyan); } +.rqe-source-meta_update { color: var(--purple); border-color: var(--purple); } +.rqe-source-auto_update { color: var(--muted); } +.rqe-source-crash_recover { color: var(--amber); border-color: var(--amber); } +.rqe-when { color: var(--muted); font-size: 0.85em; } +.rqe-reason { color: var(--muted); font-size: 0.85em; flex: 1 1 auto; } +.rqe-error { + flex-basis: 100%; + margin: 0.3em 0 0; + padding: 0.3em 0.5em; + background: rgba(243, 139, 168, 0.1); + border-left: 2px solid var(--red); + color: var(--red); + font-size: 0.8em; + white-space: pre-wrap; +} .history-note { margin-left: 1.8em; margin-top: 0.2em; diff --git a/hive-c0re/assets/index.html b/hive-c0re/assets/index.html index 25fdbd5..846c86e 100644 --- a/hive-c0re/assets/index.html +++ b/hive-c0re/assets/index.html @@ -39,6 +39,13 @@

loading…

+

◆ R3BU1LD QU3U3 ◆

+
══════════════════════════════════════════════════════════════
+

pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.

+
+

loading…

+
+

◆ M1ND H4S QU3STI0NS ◆

══════════════════════════════════════════════════════════════
diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 8c5d7eb..529da6c 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -237,6 +237,12 @@ struct StateSnapshot { /// disabled "updating…" state; live transitions arrive via the /// `MetaUpdateRunning` event (issue #259). meta_update_running: bool, + /// Current state of the global rebuild queue — pending + running + /// long-lived ops (rebuild / meta-update / spawn) plus the most + /// recent few terminal entries the queue retains for history. + /// Live transitions arrive via the `RebuildQueueChanged` event. + /// See `rebuild_queue.rs`. + rebuild_queue: Vec, /// Whether the hive-forge container is up. When true the dashboard /// links each container's config + each approval's commit into the /// forge's `agent-configs` repos. @@ -431,6 +437,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J question_history, tombstones, port_conflicts, + rebuild_queue: state.coord.rebuild_queue.snapshot(), forge_present: crate::forge::is_present().await, }) }