Compare commits

..
Author SHA1 Message Date
iris
aa149a7a62 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.
2026-08-03 01:53:39 +02:00
iris
98957b48e9 hive-jobq-graph: dispatch hive-jobq-graph-update on every render
Fetching lives in the component (mara's steer on hyperhive#2812) — a
host that needs the raw node list for something the generic tree
doesn't show (a count badge, a live-log panel keyed on a specific
node) now listens for this bubbling/composed CustomEvent instead of
doing its own parallel fetch. Fires from both the self-fetch path
(refresh()) and a host-pushed render(nodes) call, so a listener sees
every update regardless of source.

Verified via jsdom: both paths dispatch with the correct nodes in
detail.
2026-08-03 01:53:39 +02:00
iris
123eec80c4 fix tracker-tag lint: reword #2893 reference to prose 2026-08-03 01:53:39 +02:00
iris
57c9ff6d6c shared: add <hive-jobq-graph>, a generic renderer for /api/jobq/graph
Shadow-DOM custom element (attachShadowCss, own <style>, matches the
<hive-dialog>/<hive-toast> pattern) that renders any hive_jobq graph
from the wire shape hive-jobq-wire serves: a tree from parent/child
structure, a state glyph per node, payload.label verbatim, and
payload.data as a generic key/value list. Never branches on what a
label or data key means, per the wire type's own opaque-payload
contract.

Fetch endpoint is a configurable attribute
(<hive-jobq-graph endpoint="/api/jobq/graph">) rather than
hardcoded, and a public render(nodes) method lets a host push
pre-fetched data (e.g. from its own SSE stream) instead. No transport
of its own beyond the initial self-fetch — refresh() is public so the
host decides its own refresh cadence.

Verified against real production data (61-node live rebuild-queue
graph, fetched from this hive's own /api/jobq/graph) via a jsdom
render: correct tree shape, correct state glyphs, correct data-list
presence count, both the self-fetching and render()-pushed paths,
and the empty-graph path.

Not wired into any page yet — the builds.js migration (hyperhive#2812)
follows once the open payload-gap question there is settled.
2026-08-03 01:53:39 +02:00
5 changed files with 326 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/"

View file

@ -0,0 +1,79 @@
/* hive-jobq-graph.css shadow-scoped styles for <hive-jobq-graph>. 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 (<hive-dialog>, ...) 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;
}

View file

@ -0,0 +1,143 @@
// hive-jobq-graph.js — <hive-jobq-graph>, 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<hive_jobq_wire::GraphNode>` 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: <hive-jobq-graph endpoint="/api/jobq/graph"></hive-jobq-graph> —
// 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
// <hive-tab-strip>, 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 <hive-menu>/
// <hive-agent-menu> 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);