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:
parent
ecf01f5e87
commit
e14887164b
4 changed files with 103 additions and 71 deletions
|
|
@ -83,6 +83,14 @@ await build({
|
|||
// page-stylesheet bundling below (`loader: { '.css': 'css' }`), which
|
||||
// runs as its own esbuild invocation over different entry points.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
"./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/JobqGraph.js",
|
||||
"./jobq-graph.js": "./src/jobq-graph/JobqGraph.jsx",
|
||||
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css"
|
||||
},
|
||||
"files": [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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.
|
||||
// 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) — `<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.
|
||||
// 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 — 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.
|
||||
// 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';
|
||||
|
|
@ -91,55 +99,63 @@ function DataList({ data }) {
|
|||
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)),
|
||||
]),
|
||||
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 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 })),
|
||||
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 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,
|
||||
);
|
||||
}),
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -157,10 +173,8 @@ function fetchUrl(endpoint, selectedStates) {
|
|||
|
||||
// `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.
|
||||
// `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))),
|
||||
|
|
@ -200,21 +214,31 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
|
|||
// 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 })),
|
||||
),
|
||||
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 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).
|
||||
// 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;
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/* 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
|
||||
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
|
||||
(--fg, --red, ...) are plain inherited custom properties here, same as
|
||||
any other light-DOM rule. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue