|
|
|
|
@ -1,41 +1,76 @@
|
|
|
|
|
// JobqGraph.jsx — <JobqGraph>, a Preact component rendering any
|
|
|
|
|
// 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). 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.
|
|
|
|
|
// 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 (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.
|
|
|
|
|
// 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.
|
|
|
|
|
//
|
|
|
|
|
// 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.
|
|
|
|
|
// Two ways to use this: JSX (swarm-ui, or any dashboard page that
|
|
|
|
|
// renders it directly) — `<JobqGraph endpoint="..." cancellable
|
|
|
|
|
// onCancel={...} onUpdate={...} />`. Or imperative mount
|
|
|
|
|
// (dashboard/src/builds.js, plain `.js` — mounting needs no JSX pragma)
|
|
|
|
|
// — `mountJobqGraph(container, props)` returns `{ refresh(), update(props) }`.
|
|
|
|
|
//
|
|
|
|
|
// 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.
|
|
|
|
|
// 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 { h, render } from 'preact';
|
|
|
|
|
import { useState, useEffect, useCallback } from 'preact/hooks';
|
|
|
|
|
|
|
|
|
|
const STATE_GLYPH = {
|
|
|
|
|
// 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: '◐',
|
|
|
|
|
@ -48,15 +83,15 @@ const STATE_GLYPH = {
|
|
|
|
|
// 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);
|
|
|
|
|
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']);
|
|
|
|
|
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(['Pending', 'Running', 'Finishing']);
|
|
|
|
|
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
|
|
|
|
|
@ -72,9 +107,11 @@ const CANCELLABLE_STATES = new Set(['Pending', 'Running', 'Finishing']);
|
|
|
|
|
// 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 = [];
|
|
|
|
|
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);
|
|
|
|
|
@ -82,9 +119,9 @@ function buildTree(nodes) {
|
|
|
|
|
}
|
|
|
|
|
for (const n of byId.values()) {
|
|
|
|
|
n._waitsOn = (n.deps || [])
|
|
|
|
|
.filter((d) => d.kind === 'Node')
|
|
|
|
|
.filter((d): d is Extract<GraphDep, { kind: 'Node' }> => d.kind === 'Node')
|
|
|
|
|
.map((d) => byId.get(d.id))
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.filter((dep): dep is TreeNode => dep != null)
|
|
|
|
|
.map((dep) => dep.payload.label);
|
|
|
|
|
}
|
|
|
|
|
return roots;
|
|
|
|
|
@ -94,10 +131,12 @@ function buildTree(nodes) {
|
|
|
|
|
// — 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 }) {
|
|
|
|
|
function DataList({ data }: { data: unknown }) {
|
|
|
|
|
if (data == null) return null;
|
|
|
|
|
const isPlainObject = typeof data === 'object' && !Array.isArray(data);
|
|
|
|
|
const entries = isPlainObject ? Object.entries(data) : [['data', 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">
|
|
|
|
|
@ -111,7 +150,15 @@ function DataList({ data }) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function NodeView({ n, cancellable, onCancel }) {
|
|
|
|
|
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);
|
|
|
|
|
return (
|
|
|
|
|
@ -142,7 +189,13 @@ function NodeView({ n, cancellable, onCancel }) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FilterBar({ selectedStates, onToggle }) {
|
|
|
|
|
function FilterBar({
|
|
|
|
|
selectedStates,
|
|
|
|
|
onToggle,
|
|
|
|
|
}: {
|
|
|
|
|
selectedStates: Set<NodeState>;
|
|
|
|
|
onToggle: (state: NodeState) => void;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div class="jg-filter">
|
|
|
|
|
{ALL_STATES.map((state) => {
|
|
|
|
|
@ -163,7 +216,7 @@ function FilterBar({ selectedStates, onToggle }) {
|
|
|
|
|
// 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) {
|
|
|
|
|
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);
|
|
|
|
|
@ -171,18 +224,26 @@ function fetchUrl(endpoint, selectedStates) {
|
|
|
|
|
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 (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(
|
|
|
|
|
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(null); // null = loading, [] = empty-but-loaded
|
|
|
|
|
const [error, setError] = useState(null);
|
|
|
|
|
const [nodes, setNodes] = useState<GraphNode[] | null>(null); // null = loading, [] = empty-but-loaded
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const toggleState = useCallback((state) => {
|
|
|
|
|
const toggleState = useCallback((state: NodeState) => {
|
|
|
|
|
setSelectedStates((prev) => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
if (next.has(state)) next.delete(state); else next.add(state);
|
|
|
|
|
@ -198,7 +259,7 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
|
|
|
|
|
try {
|
|
|
|
|
const r = await fetch(url);
|
|
|
|
|
if (!r.ok) throw new Error('http ' + r.status);
|
|
|
|
|
const data = await r.json();
|
|
|
|
|
const data = (await r.json()) as GraphNode[];
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setNodes(data);
|
|
|
|
|
setError(null);
|
|
|
|
|
@ -239,13 +300,13 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
|
|
|
|
|
// 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) {
|
|
|
|
|
export function mountJobqGraph(container: Element, initialProps: JobqGraphProps) {
|
|
|
|
|
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(); },
|
|
|
|
|
update(next: Partial<JobqGraphProps>) { props = { ...props, ...next }; draw(); },
|
|
|
|
|
};
|
|
|
|
|
}
|