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

@ -9,6 +9,7 @@ import {
} from './common.js';
import { el } from '@hive/shared/dom.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import { mountJobqRollup } from '@hive/shared/jobq-rollup.js';
import {
containersState, questionsState,
} from './state.js';
@ -62,35 +63,40 @@ const selectionState = new Set();
// a card's pending badges are transients-only now, which already means
// "what is running") and this banner, which was pulled entirely per "dont
// replace one legacy thing with another" (a client-side tally over the
// generic graph was itself judged a stopgap). Now that the dedicated
// rollup endpoint exists (hive-jobq-wire::state_rollup, served at
// GET /api/jobq/rollup), the banner reads *that* instead — a handful of
// pre-tallied counts, not the graph.
let jobqRollupState = [];
// generic graph was itself judged a stopgap). Rendering itself later
// moved out to the shared `JobqRollup` Preact component (same one
// swarm-ui's /jobs page mounts), which owns its own fetch of
// GET /api/jobq/rollup — this file just mounts it once and bumps its
// refresh handle, mirroring builds.js's JobqGraph mount exactly.
//
// Mounted into #jobq-rollup-section, a sibling of #containers-section
// kept OUTSIDE that section's per-render `replaceChildren()` wipe (see
// dashboard.html's comment on the mount div) — re-mounting a fresh
// Preact tree on every container-state tick would work but is wasteful
// and defeats the component owning its own fetch lifecycle.
let jobqRollupHandle = null;
// Fetches the rollup fresh and re-renders. Called on cold load (see
// tabs.js's refreshState) and whenever `rebuild_queue_changed` fires
// (applyRebuildQueueChanged below) — a payload-less push trigger by
// Mounts once; a second call is a no-op (idempotent — matches
// `initCall`/`initPermissions`'s "safe to call from cold-load every
// time" shape elsewhere in this bundle).
export function initJobqRollup() {
if (jobqRollupHandle) return;
const root = $('jobq-rollup-section');
if (!root) return;
jobqRollupHandle = mountJobqRollup(root, {
endpoint: '/api/jobq/rollup',
queueHref: '/builds.html',
});
}
// Refetches on cold load (tabs.js's refreshState) and whenever
// `rebuild_queue_changed` fires — a payload-less push trigger by
// design, confirmed with atlas on the jobq-deletion tracker: the event
// carries no `queue` field this page reads, same "something changed,
// go refetch" treatment builds.js already gives its JobqGraph mount
// go refetch" treatment builds.js already gives its own JobqGraph mount
// handle's .refresh().
// Best-effort: a failed fetch leaves the previous snapshot in place
// rather than wiping the banner on a network blip.
export async function refreshJobqRollup() {
let counts;
try {
const r = await fetch('/api/jobq/rollup');
if (!r.ok) return;
counts = await r.json();
} catch {
return;
}
jobqRollupState = counts;
renderContainersFromState();
}
export function applyRebuildQueueChanged() {
refreshJobqRollup();
initJobqRollup();
jobqRollupHandle?.refresh();
}
// ─── transients ─────────────────────────────────────────────────────────────
@ -627,31 +633,9 @@ export function renderContainers(s) {
));
}
// Queue-summary banner: one compact line above the container list when
// the job queue has active work, linking to the full queue on the
// BU1LDS page. Reads GET /api/jobq/rollup's pre-tallied `roots` counts
// (see jobqRollupState above) rather than the full graph — `roots`
// because "N running / M queued" has always meant *operations*, not
// raw steps (one rebuild is ~7 nodes but 1 root); `nodes` exists on
// the same endpoint for a consumer that wants steps instead, unused
// here. `Finishing` counts as running (own work done, subtree still
// going, still in flight) — same treatment `roots` gets nowhere else,
// since the rollup endpoint doesn't collapse the two itself.
const byState = new Map(jobqRollupState.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) {
const parts = [];
if (running) parts.push(`${running} running`);
if (queued) parts.push(`${queued} queued`);
root.append(el('div', { class: 'queue-summary' },
el('span', { class: 'glyph spinner' }, '◐'), ' ',
el('strong', {}, 'build queue'), ' — ',
parts.join(' · '), ' ',
el('a', { class: 'queue-summary-link', href: '/builds.html' }, 'view queue →'),
));
}
// Queue-summary banner lives outside this section now — see
// #jobq-rollup-section / initJobqRollup, mounted once rather than
// rebuilt on every render this function does.
if (!containers.length && !transientsState.size) {
root.append(el('p', { class: 'empty' }, 'no managed containers'));