builds: mount <hive-jobq-graph> directly, drop the hand-rolled queue renderer

Per mara's explicit steer on hyperhive#2812 ("also replace the build
queue tab with this component" + "graph fetching should live in the
component, not build.js" + "dont replicate the grouping by dag"):
R3BU1LD QU3U3 is now a mounted <hive-jobq-graph endpoint="/api/jobq/graph">
element. builds.js no longer renders the queue itself, does its own
fetch, or hand-rolls a per-root tree/roll-up/cancel-button — all of
buildNodeTree/topoSort/entryFingerprint/renderQueueEntry/
firstFailedNode/rebuildQueueRowCache/QUEUE_STATE_GLYPH/rollupState is
gone.

builds.js's remaining job is listening for the component's
hive-jobq-graph-update event (added to the component in the prior
commit) to keep a flat jobqNodes array in sync, and using that for the
two things the generic view doesn't render: the count-pill and the
live-log panel. On the rebuild_queue_changed SSE tick, calls the
mounted element's .refresh() instead of doing its own fetch — that
event still carries its own queue payload on the wire (tabs.js/SW4RM
still reads it for the badges, untouched), this page just ignores it
now.

Also removed, now genuinely dead: the two elapsed/finished-time
tickers (nothing produces the .rqe-when spans they targeted anymore),
stateSlug and isoToSecs (no callers left), fmtElapsed import (no
callers left).

New @hive/shared/jobq-graph.js export entry in packages/shared's
package.json, alongside the existing hive-tab-strip.js/hive-menu.js/
etc. pattern.

docs/web-ui/dashboard.md's R3BU1LD QU3U3 section rewritten to match:
mounted-component shape, no source/reason/cancel-button/deep-link on
rows (generic component has none), settled entries show their full
step tree (Done nodes aren't filtered off this wire, unlike the old
DagView projection).

Verified against real production data again (this hive's own live
/api/jobq/graph, now settled — no in-flight build at test time) plus
a synthetic running-build case to exercise findLiveBuild's happy path:
correct live-node detection (build_log_id gate), correct in-flight
root count. Confirmed the built dist bundle actually registers
customElements.define("hive-jobq-graph", ...) — the new shared
package export resolves correctly through esbuild.

