swarm-ui: add a jobs tab, and type JobqGraph as real TypeScript
New /jobs route in swarm-ui, reusing the shared JobqGraph component against swarm-controller's own GET /api/jobq/graph (same wire shape hive-c0re's dashboard already consumes, different endpoint, no fork). Converted JobqGraph.jsx to JobqGraph.tsx with real prop/wire types (mirrors hive_jobq_wire's GraphNode/GraphDep/State by hand) instead of a hand-maintained ambient .d.ts at the swarm-ui consumer side — the .d.ts would duplicate the prop list and drift from the source the moment the component's signature changes without the declaration being touched. Both dashboard (untyped consumer, esbuild strips types) and swarm-ui (tsc --noEmit) build/typecheck clean off the one file.
This commit is contained in:
parent
61977514c6
commit
527ed8f3e2
6 changed files with 134 additions and 31 deletions
|
|
@ -1,251 +0,0 @@
|
|||
// 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(); },
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue