rebuild_queue: dashboard panel + snapshot field + SSE event wireup
This commit is contained in:
parent
11db5c2a8f
commit
47d2f766c9
4 changed files with 202 additions and 0 deletions
|
|
@ -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',
|
||||
];
|
||||
// <details> 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,13 @@
|
|||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<h2>◆ R3BU1LD QU3U3 ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">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.</p>
|
||||
<div id="rebuild-queue-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<!-- operator decisions: things waiting on you. -->
|
||||
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
|
|
|
|||
|
|
@ -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<crate::rebuild_queue::QueueEntry>,
|
||||
/// 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<AppState>) -> axum::J
|
|||
question_history,
|
||||
tombstones,
|
||||
port_conflicts,
|
||||
rebuild_queue: state.coord.rebuild_queue.snapshot(),
|
||||
forge_present: crate::forge::is_present().await,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue