hyperhive/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx
atlas 39b95c2ede treefmt: apply prettier
Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
2026-09-02 15:25:07 +02:00

363 lines
12 KiB
TypeScript

// JobqGraph.tsx — <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, 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) — `<JobqGraph endpoint="..." cancellable
// onCancel={...} onUpdate={...} />`. 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<NodeState, string> = {
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<NodeState>(["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<NodeState>([
"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<number, TreeNode>(
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<GraphDep, { kind: "Node" }> => 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<string, unknown>)
: [["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,
}: {
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 (
<div class="jg-node">
<div class="jg-row">
<span
class={
"jg-state jg-state-" +
n.state.toLowerCase() +
(flashing ? " jg-state-flash" : "")
}
title={n.state + (n.error ? " — " + n.error : "")}
onAnimationEnd={() => setFlashing(false)}
>
{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,
}: {
selectedStates: Set<NodeState>;
onToggle: (state: NodeState) => void;
}) {
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: string | undefined,
selectedStates: Set<NodeState>,
): 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<Set<NodeState>>(
() => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))),
);
const [nodes, setNodes] = useState<GraphNode[] | null>(null); // null = loading, [] = empty-but-loaded
const [error, setError] = useState<string | null>(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 (
<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>
);
}