Branch reused per mara's explicit "dont rework #3000 - continue
working on _this_ pr [#2996], it already has the component that
replaces 90% of build.js" — this ships as part of PR #2996, not a
separate PR.
This commit is contained in:
iris 2026-08-03 01:45:33 +02:00 committed by mara
commit aa149a7a62
3 changed files with 104 additions and 393 deletions

View file

@ -201,48 +201,52 @@ Three sub-tabs: **R3BU1LD QU3U3** (default), **M3T4 1NPUTS**,
The dashboard tab keeps the rebuild-queue *state* for the SW4RM The dashboard tab keeps the rebuild-queue *state* for the SW4RM
card badges without rendering these panels. card badges without rendering these panels.
**R3BU1LD QU3U3** — pending and recently-completed container **R3BU1LD QU3U3** — pending, in-flight, and recently-settled container
operations: rebuilds, meta-update cascades, and first-spawns. operations: rebuilds, meta-update cascades, and first-spawns. One
One operation runs at a time; the worker drains FIFO. Each row operation runs at a time; the worker drains FIFO. **Is a mounted
shows a state glyph (`⏸` queued / `▶` running / `✔` done / `<hive-jobq-graph endpoint="/api/jobq/graph">`** (the shared generic
`✖` failed / `⊘` cancelled), kind glyph + verb (`↻ rebuild`, graph-viewer component, `@hive/shared/jobq-graph.js`) — `builds.js`
`◆ meta_update`, `✨ spawn`, `🗑 destroy`, `↺ restart`, does not render the queue itself; it just mounts the element and
`⚡ boot`, `🔑 perm_change`, `⏹ graceful_stop`, `▶ start`, listens for its `hive-jobq-graph-update` event to drive the two things
`■ stop`), agent name, below it that the generic view doesn't show. The component owns
source chip (`manual | meta_update | auto_update | crash_recover | approval` fetching, cold and live: `GET /api/jobq/graph` on mount, and
— green for operator-approved config changes), timing, and an `.refresh()` on every `rebuild_queue_changed` SSE tick (that event
optional reason / error. A multi-step op is a single DAG: its still carries its own `Vec<QueueEntry>` payload on the wire — the
per-agent subgraphs and sub-steps (a meta-update cascade's per-agent SW4RM tab still consumes it for the badges, untouched — this page just
rebuilds, a `Reconcile`'s deferred `Start`/`Stop`) render as nodes ignores the payload and treats the tick as a refetch trigger).
within the one entry — split into subgraphs by the backend `deps`
edges — not as nested child entries. Dedup: re-enqueueing a still-queued Each row is one root graph node (`parent: null`); a multi-step op's
op for the same agent collapses into the existing entry. All timing per-agent subgraphs and sub-steps render as nodes within that one
labels stay live: running entries tick elapsed seconds every second; entry (structural `parent` edges define the tree, `deps` edges order
queued and terminal ("done N ago" / "failed N ago") labels tick every siblings). A row shows a state glyph (`⏸` pending / `▶` running /
30s so keyed rows never show stale timestamps as they persist across `◐` finishing — own work done, a sub-node still running / `✔` done /
`rebuild_queue_changed` snapshots. The in-flight phase is the running `✖` failed / `⊘` cancelled / `·` skipped) and each step's own
**node's kind** in the node chain — there is no separate sub-step label/agent. **No source chip, kind label, cancel button, timing, or
label (the old cyan `↳ <step>` sub-line went away with the DAG queue: build-log deep-link on rows** — the generic component has no
each phase is its own node now). per-node action affordances or entry-level metadata (no equivalent of
Queued entries carry a `✗` cancel button on the right edge; the old `DagView`'s `source`/`reason`/`created_at`, which were
running / done / failed / cancelled entries don't show it — the `NodeKind::Dag`-specific fields the generic wire doesn't carry); per
backend refuses cancellation for non-`Queued` rows anyway mara's steer on hyperhive#2812 ("dont feel constrained by what the ui
(`POST /api/rebuild-queue/{id}/cancel`). Successful does currently"), the first cut presents what the endpoint actually
cancel flips the row to `⊘ cancelled` via the next gives rather than reconstructing the old per-row chrome. Settled
`rebuild_queue_changed` snapshot. entries render their **full step tree**, not just a bare summary —
Cold-loaded from `/api/state.rebuild_queue`; live updates via unlike the old `DagView` projection, this wire does not filter `Done`
`rebuild_queue_changed` snapshot event. nodes out.
Below the queue, a **live build-log panel** (`#rebuild-live-log`, Below the queue, a **live build-log panel** (`#rebuild-live-log`,
`renderRebuildLiveLog`) streams the currently-running rebuild's output `renderRebuildLiveLog`) shows the currently-running rebuild's output
inline — collapsible, with a live/ok/fail badge and a `↓ raw` download. inline — collapsible, with a live/ok/fail badge and a `↓ raw`
It's keyed to the running entry's `build_log_id` and opens one download. It's keyed to the first `Running` node (in wire order)
`EventSource` to `GET /api/build-logs/id/{id}/stream` (the same stream whose `payload.data.build_log_id` is set — read from the
the BUILD L0GS tab uses; the stream replays accumulated output on `hive-jobq-graph-update` event's node list, same source as the count
connect). It lives in its own container outside `#rebuild-queue-section` pill, no separate fetch — and **polls** `GET /api/build-log/{id}`
so the queue's per-row re-render (rows rebuild as nodes advance) every 2s (`fetchAndRenderLiveLog` / `liveLogPollTimer`); not an
never tears down the open stream; it hides when nothing is building and `EventSource` (that's the BUILD L0GS tab's own per-row expand view
each row keeps its `logs →` link out to the full build log history. 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 `<hive-jobq-graph>`'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 **M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can
selectively `nix flake update`, rendered as an indented tree: selectively `nix flake update`, rendered as an indented tree:

View file

@ -13,55 +13,24 @@
import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } from './common.js'; import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js'; import { el } from '@hive/shared/dom.js';
import { bindAsyncForms } from '@hive/shared/forms.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/hive-tab-strip.js';
import '@hive/shared/jobq-graph.js';
// ─── derived state ─────────────────────────────────────────────────────────── // ─── derived state ───────────────────────────────────────────────────────────
let metaInputsState = []; let metaInputsState = [];
let metaUpdateRunning = false; let metaUpdateRunning = false;
let rebuildQueueState = []; // Kept in sync from <hive-jobq-graph>'s `hive-jobq-graph-update` event
// (see mountJobqGraph below) — the component owns fetching GET
// ─── DAG-derived helpers ────────────────────────────────────────────────────── // /api/jobq/graph and renders the tree itself; this flat array exists only
// DagView no longer carries top-level `kind`/`state`/`started_at`/`finished_at` // for the two things it doesn't render: the count-pill and the live-log
// — these are all derived from the NodeView array by the client. // panel.
let jobqNodes = [];
// Node/DAG states arrive in the wire spelling of `hive_jobq::State` — the let jobqGraphEl = null;
// 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<Utc>) 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) { function syncFromSnapshot(s) {
metaInputsState = (s.meta_inputs || []).slice(); metaInputsState = (s.meta_inputs || []).slice();
metaUpdateRunning = !!s.meta_update_running; metaUpdateRunning = !!s.meta_update_running;
rebuildQueueState = (s.rebuild_queue || []).slice();
} }
// ─── meta inputs ───────────────────────────────────────────────────────────── // ─── meta inputs ─────────────────────────────────────────────────────────────
@ -158,271 +127,27 @@ function renderMetaInputs(s) {
} }
// ─── rebuild queue ──────────────────────────────────────────────────────────── // ─── rebuild queue ────────────────────────────────────────────────────────────
const rebuildQueueRowCache = new Map(); // R3BU1LD QU3U3 is <hive-jobq-graph> directly (mara: "replace the build
const QUEUE_STATE_GLYPH = { // queue tab with this component") — no hand-rolled
queued: '⏸', // tree/roll-up/cancel-button rendering here anymore. The component owns
running: '▶', // fetching GET /api/jobq/graph and its own refresh(); this page just
done: '✔', // listens for its `hive-jobq-graph-update` event to keep `jobqNodes` (the
failed: '✖', // flat array) in sync for the two things the generic view doesn't render:
cancelled: '⊘', // the count-pill and the live-log panel below. No cancel button or
// Not-taken branch of an outcome split (e.g. the failure tail on a // build-log deep-link on rows either — the generic component has no
// successful deploy) — expected, not an error, so a quiet glyph rather // per-node action affordances; per "dont feel constrained by what the ui
// than an attention-grabbing one. Only ever visible while the owning // does currently," not reinventing those here for the first cut.
// DAG is still live/failed — a fully-settled green DAG drops off the function mountJobqGraph() {
// 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'); const root = $('rebuild-queue-section');
if (!root) return; if (!root) return;
root.replaceChildren();
if (!queue.length) { jobqGraphEl = el('hive-jobq-graph', { endpoint: '/api/jobq/graph' });
rebuildQueueRowCache.clear(); jobqGraphEl.addEventListener('hive-jobq-graph-update', (e) => {
root.replaceChildren(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.')); jobqNodes = e.detail.nodes || [];
return; renderRebuildLiveLog();
} updateRebuildCount();
// 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),
}); });
li.append( root.append(jobqGraphEl);
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 ───────────────────────────────────────────────── // ─── running-rebuild live log ─────────────────────────────────────────────────
@ -443,15 +168,13 @@ function clearLiveLogPoll() {
if (liveLogPollTimer) { clearInterval(liveLogPollTimer); liveLogPollTimer = null; } if (liveLogPollTimer) { clearInterval(liveLogPollTimer); liveLogPollTimer = null; }
} }
// First (entry, node) pair with a running node that has a log. // First running node with a log, in wire order (root-then-subtree per
// Gate on build_log_id so lock/noop/store-only nodes don't open a blank panel. // root, roots in enqueue order — see hive-jobq-wire's wire_snapshot doc).
function findLiveBuild(queue) { // Gate on build_log_id so lock/noop/store-only nodes don't open a blank
for (const e of queue || []) { // panel.
if (rollupState(e.nodes || []) !== 'Running') continue; function findLiveBuild() {
const node = (e.nodes || []).find((n) => n.state === 'Running' && n.build_log_id != null); return jobqNodes.find((n) => n.state === 'Running' && n.payload.data && n.payload.data.build_log_id != null)
if (node) return { entry: e, node }; || null;
}
return null;
} }
async function fetchAndRenderLiveLog(nodeId, pre) { async function fetchAndRenderLiveLog(nodeId, pre) {
@ -468,19 +191,18 @@ async function fetchAndRenderLiveLog(nodeId, pre) {
} catch { /* network blip — ignore, next poll will retry */ } } catch { /* network blip — ignore, next poll will retry */ }
} }
function renderRebuildLiveLog(queue) { function renderRebuildLiveLog() {
const root = $('rebuild-live-log'); const root = $('rebuild-live-log');
if (!root) return; if (!root) return;
const live = findLiveBuild(queue); const liveNode = findLiveBuild();
if (!live) { if (!liveNode) {
clearLiveLogPoll(); clearLiveLogPoll();
liveLogId = null; liveLogId = null;
liveLogDone = false; liveLogDone = false;
if (!root.hidden) { root.hidden = true; root.replaceChildren(); } if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
return; return;
} }
const { entry: running, node: liveNode } = live;
// Same node, already polling — just let the timer tick (or do a final // 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). // 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.setAttribute('aria-expanded', String(!liveLogCollapsed));
toggle.title = liveLogCollapsed ? 'expand live log' : 'collapse live log'; 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' }, const header = el('div', { class: 'rebuild-live-log-header' },
toggle, ' ', toggle, ' ',
el('span', { class: 'rebuild-live-log-title' }, 'live build log — '), el('span', { class: 'rebuild-live-log-title' }, 'live build log — '),
// Label the specific node's agent, not the whole DAG's agent set. // Label the specific node's agent, not the whole group's agent set.
el('code', { class: 'rqe-agent' }, liveNode.agent), el('code', { class: 'rqe-agent' }, liveAgent || ''),
' ', el('span', { class: 'rqe-kind' }, running.source + ' · ' + liveNode.kind), ' ', el('span', { class: 'rqe-kind' }, liveNode.payload.label),
' ', badge, ' ', ' ', badge, ' ',
el('a', { el('a', {
class: 'rebuild-live-log-raw', class: 'rebuild-live-log-raw',
@ -547,45 +270,23 @@ function renderRebuildLiveLog(queue) {
function updateRebuildCount() { function updateRebuildCount() {
const pill = $('builds-tab-count-rebuild'); const pill = $('builds-tab-count-rebuild');
if (!pill) return; if (!pill) return;
let n = 0; // Pending/Running/Finishing = in flight (Finishing = own work done, a
for (const e of rebuildQueueState) { // sub-node still running — still counts). Root nodes only: each is one
const s = rollupState(e.nodes || []); // queue entry.
if (s === 'Pending' || s === 'Running') n++; 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; } if (n > 0) { pill.textContent = String(n); pill.hidden = false; }
else { pill.hidden = true; } else { pill.hidden = true; }
} }
// ─── render-all (cold load + any full re-render) ────────────────────────────── // ─── render-all (cold load + any full re-render) ──────────────────────────────
function renderAll() { function renderAll() {
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
renderMetaInputs({ meta_inputs: metaInputsState }); renderMetaInputs({ meta_inputs: metaInputsState });
updateRebuildCount(); // Rebuild-queue rendering + the count-pill/live-log it drives all happen
// off <hive-jobq-graph>'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 ─────────────────────────────────────────────────────────── // ─── BUILD L0GS tab ───────────────────────────────────────────────────────────
// All-agents build log history with expand-to-detail. Live builds stream in // All-agents build log history with expand-to-detail. Live builds stream in
// real time. Lazy-loaded on first tab activation; auto-refreshes (debounced // real time. Lazy-loaded on first tab activation; auto-refreshes (debounced
@ -742,10 +443,12 @@ if (buildRefresh) buildRefresh.addEventListener('click', fetchBuild);
// ─── SSE handlers ───────────────────────────────────────────────────────────── // ─── SSE handlers ─────────────────────────────────────────────────────────────
let buildRefreshTimer = null; let buildRefreshTimer = null;
const SSE_HANDLERS = { const SSE_HANDLERS = {
rebuild_queue_changed(ev) { rebuild_queue_changed() {
rebuildQueueState = (ev.queue || []).slice(); // No `ev.queue` payload read anymore — this event still carries its own
renderRebuildQueue({ rebuild_queue: rebuildQueueState }); // queue snapshot on the wire (tabs.js/SW4RM still consumes it for the
updateRebuildCount(); // swarm badges, untouched by this), this page just treats it as a
// refetch trigger. <hive-jobq-graph> owns the actual fetch.
if (jobqGraphEl) jobqGraphEl.refresh();
// Auto-refresh build log list when the queue changes and BUILD L0GS is active. // Auto-refresh build log list when the queue changes and BUILD L0GS is active.
if (buildTabs && buildTabs.active() === 'buildlogs') { if (buildTabs && buildTabs.active() === 'buildlogs') {
if (buildRefreshTimer) clearTimeout(buildRefreshTimer); if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
@ -776,6 +479,9 @@ async function refreshState() {
async function init() { async function init() {
initServerWarnings(); initServerWarnings();
bindAsyncForms(() => refreshState()); bindAsyncForms(() => refreshState());
// Self-fetches on mount (<hive-jobq-graph>'s own connectedCallback) —
// no explicit initial fetch needed here, unlike meta inputs below.
mountJobqGraph();
buildTabs = document.getElementById('builds-tabbar').configure({ buildTabs = document.getElementById('builds-tabbar').configure({
tabs: [ tabs: [

View file

@ -22,7 +22,8 @@
"./modal.js": "./src/modal.js", "./modal.js": "./src/modal.js",
"./shadow-css.js": "./src/shadow-css.js", "./shadow-css.js": "./src/shadow-css.js",
"./hive-menu.js": "./src/hive-menu/hive-menu.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": [ "files": [
"src/" "src/"