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 **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
`<hive-jobq-graph endpoint="/api/jobq/graph">`** (the shared generic `JobqGraph`** (the shared generic graph-viewer Preact component,
graph-viewer component, `@hive/shared/jobq-graph.js`) — `builds.js` `@hive/shared/jobq-graph.js` — the same one swarm-ui uses) —
does not render the queue itself; it just mounts the element and `builds.js` does not render the queue itself; it just mounts the component
listens for its `hive-jobq-graph-update` event to drive the two things (`mountJobqGraph(container, props)`, since this page has no JSX
below it that the generic view doesn't show. The component owns pipeline) with an `onUpdate` callback to drive the two things below it
fetching, cold and live: `GET /api/jobq/graph` on mount, and that the generic view doesn't show. The component owns fetching, cold
`.refresh()` on every `rebuild_queue_changed` SSE tick (that event is a and live: `GET /api/jobq/graph` on mount, and a refetch via the mount
bare `{ seq }` trigger — it carried a typed queue snapshot until every handle's `.refresh()` on every `rebuild_queue_changed` SSE tick (that
consumer had moved to the generic endpoint, and now carries none; both event is a bare `{ seq }` trigger — it carried a typed queue snapshot
dashboard pages treat the tick as a pure refetch trigger). 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 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
@ -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 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 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 a per-node cancel button (`✕`) on any non-terminal row — the button
dispatches `hive-jobq-graph-cancel`, and the page does the actual calls the mount's `onCancel(id)` callback, and the page does the actual
`POST /api/rebuild-queue/{id}/cancel`, matching the wire event as its `POST /api/rebuild-queue/{id}/cancel`, matching that callback to 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
@ -266,7 +267,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 `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 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
@ -284,16 +285,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 whose `payload.data.build_log_id` is set — read from the `onUpdate`
`hive-jobq-graph-update` event's node list, same source as the count callback's node list, same source as the count pill, no separate
pill, no separate fetch — and **polls** `GET /api/build-log/{id}` fetch — and **polls** `GET /api/build-log/{id}` every 2s
every 2s (`fetchAndRenderLiveLog` / `liveLogPollTimer`); not an (`fetchAndRenderLiveLog` / `liveLogPollTimer`); not an `EventSource`
`EventSource` (that's the BUILD L0GS tab's own per-row expand view (that's the BUILD L0GS tab's own per-row expand view below — `GET
below — `GET /api/build-logs/id/{id}/stream`, real SSE, replays /api/build-logs/id/{id}/stream`, real SSE, replays accumulated output
accumulated output on connect — a separate mechanism). The live-log on connect — a separate mechanism). The live-log panel lives in its
panel lives in its own container outside `#rebuild-queue-section` so own container outside `#rebuild-queue-section` so the mounted
the mounted `<hive-jobq-graph>`'s own re-renders never disturb the `JobqGraph`'s own re-renders never disturb the open poll; it hides
open poll; it hides when nothing is building. 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,12 +1000,16 @@
"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,6 +83,14 @@ 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,6 +10,7 @@
"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,6 +9,7 @@
(`.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 first-spawns. Rendered from GET /api/jobq/graph by the
<hive-jobq-graph>; `rebuild_queue_changed` over `JobqGraph` Preact component (@hive/shared/jobq-graph.js);
/api/dashboard/stream is the refresh trigger and carries no `rebuild_queue_changed` over /api/dashboard/stream is the
payload of its own. Default tab. --> refresh trigger and carries no 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,18 +16,17 @@ 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 '@hive/shared/jobq-graph.js'; import { mountJobqGraph } from '@hive/shared/jobq-graph.js';
// ─── derived state ─────────────────────────────────────────────────────────── // ─── derived state ───────────────────────────────────────────────────────────
let metaInputsState = []; let metaInputsState = [];
let metaUpdateRunning = false; let metaUpdateRunning = false;
// Kept in sync from <hive-jobq-graph>'s `hive-jobq-graph-update` event // Kept in sync from JobqGraph's `onUpdate` callback (see mountRebuildQueue
// (see mountJobqGraph below) — the component owns fetching GET // below) — the component owns fetching GET /api/jobq/graph and renders
// /api/jobq/graph and renders the tree itself; this flat array exists only // the tree itself; this flat array exists only for the two things it
// for the two things it doesn't render: the count-pill and the live-log // doesn't render: the count-pill and the live-log panel.
// panel.
let jobqNodes = []; let jobqNodes = [];
let jobqGraphEl = null; let jobqGraphHandle = null;
function syncFromSnapshot(s) { function syncFromSnapshot(s) {
metaInputsState = (s.meta_inputs || []).slice(); metaInputsState = (s.meta_inputs || []).slice();
@ -128,31 +127,33 @@ function renderMetaInputs(s) {
} }
// ─── rebuild queue ──────────────────────────────────────────────────────────── // ─── rebuild queue ────────────────────────────────────────────────────────────
// R3BU1LD QU3U3 is <hive-jobq-graph> directly (mara: "replace the build // R3BU1LD QU3U3 is `JobqGraph` (Preact, mounted imperatively — see
// queue tab with this component") — no hand-rolled tree/roll-up rendering // mountJobqGraph's own doc comment in @hive/shared/jobq-graph.js for why
// here anymore. The component owns fetching GET /api/jobq/graph and its // this file has no JSX pipeline) directly — no hand-rolled tree/roll-up
// own refresh(); this page just listens for its `hive-jobq-graph-update` // rendering here anymore. The component owns fetching GET /api/jobq/graph
// event to keep `jobqNodes` (the flat array) in sync for the two things // and its own refetch (`.refresh()` on the mount handle); this page just
// the generic view doesn't render itself: the count-pill and the live-log // reads its `onUpdate` callback to keep `jobqNodes` (the flat array) in
// panel below. // 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 // Cancel is the one action this page *does* wire up: the `cancellable`
// `cancellable` attribute turns on the component's own per-node cancel // prop turns on the component's own per-node cancel button, which calls
// button, which dispatches `hive-jobq-graph-cancel` rather than posting // `onCancel(id)` rather than posting anything — the endpoint
// anything — the endpoint (`/api/rebuild-queue/{id}/cancel`) is this // (`/api/rebuild-queue/{id}/cancel`) is this page's domain concept, not
// page's domain concept, not the generic component's. // the generic component's.
function mountJobqGraph() { function mountRebuildQueue() {
const root = $('rebuild-queue-section'); const root = $('rebuild-queue-section');
if (!root) return; if (!root) return;
root.replaceChildren(); root.replaceChildren();
jobqGraphEl = el('hive-jobq-graph', { endpoint: '/api/jobq/graph', cancellable: '' }); jobqGraphHandle = mountJobqGraph(root, {
jobqGraphEl.addEventListener('hive-jobq-graph-update', (e) => { endpoint: '/api/jobq/graph',
jobqNodes = e.detail.nodes || []; cancellable: true,
onUpdate: (nodes) => {
jobqNodes = nodes || [];
renderRebuildLiveLog(); renderRebuildLiveLog();
updateRebuildCount(); updateRebuildCount();
}); },
jobqGraphEl.addEventListener('hive-jobq-graph-cancel', async (e) => { onCancel: async (id) => {
const { id } = e.detail;
const node = jobqNodes.find((n) => n.id === id); const node = jobqNodes.find((n) => n.id === id);
const label = node ? node.payload.label : 'node ' + id; const label = node ? node.payload.label : 'node ' + id;
if (!(await themedConfirm({ if (!(await themedConfirm({
@ -164,12 +165,12 @@ function mountJobqGraph() {
if (!r.ok) throw new Error('http ' + r.status); if (!r.ok) throw new Error('http ' + r.status);
// No manual refresh: cancel flips node state, which fires // No manual refresh: cancel flips node state, which fires
// rebuild_queue_changed over SSE — the existing handler below // rebuild_queue_changed over SSE — the existing handler below
// already calls jobqGraphEl.refresh() on that tick. // already calls jobqGraphHandle.refresh() on that tick.
} catch (err) { } catch (err) {
console.error('cancel failed', err); console.error('cancel failed', err);
} }
},
}); });
root.append(jobqGraphEl);
} }
// ─── running-rebuild live log ───────────────────────────────────────────────── // ─── running-rebuild live log ─────────────────────────────────────────────────
@ -305,8 +306,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 <hive-jobq-graph>'s own hive-jobq-graph-update event (see // off JobqGraph's own onUpdate callback (see mountRebuildQueue) —
// mountJobqGraph) — nothing to render here directly. // nothing to render here directly.
} }
// ─── BUILD L0GS tab ─────────────────────────────────────────────────────────── // ─── BUILD L0GS tab ───────────────────────────────────────────────────────────
@ -469,8 +470,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. <hive-jobq-graph> owns the actual fetch. // refetch trigger. `JobqGraph` owns the actual fetch.
if (jobqGraphEl) jobqGraphEl.refresh(); if (jobqGraphHandle) jobqGraphHandle.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);
@ -501,9 +502,9 @@ async function refreshState() {
async function init() { async function init() {
initServerWarnings(); initServerWarnings();
bindAsyncForms(() => refreshState()); bindAsyncForms(() => refreshState());
// Self-fetches on mount (<hive-jobq-graph>'s own connectedCallback) — // Self-fetches on mount (JobqGraph's own effect) — no explicit initial
// no explicit initial fetch needed here, unlike meta inputs below. // fetch needed here, unlike meta inputs below.
mountJobqGraph(); mountRebuildQueue();
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 it for // go refetch" treatment builds.js already gives its JobqGraph mount
// <hive-jobq-graph>.refresh(). // handle's .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 <hive-jobq-graph>.refresh() (its own // /builds.html gives it for its JobqGraph mount handle's .refresh()
// separate subscription). // (its own 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,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.", "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",
@ -25,7 +28,8 @@
"./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/hive-jobq-graph.js" "./jobq-graph.js": "./src/jobq-graph/JobqGraph.jsx",
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css"
}, },
"files": [ "files": [
"src/" "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 /* jobq-graph.css styles for the `JobqGraph` Preact component
custom properties (--fg, --red, ...) pierce the shadow boundary by (`./JobqGraph.jsx`), rendered into light DOM under `.jg-root`.
inheritance and are used directly; only plain class rules live here, `@import` this from a page-level CSS file (dashboard) or a
same split every other shadow-DOM component (<hive-dialog>, ...) uses. */ 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; display: block;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;

View file

@ -345,10 +345,11 @@ 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 — `<hive-jobq-graph>` (the one consumer) already treats /// `parent` id — `JobqGraph` (`@hive/shared/jobq-graph.js`, the one
/// an unresolvable parent as a new root (`buildTree`'s fallback), so a /// consumer) already treats an unresolvable parent as a new root
/// filtered-out ancestor surfaces a still-matching descendant one level /// (`buildTree`'s fallback), so a filtered-out ancestor surfaces a
/// higher rather than hiding or orphaning it. /// still-matching descendant one level higher rather than hiding or
/// 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-LIwW5Nn9cSqJHCm1czPIVxQdP5OqWk7U/4kvkwaj2ts="; npmDepsHash = "sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI=";
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-LIwW5Nn9cSqJHCm1czPIVxQdP5OqWk7U/4kvkwaj2ts="; npmDepsHash = "sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI=";
# `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-LIwW5Nn9cSqJHCm1czPIVxQdP5OqWk7U/4kvkwaj2ts="; npmDepsHash = "sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI=";
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild