extract build-queue rollup as a shared Preact component

New @hive/shared/jobq-rollup.js (JobqRollup.tsx + jobq-rollup.css),
mirroring JobqGraph's shape exactly: JSX use plus an imperative
mountJobqRollup(container, props) for a plain-.js call site. Fetches
Vec<hive_jobq_wire::StateCount> off `endpoint`, sums Running+Finishing
roots as "running" and Pending roots as "queued", renders nothing when
both are zero. Optional `queueHref` adds a "view queue -> " link.

Swapped dashboard's hand-rolled queue-summary banner (swarm.js) over to
this component instead of keeping two parallel implementations - same
"one shared component" pattern JobqGraph already set for the rebuild
queue tree view. Mounted once into a new #jobq-rollup-section, kept as
a sibling of (not inside) #containers-section since that section gets
replaceChildren()-wiped on every container-state render, which would
tear down and remount a Preact tree on every tick. Refreshed via the
mount handle's .refresh() on rebuild_queue_changed, same as builds.js's
JobqGraph handle.

Also mounted in swarm-ui's /jobs page, above JobqGraph, with no
queueHref (a link back to the page you're already on is noise) - the
literal ask on hyperhive#3364.

Verified: npm run build (whole workspace) and swarm-ui typecheck both
clean, comment-block + issue-ref lints run manually, headless-chromium
screenshots of both the dashboard SW4RM tab and swarm-ui's /jobs page
against mocked /api/jobq/rollup payloads - banner renders identically
in both, with and without the queue link as expected.
This commit is contained in:
iris 2026-08-16 21:21:12 +02:00 committed by mara
commit 1111577c91
10 changed files with 208 additions and 99 deletions

View file

@ -0,0 +1,100 @@
// 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).
//
// Two ways to use this, same shape as JobqGraph: JSX (swarm-ui) —
// `<JobqRollup endpoint="..." queueHref="..." />`. Or imperative mount
// (dashboard/src/swarm.js, plain `.js`) — `mountJobqRollup(container,
// props)` returns `{ refresh(), update(props) }`.
import { h, render } from 'preact';
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"></span>{' '}
<strong>build queue</strong> {parts.join(' · ')}{' '}
{queueHref && (
<a class="jqr-link" href={queueHref}>view queue </a>
)}
</div>
);
}
// Imperative mount helper — same shape as `mountJobqGraph`. Returns
// `.refresh()` (bump the refresh token, re-fetch) and `.update(props)`
// (merge new props and re-render).
export function mountJobqRollup(container: Element, initialProps: JobqRollupProps) {
let props = initialProps;
let token = 0;
const draw = () => render(h(JobqRollup, { ...props, refreshToken: token }), container);
draw();
return {
refresh() { token += 1; draw(); },
update(next: Partial<JobqRollupProps>) { props = { ...props, ...next }; draw(); },
};
}

View file

@ -0,0 +1,25 @@
/* JobqRollup.tsx styles light DOM, `.jqr-*` prefixed to avoid
colliding with a host page's own classes (same convention
jobq-graph.css uses with `.jg-*`). Amber to match the in-progress /
"rebuilding" card tint dashboard.css already uses elsewhere. */
.jqr-summary {
display: flex;
align-items: center;
gap: 0.4em;
flex-wrap: wrap;
background: color-mix(in srgb, var(--amber) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--amber) 55%, transparent);
color: var(--amber);
padding: 0.45em 0.8em;
margin-bottom: 0.6em;
border-radius: 4px;
}
.jqr-summary strong { color: var(--amber); }
.jqr-link {
margin-left: auto;
color: var(--amber);
text-decoration: none;
font-weight: bold;
white-space: nowrap;
}
.jqr-link:hover { text-decoration: underline; }