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.
This commit is contained in:
iris 2026-08-16 15:07:16 +02:00 committed by mara
commit e14887164b
4 changed files with 103 additions and 71 deletions

View file

@ -1,227 +0,0 @@
// 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). Renders an indented state tree:
// `payload.label` verbatim, `payload.data` as a generic key/value list.
// Light DOM, 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). `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(); },
};
}