diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index 3fb1067d..e2bed64c 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -201,52 +201,48 @@ 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, 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. +**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. Below the queue, a **live build-log panel** (`#rebuild-live-log`, -`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. +`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. **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 25297147..1e8daa96 100644 --- a/frontend/packages/dashboard/src/builds.js +++ b/frontend/packages/dashboard/src/builds.js @@ -13,24 +13,55 @@ import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } from './common.js'; import { el } from '@hive/shared/dom.js'; import { bindAsyncForms } from '@hive/shared/forms.js'; -import { fmtAgo, fmtDuration, truncate } from './util.js'; +import { fmtAgo, fmtElapsed, 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; -// 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; +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; +} + function syncFromSnapshot(s) { metaInputsState = (s.meta_inputs || []).slice(); metaUpdateRunning = !!s.meta_update_running; + rebuildQueueState = (s.rebuild_queue || []).slice(); } // ─── meta inputs ───────────────────────────────────────────────────────────── @@ -127,27 +158,271 @@ function renderMetaInputs(s) { } // ─── rebuild 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 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); const root = $('rebuild-queue-section'); if (!root) return; - root.replaceChildren(); - jobqGraphEl = el('hive-jobq-graph', { endpoint: '/api/jobq/graph' }); - jobqGraphEl.addEventListener('hive-jobq-graph-update', (e) => { - jobqNodes = e.detail.nodes || []; - renderRebuildLiveLog(); - updateRebuildCount(); + + 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.append(jobqGraphEl); + 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; } // ─── running-rebuild live log ───────────────────────────────────────────────── @@ -168,13 +443,15 @@ function clearLiveLogPoll() { if (liveLogPollTimer) { clearInterval(liveLogPollTimer); liveLogPollTimer = 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; +// 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; } async function fetchAndRenderLiveLog(nodeId, pre) { @@ -191,18 +468,19 @@ async function fetchAndRenderLiveLog(nodeId, pre) { } catch { /* network blip — ignore, next poll will retry */ } } -function renderRebuildLiveLog() { +function renderRebuildLiveLog(queue) { const root = $('rebuild-live-log'); if (!root) return; - const liveNode = findLiveBuild(); + const live = findLiveBuild(queue); - if (!liveNode) { + if (!live) { 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). @@ -245,13 +523,12 @@ function renderRebuildLiveLog() { 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 group's agent set. - el('code', { class: 'rqe-agent' }, liveAgent || ''), - ' ', el('span', { class: 'rqe-kind' }, liveNode.payload.label), + // 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), ' ', badge, ' ', el('a', { class: 'rebuild-live-log-raw', @@ -270,23 +547,45 @@ function renderRebuildLiveLog() { function updateRebuildCount() { const pill = $('builds-tab-count-rebuild'); if (!pill) return; - // 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; + let n = 0; + for (const e of rebuildQueueState) { + const s = rollupState(e.nodes || []); + if (s === 'Pending' || s === 'Running') n++; + } 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 }); - // 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. + updateRebuildCount(); } +// ─── 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 @@ -443,12 +742,10 @@ if (buildRefresh) buildRefresh.addEventListener('click', fetchBuild); // ─── SSE handlers ───────────────────────────────────────────────────────────── let buildRefreshTimer = null; const SSE_HANDLERS = { - 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(); + rebuild_queue_changed(ev) { + rebuildQueueState = (ev.queue || []).slice(); + renderRebuildQueue({ rebuild_queue: rebuildQueueState }); + updateRebuildCount(); // Auto-refresh build log list when the queue changes and BUILD L0GS is active. if (buildTabs && buildTabs.active() === 'buildlogs') { if (buildRefreshTimer) clearTimeout(buildRefreshTimer); @@ -479,9 +776,6 @@ 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 01b05a6f..5134f006 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -22,8 +22,7 @@ "./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", - "./jobq-graph.js": "./src/jobq-graph/hive-jobq-graph.js" + "./side-panel.js": "./src/side-panel/hive-side-panel.js" }, "files": [ "src/" diff --git a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css deleted file mode 100644 index f9a486c0..00000000 --- a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css +++ /dev/null @@ -1,79 +0,0 @@ -/* hive-jobq-graph.css — shadow-scoped styles for . Theme - custom properties (--fg, --red, ...) pierce the shadow boundary by - inheritance and are used directly; only plain class rules live here, - same split every other shadow-DOM component (, ...) uses. */ - -:host { - display: block; - font-family: inherit; - font-size: inherit; - color: var(--fg); -} - -.jg-body { - display: flex; - flex-direction: column; - gap: 0.15em; -} - -.jg-node { - margin: 0; -} - -/* Nested groups indent + get a guide line, rather than a hand-rolled - connector-glyph tree — cheaper to build and reads fine at any depth. */ -.jg-node .jg-node { - margin-left: 1.1em; - padding-left: 0.6em; - border-left: 1px solid var(--muted); - margin-top: 0.15em; -} - -.jg-row { - display: flex; - align-items: baseline; - gap: 0.35em; - flex-wrap: wrap; -} - -.jg-state { - font-weight: bold; - min-width: 1.2em; - text-align: center; -} -.jg-state-pending { color: var(--muted); } -.jg-state-running { color: var(--cyan); } -.jg-state-finishing { color: var(--cyan); opacity: 0.75; } -.jg-state-done { color: var(--green); } -.jg-state-failed { color: var(--red); } -.jg-state-cancelled { color: var(--muted); text-decoration: line-through; } -.jg-state-skipped { color: var(--muted); opacity: 0.5; } - -.jg-label { - color: var(--fg); -} - -.jg-data { - margin: 0.1em 0 0 1.6em; - font-size: 0.85em; - color: var(--muted); - display: grid; - grid-template-columns: auto 1fr; - gap: 0 0.5em; -} -.jg-data dt { font-weight: 600; } -.jg-data dd { margin: 0; word-break: break-word; } - -.jg-error { - color: var(--red); - font-size: 0.85em; - margin: 0.2em 0 0.2em 1.6em; - white-space: pre-wrap; -} - -.jg-empty, -.jg-error-msg { - color: var(--muted); - font-style: italic; - margin: 0; -} diff --git a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js b/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js deleted file mode 100644 index c593587d..00000000 --- a/frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js +++ /dev/null @@ -1,143 +0,0 @@ -// hive-jobq-graph.js — , a shadow-DOM custom element that -// renders any hive_jobq graph generically from the wire shape served by -// GET /api/jobq/graph (or any endpoint serving the same -// `Vec` shape — see hive-jobq-wire's README). -// Renders each root + its subtree as an indented tree: state glyph, -// `payload.label` verbatim, and `payload.data` (if present) as a generic -// key/value list — this element never branches on what a label or a data -// key means, matching the "opaque payload" contract the wire type -// documents. A consumer wanting domain-specific rendering (an agent chip, -// a build-log link, ...) does its own thing on top; this is the generic -// floor every jobq gets for free. -// -// Usage: — -// self-fetches on connect. `.refresh()` (public) re-fetches + re-renders; -// `.render(nodes)` (public) renders host-pushed data directly, no fetch. -// Fetching lives here, not the host page (per the issue this element was -// built for) — every render dispatches a bubbling/composed `hive-jobq-graph-update` -// event (`detail: { nodes }`) so a host needing the raw list for -// something the tree doesn't show (a count badge, a live-log panel) -// listens instead of running its own parallel fetch. -// -// Shadow DOM + own styles, per instruction — unlike light-DOM -// , this renders a whole subtree nothing else needs to -// select into. Theme custom properties (--fg, --red, ...) still pierce -// the shadow boundary by inheritance; only plain class rules are local. - -import { el } from '../dom.js'; -import { attachShadowCss } from '../shadow-css.js'; -import graphCss from './hive-jobq-graph.css'; - -const STATE_GLYPH = { - Pending: '⏸', - Running: '▶', - Finishing: '◐', - Done: '✔', - Failed: '✖', - Cancelled: '⊘', - Skipped: '·', -}; - -// Build a parent/child tree from the flat wire array. `parent` (structural -// grouping) defines tree shape. Sibling order follows array order, which -// is already root-then-subtree per root per `GraphWire::wire_snapshot`'s -// own doc contract — no client-side topo-sort needed for *display* order -// (dependency edges are for a consumer's own logic, e.g. cancel-gating, -// not for render order). -function buildTree(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); - } - return roots; -} - -// `payload.data` is an opaque JSON value from the host's `WireNode::data` -// — render it as a generic key/value list when it's a plain object (the -// only shape a host is expected to send; anything else falls back to a -// single stringified row rather than silently dropping it). -function renderDataList(data) { - if (data == null) return null; - const isPlainObject = typeof data === 'object' && !Array.isArray(data); - const entries = isPlainObject ? Object.entries(data) : [['data', data]]; - if (!entries.length) return null; - const dl = el('dl', { class: 'jg-data' }); - for (const [k, v] of entries) { - dl.append( - el('dt', {}, k), - el('dd', {}, typeof v === 'string' ? v : JSON.stringify(v)), - ); - } - return dl; -} - -function renderNode(n) { - const glyph = STATE_GLYPH[n.state] || '?'; - const row = el('div', { class: 'jg-row' }, - el('span', { - class: 'jg-state jg-state-' + n.state.toLowerCase(), - title: n.state + (n.error ? ' — ' + n.error : ''), - }, glyph), - ' ', - el('span', { class: 'jg-label' }, n.payload.label), - ); - const wrap = el('div', { class: 'jg-node' }, row); - const data = renderDataList(n.payload.data); - if (data) wrap.append(data); - if (n.error) wrap.append(el('pre', { class: 'jg-error' }, n.error)); - for (const child of n._children) wrap.append(renderNode(child)); - return wrap; -} - -class HiveJobqGraph extends HTMLElement { - connectedCallback() { - // Reconnect-without-detach guard — same hazard / - // hit when a row cache moves an already-built - // element without a real detach. - if (this._root) return; - this._root = attachShadowCss(this, graphCss); - this._body = el('div', { class: 'jg-body' }); - this._root.append(this._body); - this.refresh(); - } - - // Re-fetch `endpoint` and re-render. Public so a host page can call it - // on its own refresh cadence (SSE tick, poll, whatever fits the page) — - // this element intentionally owns no transport of its own. - async refresh() { - const endpoint = this.getAttribute('endpoint'); - if (!endpoint || !this._body) return; - let nodes; - try { - const r = await fetch(endpoint); - if (!r.ok) throw new Error('http ' + r.status); - nodes = await r.json(); - } catch (err) { - this._body.replaceChildren(el('p', { class: 'jg-error-msg' }, 'fetch failed: ' + err)); - return; - } - this.render(nodes); - } - - // Render a pre-fetched node array directly, bypassing `endpoint` — for a - // host that already has the data and doesn't want a redundant fetch. - render(nodes) { - if (!this._body) return; - this._body.replaceChildren(); - if (!nodes || !nodes.length) { - this._body.append(el('p', { class: 'jg-empty' }, 'empty')); - } else { - const roots = buildTree(nodes); - for (const root of roots) this._body.append(renderNode(root)); - } - this.dispatchEvent(new CustomEvent('hive-jobq-graph-update', { - detail: { nodes: nodes || [] }, - bubbles: true, - composed: true, - })); - } -} -customElements.define('hive-jobq-graph', HiveJobqGraph);