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.
This commit is contained in:
iris 2026-08-16 14:58:15 +02:00 committed by mara
commit 37161cd136
13 changed files with 344 additions and 336 deletions

View file

@ -228,16 +228,18 @@ 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, a Preact port
of the former shadow-DOM `<hive-jobq-graph>` custom element) — `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 +252,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 +268,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 +286,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

@ -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.js",
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css"
},
"files": [
"src/"

View file

@ -0,0 +1,229 @@
// JobqGraph.js — <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). Preact port of the former shadow-DOM
// `<hive-jobq-graph>` custom element, same contract (indented state
// tree, `payload.label` verbatim, `payload.data` as a generic key/value
// list) — light DOM now, since both the dashboard (no JSX pipeline) and
// swarm-ui (JSX) consume it. Written with plain `h()` calls, not JSX, so
// one file compiles unmodified under both consumers' esbuild configs.
//
// `cancellable` adds a per-node cancel button calling `onCancel(id)`
// directly (a plain prop, not a DOM CustomEvent — no shadow boundary to
// cross anymore). `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) — `<JobqGraph endpoint="..."
// cancellable onCancel={...} onUpdate={...} />`, a normal component. Or
// imperative mount (dashboard, no JSX) — `mountJobqGraph(container,
// props)` returns a `{ refresh(), update(props) }` handle; see
// dashboard/src/builds.js.
//
// Styles live in `@hive/shared/jobq-graph.css`, `@import`ed from a
// page/component CSS file rather than imported here — the dashboard's
// JS bundle treats `.css` imports as raw text, swarm-ui's extracts real
// stylesheets, and importing here would silently pick whichever the
// last consumer's config wanted.
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 h('dl', { class: 'jg-data' },
entries.map(([k, v]) => [
h('dt', { key: k + '-dt' }, k),
h('dd', { key: k + '-dd' }, typeof v === 'string' ? v : JSON.stringify(v)),
]),
);
}
function NodeView({ n, cancellable, onCancel }) {
const glyph = STATE_GLYPH[n.state] || '?';
const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
return h('div', { class: 'jg-node' },
h('div', { class: 'jg-row' },
h('span', {
class: 'jg-state jg-state-' + n.state.toLowerCase(),
title: n.state + (n.error ? ' — ' + n.error : ''),
}, glyph),
' ',
h('span', { class: 'jg-label' }, n.payload.label),
showCancel
? h('button', {
type: 'button', class: 'jg-cancel-btn', title: 'cancel ' + n.payload.label,
onClick: () => onCancel && onCancel(n.id),
}, '✕')
: null,
),
n._waitsOn && n._waitsOn.length
? h('div', { class: 'jg-waits-on' }, 'waits on: ' + n._waitsOn.join(', '))
: null,
h(DataList, { data: n.payload.data }),
n.error ? h('pre', { class: 'jg-error' }, n.error) : null,
n._children.map((child) => h(NodeView, { key: child.id, n: child, cancellable, onCancel })),
);
}
function FilterBar({ selectedStates, onToggle }) {
return h('div', { class: 'jg-filter' },
ALL_STATES.map((state) => {
const id = 'jg-filter-' + state.toLowerCase();
return h('label', {
key: state, for: id, class: 'jg-filter-label jg-state-' + state.toLowerCase(),
},
h('input', {
type: 'checkbox', id, checked: selectedStates.has(state),
onChange: () => onToggle(state),
}),
' ', STATE_GLYPH[state] + ' ' + state,
);
}),
);
}
// `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 needing an
// imperative ref into this component. Same rationale `hive-jobq-graph`'s
// public `.refresh()` method served, Preact-idiomatic shape instead of a
// custom-element method.
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 h('div', { class: 'jg-root' },
h(FilterBar, { selectedStates, onToggle: toggleState }),
h('div', { class: 'jg-body' },
error ? h('p', { class: 'jg-error-msg' }, 'fetch failed: ' + error)
: nodes === null ? h('p', { class: 'jg-empty' }, 'loading…')
: !nodes.length ? h('p', { class: 'jg-empty' }, 'empty')
: buildTree(nodes).map((root) => h(NodeView, { key: root.id, n: root, cancellable, onCancel })),
),
);
}
// Imperative mount helper for a non-JSX host (the dashboard). Returns a
// handle mirroring the old custom element's public surface:
// `.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,14 @@
/* 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.js`). Light-DOM adaptation of the former shadow-scoped
`<hive-jobq-graph>` stylesheet `:host` became `.jg-root`, everything
else unchanged. `@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.js'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 no shadow boundary to pierce anymore. */
: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