diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index e2bed64c..3fb1067d 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -201,48 +201,52 @@ Three sub-tabs: **R3BU1LD QU3U3** (default), **M3T4 1NPUTS**, The dashboard tab keeps the rebuild-queue *state* for the SW4RM card badges without rendering these panels. -**R3BU1LD QU3U3** — pending and recently-completed container -operations: rebuilds, meta-update cascades, and first-spawns. -One operation runs at a time; the worker drains FIFO. Each row -shows a state glyph (`⏸` queued / `▶` running / `✔` done / -`✖` failed / `⊘` cancelled), kind glyph + verb (`↻ rebuild`, -`◆ meta_update`, `✨ spawn`, `🗑 destroy`, `↺ restart`, -`⚡ boot`, `🔑 perm_change`, `⏹ graceful_stop`, `▶ start`, -`■ stop`), agent name, -source chip (`manual | meta_update | auto_update | crash_recover | approval` -— green for operator-approved config changes), timing, and an -optional reason / error. A multi-step op is a single DAG: its -per-agent subgraphs and sub-steps (a meta-update cascade's per-agent -rebuilds, a `Reconcile`'s deferred `Start`/`Stop`) render as nodes -within the one entry — split into subgraphs by the backend `deps` -edges — not as nested child entries. Dedup: re-enqueueing a still-queued -op for the same agent collapses into the existing entry. All timing -labels stay live: running entries tick elapsed seconds every second; -queued and terminal ("done N ago" / "failed N ago") labels tick every -30s so keyed rows never show stale timestamps as they persist across -`rebuild_queue_changed` snapshots. The in-flight phase is the running -**node's kind** in the node chain — there is no separate sub-step -label (the old cyan `↳ ` sub-line went away with the DAG queue: -each phase is its own node now). -Queued entries carry a `✗` cancel button on the right edge; -running / done / failed / cancelled entries don't show it — the -backend refuses cancellation for non-`Queued` rows anyway -(`POST /api/rebuild-queue/{id}/cancel`). Successful -cancel flips the row to `⊘ cancelled` via the next -`rebuild_queue_changed` snapshot. -Cold-loaded from `/api/state.rebuild_queue`; live updates via -`rebuild_queue_changed` snapshot event. +**R3BU1LD QU3U3** — pending, in-flight, and recently-settled container +operations: rebuilds, meta-update cascades, and first-spawns. One +operation runs at a time; the worker drains FIFO. **Is a mounted +``** (the shared generic +graph-viewer component, `@hive/shared/jobq-graph.js`) — `builds.js` +does not render the queue itself; it just mounts the element and +listens for its `hive-jobq-graph-update` event to drive the two things +below it that the generic view doesn't show. The component owns +fetching, cold and live: `GET /api/jobq/graph` on mount, and +`.refresh()` on every `rebuild_queue_changed` SSE tick (that event +still carries its own `Vec` payload on the wire — the +SW4RM tab still consumes it for the badges, untouched — this page just +ignores the payload and treats the tick as a refetch trigger). + +Each row is one root graph node (`parent: null`); a multi-step op's +per-agent subgraphs and sub-steps render as nodes within that one +entry (structural `parent` edges define the tree, `deps` edges order +siblings). A row shows a state glyph (`⏸` pending / `▶` running / +`◐` finishing — own work done, a sub-node still running / `✔` done / +`✖` failed / `⊘` cancelled / `·` skipped) and each step's own +label/agent. **No source chip, kind label, cancel button, timing, or +build-log deep-link on rows** — the generic component has no +per-node action affordances or entry-level metadata (no equivalent of +the old `DagView`'s `source`/`reason`/`created_at`, which were +`NodeKind::Dag`-specific fields the generic wire doesn't carry); per +mara's steer on hyperhive#2812 ("dont feel constrained by what the ui +does currently"), the first cut presents what the endpoint actually +gives rather than reconstructing the old per-row chrome. Settled +entries render their **full step tree**, not just a bare summary — +unlike the old `DagView` projection, this wire does not filter `Done` +nodes out. Below the queue, a **live build-log panel** (`#rebuild-live-log`, -`renderRebuildLiveLog`) streams the currently-running rebuild's output -inline — collapsible, with a live/ok/fail badge and a `↓ raw` download. -It's keyed to the running entry's `build_log_id` and opens one -`EventSource` to `GET /api/build-logs/id/{id}/stream` (the same stream -the BUILD L0GS tab uses; the stream replays accumulated output on -connect). It lives in its own container outside `#rebuild-queue-section` -so the queue's per-row re-render (rows rebuild as nodes advance) -never tears down the open stream; it hides when nothing is building and -each row keeps its `logs →` link out to the full build log history. +`renderRebuildLiveLog`) shows the currently-running rebuild's output +inline — collapsible, with a live/ok/fail badge and a `↓ raw` +download. It's keyed to the first `Running` node (in wire order) +whose `payload.data.build_log_id` is set — read from the +`hive-jobq-graph-update` event's node list, same source as the count +pill, no separate fetch — and **polls** `GET /api/build-log/{id}` +every 2s (`fetchAndRenderLiveLog` / `liveLogPollTimer`); not an +`EventSource` (that's the BUILD L0GS tab's own per-row expand view +below — `GET /api/build-logs/id/{id}/stream`, real SSE, replays +accumulated output on connect — a separate mechanism). The live-log +panel lives in its own container outside `#rebuild-queue-section` so +the mounted ``'s own re-renders never disturb the +open poll; it hides when nothing is building. **M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can selectively `nix flake update`, rendered as an indented tree: diff --git a/frontend/packages/dashboard/src/builds.js b/frontend/packages/dashboard/src/builds.js index 1e8daa96..25297147 100644 --- a/frontend/packages/dashboard/src/builds.js +++ b/frontend/packages/dashboard/src/builds.js @@ -13,55 +13,24 @@ import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } from './common.js'; import { el } from '@hive/shared/dom.js'; import { bindAsyncForms } from '@hive/shared/forms.js'; -import { fmtAgo, fmtElapsed, fmtDuration, truncate } from './util.js'; +import { fmtAgo, fmtDuration, truncate } from './util.js'; import '@hive/shared/hive-tab-strip.js'; +import '@hive/shared/jobq-graph.js'; // ─── derived state ─────────────────────────────────────────────────────────── let metaInputsState = []; let metaUpdateRunning = false; -let rebuildQueueState = []; - -// ─── DAG-derived helpers ────────────────────────────────────────────────────── -// DagView no longer carries top-level `kind`/`state`/`started_at`/`finished_at` -// — these are all derived from the NodeView array by the client. - -// Node/DAG states arrive in the wire spelling of `hive_jobq::State` — the -// scheduler's own enum, serialised verbatim, so the names are PascalCase and -// there is no separate display-shaped wire type. Compare against those names; -// lowercase only where a CSS class or human-facing label needs it. -const stateSlug = (s) => String(s || '').toLowerCase(); - -// Rollup state from nodes (Failed > Cancelled > Running > Pending). -// `Done` nodes are excluded from the payload, so a fully-done DAG is absent; -// an empty nodes array should not arise in practice — return 'Done' defensively. -function rollupState(nodes) { - const ns = nodes || []; - if (!ns.length) return 'Done'; - if (ns.some((n) => n.state === 'Failed')) return 'Failed'; - if (ns.some((n) => n.state === 'Cancelled')) return 'Cancelled'; - // `Finishing` is a node whose own work is done while its sub-nodes still - // run — in flight, so it counts as running. - if (ns.some((n) => n.state === 'Running' || n.state === 'Finishing')) return 'Running'; - // A skipped node is a branch the run ruled out, which is expected on a - // healthy DAG — it must not make the roll-up read as still-pending. This - // mirrors `DagView::rollup_state` in hive-host-sock; edit the two together. - if (ns.every((n) => n.state === 'Skipped' || n.state === 'Done')) return 'Done'; - return 'Pending'; -} - - -// Parse an RFC3339 datetime string (from DateTime) to unix seconds. -function isoToSecs(s) { - if (!s) return null; - const ms = Date.parse(s); - return Number.isFinite(ms) ? Math.floor(ms / 1000) : null; -} - +// Kept in sync from 's `hive-jobq-graph-update` event +// (see mountJobqGraph below) — the component owns fetching GET +// /api/jobq/graph and renders the tree itself; this flat array exists only +// for the two things it doesn't render: the count-pill and the live-log +// panel. +let jobqNodes = []; +let jobqGraphEl = null; function syncFromSnapshot(s) { metaInputsState = (s.meta_inputs || []).slice(); metaUpdateRunning = !!s.meta_update_running; - rebuildQueueState = (s.rebuild_queue || []).slice(); } // ─── meta inputs ───────────────────────────────────────────────────────────── @@ -158,271 +127,27 @@ function renderMetaInputs(s) { } // ─── rebuild queue ──────────────────────────────────────────────────────────── -const rebuildQueueRowCache = new Map(); -const QUEUE_STATE_GLYPH = { - queued: '⏸', - running: '▶', - done: '✔', - failed: '✖', - cancelled: '⊘', - // Not-taken branch of an outcome split (e.g. the failure tail on a - // successful deploy) — expected, not an error, so a quiet glyph rather - // than an attention-grabbing one. Only ever visible while the owning - // DAG is still live/failed — a fully-settled green DAG drops off the - // wire entirely (see hive-host-sock::jobs::dag_view). Same glyph - // hivectl uses for the same state (no contract between them, just - // consistent taste). - skipped: '·', -}; - -function firstFailedNode(entry) { - return (entry.nodes || []).find((n) => n.state === 'Failed') || null; -} - -// Topo-sort a flat node list using `deps` edges. Nodes whose deps are all -// absent (Done, filtered) or within the set come first. Falls back to -// original array order on ties or cycles. -function topoSort(nodes) { - const ids = new Set(nodes.map((n) => n.id)); - const indeg = new Map(nodes.map((n) => [n.id, 0])); - for (const n of nodes) { - for (const d of n.deps || []) { - if (ids.has(d)) indeg.set(n.id, indeg.get(n.id) + 1); - } - } - const remaining = new Map(nodes.map((n) => [n.id, n])); - const ordered = []; - const ready = nodes.filter((n) => indeg.get(n.id) === 0); - while (ready.length) { - const n = ready.shift(); - if (!remaining.has(n.id)) continue; - remaining.delete(n.id); - ordered.push(n); - for (const other of nodes) { - if ((other.deps || []).includes(n.id) && remaining.has(other.id)) { - indeg.set(other.id, indeg.get(other.id) - 1); - if (indeg.get(other.id) === 0) ready.push(other); - } - } - } - for (const n of nodes) { - if (remaining.has(n.id)) ordered.push(n); - } - return ordered; -} - -// Build a tree from a flat node list using the `parent` field provided by -// the backend. Nodes without a `parent` (or whose parent id is absent from -// the node set) are roots. Children within each parent group are -// topo-sorted by `deps` so siblings render in dependency order. -// Returns an array of root nodes, each augmented with a `_children` array. -function buildNodeTree(nodes) { - const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }])); - const roots = []; - for (const n of byId.values()) { - const p = n.parent != null ? byId.get(n.parent) : null; - if (p) { - p._children.push(n); - } else { - roots.push(n); - } - } - // Topo-sort roots and each children list by deps. - function sortGroup(group) { - const sorted = topoSort(group); - for (const n of sorted) sortGroup(n._children); - return sorted; - } - return sortGroup(roots); -} - -function rebuildQueueEntryFingerprint(entry) { - const nodes = entry.nodes || []; - return JSON.stringify({ - state: rollupState(nodes), - source: entry.source, - started_at: isoToSecs(entry.started_at), - created_at: entry.created_at, - finished_at: isoToSecs(entry.finished_at), - reason: entry.reason, - nodes: nodes.map((n) => [n.kind, n.state, isoToSecs(n.started_at), isoToSecs(n.finished_at), n.error]), - }); -} - -function renderRebuildQueue(s) { - const queue = s.rebuild_queue || []; - renderRebuildLiveLog(queue); +// R3BU1LD QU3U3 is directly (mara: "replace the build +// queue tab with this component") — no hand-rolled +// tree/roll-up/cancel-button rendering here anymore. The component owns +// fetching GET /api/jobq/graph and its own refresh(); this page just +// listens for its `hive-jobq-graph-update` event to keep `jobqNodes` (the +// flat array) in sync for the two things the generic view doesn't render: +// the count-pill and the live-log panel below. No cancel button or +// build-log deep-link on rows either — the generic component has no +// per-node action affordances; per "dont feel constrained by what the ui +// does currently," not reinventing those here for the first cut. +function mountJobqGraph() { const root = $('rebuild-queue-section'); if (!root) return; - - if (!queue.length) { - rebuildQueueRowCache.clear(); - root.replaceChildren(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.')); - return; - } - - // Every multi-step op is a single DAG now (its whole graph lives in - // `nodes`, split into subgraphs by the backend `deps` edges) — there are - // no cross-DAG parent/child links to group. Render each queue entry in - // enqueue order. - const orderedLis = []; - for (const entry of queue) { - const fp = rebuildQueueEntryFingerprint(entry); - const cached = rebuildQueueRowCache.get(entry.id); - let li; - if (cached && cached.fingerprint === fp) { - li = cached.el; - } else { - li = renderQueueEntry(entry); - rebuildQueueRowCache.set(entry.id, { el: li, fingerprint: fp }); - } - orderedLis.push(li); - } - - 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); - } - } - - let ul = root.querySelector('ul.rebuild-queue'); - if (!ul) { - ul = el('ul', { class: 'rebuild-queue' }); - root.replaceChildren(ul); - } - 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) { - const nodes = entry.nodes || []; - const state = rollupState(nodes); - const startedAt = isoToSecs(entry.started_at); - const finishedAt = isoToSecs(entry.finished_at); - const createdAt = isoToSecs(entry.created_at); - - const slug = stateSlug(state); - const li = el('li', { - class: 'rebuild-queue-entry rqe-' + slug, - 'data-id': String(entry.id), + root.replaceChildren(); + jobqGraphEl = el('hive-jobq-graph', { endpoint: '/api/jobq/graph' }); + jobqGraphEl.addEventListener('hive-jobq-graph-update', (e) => { + jobqNodes = e.detail.nodes || []; + renderRebuildLiveLog(); + updateRebuildCount(); }); - li.append( - el('span', { class: 'rqe-state', title: slug }, QUEUE_STATE_GLYPH[slug] || '?'), - ' ', - el('span', { class: 'rqe-kind' }, entry.source), - ); - li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source)); - if (state === 'Pending') { - li.append(' ', el('span', { - class: 'rqe-when', - 'data-rqe-enqueued': String(createdAt ?? ''), - }, '· queued ' + (createdAt ? fmtAgo(createdAt) : ''))); - } else if (state === 'Running' && startedAt) { - const elapsed = Math.max(0, Math.floor(Date.now() / 1000) - startedAt); - li.append(' ', el('span', { - class: 'rqe-when', - 'data-rqe-elapsed': String(startedAt), - }, '· ' + fmtElapsed(elapsed))); - } else if (finishedAt) { - li.append(' ', el('span', { - class: 'rqe-when', - 'data-rqe-finished': String(finishedAt), - 'data-rqe-state': slug, - }, '· ' + slug + ' ' + fmtAgo(finishedAt))); - } - if (entry.reason) { - const r = entry.reason.split('\n')[0]; - li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60))); - } - // Per-node tree: render the jobq recursive parent/child tree. - // `parent` edges (structural grouping) define the tree shape; - // `deps` edges order siblings within each parent group. - // `Done` nodes are excluded from the payload by the backend, so only - // live nodes appear here; `Failed` DAGs linger until the history cap. - if (nodes.length) { - const treeRoot = el('div', { class: 'rqe-nodes-tree' }); - const treeNodes = buildNodeTree(nodes); - function renderTreeNode(n, depth, isLast, ancestorLines) { - // ancestorLines: boolean[] where true = draw a vertical guide line at - // that ancestor depth level (the ancestor was not the last sibling, so - // its remaining siblings need a guide column below it). - const row = el('div', { class: 'rqe-tree-row' }); - if (depth > 0) { - // One guide column per ancestor level — draws a vertical line through - // columns where the ancestor still has siblings below it. - for (const hasLine of ancestorLines) { - row.append(el('span', { - class: 'rqe-tree-guide' + (hasLine ? ' rqe-tree-guide-line' : ''), - })); - } - // Connector: L-shaped for last child, T-shaped for mid child. - row.append(el('span', { - class: 'rqe-tree-connector' - + (isLast ? ' rqe-tree-connector-last' : ' rqe-tree-connector-mid'), - })); - } - const chip = el('span', { - class: 'rqe-node rqe-node-' + stateSlug(n.state), - title: (n.agent ? n.agent + ' · ' : '') + n.kind + ' · ' + n.state - + (n.error ? ' — ' + n.error : ''), - }, (QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + n.kind); - if (n.agent) { - chip.append(el('span', { class: 'rqe-node-agent' }, ' · ' + n.agent)); - } - row.append(chip); - if (n.build_log_id != null) { - // Deep-links into the BUILD L0GS tab's rich view (auto-expands + - // scrolls to this row there, see fetchBuild's `?id=N` handling) - // rather than the raw-text download endpoint — a plain download - // is surprising here since nothing about a printer-glyph icon - // says "this leaves the app". The raw download is still one - // click away once on that row. - row.append(el('a', { - class: 'rqe-log-link rqe-node-log', - href: '/builds.html?id=' + n.build_log_id + '#buildlogs', - title: 'view build log for ' + n.kind + ' node', - }, '⎙')); - } - treeRoot.append(row); - // Propagate ancestor lines to children: inherit this node's columns, - // plus whether this node itself continues below (not the last sibling). - const childAncestorLines = depth === 0 ? [] : [...ancestorLines, !isLast]; - n._children.forEach((child, i) => { - renderTreeNode(child, depth + 1, i === n._children.length - 1, childAncestorLines); - }); - } - treeNodes.forEach((n, i) => renderTreeNode(n, 0, i === treeNodes.length - 1, [])); - li.append(treeRoot); - } - const failed = firstFailedNode(entry); - if (failed && failed.error) { - li.append(el('pre', { class: 'rqe-error', title: failed.error }, truncate(failed.error, 200))); - } - if (state === 'Pending') { - const cancelForm = el('form', { - method: 'POST', - action: '/api/rebuild-queue/' + entry.id + '/cancel', - class: 'inline rqe-cancel', - 'data-async': '', - 'data-confirm': - `cancel ${entry.source} (queue id ${entry.id})? ` + - `the row drops from the queue and never runs. running / done / failed entries can't be cancelled this way.`, - }); - cancelForm.append(el('button', { - type: 'submit', - class: 'rqe-cancel-btn', - title: 'cancel this queued ' + entry.source, - 'aria-label': 'cancel queued ' + entry.source, - }, '✗')); - li.append(cancelForm); - } - return li; + root.append(jobqGraphEl); } // ─── running-rebuild live log ───────────────────────────────────────────────── @@ -443,15 +168,13 @@ function clearLiveLogPoll() { if (liveLogPollTimer) { clearInterval(liveLogPollTimer); liveLogPollTimer = null; } } -// First (entry, node) pair with a running node that has a log. -// Gate on build_log_id so lock/noop/store-only nodes don't open a blank panel. -function findLiveBuild(queue) { - for (const e of queue || []) { - if (rollupState(e.nodes || []) !== 'Running') continue; - const node = (e.nodes || []).find((n) => n.state === 'Running' && n.build_log_id != null); - if (node) return { entry: e, node }; - } - return null; +// First running node with a log, in wire order (root-then-subtree per +// root, roots in enqueue order — see hive-jobq-wire's wire_snapshot doc). +// Gate on build_log_id so lock/noop/store-only nodes don't open a blank +// panel. +function findLiveBuild() { + return jobqNodes.find((n) => n.state === 'Running' && n.payload.data && n.payload.data.build_log_id != null) + || null; } async function fetchAndRenderLiveLog(nodeId, pre) { @@ -468,19 +191,18 @@ async function fetchAndRenderLiveLog(nodeId, pre) { } catch { /* network blip — ignore, next poll will retry */ } } -function renderRebuildLiveLog(queue) { +function renderRebuildLiveLog() { const root = $('rebuild-live-log'); if (!root) return; - const live = findLiveBuild(queue); + const liveNode = findLiveBuild(); - if (!live) { + if (!liveNode) { clearLiveLogPoll(); liveLogId = null; liveLogDone = false; if (!root.hidden) { root.hidden = true; root.replaceChildren(); } return; } - const { entry: running, node: liveNode } = live; // Same node, already polling — just let the timer tick (or do a final // fetch if the node just went non-running and we haven't marked done yet). @@ -523,12 +245,13 @@ function renderRebuildLiveLog(queue) { toggle.setAttribute('aria-expanded', String(!liveLogCollapsed)); toggle.title = liveLogCollapsed ? 'expand live log' : 'collapse live log'; }); + const liveAgent = liveNode.payload.data && liveNode.payload.data.agent; const header = el('div', { class: 'rebuild-live-log-header' }, toggle, ' ', el('span', { class: 'rebuild-live-log-title' }, 'live build log — '), - // Label the specific node's agent, not the whole DAG's agent set. - el('code', { class: 'rqe-agent' }, liveNode.agent), - ' ', el('span', { class: 'rqe-kind' }, running.source + ' · ' + liveNode.kind), + // Label the specific node's agent, not the whole group's agent set. + el('code', { class: 'rqe-agent' }, liveAgent || ''), + ' ', el('span', { class: 'rqe-kind' }, liveNode.payload.label), ' ', badge, ' ', el('a', { class: 'rebuild-live-log-raw', @@ -547,45 +270,23 @@ function renderRebuildLiveLog(queue) { function updateRebuildCount() { const pill = $('builds-tab-count-rebuild'); if (!pill) return; - let n = 0; - for (const e of rebuildQueueState) { - const s = rollupState(e.nodes || []); - if (s === 'Pending' || s === 'Running') n++; - } + // Pending/Running/Finishing = in flight (Finishing = own work done, a + // sub-node still running — still counts). Root nodes only: each is one + // queue entry. + const n = jobqNodes.filter((n) => n.parent == null + && (n.state === 'Pending' || n.state === 'Running' || n.state === 'Finishing')).length; if (n > 0) { pill.textContent = String(n); pill.hidden = false; } else { pill.hidden = true; } } // ─── render-all (cold load + any full re-render) ────────────────────────────── function renderAll() { - renderRebuildQueue({ rebuild_queue: rebuildQueueState }); renderMetaInputs({ meta_inputs: metaInputsState }); - updateRebuildCount(); + // Rebuild-queue rendering + the count-pill/live-log it drives all happen + // off 's own hive-jobq-graph-update event (see + // mountJobqGraph) — nothing to render here directly. } -// ─── elapsed-time tickers ───────────────────────────────────────────────────── -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; - span.textContent = '· ' + fmtElapsed(Math.max(0, now - started)); - } -}, 1000); -setInterval(() => { - 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); - // ─── BUILD L0GS tab ─────────────────────────────────────────────────────────── // All-agents build log history with expand-to-detail. Live builds stream in // real time. Lazy-loaded on first tab activation; auto-refreshes (debounced @@ -742,10 +443,12 @@ if (buildRefresh) buildRefresh.addEventListener('click', fetchBuild); // ─── SSE handlers ───────────────────────────────────────────────────────────── let buildRefreshTimer = null; const SSE_HANDLERS = { - rebuild_queue_changed(ev) { - rebuildQueueState = (ev.queue || []).slice(); - renderRebuildQueue({ rebuild_queue: rebuildQueueState }); - updateRebuildCount(); + rebuild_queue_changed() { + // No `ev.queue` payload read anymore — this event still carries its own + // queue snapshot on the wire (tabs.js/SW4RM still consumes it for the + // swarm badges, untouched by this), this page just treats it as a + // refetch trigger. owns the actual fetch. + if (jobqGraphEl) jobqGraphEl.refresh(); // Auto-refresh build log list when the queue changes and BUILD L0GS is active. if (buildTabs && buildTabs.active() === 'buildlogs') { if (buildRefreshTimer) clearTimeout(buildRefreshTimer); @@ -776,6 +479,9 @@ async function refreshState() { async function init() { initServerWarnings(); bindAsyncForms(() => refreshState()); + // Self-fetches on mount ('s own connectedCallback) — + // no explicit initial fetch needed here, unlike meta inputs below. + mountJobqGraph(); buildTabs = document.getElementById('builds-tabbar').configure({ tabs: [ diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index 5134f006..01b05a6f 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -22,7 +22,8 @@ "./modal.js": "./src/modal.js", "./shadow-css.js": "./src/shadow-css.js", "./hive-menu.js": "./src/hive-menu/hive-menu.js", - "./side-panel.js": "./src/side-panel/hive-side-panel.js" + "./side-panel.js": "./src/side-panel/hive-side-panel.js", + "./jobq-graph.js": "./src/jobq-graph/hive-jobq-graph.js" }, "files": [ "src/"