Compare commits

..
17 changed files with 339 additions and 375 deletions

View file

@ -228,17 +228,16 @@ not the graph.
**R3BU1LD QU3U3** — pending, in-flight, and recently-settled container **R3BU1LD QU3U3** — pending, in-flight, and recently-settled container
operations: rebuilds, meta-update cascades, and first-spawns. One operations: rebuilds, meta-update cascades, and first-spawns. One
operation runs at a time; the worker drains FIFO. **Is a mounted operation runs at a time; the worker drains FIFO. **Is a mounted
`JobqGraph`** (the shared generic graph-viewer Preact component, `<hive-jobq-graph endpoint="/api/jobq/graph">`** (the shared generic
`@hive/shared/jobq-graph.js` — the same one swarm-ui uses) — graph-viewer component, `@hive/shared/jobq-graph.js`) — `builds.js`
`builds.js` does not render the queue itself; it just mounts the component does not render the queue itself; it just mounts the element and
(`mountJobqGraph(container, props)`, since this page has no JSX listens for its `hive-jobq-graph-update` event to drive the two things
pipeline) with an `onUpdate` callback to drive the two things below it below it that the generic view doesn't show. The component owns
that the generic view doesn't show. The component owns fetching, cold fetching, cold and live: `GET /api/jobq/graph` on mount, and
and live: `GET /api/jobq/graph` on mount, and a refetch via the mount `.refresh()` on every `rebuild_queue_changed` SSE tick (that event is a
handle's `.refresh()` on every `rebuild_queue_changed` SSE tick (that bare `{ seq }` trigger — it carried a typed queue snapshot until every
event is a bare `{ seq }` trigger — it carried a typed queue snapshot consumer had moved to the generic endpoint, and now carries none; both
until every consumer had moved to the generic endpoint, and now carries dashboard pages treat the tick as a pure refetch trigger).
none; both dashboard pages treat the tick as a pure refetch trigger).
Each row is one root graph node (`parent: null`); a multi-step op's 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 per-agent subgraphs and sub-steps render as nodes within that one
@ -251,10 +250,10 @@ as a plain "waits on: `<label>`" text line under the row rather than a
gutter rail — a rail breaks visually whenever a nested subtree sits gutter rail — a rail breaks visually whenever a nested subtree sits
between the two related rows, since a text line needs no continuous between the two related rows, since a text line needs no continuous
vertical space to draw. vertical space to draw.
`builds.js` mounts the component with `cancellable` set, which turns on `builds.js` mounts the element with `cancellable` set, which turns on
a per-node cancel button (`✕`) on any non-terminal row — the button a per-node cancel button (`✕`) on any non-terminal row — the button
calls the mount's `onCancel(id)` callback, and the page does the actual dispatches `hive-jobq-graph-cancel`, and the page does the actual
`POST /api/rebuild-queue/{id}/cancel`, matching that callback to its `POST /api/rebuild-queue/{id}/cancel`, matching the wire event as its
own domain concept (the component knows nothing about that endpoint). own domain concept (the component knows nothing about that endpoint).
**Rows carry no source chip, kind label, timing, or build-log **Rows carry no source chip, kind label, timing, or build-log
deep-link** — the generic graph wire doesn't carry those fields, and deep-link** — the generic graph wire doesn't carry those fields, and
@ -267,7 +266,7 @@ does not filter `Done` nodes out.
the tree — one per lifecycle state, matching the row glyphs — lets the the tree — one per lifecycle state, matching the row glyphs — lets the
operator narrow which root groups render; unchecking a state re-fetches operator narrow which root groups render; unchecking a state re-fetches
`GET /api/jobq/graph?states=<checked, comma-joined>` rather than `GET /api/jobq/graph?states=<checked, comma-joined>` rather than
hiding rows client-side, so the `onUpdate` callback's node list (and hiding rows client-side, so `hive-jobq-graph-update`'s node list (and
everything downstream of it — the count pill, the live-log panel) only everything downstream of it — the count pill, the live-log panel) only
ever sees what's actually shown. Filtering is by a **root's own** ever sees what's actually shown. Filtering is by a **root's own**
state, which is already its subtree's rolled-up answer, so a group is state, which is already its subtree's rolled-up answer, so a group is
@ -285,16 +284,16 @@ Below the queue, a **live build-log panel** (`#rebuild-live-log`,
`renderRebuildLiveLog`) shows 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` inline — collapsible, with a live/ok/fail badge and a `↓ raw`
download. It's keyed to the first `Running` node (in wire order) download. It's keyed to the first `Running` node (in wire order)
whose `payload.data.build_log_id` is set — read from the `onUpdate` whose `payload.data.build_log_id` is set — read from the
callback's node list, same source as the count pill, no separate `hive-jobq-graph-update` event's node list, same source as the count
fetch — and **polls** `GET /api/build-log/{id}` every 2s pill, no separate fetch — and **polls** `GET /api/build-log/{id}`
(`fetchAndRenderLiveLog` / `liveLogPollTimer`); not an `EventSource` every 2s (`fetchAndRenderLiveLog` / `liveLogPollTimer`); not an
(that's the BUILD L0GS tab's own per-row expand view below — `GET `EventSource` (that's the BUILD L0GS tab's own per-row expand view
/api/build-logs/id/{id}/stream`, real SSE, replays accumulated output below — `GET /api/build-logs/id/{id}/stream`, real SSE, replays
on connect — a separate mechanism). The live-log panel lives in its accumulated output on connect — a separate mechanism). The live-log
own container outside `#rebuild-queue-section` so the mounted panel lives in its own container outside `#rebuild-queue-section` so
`JobqGraph`'s own re-renders never disturb the open poll; it hides the mounted `<hive-jobq-graph>`'s own re-renders never disturb the
when nothing is building. 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

@ -1000,16 +1000,12 @@
"dependencies": { "dependencies": {
"@hive/shared": "*", "@hive/shared": "*",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"marked": "18.0.6", "marked": "18.0.6"
"preact": "10.29.8"
} }
}, },
"packages/shared": { "packages/shared": {
"name": "@hive/shared", "name": "@hive/shared",
"version": "0.0.0", "version": "0.0.0"
"dependencies": {
"preact": "10.29.8"
}
}, },
"packages/swarm-ui": { "packages/swarm-ui": {
"name": "@hive/swarm-ui", "name": "@hive/swarm-ui",

View file

@ -83,14 +83,6 @@ await build({
// page-stylesheet bundling below (`loader: { '.css': 'css' }`), which // page-stylesheet bundling below (`loader: { '.css': 'css' }`), which
// runs as its own esbuild invocation over different entry points. // runs as its own esbuild invocation over different entry points.
loader: { '.css': 'text' }, loader: { '.css': 'text' },
// `@hive/shared/jobq-graph.js` resolves to a real `.jsx` file
// (`JobqGraph.jsx`), pulled in transitively by `builds.js` — esbuild
// already picks the `jsx` loader for `.jsx` by extension, this just
// sets the transform mode to match swarm-ui's (which also authors
// this file). No other entry here uses JSX today; this doesn't turn
// any plain `.js` file into one, `.js` still parses as plain JS.
jsx: 'automatic',
jsxImportSource: 'preact',
}); });
// Stream-worker entry (#448). Lives in a separate bundle: SharedWorker // Stream-worker entry (#448). Lives in a separate bundle: SharedWorker

