Compare commits

...
Author SHA1 Message Date
iris
92bb5340b7 nix: bump npmDepsHash for the new preact dependency
frontend/package-lock.json changed (preact added to dashboard + shared
in the earlier commits on this branch) but the fixed-output npm-deps
hash pinned in nix/checks.nix, nix/packages/frontend.nix, and
nix/packages/swarm-ui.nix (all three hash the one shared lockfile,
per checks.nix's own comment) wasn't updated to match — argus caught
it on review. Recomputed with nix run nixpkgs#prefetch-npm-deps.
2026-08-16 15:18:25 +02:00
iris
e14887164b jobq-graph: author JobqGraph in real JSX, not hand-written h() calls
Mara on PR#3315: "shouldnt the pattern be that the old dashboard has a
dep on preact and has a preact instance running for the jobq view?
then we could get rid of a lot of extra plumbing" - right: the plain
h() authoring existed only to dodge adding JSX support to the
dashboard's esbuild config, and that dodge is exactly the plumbing to
remove now that the dashboard already depends on preact.

- JobqGraph.js -> JobqGraph.jsx, rewritten in real JSX.
- dashboard/build.mjs: added jsx: 'automatic', jsxImportSource: 'preact'
  to the JS-bundle esbuild call (esbuild already picks the jsx loader
  for .jsx by extension; this just sets the transform mode, matching
  swarm-ui's config). No other entry in that bundle uses JSX today.
- shared/package.json: export target updated to the .jsx file.

The CSS-as-page-level-@import structure is unchanged and stays that
way regardless of JSX: dashboard bundles this component transitively
through one esbuild call whose .css loader is 'text' (for the
shadow-DOM components that need their CSS as a literal string), and
esbuild's loader map is global per call, not per-module - importing
CSS from this component would silently pick up that loader too.
Explained in the file's own top comment.

Verified: npm run build (whole workspace) and npm run typecheck
(swarm-ui) clean. Re-ran the same headless-chromium screenshot against
a mock GET /api/jobq/graph payload as the previous verification -
pixel-identical to the h()-based version, confirming this is a pure
authoring-style refactor with no behavior change.
2026-08-16 15:18:25 +02:00
iris
ecf01f5e87 docs: remove historic framing, state what JobqGraph is now 2026-08-16 15:18:25 +02:00
iris
37161cd136 dashboard: replace <hive-jobq-graph> with a shared Preact component
Ports the shadow-DOM <hive-jobq-graph> custom element
(frontend/packages/shared/src/jobq-graph/) to a Preact component
(JobqGraph.js) shared by the dashboard and swarm-ui, per hyperhive#3310.

- JobqGraph.js: written with plain h() calls (no JSX) so the same file
  compiles unmodified under both the dashboard's text-loader CSS config
  and swarm-ui's JSX config. Exports `JobqGraph` for JSX use and
  `mountJobqGraph(container, props)` for the dashboard's non-JSX
  imperative mount, returning a `{refresh(), update()}` handle matching
  the old custom element's public surface. Same rendering contract as
  before: indented state tree, payload.label verbatim, payload.data as
  a generic key/value list, "waits on: <label>" text for Node-kind deps,
  per-state filter checkboxes, optional cancel button.
- jobq-graph.css: light-DOM adaptation of the old shadow-scoped
  stylesheet (:host -> .jg-root, otherwise unchanged).
- dashboard/src/builds.js: local mountJobqGraph() renamed to
  mountRebuildQueue() to avoid colliding with the newly-imported shared
  mountJobqGraph; cancel handling is now a plain onCancel callback
  instead of a DOM CustomEvent listener (no shadow boundary to cross
  anymore).
- dashboard + shared package.json: added preact as a dependency (matches
  swarm-ui's existing pin, 10.29.8) - the dashboard was a vanilla-JS MPA
  with no Preact/JSX pipeline before this.
- Removed the old hive-jobq-graph.js/.css entirely (confirmed via grep
  it had exactly one consumer, dashboard/src/builds.js, so this is a
  clean swap, not parallel maintenance of two implementations).
- Updated stale doc-comment references to the old element name in
  builds.html, tabs.js, swarm.js, docs/web-ui/dashboard.md, and
  hive-c0re/src/job_queue/mod.rs.

Verified: npm run build (whole frontend workspace) and npm run
typecheck (swarm-ui) both clean; cargo build/clippy/test -p hive-c0re
all clean (331 tests, 0 failures); headless-chromium screenshot of
/builds.html against a mock GET /api/jobq/graph payload confirms full
visual/behavioral parity with the old custom element (tree, filter
checkboxes, cancel buttons, error text, waits-on line, data list, live
build log panel).

This covers the dashboard-replacement half of hyperhive#3310 only. The
swarm-ui half (rendering the CreateAgent DAG on the agent-creation page)
is downstream of hyperhive#3306/#3124 landing - no swarm-ui page exists
yet to mount it in.
2026-08-16 15:18:25 +02:00
17 changed files with 375 additions and 339 deletions

View file

@ -228,16 +228,17 @@ not the graph.
**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 is a
bare `{ seq }` trigger — it carried a typed queue snapshot until every
consumer had moved to the generic endpoint, and now carries none; both
dashboard pages treat the tick as a pure refetch trigger).
`JobqGraph`** (the shared generic graph-viewer Preact component,
`@hive/shared/jobq-graph.js` — the same one swarm-ui uses) —
`builds.js` does not render the queue itself; it just mounts the component
(`mountJobqGraph(container, props)`, since this page has no JSX
pipeline) with an `onUpdate` callback 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 a refetch via the mount
handle's `.refresh()` on every `rebuild_queue_changed` SSE tick (that
event is a bare `{ seq }` trigger — it carried a typed queue snapshot
until every consumer had moved to the generic endpoint, and now carries
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
per-agent subgraphs and sub-steps render as nodes within that one
@ -250,10 +251,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
between the two related rows, since a text line needs no continuous
vertical space to draw.
`builds.js` mounts the element with `cancellable` set, which turns on
`builds.js` mounts the component with `cancellable` set, which turns on
a per-node cancel button (`✕`) on any non-terminal row — the button
dispatches `hive-jobq-graph-cancel`, and the page does the actual
`POST /api/rebuild-queue/{id}/cancel`, matching the wire event as its
calls the mount's `onCancel(id)` callback, and the page does the actual
`POST /api/rebuild-queue/{id}/cancel`, matching that callback to its
own domain concept (the component knows nothing about that endpoint).
**Rows carry no source chip, kind label, timing, or build-log
deep-link** — the generic graph wire doesn't carry those fields, and
@ -266,7 +267,7 @@ does not filter `Done` nodes out.
the tree — one per lifecycle state, matching the row glyphs — lets the
operator narrow which root groups render; unchecking a state re-fetches
`GET /api/jobq/graph?states=<checked, comma-joined>` rather than
hiding rows client-side, so `hive-jobq-graph-update`'s node list (and
hiding rows client-side, so the `onUpdate` callback's node list (and
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**
state, which is already its subtree's rolled-up answer, so a group is
@ -284,16 +285,16 @@ 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.
whose `payload.data.build_log_id` is set — read from the `onUpdate`
callback'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
`JobqGraph`'s own re-renders never disturb the open poll; it hides
when nothing is building.
**M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can
selectively `nix flake update`, rendered as an indented tree:

View file

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

View file

@ -83,6 +83,14 @@ await build({
// page-stylesheet bundling below (`loader: { '.css': 'css' }`), which
// runs as its own esbuild invocation over different entry points.
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

View file

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

View file

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

View file

@ -27,10 +27,10 @@
<main class="builds-main">
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
first-spawns. Rendered from GET /api/jobq/graph by
<hive-jobq-graph>; `rebuild_queue_changed` over
/api/dashboard/stream is the refresh trigger and carries no
payload of its own. Default tab. -->
first-spawns. Rendered from GET /api/jobq/graph by the
`JobqGraph` Preact component (@hive/shared/jobq-graph.js);
`rebuild_queue_changed` over /api/dashboard/stream is the
refresh trigger and carries no payload of its own. Default tab. -->
<section class="builds-pane" id="builds-pane-rebuild" data-tab-pane="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>

View file

@ -16,18 +16,17 @@ import { bindAsyncForms } from '@hive/shared/forms.js';
import { themedConfirm } from '@hive/shared/modal.js';
import { fmtAgo, fmtDuration, truncate } from './util.js';
import '@hive/shared/hive-tab-strip.js';
import '@hive/shared/jobq-graph.js';
import { mountJobqGraph } from '@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.
// Kept in sync from JobqGraph's `onUpdate` callback (see mountRebuildQueue
// 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 jobqGraphHandle = null;
function syncFromSnapshot(s) {
metaInputsState = (s.meta_inputs || []).slice();
@ -128,48 +127,50 @@ 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 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 itself: the count-pill and the live-log
// panel below.
// R3BU1LD QU3U3 is `JobqGraph` (Preact, mounted imperatively — see
// mountJobqGraph's own doc comment in @hive/shared/jobq-graph.js for why
// this file has no JSX pipeline) directly — no hand-rolled tree/roll-up
// rendering here anymore. The component owns fetching GET /api/jobq/graph
// and its own refetch (`.refresh()` on the mount handle); this page just
// reads its `onUpdate` callback to keep `jobqNodes` (the flat array) in
// sync for the two things the generic view doesn't render itself: the
// count-pill and the live-log panel below.
//
// Cancel is the one action this page *does* wire up: the
// `cancellable` attribute turns on the component's own per-node cancel
// button, which dispatches `hive-jobq-graph-cancel` rather than posting
// anything — the endpoint (`/api/rebuild-queue/{id}/cancel`) is this
// page's domain concept, not the generic component's.
function mountJobqGraph() {
// Cancel is the one action this page *does* wire up: the `cancellable`
// prop turns on the component's own per-node cancel button, which calls
// `onCancel(id)` rather than posting anything — the endpoint
// (`/api/rebuild-queue/{id}/cancel`) is this page's domain concept, not
// the generic component's.
function mountRebuildQueue() {
const root = $('rebuild-queue-section');
if (!root) return;
root.replaceChildren();
jobqGraphEl = el('hive-jobq-graph', { endpoint: '/api/jobq/graph', cancellable: '' });
jobqGraphEl.addEventListener('hive-jobq-graph-update', (e) => {
jobqNodes = e.detail.nodes || [];
renderRebuildLiveLog();
updateRebuildCount();
jobqGraphHandle = mountJobqGraph(root, {
endpoint: '/api/jobq/graph',
cancellable: true,
onUpdate: (nodes) => {
jobqNodes = nodes || [];
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 ─────────────────────────────────────────────────
@ -305,8 +306,8 @@ function updateRebuildCount() {
function renderAll() {
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.
// off JobqGraph's own onUpdate callback (see mountRebuildQueue) —
// nothing to render here directly.
}
// ─── BUILD L0GS tab ───────────────────────────────────────────────────────────
@ -469,8 +470,8 @@ const SSE_HANDLERS = {
// 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();
// refetch trigger. `JobqGraph` owns the actual fetch.
if (jobqGraphHandle) jobqGraphHandle.refresh();
// Auto-refresh build log list when the queue changes and BUILD L0GS is active.
if (buildTabs && buildTabs.active() === 'buildlogs') {
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
@ -501,9 +502,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();
// Self-fetches on mount (JobqGraph's own effect) — no explicit initial
// fetch needed here, unlike meta inputs below.
mountRebuildQueue();
buildTabs = document.getElementById('builds-tabbar').configure({
tabs: [

View file

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

View file

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

View file

@ -5,6 +5,9 @@
"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",
"main": "./src/index.js",
"dependencies": {
"preact": "10.29.8"
},
"exports": {
".": "./src/index.js",
"./terminal.js": "./src/terminal/terminal.js",
@ -25,7 +28,8 @@
"./hive-menu.js": "./src/hive-menu/hive-menu.js",
"./hive-warn.js": "./src/hive-warn/hive-warn.js",
"./side-panel.js": "./src/side-panel/hive-side-panel.js",
"./jobq-graph.js": "./src/jobq-graph/hive-jobq-graph.js"
"./jobq-graph.js": "./src/jobq-graph/JobqGraph.jsx",
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css"
},
"files": [
"src/"

View file

@ -0,0 +1,251 @@
// 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,240 +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.
//
// `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

@ -1,9 +1,13 @@
/* 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. */
/* jobq-graph.css styles for the `JobqGraph` Preact component
(`./JobqGraph.jsx`), rendered into light DOM under `.jg-root`.
`@import` this from a page-level CSS file (dashboard) or a
component-level one (swarm-ui), matching how every other
`@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. */
:host {
.jg-root {
display: block;
font-family: inherit;
font-size: inherit;

View file

@ -345,10 +345,11 @@ impl JobQueue {
/// costs nothing at that size.
///
/// A node whose *parent* got filtered out still rides with its original
/// `parent` id — `<hive-jobq-graph>` (the one consumer) already treats
/// an unresolvable parent as a new root (`buildTree`'s fallback), so a
/// filtered-out ancestor surfaces a still-matching descendant one level
/// higher rather than hiding or orphaning it.
/// `parent` id — `JobqGraph` (`@hive/shared/jobq-graph.js`, the one
/// consumer) already treats an unresolvable parent as a new root
/// (`buildTree`'s fallback), so a filtered-out ancestor surfaces a
/// still-matching descendant one level higher rather than hiding or
/// orphaning it.
///
/// The projection itself is [`hive_jobq_wire`]'s; all this layer supplies
/// 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
# lockstep with those two whenever `frontend/package-lock.json`
# changes (`prefetch-npm-deps frontend/package-lock.json`).
npmDepsHash = "sha256-LIwW5Nn9cSqJHCm1czPIVxQdP5OqWk7U/4kvkwaj2ts=";
npmDepsHash = "sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI=";
buildPhase = ''
runHook preBuild

View file

@ -40,7 +40,7 @@ buildNpmPackage {
# Update whenever the lockfile changes. Recompute locally with the
# same command (`pkgs.prefetch-npm-deps`), or let the build fail
# and copy the actual hash from the error message.
npmDepsHash = "sha256-LIwW5Nn9cSqJHCm1czPIVxQdP5OqWk7U/4kvkwaj2ts=";
npmDepsHash = "sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI=";
# `npm run build` recurses into all workspaces (`--workspaces
# --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`
# whenever the lockfile changes — same value as `./frontend.nix`'s
# `npmDepsHash`, both hash the one shared lockfile.
npmDepsHash = "sha256-LIwW5Nn9cSqJHCm1czPIVxQdP5OqWk7U/4kvkwaj2ts=";
npmDepsHash = "sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI=";
buildPhase = ''
runHook preBuild