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

@ -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

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

View file

@ -1,28 +1,36 @@
// JobqGraph.js <JobqGraph>, a Preact component rendering any // JobqGraph.jsx <JobqGraph>, a Preact component rendering any
// hive_jobq graph from the wire shape GET /api/jobq/graph serves (any // hive_jobq graph from the wire shape GET /api/jobq/graph serves (any
// endpoint serving `Vec<hive_jobq_wire::GraphNode>` works see // endpoint serving `Vec<hive_jobq_wire::GraphNode>` works see
// hive-jobq-wire's README). Renders an indented state tree: // hive-jobq-wire's README). Renders an indented state tree:
// `payload.label` verbatim, `payload.data` as a generic key/value list. // `payload.label` verbatim, `payload.data` as a generic key/value list.
// Light DOM, since both the dashboard (no JSX pipeline) and swarm-ui // Light DOM, shared by the dashboard and swarm-ui both esbuild
// (JSX) consume it. Written with plain `h()` calls, not JSX, so one file // configs run `jsx: 'automatic', jsxImportSource: 'preact'`, so this
// compiles unmodified under both consumers' esbuild configs. // file is real JSX in either build, not hand-written `h()` calls.
// //
// `cancellable` adds a per-node cancel button calling `onCancel(id)` // `cancellable` adds a per-node cancel button calling `onCancel(id)`
// directly (a plain prop). `onUpdate(nodes)` fires after every fetch, // directly (a plain prop). `onUpdate(nodes)` fires after every fetch,
// for a host needing the raw list (a count badge, a live-log panel) // for a host needing the raw list (a count badge, a live-log panel)
// without its own parallel fetch. // without its own parallel fetch.
// //
// Two ways to use this: JSX (swarm-ui) `<JobqGraph endpoint="..." // Two ways to use this: JSX (swarm-ui, or any future dashboard page
// cancellable onCancel={...} onUpdate={...} />`, a normal component. Or // that renders it directly) `<JobqGraph endpoint="..." cancellable
// imperative mount (dashboard, no JSX) `mountJobqGraph(container, // onCancel={...} onUpdate={...} />`, a normal component. Or imperative
// props)` returns a `{ refresh(), update(props) }` handle; see // mount (dashboard/src/builds.js, which stays plain `.js` a `.jsx`
// dashboard/src/builds.js. // 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 // Styles live in `@hive/shared/jobq-graph.css`, `@import`ed from a
// page/component CSS file rather than imported here the dashboard's // page/component CSS file rather than imported here. This one stays
// JS bundle treats `.css` imports as raw text, swarm-ui's extracts real // necessary regardless of the JSX question above: dashboard bundles
// stylesheets, and importing here would silently pick whichever the // every page entry (including this component, pulled in transitively)
// last consumer's config wanted. // 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 { h, render } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks'; import { useState, useEffect, useCallback } from 'preact/hooks';
@ -91,55 +99,63 @@ function DataList({ data }) {
const isPlainObject = typeof data === 'object' && !Array.isArray(data); const isPlainObject = typeof data === 'object' && !Array.isArray(data);
const entries = isPlainObject ? Object.entries(data) : [['data', data]]; const entries = isPlainObject ? Object.entries(data) : [['data', data]];
if (!entries.length) return null; if (!entries.length) return null;
return h('dl', { class: 'jg-data' }, return (
entries.map(([k, v]) => [ <dl class="jg-data">
h('dt', { key: k + '-dt' }, k), {entries.map(([k, v]) => (
h('dd', { key: k + '-dd' }, typeof v === 'string' ? v : JSON.stringify(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 }) { function NodeView({ n, cancellable, onCancel }) {
const glyph = STATE_GLYPH[n.state] || '?'; const glyph = STATE_GLYPH[n.state] || '?';
const showCancel = cancellable && CANCELLABLE_STATES.has(n.state); const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
return h('div', { class: 'jg-node' }, return (
h('div', { class: 'jg-row' }, <div class="jg-node">
h('span', { <div class="jg-row">
class: 'jg-state jg-state-' + n.state.toLowerCase(), <span class={'jg-state jg-state-' + n.state.toLowerCase()}
title: n.state + (n.error ? ' — ' + n.error : ''), title={n.state + (n.error ? ' — ' + n.error : '')}>
}, glyph), {glyph}
' ', </span>
h('span', { class: 'jg-label' }, n.payload.label), {' '}
showCancel <span class="jg-label">{n.payload.label}</span>
? h('button', { {showCancel && (
type: 'button', class: 'jg-cancel-btn', title: 'cancel ' + n.payload.label, <button type="button" class="jg-cancel-btn" title={'cancel ' + n.payload.label}
onClick: () => onCancel && onCancel(n.id), onClick={() => onCancel && onCancel(n.id)}>
}, '✕')
: null, </button>
), )}
n._waitsOn && n._waitsOn.length </div>
? h('div', { class: 'jg-waits-on' }, 'waits on: ' + n._waitsOn.join(', ')) {n._waitsOn && n._waitsOn.length > 0 && (
: null, <div class="jg-waits-on">waits on: {n._waitsOn.join(', ')}</div>
h(DataList, { data: n.payload.data }), )}
n.error ? h('pre', { class: 'jg-error' }, n.error) : null, <DataList data={n.payload.data} />
n._children.map((child) => h(NodeView, { key: child.id, n: child, cancellable, onCancel })), {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 }) { function FilterBar({ selectedStates, onToggle }) {
return h('div', { class: 'jg-filter' }, return (
ALL_STATES.map((state) => { <div class="jg-filter">
const id = 'jg-filter-' + state.toLowerCase(); {ALL_STATES.map((state) => {
return h('label', { const id = 'jg-filter-' + state.toLowerCase();
key: state, for: id, class: 'jg-filter-label jg-state-' + state.toLowerCase(), return (
}, <label key={state} for={id} class={'jg-filter-label jg-state-' + state.toLowerCase()}>
h('input', { <input type="checkbox" id={id} checked={selectedStates.has(state)}
type: 'checkbox', id, checked: selectedStates.has(state), onChange={() => onToggle(state)} />
onChange: () => onToggle(state), {' '}{STATE_GLYPH[state] + ' ' + state}
}), </label>
' ', STATE_GLYPH[state] + ' ' + state, );
); })}
}), </div>
); );
} }
@ -157,10 +173,8 @@ function fetchUrl(endpoint, selectedStates) {
// `refreshToken` is not read anywhere in the body its only job is to // `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 // change identity so the effect below re-runs, giving a host (or
// `mountJobqGraph`) an explicit "refetch now" lever without needing an // `mountJobqGraph`) an explicit "refetch now" lever without an
// imperative ref into this component. Same rationale `hive-jobq-graph`'s // imperative ref into this component.
// public `.refresh()` method served, Preact-idiomatic shape instead of a
// custom-element method.
export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }) { export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }) {
const [selectedStates, setSelectedStates] = useState( const [selectedStates, setSelectedStates] = useState(
() => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))), () => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))),
@ -200,21 +214,31 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
// string form below already changes identity exactly when contents do. // string form below already changes identity exactly when contents do.
}, [endpoint, Array.from(selectedStates).sort().join(','), refreshToken]); }, [endpoint, Array.from(selectedStates).sort().join(','), refreshToken]);
return h('div', { class: 'jg-root' }, return (
h(FilterBar, { selectedStates, onToggle: toggleState }), <div class="jg-root">
h('div', { class: 'jg-body' }, <FilterBar selectedStates={selectedStates} onToggle={toggleState} />
error ? h('p', { class: 'jg-error-msg' }, 'fetch failed: ' + error) <div class="jg-body">
: nodes === null ? h('p', { class: 'jg-empty' }, 'loading…') {error ? (
: !nodes.length ? h('p', { class: 'jg-empty' }, 'empty') <p class="jg-error-msg">fetch failed: {error}</p>
: buildTree(nodes).map((root) => h(NodeView, { key: root.id, n: root, cancellable, onCancel })), ) : 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 non-JSX host (the dashboard). Returns a // Imperative mount helper for a call site that isn't itself a JSX file
// handle mirroring the old custom element's public surface: // (dashboard/src/builds.js plain `.js`, no per-file JSX pragma to
// `.refresh()` (re-fetch with the current props) and `.update(props)` // write `<JobqGraph .../>` inline). Returns a handle: `.refresh()`
// (merge new props e.g. a different `endpoint` and re-render). // (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) { export function mountJobqGraph(container, initialProps) {
let props = initialProps; let props = initialProps;
let token = 0; let token = 0;

View file

@ -1,8 +1,8 @@
/* jobq-graph.css styles for the `JobqGraph` Preact component /* jobq-graph.css styles for the `JobqGraph` Preact component
(`./JobqGraph.js`), rendered into light DOM under `.jg-root`. (`./JobqGraph.jsx`), rendered into light DOM under `.jg-root`.
`@import` this from a page-level CSS file (dashboard) or a `@import` this from a page-level CSS file (dashboard) or a
component-level one (swarm-ui), matching how every other component-level one (swarm-ui), matching how every other
`@hive/shared` stylesheet is consumed see JobqGraph.js's top comment `@hive/shared` stylesheet is consumed see JobqGraph.jsx's top comment
for why this file is never imported from JS. Theme custom properties for why this file is never imported from JS. Theme custom properties
(--fg, --red, ...) are plain inherited custom properties here, same as (--fg, --red, ...) are plain inherited custom properties here, same as
any other light-DOM rule. */ any other light-DOM rule. */