// JobqGraph.tsx — , 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, whose types the interfaces below mirror by // hand). 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'` and transpile `.tsx` (types stripped, not // checked) the same way regardless of whether the consumer runs `tsc` // itself — swarm-ui does (`npm run typecheck`), dashboard doesn't, both // build clean off this one file. Real TypeScript rather than a hand- // maintained ambient `.d.ts` at each TS consumer, which would duplicate // the prop list and drift the moment this file's signature changes. // // `cancellable` adds a per-node cancel button calling `onCancel(id)` // directly. `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. // // JSX (swarm-ui) — ``. Or plain `render(h(JobqGraph, // props), container)` (dashboard/src/builds.js, no JSX pragma needed) // — call again with a bumped `refreshToken` to refetch. // // Styles live in `@hive/shared/jobq-graph.css`, `@import`ed from a // page/component CSS file rather than imported here — dashboard bundles // this component transitively through an esbuild call whose `.css` // loader is `text` (for unrelated shadow-DOM components' CSS-as-string // needs), and that loader is global per call, not per-module. import { useState, useEffect, useCallback, useRef } from 'preact/hooks'; // Mirrors `hive_jobq_wire::StateSchema` verbatim (variant names, no // `rename_all`) — see that enum's own doc comment for why it's kept in // an exhaustive match on the Rust side; this union is this file's // equivalent contract. type NodeState = 'Pending' | 'Running' | 'Finishing' | 'Done' | 'Failed' | 'Cancelled' | 'Skipped'; type TerminalState = 'Done' | 'Failed' | 'Cancelled' | 'Skipped'; // Mirrors `hive_jobq_wire::GraphDep` — externally tagged on `kind`, // values are the Rust variant names verbatim. type GraphDep = | { kind: 'Node'; id: number; accepts: TerminalState[] } | { kind: 'Resource'; name: string; count: number }; interface NodePayload { label: string; data?: unknown; } // Mirrors `hive_jobq_wire::GraphNode`. `id`/`parent` are `WireId` // (`u64` on the wire) — `number` here, same as the rest of this // frontend treats wire ids; large enough ids losing precision in JS // is a pre-existing, unrelated constraint, not something this // conversion introduces. export interface GraphNode { id: number; parent?: number; state: NodeState; deps?: GraphDep[]; error?: string; payload: NodePayload; } interface TreeNode extends GraphNode { _children: TreeNode[]; _waitsOn: string[]; } const STATE_GLYPH: Record = { 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) as NodeState[]; // 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: GraphNode[]): TreeNode[] { const byId = new Map( nodes.map((n) => [n.id, { ...n, _children: [], _waitsOn: [] }]), ); const roots: TreeNode[] = []; 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 is Extract => d.kind === 'Node') .map((d) => byId.get(d.id)) .filter((dep): dep is TreeNode => dep != null) .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 }: { data: unknown }) { if (data == null) return null; const isPlainObject = typeof data === 'object' && !Array.isArray(data); const entries: [string, unknown][] = isPlainObject ? Object.entries(data as Record) : [['data', data]]; if (!entries.length) return null; return (
{entries.map(([k, v]) => ( <>
{k}
{typeof v === 'string' ? v : JSON.stringify(v)}
))}
); } function NodeView({ n, cancellable, onCancel, }: { n: TreeNode; cancellable: boolean; onCancel?: (id: number) => void; }) { const glyph = STATE_GLYPH[n.state] || '?'; const showCancel = cancellable && CANCELLABLE_STATES.has(n.state); // Flash the state glyph on a genuine state change (Pending → Running, // etc.), not on mount — `prevState` starts at the node's own initial // state, so the first render never flashes. Does not fire on every // fetch: `n` is a fresh object each snapshot (see `buildTree`), but // its `.state` value is only actually different when the job's real // state moved. const prevState = useRef(n.state); const [flashing, setFlashing] = useState(false); useEffect(() => { if (prevState.current !== n.state) { prevState.current = n.state; setFlashing(true); } }, [n.state]); return (
setFlashing(false)}> {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, }: { selectedStates: Set; onToggle: (state: NodeState) => void; }) { return (
{ALL_STATES.map((state) => { const id = 'jg-filter-' + state.toLowerCase(); return ( ); })}
); } // `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: string | undefined, selectedStates: Set): string | null { 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; } export interface JobqGraphProps { endpoint?: string; cancellable?: boolean; onUpdate?: (nodes: GraphNode[]) => void; onCancel?: (id: number) => void; refreshToken?: number; } // `refreshToken` is not read anywhere in the body — its only job is to // change identity so the effect below re-runs, giving a host an // explicit "refetch now" lever (bump it and re-render) without an // imperative ref into this component. export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }: JobqGraphProps) { 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: NodeState) => { 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()) as GraphNode[]; 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 (
{error ? (

fetch failed: {error}

) : nodes === null ? (

loading…

) : !nodes.length ? (

empty

) : ( buildTree(nodes).map((root) => ( )) )}
); }