View file

@ -10,7 +10,6 @@
"dependencies": { "dependencies": {
"@hive/shared": "*", "@hive/shared": "*",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"marked": "18.0.6", "marked": "18.0.6"
"preact": "10.29.8"
} }
} }

View file

@ -9,7 +9,6 @@
(`.builds-shell` body); only the build-logs-* component rules are used. */ (`.builds-shell` body); only the build-logs-* component rules are used. */
@import "./system-sections.css"; @import "./system-sections.css";
@import "./logs.css"; @import "./logs.css";
@import "@hive/shared/jobq-graph.css";
body.builds-shell { body.builds-shell {
margin: 0; margin: 0;

View file

@ -27,10 +27,10 @@
<main class="builds-main"> <main class="builds-main">
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and <!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
first-spawns. Rendered from GET /api/jobq/graph by the first-spawns. Rendered from GET /api/jobq/graph by
`JobqGraph` Preact component (@hive/shared/jobq-graph.js); <hive-jobq-graph>; `rebuild_queue_changed` over
`rebuild_queue_changed` over /api/dashboard/stream is the /api/dashboard/stream is the refresh trigger and carries no
refresh trigger and carries no payload of its own. Default tab. --> payload of its own. Default tab. -->
<section class="builds-pane" id="builds-pane-rebuild" data-tab-pane="rebuild" <section class="builds-pane" id="builds-pane-rebuild" data-tab-pane="rebuild"
role="tabpanel" aria-labelledby="builds-tab-rebuild"> role="tabpanel" aria-labelledby="builds-tab-rebuild">
<p class="meta">pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.</p> <p class="meta">pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.</p>

View file

@ -16,17 +16,18 @@ import { bindAsyncForms } from '@hive/shared/forms.js';
import { themedConfirm } from '@hive/shared/modal.js'; import { themedConfirm } from '@hive/shared/modal.js';
import { fmtAgo, 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 { mountJobqGraph } from '@hive/shared/jobq-graph.js'; import '@hive/shared/jobq-graph.js';
// ─── derived state ─────────────────────────────────────────────────────────── // ─── derived state ───────────────────────────────────────────────────────────
let metaInputsState = []; let metaInputsState = [];
let metaUpdateRunning = false; let metaUpdateRunning = false;
// Kept in sync from JobqGraph's `onUpdate` callback (see mountRebuildQueue // Kept in sync from <hive-jobq-graph>'s `hive-jobq-graph-update` event
// below) — the component owns fetching GET /api/jobq/graph and renders // (see mountJobqGraph below) — the component owns fetching GET
// the tree itself; this flat array exists only for the two things it // /api/jobq/graph and renders the tree itself; this flat array exists only
// doesn't render: the count-pill and the live-log panel. // for the two things it doesn't render: the count-pill and the live-log
// panel.
let jobqNodes = []; let jobqNodes = [];
let jobqGraphHandle = null; let jobqGraphEl = null;
function syncFromSnapshot(s) { function syncFromSnapshot(s) {
metaInputsState = (s.meta_inputs || []).slice(); metaInputsState = (s.meta_inputs || []).slice();
@ -127,50 +128,48 @@ function renderMetaInputs(s) {
} }
// ─── rebuild queue ──────────────────────────────────────────────────────────── // ─── rebuild queue ────────────────────────────────────────────────────────────
// R3BU1LD QU3U3 is `JobqGraph` (Preact, mounted imperatively — see // R3BU1LD QU3U3 is <hive-jobq-graph> directly (mara: "replace the build
// mountJobqGraph's own doc comment in @hive/shared/jobq-graph.js for why // queue tab with this component") — no hand-rolled tree/roll-up rendering
// this file has no JSX pipeline) directly — no hand-rolled tree/roll-up // here anymore. The component owns fetching GET /api/jobq/graph and its
// rendering here anymore. The component owns fetching GET /api/jobq/graph // own refresh(); this page just listens for its `hive-jobq-graph-update`
// and its own refetch (`.refresh()` on the mount handle); this page just // event to keep `jobqNodes` (the flat array) in sync for the two things
// reads its `onUpdate` callback to keep `jobqNodes` (the flat array) in // the generic view doesn't render itself: the count-pill and the live-log
// sync for the two things the generic view doesn't render itself: the // panel below.
// count-pill and the live-log panel below.
// //
// Cancel is the one action this page *does* wire up: the `cancellable` // Cancel is the one action this page *does* wire up: the
// prop turns on the component's own per-node cancel button, which calls // `cancellable` attribute turns on the component's own per-node cancel
// `onCancel(id)` rather than posting anything — the endpoint // button, which dispatches `hive-jobq-graph-cancel` rather than posting
// (`/api/rebuild-queue/{id}/cancel`) is this page's domain concept, not // anything — the endpoint (`/api/rebuild-queue/{id}/cancel`) is this
// the generic component's. // page's domain concept, not the generic component's.
function mountRebuildQueue() { function mountJobqGraph() {
const root = $('rebuild-queue-section'); const root = $('rebuild-queue-section');
if (!root) return; if (!root) return;
root.replaceChildren(); root.replaceChildren();
jobqGraphHandle = mountJobqGraph(root, { jobqGraphEl = el('hive-jobq-graph', { endpoint: '/api/jobq/graph', cancellable: '' });
endpoint: '/api/jobq/graph', jobqGraphEl.addEventListener('hive-jobq-graph-update', (e) => {
cancellable: true, jobqNodes = e.detail.nodes || [];
onUpdate: (nodes) => { renderRebuildLiveLog();
jobqNodes = nodes || []; updateRebuildCount();
renderRebuildLiveLog();
updateRebuildCount();
},
onCancel: async (id) => {
const node = jobqNodes.find((n) => n.id === id);
const label = node ? node.payload.label : 'node ' + id;
if (!(await themedConfirm({
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
danger: true, confirmLabel: '✕ cancel',
}))) return;
try {
const r = await fetch('/api/rebuild-queue/' + id + '/cancel', { method: 'POST' });
if (!r.ok) throw new Error('http ' + r.status);
// No manual refresh: cancel flips node state, which fires
// rebuild_queue_changed over SSE — the existing handler below
// already calls jobqGraphHandle.refresh() on that tick.
} catch (err) {
console.error('cancel failed', err);
}
},
}); });
jobqGraphEl.addEventListener('hive-jobq-graph-cancel', async (e) => {
const { id } = e.detail;
const node = jobqNodes.find((n) => n.id === id);
const label = node ? node.payload.label : 'node ' + id;
if (!(await themedConfirm({
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
danger: true, confirmLabel: '✕ cancel',
}))) return;
try {
const r = await fetch('/api/rebuild-queue/' + id + '/cancel', { method: 'POST' });
if (!r.ok) throw new Error('http ' + r.status);
// No manual refresh: cancel flips node state, which fires
// rebuild_queue_changed over SSE — the existing handler below
// already calls jobqGraphEl.refresh() on that tick.
} catch (err) {
console.error('cancel failed', err);
}
});
root.append(jobqGraphEl);
} }
// ─── running-rebuild live log ───────────────────────────────────────────────── // ─── running-rebuild live log ─────────────────────────────────────────────────
@ -306,8 +305,8 @@ function updateRebuildCount() {
function renderAll() { function renderAll() {
renderMetaInputs({ meta_inputs: metaInputsState }); renderMetaInputs({ meta_inputs: metaInputsState });
// Rebuild-queue rendering + the count-pill/live-log it drives all happen // Rebuild-queue rendering + the count-pill/live-log it drives all happen
// off JobqGraph's own onUpdate callback (see mountRebuildQueue) — // off <hive-jobq-graph>'s own hive-jobq-graph-update event (see
// nothing to render here directly. // mountJobqGraph) — nothing to render here directly.
} }
// ─── BUILD L0GS tab ─────────────────────────────────────────────────────────── // ─── BUILD L0GS tab ───────────────────────────────────────────────────────────
@ -470,8 +469,8 @@ const SSE_HANDLERS = {
// No `ev.queue` payload read anymore — this event still carries its own // 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 // 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 // swarm badges, untouched by this), this page just treats it as a
// refetch trigger. `JobqGraph` owns the actual fetch. // refetch trigger. <hive-jobq-graph> owns the actual fetch.
if (jobqGraphHandle) jobqGraphHandle.refresh(); 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);
@ -502,9 +501,9 @@ async function refreshState() {
async function init() { async function init() {
initServerWarnings(); initServerWarnings();
bindAsyncForms(() => refreshState()); bindAsyncForms(() => refreshState());
// Self-fetches on mount (JobqGraph's own effect) — no explicit initial // Self-fetches on mount (<hive-jobq-graph>'s own connectedCallback) —
// fetch needed here, unlike meta inputs below. // no explicit initial fetch needed here, unlike meta inputs below.
mountRebuildQueue(); mountJobqGraph();
buildTabs = document.getElementById('builds-tabbar').configure({ buildTabs = document.getElementById('builds-tabbar').configure({
tabs: [ tabs: [

View file

@ -73,8 +73,8 @@ let jobqRollupState = [];
// (applyRebuildQueueChanged below) — a payload-less push trigger by // (applyRebuildQueueChanged below) — a payload-less push trigger by
// design, confirmed with atlas on the jobq-deletion tracker: the event // design, confirmed with atlas on the jobq-deletion tracker: the event
// carries no `queue` field this page reads, same "something changed, // carries no `queue` field this page reads, same "something changed,
// go refetch" treatment builds.js already gives its JobqGraph mount // go refetch" treatment builds.js already gives it for
// handle's .refresh(). // <hive-jobq-graph>.refresh().
// Best-effort: a failed fetch leaves the previous snapshot in place // Best-effort: a failed fetch leaves the previous snapshot in place
// rather than wiping the banner on a network blip. // rather than wiping the banner on a network blip.
export async function refreshJobqRollup() { export async function refreshJobqRollup() {

View file

@ -311,8 +311,8 @@ window.marked = marked;
// handled on /core.html now (the SYST3M panels moved there). // handled on /core.html now (the SYST3M panels moved there).
// rebuild_queue_changed: refreshes the SW4RM queue-summary banner // rebuild_queue_changed: refreshes the SW4RM queue-summary banner
// (see swarm.js) — a payload-less push trigger, same treatment // (see swarm.js) — a payload-less push trigger, same treatment
// /builds.html gives it for its JobqGraph mount handle's .refresh() // /builds.html gives it for <hive-jobq-graph>.refresh() (its own
// (its own separate subscription). // separate subscription).
rebuild_queue_changed: applyRebuildQueueChanged, rebuild_queue_changed: applyRebuildQueueChanged,
schedules_changed: applySchedulesChanged, schedules_changed: applySchedulesChanged,
capabilities_changed: applyCapabilitiesChanged, capabilities_changed: applyCapabilitiesChanged,

View file

@ -5,9 +5,6 @@
"description": "Shared frontend modules used by both the dashboard and the per-agent UI: terminal log pane, Catppuccin palette, base typography. Imported by sibling workspaces; not bundled standalone.", "description": "Shared frontend modules used by both the dashboard and the per-agent UI: terminal log pane, Catppuccin palette, base typography. Imported by sibling workspaces; not bundled standalone.",
"type": "module", "type": "module",
"main": "./src/index.js", "main": "./src/index.js",
"dependencies": {
"preact": "10.29.8"
},
"exports": { "exports": {
".": "./src/index.js", ".": "./src/index.js",
"./terminal.js": "./src/terminal/terminal.js", "./terminal.js": "./src/terminal/terminal.js",
@ -28,8 +25,7 @@
"./hive-menu.js": "./src/hive-menu/hive-menu.js", "./hive-menu.js": "./src/hive-menu/hive-menu.js",
"./hive-warn.js": "./src/hive-warn/hive-warn.js", "./hive-warn.js": "./src/hive-warn/hive-warn.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/JobqGraph.jsx", "./jobq-graph.js": "./src/jobq-graph/hive-jobq-graph.js"
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css"
}, },
"files": [ "files": [
"src/" "src/"

View file

@ -1,251 +0,0 @@
// JobqGraph.jsx <JobqGraph>, a Preact component rendering any
// hive_jobq graph from the wire shape GET /api/jobq/graph serves (any
// endpoint serving `Vec<hive_jobq_wire::GraphNode>` works see
// hive-jobq-wire's README). Renders an indented state tree:
// `payload.label` verbatim, `payload.data` as a generic key/value list.
// Light DOM, shared by the dashboard and swarm-ui both esbuild
// configs run `jsx: 'automatic', jsxImportSource: 'preact'`, so this
// file is real JSX in either build, not hand-written `h()` calls.
//
// `cancellable` adds a per-node cancel button calling `onCancel(id)`
// directly (a plain prop). `onUpdate(nodes)` fires after every fetch,
// for a host needing the raw list (a count badge, a live-log panel)
// without its own parallel fetch.
//
// Two ways to use this: JSX (swarm-ui, or any future dashboard page
// that renders it directly) `<JobqGraph endpoint="..." cancellable
// onCancel={...} onUpdate={...} />`, a normal component. Or imperative
// mount (dashboard/src/builds.js, which stays plain `.js` a `.jsx`
// call site still needs a JSX-aware file, mounting doesn't)
// `mountJobqGraph(container, props)` returns a `{ refresh(),
// update(props) }` handle.
//
// Styles live in `@hive/shared/jobq-graph.css`, `@import`ed from a
// page/component CSS file rather than imported here. This one stays
// necessary regardless of the JSX question above: dashboard bundles
// every page entry (including this component, pulled in transitively)
// through one esbuild call whose `.css` loader is `text` a handful of
// shadow-DOM components (modal.js, hive-btn.js) need their CSS as a
// literal string to inject into a shadow root, and esbuild's loader map
// is global per call, not per-module. Importing `.css` here would
// silently pick up that `text` loader too and bind a useless string
// instead of applying styles, so this file imports no CSS at all;
// each consumer's own page/component CSS `@import`s it instead.
import { h, render } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
const STATE_GLYPH = {
Pending: '⏸',
Running: '▶',
Finishing: '◐',
Done: '✔',
Failed: '✖',
Cancelled: '⊘',
Skipped: '·',
};
// Declaration order doubles as render order for the filter checkboxes
// matches `hive_jobq_wire::ALL_STATES` on the wire, so the row reads in
// the same lifecycle order the rollup endpoint counts in.
const ALL_STATES = Object.keys(STATE_GLYPH);
// Product call: "default selection filters out skipped and done."
const DEFAULT_HIDDEN_STATES = new Set(['Done', 'Skipped']);
// Non-terminal states a cancel button makes sense on. Finishing is
// included "own logic done, children still running" is still a subtree
// worth stopping early.
const CANCELLABLE_STATES = new Set(['Pending', 'Running', 'Finishing']);
// 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 reordering; a dependency is named in text (see
// `_waitsOn` below), not implied by render position.
//
// Also resolves each node's `Node`-kind deps to the referenced node's own
// label, once, so rendering doesn't need a second lookup pass. Looked up
// globally (`byId`, every node in this snapshot), not scoped to siblings
// a dep is expected to always name a sibling, but resolving globally means
// a label still shows correctly even if that expectation is ever wrong,
// instead of silently dropping the edge. A dep naming an id outside this
// snapshot (a filtered view) or a `Resource`-kind dep has nothing to point
// at and is simply not listed.
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);
}
for (const n of byId.values()) {
n._waitsOn = (n.deps || [])
.filter((d) => d.kind === 'Node')
.map((d) => byId.get(d.id))
.filter(Boolean)
.map((dep) => dep.payload.label);
}
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 DataList({ 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;
return (
<dl class="jg-data">
{entries.map(([k, v]) => (
<>
<dt key={k + '-dt'}>{k}</dt>
<dd key={k + '-dd'}>{typeof v === 'string' ? v : JSON.stringify(v)}</dd>
</>
))}
</dl>
);
}
function NodeView({ n, cancellable, onCancel }) {
const glyph = STATE_GLYPH[n.state] || '?';
const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
return (
<div class="jg-node">
<div class="jg-row">
<span class={'jg-state jg-state-' + n.state.toLowerCase()}
title={n.state + (n.error ? ' — ' + n.error : '')}>
{glyph}
</span>
{' '}
<span class="jg-label">{n.payload.label}</span>
{showCancel && (
<button type="button" class="jg-cancel-btn" title={'cancel ' + n.payload.label}
onClick={() => onCancel && onCancel(n.id)}>
</button>
)}
</div>
{n._waitsOn && n._waitsOn.length > 0 && (
<div class="jg-waits-on">waits on: {n._waitsOn.join(', ')}</div>
)}
<DataList data={n.payload.data} />
{n.error && <pre class="jg-error">{n.error}</pre>}
{n._children.map((child) => (
<NodeView key={child.id} n={child} cancellable={cancellable} onCancel={onCancel} />
))}
</div>
);
}
function FilterBar({ selectedStates, onToggle }) {
return (
<div class="jg-filter">
{ALL_STATES.map((state) => {
const id = 'jg-filter-' + state.toLowerCase();
return (
<label key={state} for={id} class={'jg-filter-label jg-state-' + state.toLowerCase()}>
<input type="checkbox" id={id} checked={selectedStates.has(state)}
onChange={() => onToggle(state)} />
{' '}{STATE_GLYPH[state] + ' ' + state}
</label>
);
})}
</div>
);
}
// `endpoint` plus the current filter selection as a `states=` query
// param omitted entirely when every state is checked, so the
// unfiltered default case sends the exact same request as before this
// filter existed.
function fetchUrl(endpoint, selectedStates) {
if (!endpoint) return null;
if (selectedStates.size >= ALL_STATES.length) return endpoint;
const url = new URL(endpoint, window.location.origin);
url.searchParams.set('states', Array.from(selectedStates).join(','));
return url.pathname + url.search;
}
// `refreshToken` is not read anywhere in the body its only job is to
// change identity so the effect below re-runs, giving a host (or
// `mountJobqGraph`) an explicit "refetch now" lever without an
// imperative ref into this component.
export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }) {
const [selectedStates, setSelectedStates] = useState(
() => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))),
);
const [nodes, setNodes] = useState(null); // null = loading, [] = empty-but-loaded
const [error, setError] = useState(null);
const toggleState = useCallback((state) => {
setSelectedStates((prev) => {
const next = new Set(prev);
if (next.has(state)) next.delete(state); else next.add(state);
return next;
});
}, []);
useEffect(() => {
const url = fetchUrl(endpoint, selectedStates);
if (!url) return undefined;
let cancelled = false;
(async () => {
try {
const r = await fetch(url);
if (!r.ok) throw new Error('http ' + r.status);
const data = await r.json();
if (cancelled) return;
setNodes(data);
setError(null);
if (onUpdate) onUpdate(data);
} catch (err) {
if (cancelled) return;
setError(String(err));
}
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedStates is a Set;
// its *contents* are what should retrigger the fetch, not its identity, and the
// string form below already changes identity exactly when contents do.
}, [endpoint, Array.from(selectedStates).sort().join(','), refreshToken]);
return (
<div class="jg-root">
<FilterBar selectedStates={selectedStates} onToggle={toggleState} />
<div class="jg-body">
{error ? (
<p class="jg-error-msg">fetch failed: {error}</p>
) : nodes === null ? (
<p class="jg-empty">loading</p>
) : !nodes.length ? (
<p class="jg-empty">empty</p>
) : (
buildTree(nodes).map((root) => (
<NodeView key={root.id} n={root} cancellable={cancellable} onCancel={onCancel} />
))
)}
</div>
</div>
);
}
// Imperative mount helper for a call site that isn't itself a JSX file
// (dashboard/src/builds.js plain `.js`, no per-file JSX pragma to
// write `<JobqGraph .../>` inline). Returns a handle: `.refresh()`
// (re-fetch with the current props) and `.update(props)` (merge new
// props e.g. a different `endpoint` and re-render).
export function mountJobqGraph(container, initialProps) {
let props = initialProps;
let token = 0;
const draw = () => render(h(JobqGraph, { ...props, refreshToken: token }), container);
draw();
return {
refresh() { token += 1; draw(); },
update(next) { props = { ...props, ...next }; draw(); },
};
}

View file

@ -1,13 +1,9 @@
/* jobq-graph.css styles for the `JobqGraph` Preact component /* hive-jobq-graph.css shadow-scoped styles for <hive-jobq-graph>. Theme
(`./JobqGraph.jsx`), rendered into light DOM under `.jg-root`. custom properties (--fg, --red, ...) pierce the shadow boundary by
`@import` this from a page-level CSS file (dashboard) or a inheritance and are used directly; only plain class rules live here,
component-level one (swarm-ui), matching how every other same split every other shadow-DOM component (<hive-dialog>, ...) uses. */
`@hive/shared` stylesheet is consumed see JobqGraph.jsx's top comment
for why this file is never imported from JS. Theme custom properties
(--fg, --red, ...) are plain inherited custom properties here, same as
any other light-DOM rule. */
.jg-root { :host {
display: block; display: block;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;

View file

@ -0,0 +1,240 @@
// 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.
//
// `Node`-kind dep edges get a plain "waits on: <label>" text line — see
// `buildTree`'s `_waitsOn` resolution. `cancellable` attribute adds a
// per-node cancel button dispatching `hive-jobq-graph-cancel` instead of
// POSTing anything itself — see `CANCELLABLE_STATES` + the click-delegate
// in `connectedCallback` below for both. A per-state checkbox row above
// the tree re-fetches `endpoint` with `?states=<checked>` on toggle — see
// `_buildFilterBar`/`_fetchUrl` below and docs/web-ui/dashboard.md's
// R3BU1LD QU3U3 section for the full filter rationale.
//
// Usage: <hive-jobq-graph endpoint="/api/jobq/graph"></hive-jobq-graph> —
// self-fetches on connect. `.refresh()` re-fetches + re-renders;
// `.render(nodes)` renders host-pushed data directly, no fetch. Every
// render dispatches a bubbling/composed `hive-jobq-graph-update` event
// (`detail: { nodes }`) so a host needing the raw list (a count badge, a
// live-log panel) listens instead of running its own parallel fetch.
//
// Shadow DOM + own styles: theme custom properties (--fg, --red, ...)
// pierce the shadow boundary by inheritance; only plain class rules 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: '·',
};
// Declaration order doubles as render order for the filter checkboxes —
// matches `hive_jobq_wire::ALL_STATES` on the wire, so the row reads in the
// same lifecycle order the rollup endpoint counts in.
const ALL_STATES = Object.keys(STATE_GLYPH);
// Product call: "default selection filters out skipped and done."
const DEFAULT_HIDDEN_STATES = new Set(['Done', 'Skipped']);
// Non-terminal states a cancel button makes sense on. Finishing is
// included — "own logic done, children still running" is still a subtree
// worth stopping early.
const CANCELLABLE_STATES = new Set(['Pending', 'Running', 'Finishing']);
// 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 reordering; a dependency is named in text (see
// `_waitsOn` below), not implied by render position.
//
// Also resolves each node's `Node`-kind deps to the referenced node's own
// label, once, so rendering doesn't need a second lookup pass. Looked up
// globally (`byId`, every node in this snapshot), not scoped to siblings —
// a dep is expected to always name a sibling, but resolving globally means
// a label still shows correctly even if that expectation is ever wrong,
// instead of silently dropping the edge. A dep naming an id outside this
// snapshot (a filtered view) or a `Resource`-kind dep has nothing to point
// at and is simply not listed.
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);
}
for (const n of byId.values()) {
n._waitsOn = (n.deps || [])
.filter((d) => d.kind === 'Node')
.map((d) => byId.get(d.id))
.filter(Boolean)
.map((dep) => dep.payload.label);
}
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, cancellable) {
const glyph = STATE_GLYPH[n.state] || '?';
const showCancel = cancellable && CANCELLABLE_STATES.has(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),
showCancel
? el('button', {
type: 'button', class: 'jg-cancel-btn', 'data-cancel-id': String(n.id),
title: 'cancel ' + n.payload.label,
}, '✕')
: null,
);
const wrap = el('div', { class: 'jg-node' }, row);
if (n._waitsOn && n._waitsOn.length) {
wrap.append(el('div', { class: 'jg-waits-on' }, 'waits on: ' + n._waitsOn.join(', ')));
}
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, cancellable));
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;
// Set before the first `refresh()` call below, so the very first fetch
// already carries the default filter rather than flashing every state
// and re-fetching a moment later.
this._selectedStates = new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s)));
this._root = attachShadowCss(this, graphCss);
this._filterBar = this._buildFilterBar();
this._body = el('div', { class: 'jg-body' });
this._root.append(this._filterBar, this._body);
// One delegated listener rather than a per-button one — cancel buttons
// come and go on every re-render, a delegated listener on the stable
// container doesn't need rebinding.
this._body.addEventListener('click', (e) => {
const btn = e.target.closest('.jg-cancel-btn');
if (!btn) return;
this.dispatchEvent(new CustomEvent('hive-jobq-graph-cancel', {
detail: { id: Number(btn.dataset.cancelId) },
bubbles: true,
composed: true,
}));
});
this.refresh();
}
// One checkbox per `ALL_STATES` entry, pre-ticked per `_selectedStates`.
// Built once at connect — toggling a box mutates `_selectedStates` and
// re-fetches rather than rebuilding the row, so focus/scroll position in
// the row itself is never disturbed by a data refresh.
_buildFilterBar() {
const bar = el('div', { class: 'jg-filter' });
for (const state of ALL_STATES) {
const id = 'jg-filter-' + state.toLowerCase();
const cb = el('input', { type: 'checkbox', id });
cb.checked = this._selectedStates.has(state);
cb.addEventListener('change', () => {
if (cb.checked) this._selectedStates.add(state);
else this._selectedStates.delete(state);
this.refresh();
});
const label = el('label', { for: id, class: 'jg-filter-label jg-state-' + state.toLowerCase() },
cb, ' ', STATE_GLYPH[state] + ' ' + state);
bar.append(label);
}
return bar;
}
// `endpoint` plus the current filter selection as a `states=` query
// param — omitted entirely when every state is checked, so the
// unfiltered default case sends the exact same request as before this
// filter existed.
_fetchUrl() {
const endpoint = this.getAttribute('endpoint');
if (!endpoint) return null;
if (!this._selectedStates || this._selectedStates.size >= ALL_STATES.length) return endpoint;
const url = new URL(endpoint, window.location.origin);
url.searchParams.set('states', Array.from(this._selectedStates).join(','));
return url.pathname + url.search;
}
// Re-fetch `endpoint` (filtered by the current checkbox selection) 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 url = this._fetchUrl();
if (!url || !this._body) return;
let nodes;
try {
const r = await fetch(url);
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);
const cancellable = this.hasAttribute('cancellable');
for (const root of roots) this._body.append(renderNode(root, cancellable));
}
this.dispatchEvent(new CustomEvent('hive-jobq-graph-update', {
detail: { nodes: nodes || [] },
bubbles: true,
composed: true,
}));
}
}
customElements.define('hive-jobq-graph', HiveJobqGraph);

View file

@ -345,11 +345,10 @@ impl JobQueue {
/// costs nothing at that size. /// costs nothing at that size.
/// ///
/// A node whose *parent* got filtered out still rides with its original /// A node whose *parent* got filtered out still rides with its original
/// `parent` id — `JobqGraph` (`@hive/shared/jobq-graph.js`, the one /// `parent` id — `<hive-jobq-graph>` (the one consumer) already treats
/// consumer) already treats an unresolvable parent as a new root /// an unresolvable parent as a new root (`buildTree`'s fallback), so a
/// (`buildTree`'s fallback), so a filtered-out ancestor surfaces a /// filtered-out ancestor surfaces a still-matching descendant one level
/// still-matching descendant one level higher rather than hiding or /// higher rather than hiding or orphaning it.
/// orphaning it.
/// ///
/// The projection itself is [`hive_jobq_wire`]'s; all this layer supplies /// The projection itself is [`hive_jobq_wire`]'s; all this layer supplies
/// is *which* nodes to show — see [`visible_roots`] for why the graph /// is *which* nodes to show — see [`visible_roots`] for why the graph

View file

@ -152,7 +152,7 @@ in
# Same lockfile as `frontend`/`swarm-ui` above — recompute in # Same lockfile as `frontend`/`swarm-ui` above — recompute in
# lockstep with those two whenever `frontend/package-lock.json` # lockstep with those two whenever `frontend/package-lock.json`
# changes (`prefetch-npm-deps frontend/package-lock.json`). # changes (`prefetch-npm-deps frontend/package-lock.json`).
npmDepsHash = "sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI="; npmDepsHash = "sha256-LIwW5Nn9cSqJHCm1czPIVxQdP5OqWk7U/4kvkwaj2ts=";
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild

View file

@ -40,7 +40,7 @@ buildNpmPackage {
# Update whenever the lockfile changes. Recompute locally with the # Update whenever the lockfile changes. Recompute locally with the
# same command (`pkgs.prefetch-npm-deps`), or let the build fail # same command (`pkgs.prefetch-npm-deps`), or let the build fail
# and copy the actual hash from the error message. # and copy the actual hash from the error message.
npmDepsHash = "sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI="; npmDepsHash = "sha256-LIwW5Nn9cSqJHCm1czPIVxQdP5OqWk7U/4kvkwaj2ts=";
# `npm run build` recurses into all workspaces (`--workspaces # `npm run build` recurses into all workspaces (`--workspaces
# --if-present`). The workspaces' build scripts each run their own # --if-present`). The workspaces' build scripts each run their own

View file

@ -35,7 +35,7 @@ buildNpmPackage {
# Recompute with `prefetch-npm-deps frontend/package-lock.json` # Recompute with `prefetch-npm-deps frontend/package-lock.json`
# whenever the lockfile changes — same value as `./frontend.nix`'s # whenever the lockfile changes — same value as `./frontend.nix`'s
# `npmDepsHash`, both hash the one shared lockfile. # `npmDepsHash`, both hash the one shared lockfile.
npmDepsHash = "sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI="; npmDepsHash = "sha256-LIwW5Nn9cSqJHCm1czPIVxQdP5OqWk7U/4kvkwaj2ts=";
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild