hyperhive/frontend/packages/shared/src/jobq-graph/JobqGraph.jsx
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

251 lines
9.9 KiB
JavaScript

// 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(); },
};
}