Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa149a7a62 | ||
|
|
98957b48e9 | ||
|
|
123eec80c4 | ||
|
|
57c9ff6d6c |
5 changed files with 326 additions and 393 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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/"
|
||||
|
|
|
|||
79
frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css
Normal file
79
frontend/packages/shared/src/jobq-graph/hive-jobq-graph.css
Normal 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;
|
||||
}
|
||||
143
frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js
Normal file
143
frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js
Normal 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);
|
||||
Loading…
Reference in a new issue