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

@ -986,17 +986,21 @@ agent is stale. Banner pulses on each broker SSE event
**Build-queue summary banner** — when the job queue has any active **Build-queue summary banner** — when the job queue has any active
work, a compact amber banner sits above the container list: `◐ build work, a compact amber banner sits above the container list: `◐ build
queue — N running · M queued — view queue →` (the link goes to the queue — N running · M queued — view queue →` (the link goes to the
BU1LDS page's R3BU1LD QU3U3). Reads `GET /api/jobq/rollup` BU1LDS page's R3BU1LD QU3U3). The shared `JobqRollup` Preact component
(`hive-jobq-wire::state_rollup`, `jobqRollupState` in `swarm.js`) — (`@hive/shared/jobq-rollup.js` — the same one swarm-ui's `/jobs` page
`Vec<{ state, nodes, roots }>`, every lifecycle state present in a mounts, pointed at swarm-controller's own rollup endpoint instead),
fixed order, zero counts included — rather than the full mounted once into `#jobq-rollup-section` by `swarm.js::initJobqRollup`
`/api/jobq/graph` tree: `running` sums the `Running` and `Finishing` and refreshed via its own handle rather than being re-rendered by
entries' `roots` (`Finishing` = own work done, subtree still going, `renderContainers`. Reads `GET /api/jobq/rollup`
still in flight), `queued` reads the `Pending` entry's `roots`. (`hive-jobq-wire::state_rollup`) — `Vec<{ state, nodes, roots }>`,
`roots` specifically, not `nodes` — the banner means *N whole every lifecycle state present in a fixed order, zero counts included —
operations*, not raw steps (one rebuild is ~7 nodes but 1 root); rather than the full `/api/jobq/graph` tree: `running` sums the
`nodes` exists on the same endpoint for a consumer that wants `Running` and `Finishing` entries' `roots` (`Finishing` = own work
step-level counts instead, unused here. done, subtree still going, still in flight), `queued` reads the
`Pending` entry's `roots`. `roots` specifically, not `nodes` — the
banner means *N whole operations*, not raw steps (one rebuild is ~7
nodes but 1 root); `nodes` exists on the same endpoint for a consumer
that wants step-level counts instead, unused here.
### Themed dialogs ### Themed dialogs

View file

@ -7,6 +7,7 @@
referenced from dashboard JS (the schedules view uses `.rqe-source*`), referenced from dashboard JS (the schedules view uses `.rqe-source*`),
so the dashboard pulls them in here. */ so the dashboard pulls them in here. */
@import "./system-sections.css"; @import "./system-sections.css";
@import "@hive/shared/jobq-rollup.css";
/* tabbed dashboard chrome /* tabbed dashboard chrome
Top-of-page sticky header with banner + tab strip. SSE stays Top-of-page sticky header with banner + tab strip. SSE stays
@ -456,30 +457,9 @@ hive-agent-menu {
Notification controls below sit between the banner and the containers. */ Notification controls below sit between the banner and the containers. */
/* Build-queue summary banner on the SW4RM tab: one compact line /* Build-queue summary banner on the SW4RM tab moved to the shared
when the rebuild queue has active work, with a link to the full queue on `JobqRollup` Preact component (`.jqr-*` classes, imported above)
the C0R3 page. Amber to match the in-progress / "rebuilding" card tint. */ mounted by swarm.js::initJobqRollup into #jobq-rollup-section. */
.queue-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;
}
.queue-summary strong { color: var(--amber); }
.queue-summary-link {
margin-left: auto;
color: var(--amber);
text-decoration: none;
font-weight: bold;
white-space: nowrap;
}
.queue-summary-link:hover { text-decoration: underline; }
/* .notif-row / .btn-notif moved to settings.css with the S3TT1NGS /* .notif-row / .btn-notif moved to settings.css with the S3TT1NGS
page. */ page. */

View file

