Compare commits

..
6 changed files with 49 additions and 140 deletions

View file

@ -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": [

View file

@ -1,76 +1,41 @@
// JobqGraph.tsx <JobqGraph>, a Preact component rendering any
// JobqGraph.jsx <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.
// 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) `<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) }`.
// 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.
//
// 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<NodeState, string> = {
const STATE_GLYPH = {
Pending: '⏸',
Running: '▶',
Finishing: '◐',
@ -83,15 +48,15 @@ const STATE_GLYPH: Record<NodeState, string> = {
// 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<NodeState>(['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<NodeState>(['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<NodeState>(['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<number, TreeNode>(
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<GraphDep, { kind: 'Node' }> => 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<string, unknown>)
: [['data', data]];
const entries = isPlainObject ? Object.entries(data) : [['data', data]];
if (!entries.length) return null;
return (
<dl class="jg-data">
@ -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<NodeState>;
onToggle: (state: NodeState) => void;
}) {
function FilterBar({ selectedStates, onToggle }) {
return (
<div class="jg-filter">
{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<NodeState>): 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<NodeState>):
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<Set<NodeState>>(
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<GraphNode[] | null>(null); // null = loading, [] = empty-but-loaded
const [error, setError] = useState<string | null>(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 `<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: 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<JobqGraphProps>) { props = { ...props, ...next }; draw(); },
update(next) { props = { ...props, ...next }; draw(); },
};
}

View file

@ -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() {
<Shell>
<Switch>
<Route path="/" component={Home} />
<Route path="/jobs" component={JobsPage} />
<Route path="/components" component={ComponentsPage} />
<Route component={NotFound} />
</Switch>

View file

@ -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";

View file

@ -1,20 +0,0 @@
// <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>
);
}

View file

@ -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' },
];