// JobqGraph.js — , 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. // // `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. // // 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. 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 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)), ]), ); } 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 })), ); } 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, ); }), ); } // `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 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. 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 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 })), ), ); } // 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). 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(); }, }; }