@ -97,6 +97,13 @@
<!-- Swarm / hive identity headline. Populated by refreshState from <!-- Swarm / hive identity headline. Populated by refreshState from
hive_name + swarm_name; stays hidden when neither is set. --> hive_name + swarm_name; stays hidden when neither is set. -->
<h2 id="swarm-identity" hidden></h2> <h2 id="swarm-identity" hidden></h2>
<!-- JobqRollup mount point, kept outside #containers-section
deliberately — that section is wiped + rebuilt on every
container-list render (see swarm.js::renderContainers), which
would tear down and remount the Preact tree on every
container-state tick. Mounted once by swarm.js::initJobqRollup,
refreshed via its own handle rather than by re-rendering. -->
<div id="jobq-rollup-section"></div>
<div id="containers-section"> <div id="containers-section">
<p class="meta">loading…</p> <p class="meta">loading…</p>
</div> </div>

View file

@ -9,6 +9,7 @@ import {
} from './common.js'; } from './common.js';
import { el } from '@hive/shared/dom.js'; import { el } from '@hive/shared/dom.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js'; import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import { mountJobqRollup } from '@hive/shared/jobq-rollup.js';
import { import {
containersState, questionsState, containersState, questionsState,
} from './state.js'; } from './state.js';
@ -62,35 +63,40 @@ const selectionState = new Set();
// a card's pending badges are transients-only now, which already means // a card's pending badges are transients-only now, which already means
// "what is running") and this banner, which was pulled entirely per "dont // "what is running") and this banner, which was pulled entirely per "dont
// replace one legacy thing with another" (a client-side tally over the // replace one legacy thing with another" (a client-side tally over the
// generic graph was itself judged a stopgap). Now that the dedicated // generic graph was itself judged a stopgap). Rendering itself later
// rollup endpoint exists (hive-jobq-wire::state_rollup, served at // moved out to the shared `JobqRollup` Preact component (same one
// GET /api/jobq/rollup), the banner reads *that* instead — a handful of // swarm-ui's /jobs page mounts), which owns its own fetch of
// pre-tallied counts, not the graph. // GET /api/jobq/rollup — this file just mounts it once and bumps its
let jobqRollupState = []; // 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 // Mounts once; a second call is a no-op (idempotent — matches
// tabs.js's refreshState) and whenever `rebuild_queue_changed` fires // `initCall`/`initPermissions`'s "safe to call from cold-load every
// (applyRebuildQueueChanged below) — a payload-less push trigger by // 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 // design, confirmed with atlas on the jobq-deletion tracker: the event
// carries no `queue` field this page reads, same "something changed, // 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(). // 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() { export function applyRebuildQueueChanged() {
refreshJobqRollup(); initJobqRollup();
jobqRollupHandle?.refresh();
} }
// ─── transients ───────────────────────────────────────────────────────────── // ─── transients ─────────────────────────────────────────────────────────────
@ -627,31 +633,9 @@ export function renderContainers(s) {
)); ));
} }
// Queue-summary banner: one compact line above the container list when // Queue-summary banner lives outside this section now — see
// the job queue has active work, linking to the full queue on the // #jobq-rollup-section / initJobqRollup, mounted once rather than
// BU1LDS page. Reads GET /api/jobq/rollup's pre-tallied `roots` counts // rebuilt on every render this function does.
// (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 →'),
));
}
if (!containers.length && !transientsState.size) { if (!containers.length && !transientsState.size) {
root.append(el('p', { class: 'empty' }, 'no managed containers')); root.append(el('p', { class: 'empty' }, 'no managed containers'));

View file

@ -42,7 +42,7 @@ import {
renderQuestions, activeQuestionCount, renderQuestions, activeQuestionCount,
} from './call.js'; } from './call.js';
import { import {
refreshJobqRollup, syncTransientsFromSnapshot, initJobqRollup, syncTransientsFromSnapshot,
applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved, applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved,
applyTransientSet, applyTransientCleared, applyTransientSet, applyTransientCleared,
renderContainers, renderContainersFromState, renderContainers, renderContainersFromState,
@ -232,13 +232,12 @@ window.marked = marked;
syncTransientsFromSnapshot(s); syncTransientsFromSnapshot(s);
syncContainersFromSnapshot(s); syncContainersFromSnapshot(s);
// Job-queue rollup feeds only the SW4RM queue-summary banner // Job-queue rollup feeds only the SW4RM queue-summary banner
// (per-agent card badges are transient-only — see swarm.js). // (per-agent card badges are transient-only — see swarm.js). Its
// Self-fetches GET /api/jobq/rollup — not read off `s` (this // own `JobqRollup` mount self-fetches GET /api/jobq/rollup — not
// page's snapshot carries no jobq field) — fire-and-forget: // read off `s` (this page's snapshot carries no jobq field).
// renderContainers below runs off whatever jobqRollupState // initJobqRollup mounts once and is a no-op on later calls; the
// already holds, and refreshJobqRollup's own re-render catches // mount's own effect handles the actual fetch.
// up once the fetch resolves. initJobqRollup();
refreshJobqRollup();
renderContainers(s); renderContainers(s);
// Sync the derived approvals + questions stores from the // Sync the derived approvals + questions stores from the
// snapshot, then render. Live `*_added` / `*_resolved` events // snapshot, then render. Live `*_added` / `*_resolved` events

View file

@ -29,7 +29,9 @@
"./hive-warn.js": "./src/hive-warn/hive-warn.js", "./hive-warn.js": "./src/hive-warn/hive-warn.js",
"./side-panel.js": "./src/side-panel/hive-side-panel.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.tsx",
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css" "./jobq-graph.css": "./src/jobq-graph/jobq-graph.css",
"./jobq-rollup.js": "./src/jobq-rollup/JobqRollup.tsx",
"./jobq-rollup.css": "./src/jobq-rollup/jobq-rollup.css"
}, },
"files": [ "files": [
"src/" "src/"

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; }

View file

@ -1,7 +1,9 @@
/* JobsPage wraps the shared JobqGraph component pointed at the /* JobsPage wraps the shared JobqGraph + JobqRollup components pointed
swarm-controller's own /api/jobq/graph endpoint (same wire shape at the swarm-controller's own /api/jobq/{graph,rollup} endpoints (same
hive-c0re's dashboard consumes, see hive-jobq-wire's README). Styles wire shapes hive-c0re's dashboard consumes, see hive-jobq-wire's
are @hive/shared's jobq-graph.css, @import'ed here rather than from README). Styles are @hive/shared's own CSS files, @import'ed here
the component file itself see JobqGraph.tsx's own comment for why rather than from the component files themselves see JobqGraph.tsx's
that split exists (esbuild's loader map is global per bundle call). */ own comment for why that split exists (esbuild's loader map is global
per bundle call). */
@import "@hive/shared/jobq-graph.css"; @import "@hive/shared/jobq-graph.css";
@import "@hive/shared/jobq-rollup.css";

View file

@ -7,13 +7,19 @@
// SwarmNodeKind/SwarmResourceKind), so this renders an empty tree today // SwarmNodeKind/SwarmResourceKind), so this renders an empty tree today
// — the page exists so the wiring is in place before the first real // — the page exists so the wiring is in place before the first real
// swarm-level job (e.g. CreateAgent) lands. // swarm-level job (e.g. CreateAgent) lands.
//
// JobqRollup sits above the graph, same "N running / M queued" banner
// the dashboard's SW4RM tab shows — no `queueHref`, since a "view
// queue →" link back to this same page would be noise.
import { JobqGraph } from '@hive/shared/jobq-graph.js'; import { JobqGraph } from '@hive/shared/jobq-graph.js';
import { JobqRollup } from '@hive/shared/jobq-rollup.js';
import { Panel } from '../ui/panel/Panel.js'; import { Panel } from '../ui/panel/Panel.js';
import './JobsPage.css'; import './JobsPage.css';
export function JobsPage() { export function JobsPage() {
return ( return (
<Panel title="jobs"> <Panel title="jobs">
<JobqRollup endpoint="/api/jobq/rollup" />
<JobqGraph endpoint="/api/jobq/graph" /> <JobqGraph endpoint="/api/jobq/graph" />
</Panel> </Panel>
); );