diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs index 7276a80a..b18e14ea 100644 --- a/frontend/packages/dashboard/build.mjs +++ b/frontend/packages/dashboard/build.mjs @@ -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 diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index 432a09db..6c3bf4f9 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -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": [ diff --git a/frontend/packages/shared/src/jobq-graph/JobqGraph.js b/frontend/packages/shared/src/jobq-graph/JobqGraph.jsx similarity index 60% rename from frontend/packages/shared/src/jobq-graph/JobqGraph.js rename to frontend/packages/shared/src/jobq-graph/JobqGraph.jsx index 000e35ec..1c8694e5 100644 --- a/frontend/packages/shared/src/jobq-graph/JobqGraph.js +++ b/frontend/packages/shared/src/jobq-graph/JobqGraph.jsx @@ -1,28 +1,36 @@ -// JobqGraph.js — , a Preact component rendering any +// JobqGraph.jsx — , a Preact component rendering any // hive_jobq graph from the wire shape GET /api/jobq/graph serves (any // endpoint serving `Vec` 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) — ``, 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) — ``, 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 ( +
+ {entries.map(([k, v]) => ( + <> +
{k}
+
{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 })), + return ( +
+
+ + {glyph} + + {' '} + {n.payload.label} + {showCancel && ( + + )} +
+ {n._waitsOn && n._waitsOn.length > 0 && ( +
waits on: {n._waitsOn.join(', ')}
+ )} + + {n.error &&
{n.error}
} + {n._children.map((child) => ( + + ))} +
); } 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 ( +
+ {ALL_STATES.map((state) => { + const id = 'jg-filter-' + state.toLowerCase(); + return ( + + ); + })} +
); } @@ -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 ( +
+ +
+ {error ? ( +

fetch failed: {error}

+ ) : nodes === null ? ( +

loading…

+ ) : !nodes.length ? ( +

empty

+ ) : ( + buildTree(nodes).map((root) => ( + + )) + )} +
+
); } -// 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 `` 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; diff --git a/frontend/packages/shared/src/jobq-graph/jobq-graph.css b/frontend/packages/shared/src/jobq-graph/jobq-graph.css index 8cd88245..39560d6a 100644 --- a/frontend/packages/shared/src/jobq-graph/jobq-graph.css +++ b/frontend/packages/shared/src/jobq-graph/jobq-graph.css @@ -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. */