Compare commits

..
5 changed files with 393 additions and 326 deletions

View file

@ -201,52 +201,48 @@ Three sub-tabs: **R3BU1LD QU3U3** (default), **M3T4 1NPUTS**,
The dashboard tab keeps the rebuild-queue *state* for the SW4RM
card badges without rendering these panels.
**R3BU1LD QU3U3** — pending, in-flight, and recently-settled container
operations: rebuilds, meta-update cascades, and first-spawns. One
operation runs at a time; the worker drains FIFO. **Is a mounted
`<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.
**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.
Below the queue, a **live build-log panel** (`#rebuild-live-log`,
`renderRebuildLiveLog`) shows the currently-running rebuild's output
inline — collapsible, with a live/ok/fail badge and a `↓ raw`
download. It's keyed to the first `Running` node (in wire order)
whose `payload.data.build_log_id` is set — read from the
`hive-jobq-graph-update` event's node list, same source as the count
pill, no separate fetch — and **polls** `GET /api/build-log/{id}`
every 2s (`fetchAndRenderLiveLog` / `liveLogPollTimer`); not an
`EventSource` (that's the BUILD L0GS tab's own per-row expand view
below — `GET /api/build-logs/id/{id}/stream`, real SSE, replays
accumulated output on connect — a separate mechanism). The live-log
panel lives in its own container outside `#rebuild-queue-section` so
the mounted `<hive-jobq-graph>`'s own re-renders never disturb the
open poll; it hides when nothing is building.
`renderRebuildLiveLog`) streams the currently-running rebuild's output
inline — collapsible, with a live/ok/fail badge and a `↓ raw` download.
It's keyed to the running entry's `build_log_id` and opens one
`EventSource` to `GET /api/build-logs/id/{id}/stream` (the same stream
the BUILD L0GS tab uses; the stream replays accumulated output on
connect). It lives in its own container outside `#rebuild-queue-section`
so the queue's per-row re-render (rows rebuild as nodes advance)
never tears down the open stream; it hides when nothing is building and
each row keeps its `logs →` link out to the full build log history.
**M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can
selectively `nix flake update`, rendered as an indented tree:

View file

