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
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 `↳ <step>` 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
`<hive-jobq-graph endpoint="/api/jobq/graph">`** (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<QueueEntry>` 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 `<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
selectively `nix flake update`, rendered as an indented tree:

View file

@ -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<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;
}
// Kept in sync from <hive-jobq-graph>'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 <hive-jobq-graph> 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 <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 ───────────────────────────────────────────────────────────
// 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. <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.
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 (<hive-jobq-graph>'s own connectedCallback) —
// no explicit initial fetch needed here, unlike meta inputs below.
mountJobqGraph();
buildTabs = document.getElementById('builds-tabbar').configure({
tabs: [

View file

@ -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/"