swarm-ui: add a jobs tab, and type JobqGraph as real TypeScript
New /jobs route in swarm-ui, reusing the shared JobqGraph component against swarm-controller's own GET /api/jobq/graph (same wire shape hive-c0re's dashboard already consumes, different endpoint, no fork). Converted JobqGraph.jsx to JobqGraph.tsx with real prop/wire types (mirrors hive_jobq_wire's GraphNode/GraphDep/State by hand) instead of a hand-maintained ambient .d.ts at the swarm-ui consumer side — the .d.ts would duplicate the prop list and drift from the source the moment the component's signature changes without the declaration being touched. Both dashboard (untyped consumer, esbuild strips types) and swarm-ui (tsc --noEmit) build/typecheck clean off the one file.
This commit is contained in:
parent
61977514c6
commit
527ed8f3e2
6 changed files with 134 additions and 31 deletions
|
|
@ -28,7 +28,7 @@
|
|||
"./hive-menu.js": "./src/hive-menu/hive-menu.js",
|
||||
"./hive-warn.js": "./src/hive-warn/hive-warn.js",
|
||||
"./side-panel.js": "./src/side-panel/hive-side-panel.js",
|
||||
"./jobq-graph.js": "./src/jobq-graph/JobqGraph.jsx",
|
||||
"./jobq-graph.js": "./src/jobq-graph/JobqGraph.tsx",
|
||||
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css"
|
||||
},
|
||||
"files": [
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
// 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 esbuild transpiles `.tsx` (types
|
||||
// stripped, not checked) the same way regardless of whether the
|
||||
// consuming package runs `tsc` itself — swarm-ui does (`npm run
|
||||
// typecheck`), dashboard doesn't, both build clean off the same file.
|
||||
//
|
||||
// Real TypeScript rather than a hand-maintained ambient `.d.ts` at each
|
||||
// TS consumer: a `declare module` block duplicates the prop list and
|
||||
// drifts the moment this file's signature changes without the
|
||||
// declaration being touched. Typing the source once means every
|
||||
// consumer's typecheck is checking the real shape, not a copy of it.
|
||||
//
|
||||
// `cancellable` adds a per-node cancel button calling `onCancel(id)`
|
||||
// directly (a plain prop). `onUpdate(nodes)` fires after every fetch,
|
||||
|
|
@ -15,14 +24,14 @@
|
|||
// 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`
|
||||
// mount (dashboard/src/builds.js, which stays plain `.js` — a `.tsx`
|
||||
// call site still needs a JSX-aware file, mounting doesn't) —
|
||||
// `mountJobqGraph(container, props)` returns a `{ refresh(),
|
||||
// update(props) }` handle.
|
||||
//
|
||||
// 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
|
||||
// necessary regardless of the JSX/TS 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
|
||||
|
|
@ -35,7 +44,45 @@
|
|||
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 +95,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 +119,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 +131,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 +143,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 +162,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 +201,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 +228,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 +236,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 +271,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 +312,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(); },
|
||||
};
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { useEffect, useState } from 'preact/hooks';
|
|||
import { Route, Switch } from 'wouter-preact';
|
||||
import { Shell } from './shell/Shell.js';
|
||||
import { ComponentsPage } from './pages/ComponentsPage.js';
|
||||
import { JobsPage } from './pages/JobsPage.js';
|
||||
import { Panel } from './ui/panel/Panel.js';
|
||||
import { StatusChip } from './ui/status-chip/StatusChip.js';
|
||||
import { Table, type TableColumn } from './ui/table/Table.js';
|
||||
|
|
@ -70,6 +71,7 @@ export function App() {
|
|||
<Shell>
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/jobs" component={JobsPage} />
|
||||
<Route path="/components" component={ComponentsPage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
|
|
|
|||
7
frontend/packages/swarm-ui/src/pages/JobsPage.css
Normal file
7
frontend/packages/swarm-ui/src/pages/JobsPage.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/* JobsPage — wraps the shared JobqGraph component pointed at the
|
||||
swarm-controller's own /api/jobq/graph endpoint (same wire shape
|
||||
hive-c0re's dashboard consumes, see hive-jobq-wire's README). Styles
|
||||
are @hive/shared's jobq-graph.css, @import'ed here rather than from
|
||||
the component file itself — see JobqGraph.jsx's own comment for why
|
||||
that split exists (esbuild's loader map is global per bundle call). */
|
||||
@import "@hive/shared/jobq-graph.css";
|
||||
20
frontend/packages/swarm-ui/src/pages/JobsPage.tsx
Normal file
20
frontend/packages/swarm-ui/src/pages/JobsPage.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// <JobsPage> — the swarm-level job graph. Reuses the shared JobqGraph
|
||||
// component (originally built for the per-hive dashboard's rebuild
|
||||
// queue) pointed at swarm-controller's own GET /api/jobq/graph instead
|
||||
// of hive-c0re's — same wire shape (Vec<hive_jobq_wire::GraphNode>),
|
||||
// different endpoint, no component fork. swarm-controller's graph has
|
||||
// no real node kinds wired in yet (see swarm-controller/src/main.rs's
|
||||
// SwarmNodeKind/SwarmResourceKind), so this renders an empty tree today
|
||||
// — the page exists so the wiring is in place before the first real
|
||||
// swarm-level job (e.g. CreateAgent) lands.
|
||||
import { JobqGraph } from '@hive/shared/jobq-graph.js';
|
||||
import { Panel } from '../ui/panel/Panel.js';
|
||||
import './JobsPage.css';
|
||||
|
||||
export function JobsPage() {
|
||||
return (
|
||||
<Panel title="jobs">
|
||||
<JobqGraph endpoint="/api/jobq/graph" />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import './Shell.css';
|
|||
|
||||
const NAV_ITEMS: { href: string; label: string }[] = [
|
||||
{ href: '/', label: 'overview' },
|
||||
{ href: '/jobs', label: 'jobs' },
|
||||
{ href: '/components', label: 'components' },
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue