hyperhive/frontend/packages/shared/src/jobq-rollup/JobqRollup.tsx
iris 5c9d89dc64 drop the mountJobqRollup wrapper, render(h(...)) directly
mara, on review: expected the plain Preact pattern (render(h(Widget,
props), container), call again to update) rather than a custom
mountX() returning {refresh(), update()}. Preact's own render is
already the re-render/diff entry point, so the wrapper was indirection
this component didn't need - swarm.js (plain .js, no JSX pragma
required for h()/render() either) now calls render(h(JobqRollup,
{...refreshToken}), root) directly, bumping a module-level token to
force a refetch instead of holding a mount handle.

JobqGraph/mountJobqGraph (a separate, already-merged component) is
untouched - out of scope for this PR, flagged as a possible follow-up
if she wants the same simplification there.

Re-verified: npm run build (whole workspace) + swarm-ui typecheck
clean, comment-block + issue-ref lints clean, re-screenshotted the
dashboard SW4RM tab against the same mocked payload - identical
render, spinner now visibly mid-rotation in the frame (confirms the
animation is live, not just present in markup).
2026-08-16 22:08:38 +02:00

90 lines
3.5 KiB
TypeScript

// JobqRollup.tsx — <JobqRollup>, a compact one-line banner over
// GET .../api/jobq/rollup's pre-tallied `Vec<hive_jobq_wire::StateCount>`
// (any endpoint serving that shape works — hive-c0re and swarm-controller
// both do). Extracted out of dashboard/src/swarm.js's hand-rolled
// queue-summary banner, which this component now replaces there too —
// same "one shared component, not two parallel implementations" pattern
// `JobqGraph` already established for the rebuild-queue tree view.
//
// Reads `roots` (not `nodes`) — "N running / M queued" has always meant
// *operations*, not raw steps (one rebuild is ~7 nodes but 1 root).
// `Finishing` counts as running (own work done, subtree still going,
// still in flight) — the rollup endpoint doesn't collapse the two
// itself, so this component does. Renders nothing at all when there's
// no active work, matching the banner it replaces (an idle queue is not
// worth a line of chrome).
//
// `queueHref`, when given, adds a "view queue →" link (dashboard links to
// /builds.html; swarm-ui's /jobs page omits it — a link to the page
// you're already on is noise).
//
// The glyph carries `.spinner` (`@hive/shared/base.css`, imported by
// both consumers) for the "actively happening" spin — a static
// screenshot can't tell a frozen spinner from a missing one.
//
// JSX (swarm-ui) — `<JobqRollup endpoint="..." queueHref="..." />`. Or
// plain `render(h(JobqRollup, props), container)` (dashboard/src/
// swarm.js, no JSX pragma needed) — call again with a bumped
// `refreshToken` to force a refetch. No mount wrapper: `render` is
// already the re-render/diff entry point.
import { useState, useEffect } from 'preact/hooks';
// Mirrors `hive_jobq_wire::StateSchema` — only the subset this banner
// cares about, not the full union `JobqGraph.tsx` mirrors, since a
// rollup row's `state` is read by exact string match, not rendered.
type NodeState = 'Pending' | 'Running' | 'Finishing' | 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
interface StateCount {
state: NodeState;
nodes: number;
roots: number;
}
export interface JobqRollupProps {
endpoint?: string;
queueHref?: string;
refreshToken?: number;
}
export function JobqRollup({ endpoint, queueHref, refreshToken = 0 }: JobqRollupProps) {
const [counts, setCounts] = useState<StateCount[]>([]);
useEffect(() => {
if (!endpoint) return undefined;
let cancelled = false;
// Best-effort: a failed fetch leaves the previous snapshot in
// place rather than wiping the banner on a network blip — same
// rule the hand-rolled version followed.
(async () => {
try {
const r = await fetch(endpoint);
if (!r.ok) return;
const data = (await r.json()) as StateCount[];
if (!cancelled) setCounts(data);
} catch {
// ignore — keep the previous snapshot
}
})();
return () => { cancelled = true; };
}, [endpoint, refreshToken]);
const byState = new Map(counts.map((c) => [c.state, c]));
const running = (byState.get('Running')?.roots ?? 0) + (byState.get('Finishing')?.roots ?? 0);
const queued = byState.get('Pending')?.roots ?? 0;
if (!running && !queued) return null;
const parts: string[] = [];
if (running) parts.push(`${running} running`);
if (queued) parts.push(`${queued} queued`);
return (
<div class="jqr-summary">
<span class="jqr-glyph spinner"></span>{' '}
<strong>build queue</strong> {parts.join(' · ')}{' '}
{queueHref && (
<a class="jqr-link" href={queueHref}>view queue </a>
)}
</div>
);
}