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 });
|
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,
|
// Derived transient state — cold-loaded from /api/state.transients,
|
||||||
// then mutated live by `transient_set` / `transient_cleared`. Keyed
|
// then mutated live by `transient_set` / `transient_cleared`. Keyed
|
||||||
// by agent name so add/remove are O(1). `since_unix` is wall-clock so
|
// 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) + '…';
|
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 ──────────────────────────────────────────────────────────
|
||||||
// Reminders aren't part of /api/state (separate sqlite table, separate
|
// Reminders aren't part of /api/state (separate sqlite table, separate
|
||||||
// mutation cadence). Refresh fires alongside refreshState() so a
|
// mutation cadence). Refresh fires alongside refreshState() so a
|
||||||
|
|
@ -1764,6 +1893,7 @@
|
||||||
'inbox-section',
|
'inbox-section',
|
||||||
'approvals-section',
|
'approvals-section',
|
||||||
'meta-inputs-section',
|
'meta-inputs-section',
|
||||||
|
'rebuild-queue-section',
|
||||||
'reminders-section',
|
'reminders-section',
|
||||||
];
|
];
|
||||||
// <details> sections that should survive a refresh need a stable
|
// <details> sections that should survive a refresh need a stable
|
||||||
|
|
@ -1830,6 +1960,7 @@
|
||||||
syncContainersFromSnapshot(s);
|
syncContainersFromSnapshot(s);
|
||||||
syncTombstonesFromSnapshot(s);
|
syncTombstonesFromSnapshot(s);
|
||||||
syncMetaInputsFromSnapshot(s);
|
syncMetaInputsFromSnapshot(s);
|
||||||
|
syncRebuildQueueFromSnapshot(s);
|
||||||
renderContainers(s);
|
renderContainers(s);
|
||||||
renderTombstones(s);
|
renderTombstones(s);
|
||||||
// Sync the derived approvals + questions stores from the
|
// Sync the derived approvals + questions stores from the
|
||||||
|
|
@ -1842,6 +1973,7 @@
|
||||||
syncApprovalsFromSnapshot(s);
|
syncApprovalsFromSnapshot(s);
|
||||||
renderApprovals();
|
renderApprovals();
|
||||||
renderMetaInputs(s);
|
renderMetaInputs(s);
|
||||||
|
renderRebuildQueue(s);
|
||||||
refreshReminders();
|
refreshReminders();
|
||||||
restoreOpenDetails(openDetails);
|
restoreOpenDetails(openDetails);
|
||||||
notifyDeltas(s);
|
notifyDeltas(s);
|
||||||
|
|
@ -1963,6 +2095,7 @@
|
||||||
tombstones_changed: (ev) => { applyTombstonesChanged(ev); },
|
tombstones_changed: (ev) => { applyTombstonesChanged(ev); },
|
||||||
meta_inputs_changed: (ev) => { applyMetaInputsChanged(ev); },
|
meta_inputs_changed: (ev) => { applyMetaInputsChanged(ev); },
|
||||||
meta_update_running: (ev) => { applyMetaUpdateRunning(ev); },
|
meta_update_running: (ev) => { applyMetaUpdateRunning(ev); },
|
||||||
|
rebuild_queue_changed: (ev) => { applyRebuildQueueChanged(ev); },
|
||||||
},
|
},
|
||||||
// Both history backfill and live frames flow through here, so the
|
// Both history backfill and live frames flow through here, so the
|
||||||
// inbox section ends up populated correctly on first paint and
|
// inbox section ends up populated correctly on first paint and
|
||||||
|
|
|
||||||
|
|
@ -521,6 +521,61 @@ code {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
animation: badge-pulse 1.6s ease-in-out infinite;
|
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 {
|
.history-note {
|
||||||
margin-left: 1.8em;
|
margin-left: 1.8em;
|
||||||
margin-top: 0.2em;
|
margin-top: 0.2em;
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,13 @@
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</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. -->
|
<!-- operator decisions: things waiting on you. -->
|
||||||
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
|
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,12 @@ struct StateSnapshot {
|
||||||
/// disabled "updating…" state; live transitions arrive via the
|
/// disabled "updating…" state; live transitions arrive via the
|
||||||
/// `MetaUpdateRunning` event (issue #259).
|
/// `MetaUpdateRunning` event (issue #259).
|
||||||
meta_update_running: bool,
|
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
|
/// Whether the hive-forge container is up. When true the dashboard
|
||||||
/// links each container's config + each approval's commit into the
|
/// links each container's config + each approval's commit into the
|
||||||
/// forge's `agent-configs` repos.
|
/// forge's `agent-configs` repos.
|
||||||
|
|
@ -431,6 +437,7 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
||||||
question_history,
|
question_history,
|
||||||
tombstones,
|
tombstones,
|
||||||
port_conflicts,
|
port_conflicts,
|
||||||
|
rebuild_queue: state.coord.rebuild_queue.snapshot(),
|
||||||
forge_present: crate::forge::is_present().await,
|
forge_present: crate::forge::is_present().await,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue