diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index 05d490e9..6c3bf4f9 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -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.tsx", + "./jobq-graph.js": "./src/jobq-graph/JobqGraph.jsx", "./jobq-graph.css": "./src/jobq-graph/jobq-graph.css" }, "files": [ diff --git a/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx b/frontend/packages/shared/src/jobq-graph/JobqGraph.jsx similarity index 61% rename from frontend/packages/shared/src/jobq-graph/JobqGraph.tsx rename to frontend/packages/shared/src/jobq-graph/JobqGraph.jsx index 3c549f83..1c8694e5 100644 --- a/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx +++ b/frontend/packages/shared/src/jobq-graph/JobqGraph.jsx @@ -1,76 +1,41 @@ -// JobqGraph.tsx — , a Preact component rendering any +// JobqGraph.jsx — , 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. +// 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. // // `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. +// 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, or any dashboard page that -// renders it directly) — ``. Or imperative mount -// (dashboard/src/builds.js, plain `.js` — mounting needs no JSX pragma) -// — `mountJobqGraph(container, props)` returns `{ refresh(), update(props) }`. +// Two ways to use this: JSX (swarm-ui, or any future dashboard page +// that renders it directly) — ``, 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. // // 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. +// 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. import { h, render } from 'preact'; import { useState, useEffect, useCallback } 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 = { +const STATE_GLYPH = { Pending: '⏸', Running: '▶', Finishing: '◐', @@ -83,15 +48,15 @@ const STATE_GLYPH: Record = { // 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[]; +const ALL_STATES = Object.keys(STATE_GLYPH); // Product call: "default selection filters out skipped and done." -const DEFAULT_HIDDEN_STATES = new Set(['Done', 'Skipped']); +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']); +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 @@ -107,11 +72,9 @@ 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: GraphNode[]): TreeNode[] { - const byId = new Map( - nodes.map((n) => [n.id, { ...n, _children: [], _waitsOn: [] }]), - ); - const roots: TreeNode[] = []; +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); @@ -119,9 +82,9 @@ function buildTree(nodes: GraphNode[]): TreeNode[] { } for (const n of byId.values()) { n._waitsOn = (n.deps || []) - .filter((d): d is Extract => d.kind === 'Node') + .filter((d) => d.kind === 'Node') .map((d) => byId.get(d.id)) - .filter((dep): dep is TreeNode => dep != null) + .filter(Boolean) .map((dep) => dep.payload.label); } return roots; @@ -131,12 +94,10 @@ function buildTree(nodes: GraphNode[]): TreeNode[] { // — 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 }) { +function DataList({ data }) { 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]]; + const entries = isPlainObject ? Object.entries(data) : [['data', data]]; if (!entries.length) return null; return (
@@ -150,15 +111,7 @@ function DataList({ data }: { data: unknown }) { ); } -function NodeView({ - n, - cancellable, - onCancel, -}: { - n: TreeNode; - cancellable: boolean; - onCancel?: (id: number) => void; -}) { +function NodeView({ n, cancellable, onCancel }) { const glyph = STATE_GLYPH[n.state] || '?'; const showCancel = cancellable && CANCELLABLE_STATES.has(n.state); return ( @@ -189,13 +142,7 @@ function NodeView({ ); } -function FilterBar({ - selectedStates, - onToggle, -}: { - selectedStates: Set; - onToggle: (state: NodeState) => void; -}) { +function FilterBar({ selectedStates, onToggle }) { return (
{ALL_STATES.map((state) => { @@ -216,7 +163,7 @@ function FilterBar({ // 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 { +function fetchUrl(endpoint, selectedStates) { if (!endpoint) return null; if (selectedStates.size >= ALL_STATES.length) return endpoint; const url = new URL(endpoint, window.location.origin); @@ -224,26 +171,18 @@ function fetchUrl(endpoint: string | undefined, selectedStates: Set): 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 }: JobqGraphProps) { - const [selectedStates, setSelectedStates] = useState>( +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 [nodes, setNodes] = useState(null); // null = loading, [] = empty-but-loaded + const [error, setError] = useState(null); - const toggleState = useCallback((state: NodeState) => { + const toggleState = useCallback((state) => { setSelectedStates((prev) => { const next = new Set(prev); if (next.has(state)) next.delete(state); else next.add(state); @@ -259,7 +198,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()) as GraphNode[]; + const data = await r.json(); if (cancelled) return; setNodes(data); setError(null); @@ -300,13 +239,13 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r // write `` 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: Element, initialProps: JobqGraphProps) { +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: Partial) { props = { ...props, ...next }; draw(); }, + update(next) { props = { ...props, ...next }; draw(); }, }; } diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 0b69fb3c..b68f4429 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -7,7 +7,6 @@ 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'; @@ -71,7 +70,6 @@ export function App() { - diff --git a/frontend/packages/swarm-ui/src/pages/JobsPage.css b/frontend/packages/swarm-ui/src/pages/JobsPage.css deleted file mode 100644 index beb79c0a..00000000 --- a/frontend/packages/swarm-ui/src/pages/JobsPage.css +++ /dev/null @@ -1,7 +0,0 @@ -/* 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.tsx's own comment for why - that split exists (esbuild's loader map is global per bundle call). */ -@import "@hive/shared/jobq-graph.css"; diff --git a/frontend/packages/swarm-ui/src/pages/JobsPage.tsx b/frontend/packages/swarm-ui/src/pages/JobsPage.tsx deleted file mode 100644 index 0645a566..00000000 --- a/frontend/packages/swarm-ui/src/pages/JobsPage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// — 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), -// 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 ( - - - - ); -} diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 7ba5e5a1..297ba0d2 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -19,7 +19,6 @@ import './Shell.css'; const NAV_ITEMS: { href: string; label: string }[] = [ { href: '/', label: 'overview' }, - { href: '/jobs', label: 'jobs' }, { href: '/components', label: 'components' }, ];