hyperhive/frontend/packages/shared/src/jobq-graph/JobqGraph.js

227 lines
9.2 KiB
JavaScript

// JobqGraph.js — <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, 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) — `<JobqGraph endpoint="..."
// cancellable onCancel={...} onUpdate={...} />`, 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(); },
};
}