@ -13,24 +13,55 @@
import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js';
import { bindAsyncForms } from '@hive/shared/forms.js';
import { fmtAgo, fmtDuration, truncate } from './util.js';
import { fmtAgo, fmtElapsed, fmtDuration, truncate } from './util.js';
import '@hive/shared/hive-tab-strip.js';
import '@hive/shared/jobq-graph.js';
// ─── derived state ───────────────────────────────────────────────────────────
let metaInputsState = [];
let metaUpdateRunning = false;
// Kept in sync from <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;
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;
}
function syncFromSnapshot(s) {
metaInputsState = (s.meta_inputs || []).slice();
metaUpdateRunning = !!s.meta_update_running;
rebuildQueueState = (s.rebuild_queue || []).slice();
}
// ─── meta inputs ─────────────────────────────────────────────────────────────
@ -127,27 +158,271 @@ function renderMetaInputs(s) {
}
// ─── rebuild queue ────────────────────────────────────────────────────────────
// R3BU1LD QU3U3 is <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 rebuildQueueRowCache = new Map();
const QUEUE_STATE_GLYPH = {
queued: '⏸',
running: '▶',
done: '✔',
failed: '✖',
cancelled: '⊘',
// Not-taken branch of an outcome split (e.g. the failure tail on a
// successful deploy) — expected, not an error, so a quiet glyph rather
// than an attention-grabbing one. Only ever visible while the owning
// DAG is still live/failed — a fully-settled green DAG drops off the
// wire entirely (see hive-host-sock::jobs::dag_view). Same glyph
// hivectl uses for the same state (no contract between them, just
// consistent taste).
skipped: '·',
};
function firstFailedNode(entry) {
return (entry.nodes || []).find((n) => n.state === 'Failed') || null;
}
// Topo-sort a flat node list using `deps` edges. Nodes whose deps are all
// absent (Done, filtered) or within the set come first. Falls back to
// original array order on ties or cycles.
function topoSort(nodes) {
const ids = new Set(nodes.map((n) => n.id));
const indeg = new Map(nodes.map((n) => [n.id, 0]));
for (const n of nodes) {
for (const d of n.deps || []) {
if (ids.has(d)) indeg.set(n.id, indeg.get(n.id) + 1);
}
}
const remaining = new Map(nodes.map((n) => [n.id, n]));
const ordered = [];
const ready = nodes.filter((n) => indeg.get(n.id) === 0);
while (ready.length) {
const n = ready.shift();
if (!remaining.has(n.id)) continue;
remaining.delete(n.id);
ordered.push(n);
for (const other of nodes) {
if ((other.deps || []).includes(n.id) && remaining.has(other.id)) {
indeg.set(other.id, indeg.get(other.id) - 1);
if (indeg.get(other.id) === 0) ready.push(other);
}
}
}
for (const n of nodes) {
if (remaining.has(n.id)) ordered.push(n);
}
return ordered;
}
// Build a tree from a flat node list using the `parent` field provided by
// the backend. Nodes without a `parent` (or whose parent id is absent from
// the node set) are roots. Children within each parent group are
// topo-sorted by `deps` so siblings render in dependency order.
// Returns an array of root nodes, each augmented with a `_children` array.
function buildNodeTree(nodes) {
const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }]));
const roots = [];
for (const n of byId.values()) {
const p = n.parent != null ? byId.get(n.parent) : null;
if (p) {
p._children.push(n);
} else {
roots.push(n);
}
}
// Topo-sort roots and each children list by deps.
function sortGroup(group) {
const sorted = topoSort(group);
for (const n of sorted) sortGroup(n._children);
return sorted;
}
return sortGroup(roots);
}
function rebuildQueueEntryFingerprint(entry) {
const nodes = entry.nodes || [];
return JSON.stringify({
state: rollupState(nodes),
source: entry.source,
started_at: isoToSecs(entry.started_at),
created_at: entry.created_at,
finished_at: isoToSecs(entry.finished_at),
reason: entry.reason,
nodes: nodes.map((n) => [n.kind, n.state, isoToSecs(n.started_at), isoToSecs(n.finished_at), n.error]),
});
}
function renderRebuildQueue(s) {
const queue = s.rebuild_queue || [];
renderRebuildLiveLog(queue);
const root = $('rebuild-queue-section');
if (!root) return;
root.replaceChildren();
jobqGraphEl = el('hive-jobq-graph', { endpoint: '/api/jobq/graph' });
jobqGraphEl.addEventListener('hive-jobq-graph-update', (e) => {
jobqNodes = e.detail.nodes || [];
renderRebuildLiveLog();
updateRebuildCount();
if (!queue.length) {
rebuildQueueRowCache.clear();
root.replaceChildren(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.'));
return;
}
// Every multi-step op is a single DAG now (its whole graph lives in
// `nodes`, split into subgraphs by the backend `deps` edges) — there are
// no cross-DAG parent/child links to group. Render each queue entry in
// enqueue order.
const orderedLis = [];
for (const entry of queue) {
const fp = rebuildQueueEntryFingerprint(entry);
const cached = rebuildQueueRowCache.get(entry.id);
let li;
if (cached && cached.fingerprint === fp) {
li = cached.el;
} else {
li = renderQueueEntry(entry);
rebuildQueueRowCache.set(entry.id, { el: li, fingerprint: fp });
}
orderedLis.push(li);
}
const liveIds = new Set(queue.map((e) => e.id));
for (const [id, entry] of rebuildQueueRowCache) {
if (!liveIds.has(id)) {
entry.el.remove();
rebuildQueueRowCache.delete(id);
}
}
let ul = root.querySelector('ul.rebuild-queue');
if (!ul) {
ul = el('ul', { class: 'rebuild-queue' });
root.replaceChildren(ul);
}
for (let i = 0; i < orderedLis.length; i++) {
if (ul.children[i] !== orderedLis[i]) {
ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
}
}
while (ul.children.length > orderedLis.length) ul.lastChild.remove();
}
function renderQueueEntry(entry) {
const nodes = entry.nodes || [];
const state = rollupState(nodes);
const startedAt = isoToSecs(entry.started_at);
const finishedAt = isoToSecs(entry.finished_at);
const createdAt = isoToSecs(entry.created_at);
const slug = stateSlug(state);
const li = el('li', {
class: 'rebuild-queue-entry rqe-' + slug,
'data-id': String(entry.id),
});
root.append(jobqGraphEl);
li.append(
el('span', { class: 'rqe-state', title: slug }, QUEUE_STATE_GLYPH[slug] || '?'),
' ',
el('span', { class: 'rqe-kind' }, entry.source),
);
li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source));
if (state === 'Pending') {
li.append(' ', el('span', {
class: 'rqe-when',
'data-rqe-enqueued': String(createdAt ?? ''),
}, '· queued ' + (createdAt ? fmtAgo(createdAt) : '')));
} else if (state === 'Running' && startedAt) {
const elapsed = Math.max(0, Math.floor(Date.now() / 1000) - startedAt);
li.append(' ', el('span', {
class: 'rqe-when',
'data-rqe-elapsed': String(startedAt),
}, '· ' + fmtElapsed(elapsed)));
} else if (finishedAt) {
li.append(' ', el('span', {
class: 'rqe-when',
'data-rqe-finished': String(finishedAt),
'data-rqe-state': slug,
}, '· ' + slug + ' ' + fmtAgo(finishedAt)));
}
if (entry.reason) {
const r = entry.reason.split('\n')[0];
li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60)));
}
// Per-node tree: render the jobq recursive parent/child tree.
// `parent` edges (structural grouping) define the tree shape;
// `deps` edges order siblings within each parent group.
// `Done` nodes are excluded from the payload by the backend, so only
// live nodes appear here; `Failed` DAGs linger until the history cap.
if (nodes.length) {
const treeRoot = el('div', { class: 'rqe-nodes-tree' });
const treeNodes = buildNodeTree(nodes);
function renderTreeNode(n, depth, isLast, ancestorLines) {
// ancestorLines: boolean[] where true = draw a vertical guide line at
// that ancestor depth level (the ancestor was not the last sibling, so
// its remaining siblings need a guide column below it).
const row = el('div', { class: 'rqe-tree-row' });
if (depth > 0) {
// One guide column per ancestor level — draws a vertical line through
// columns where the ancestor still has siblings below it.
for (const hasLine of ancestorLines) {
row.append(el('span', {
class: 'rqe-tree-guide' + (hasLine ? ' rqe-tree-guide-line' : ''),
}));
}
// Connector: L-shaped for last child, T-shaped for mid child.
row.append(el('span', {
class: 'rqe-tree-connector'
+ (isLast ? ' rqe-tree-connector-last' : ' rqe-tree-connector-mid'),
}));
}
const chip = el('span', {
class: 'rqe-node rqe-node-' + stateSlug(n.state),
title: (n.agent ? n.agent + ' · ' : '') + n.kind + ' · ' + n.state
+ (n.error ? ' — ' + n.error : ''),
}, (QUEUE_STATE_GLYPH[n.state] || '?') + ' ' + n.kind);
if (n.agent) {
chip.append(el('span', { class: 'rqe-node-agent' }, ' · ' + n.agent));
}
row.append(chip);
if (n.build_log_id != null) {
// Deep-links into the BUILD L0GS tab's rich view (auto-expands +
// scrolls to this row there, see fetchBuild's `?id=N` handling)
// rather than the raw-text download endpoint — a plain download
// is surprising here since nothing about a printer-glyph icon
// says "this leaves the app". The raw download is still one
// click away once on that row.
row.append(el('a', {
class: 'rqe-log-link rqe-node-log',
href: '/builds.html?id=' + n.build_log_id + '#buildlogs',
title: 'view build log for ' + n.kind + ' node',
}, '⎙'));
}
treeRoot.append(row);
// Propagate ancestor lines to children: inherit this node's columns,
// plus whether this node itself continues below (not the last sibling).
const childAncestorLines = depth === 0 ? [] : [...ancestorLines, !isLast];
n._children.forEach((child, i) => {
renderTreeNode(child, depth + 1, i === n._children.length - 1, childAncestorLines);
});
}
treeNodes.forEach((n, i) => renderTreeNode(n, 0, i === treeNodes.length - 1, []));
li.append(treeRoot);
}
const failed = firstFailedNode(entry);
if (failed && failed.error) {
li.append(el('pre', { class: 'rqe-error', title: failed.error }, truncate(failed.error, 200)));
}
if (state === 'Pending') {
const cancelForm = el('form', {
method: 'POST',
action: '/api/rebuild-queue/' + entry.id + '/cancel',
class: 'inline rqe-cancel',
'data-async': '',
'data-confirm':
`cancel ${entry.source} (queue id ${entry.id})? ` +
`the row drops from the queue and never runs. running / done / failed entries can't be cancelled this way.`,
});
cancelForm.append(el('button', {
type: 'submit',
class: 'rqe-cancel-btn',
title: 'cancel this queued ' + entry.source,
'aria-label': 'cancel queued ' + entry.source,
}, '✗'));
li.append(cancelForm);
}
return li;
}
// ─── running-rebuild live log ─────────────────────────────────────────────────
@ -168,13 +443,15 @@ function clearLiveLogPoll() {
if (liveLogPollTimer) { clearInterval(liveLogPollTimer); liveLogPollTimer = null; }
}
// First running node with a log, in wire order (root-then-subtree per
// root, roots in enqueue order — see hive-jobq-wire's wire_snapshot doc).
// Gate on build_log_id so lock/noop/store-only nodes don't open a blank
// panel.
function findLiveBuild() {
return jobqNodes.find((n) => n.state === 'Running' && n.payload.data && n.payload.data.build_log_id != null)
|| null;
// First (entry, node) pair with a running node that has a log.
// Gate on build_log_id so lock/noop/store-only nodes don't open a blank panel.
function findLiveBuild(queue) {
for (const e of queue || []) {
if (rollupState(e.nodes || []) !== 'Running') continue;
const node = (e.nodes || []).find((n) => n.state === 'Running' && n.build_log_id != null);
if (node) return { entry: e, node };
}
return null;
}
async function fetchAndRenderLiveLog(nodeId, pre) {
@ -191,18 +468,19 @@ async function fetchAndRenderLiveLog(nodeId, pre) {
} catch { /* network blip — ignore, next poll will retry */ }
}
function renderRebuildLiveLog() {
function renderRebuildLiveLog(queue) {
const root = $('rebuild-live-log');
if (!root) return;
const liveNode = findLiveBuild();
const live = findLiveBuild(queue);
if (!liveNode) {
if (!live) {
clearLiveLogPoll();
liveLogId = null;
liveLogDone = false;
if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
return;
}
const { entry: running, node: liveNode } = live;
// Same node, already polling — just let the timer tick (or do a final
// fetch if the node just went non-running and we haven't marked done yet).
@ -245,13 +523,12 @@ function renderRebuildLiveLog() {
toggle.setAttribute('aria-expanded', String(!liveLogCollapsed));
toggle.title = liveLogCollapsed ? 'expand live log' : 'collapse live log';
});
const liveAgent = liveNode.payload.data && liveNode.payload.data.agent;
const header = el('div', { class: 'rebuild-live-log-header' },
toggle, ' ',
el('span', { class: 'rebuild-live-log-title' }, 'live build log — '),
// Label the specific node's agent, not the whole group's agent set.
el('code', { class: 'rqe-agent' }, liveAgent || ''),
' ', el('span', { class: 'rqe-kind' }, liveNode.payload.label),
// Label the specific node's agent, not the whole DAG's agent set.
el('code', { class: 'rqe-agent' }, liveNode.agent),
' ', el('span', { class: 'rqe-kind' }, running.source + ' · ' + liveNode.kind),
' ', badge, ' ',
el('a', {
class: 'rebuild-live-log-raw',
@ -270,23 +547,45 @@ function renderRebuildLiveLog() {
function updateRebuildCount() {
const pill = $('builds-tab-count-rebuild');
if (!pill) return;
// Pending/Running/Finishing = in flight (Finishing = own work done, a
// sub-node still running — still counts). Root nodes only: each is one
// queue entry.
const n = jobqNodes.filter((n) => n.parent == null
&& (n.state === 'Pending' || n.state === 'Running' || n.state === 'Finishing')).length;
let n = 0;
for (const e of rebuildQueueState) {
const s = rollupState(e.nodes || []);
if (s === 'Pending' || s === 'Running') n++;
}
if (n > 0) { pill.textContent = String(n); pill.hidden = false; }
else { pill.hidden = true; }
}
// ─── render-all (cold load + any full re-render) ──────────────────────────────
function renderAll() {
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
renderMetaInputs({ meta_inputs: metaInputsState });
// Rebuild-queue rendering + the count-pill/live-log it drives all happen
// off <hive-jobq-graph>'s own hive-jobq-graph-update event (see
// mountJobqGraph) — nothing to render here directly.
updateRebuildCount();
}
// ─── elapsed-time tickers ─────────────────────────────────────────────────────
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
for (const span of document.querySelectorAll('.rqe-when[data-rqe-elapsed]')) {
const started = parseInt(span.dataset.rqeElapsed, 10);
if (!started) continue;
span.textContent = '· ' + fmtElapsed(Math.max(0, now - started));
}
}, 1000);
setInterval(() => {
for (const span of document.querySelectorAll('.rqe-when[data-rqe-enqueued]')) {
const enqueued = parseInt(span.dataset.rqeEnqueued, 10);
if (!enqueued) continue;
span.textContent = '· queued ' + fmtAgo(enqueued);
}
for (const span of document.querySelectorAll('.rqe-when[data-rqe-finished]')) {
const finished = parseInt(span.dataset.rqeFinished, 10);
if (!finished) continue;
const state = span.dataset.rqeState || '';
span.textContent = '· ' + state + ' ' + fmtAgo(finished);
}
}, 30_000);
// ─── BUILD L0GS tab ───────────────────────────────────────────────────────────
// All-agents build log history with expand-to-detail. Live builds stream in
// real time. Lazy-loaded on first tab activation; auto-refreshes (debounced
@ -443,12 +742,10 @@ if (buildRefresh) buildRefresh.addEventListener('click', fetchBuild);
// ─── SSE handlers ─────────────────────────────────────────────────────────────
let buildRefreshTimer = null;
const SSE_HANDLERS = {
rebuild_queue_changed() {
// No `ev.queue` payload read anymore — this event still carries its own
// queue snapshot on the wire (tabs.js/SW4RM still consumes it for the
// swarm badges, untouched by this), this page just treats it as a
// refetch trigger. <hive-jobq-graph> owns the actual fetch.
if (jobqGraphEl) jobqGraphEl.refresh();
rebuild_queue_changed(ev) {
rebuildQueueState = (ev.queue || []).slice();
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
updateRebuildCount();
// Auto-refresh build log list when the queue changes and BUILD L0GS is active.
if (buildTabs && buildTabs.active() === 'buildlogs') {
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
@ -479,9 +776,6 @@ async function refreshState() {
async function init() {
initServerWarnings();
bindAsyncForms(() => refreshState());
// Self-fetches on mount (<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,8 +22,7 @@
"./modal.js": "./src/modal.js",
"./shadow-css.js": "./src/shadow-css.js",
"./hive-menu.js": "./src/hive-menu/hive-menu.js",
"./side-panel.js": "./src/side-panel/hive-side-panel.js",
"./jobq-graph.js": "./src/jobq-graph/hive-jobq-graph.js"
"./side-panel.js": "./src/side-panel/hive-side-panel.js"
},
"files": [
"src/"

View file

@ -1,79 +0,0 @@
/* 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

@ -1,143 +0,0 @@
// 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);