diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md
index 683f3528..5aad6263 100644
--- a/docs/web-ui/dashboard.md
+++ b/docs/web-ui/dashboard.md
@@ -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
work, a compact amber banner sits above the container list: `◐ build
queue — N running · M queued — view queue →` (the link goes to the
-BU1LDS page's R3BU1LD QU3U3). Reads `GET /api/jobq/rollup`
-(`hive-jobq-wire::state_rollup`, `jobqRollupState` in `swarm.js`) —
-`Vec<{ state, nodes, roots }>`, every lifecycle state present in a
-fixed order, zero counts included — rather than the full
-`/api/jobq/graph` tree: `running` sums the `Running` and `Finishing`
-entries' `roots` (`Finishing` = own work 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.
+BU1LDS page's R3BU1LD QU3U3). The shared `JobqRollup` Preact component
+(`@hive/shared/jobq-rollup.js` — the same one swarm-ui's `/jobs` page
+mounts, pointed at swarm-controller's own rollup endpoint instead),
+mounted once into `#jobq-rollup-section` by `swarm.js::initJobqRollup`
+and refreshed via its own handle rather than being re-rendered by
+`renderContainers`. Reads `GET /api/jobq/rollup`
+(`hive-jobq-wire::state_rollup`) — `Vec<{ state, nodes, roots }>`,
+every lifecycle state present in a fixed order, zero counts included —
+rather than the full `/api/jobq/graph` tree: `running` sums the
+`Running` and `Finishing` entries' `roots` (`Finishing` = own work
+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
diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css
index 251652fe..44899b9e 100644
--- a/frontend/packages/dashboard/src/dashboard.css
+++ b/frontend/packages/dashboard/src/dashboard.css
@@ -7,6 +7,7 @@
referenced from dashboard JS (the schedules view uses `.rqe-source*`),
so the dashboard pulls them in here. */
@import "./system-sections.css";
+@import "@hive/shared/jobq-rollup.css";
/* ─── tabbed dashboard chrome ──────────────────────────────────────
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. */
-/* Build-queue summary banner on the SW4RM tab: one compact line
- when the rebuild queue has active work, with a link to the full queue on
- the C0R3 page. Amber to match the in-progress / "rebuilding" card tint. */
-.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; }
+/* Build-queue summary banner on the SW4RM tab moved to the shared
+ `JobqRollup` Preact component (`.jqr-*` classes, imported above) —
+ mounted by swarm.js::initJobqRollup into #jobq-rollup-section. */
/* .notif-row / .btn-notif moved to settings.css with the S3TT1NGS
page. */
diff --git a/frontend/packages/dashboard/src/dashboard.html b/frontend/packages/dashboard/src/dashboard.html
index d9bd8b53..b1baf418 100644
--- a/frontend/packages/dashboard/src/dashboard.html
+++ b/frontend/packages/dashboard/src/dashboard.html
@@ -97,6 +97,13 @@
+
+
diff --git a/frontend/packages/dashboard/src/swarm.js b/frontend/packages/dashboard/src/swarm.js
index b2966320..d2692923 100644
--- a/frontend/packages/dashboard/src/swarm.js
+++ b/frontend/packages/dashboard/src/swarm.js
@@ -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'));
diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js
index 9e0fe748..46d7502c 100644
--- a/frontend/packages/dashboard/src/tabs.js
+++ b/frontend/packages/dashboard/src/tabs.js
@@ -42,7 +42,7 @@ import {
renderQuestions, activeQuestionCount,
} from './call.js';
import {
- refreshJobqRollup, syncTransientsFromSnapshot,
+ initJobqRollup, syncTransientsFromSnapshot,
applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved,
applyTransientSet, applyTransientCleared,
renderContainers, renderContainersFromState,
@@ -232,13 +232,12 @@ window.marked = marked;
syncTransientsFromSnapshot(s);
syncContainersFromSnapshot(s);
// Job-queue rollup feeds only the SW4RM queue-summary banner
- // (per-agent card badges are transient-only — see swarm.js).
- // Self-fetches GET /api/jobq/rollup — not read off `s` (this
- // page's snapshot carries no jobq field) — fire-and-forget:
- // renderContainers below runs off whatever jobqRollupState
- // already holds, and refreshJobqRollup's own re-render catches
- // up once the fetch resolves.
- refreshJobqRollup();
+ // (per-agent card badges are transient-only — see swarm.js). Its
+ // own `JobqRollup` mount self-fetches GET /api/jobq/rollup — not
+ // read off `s` (this page's snapshot carries no jobq field).
+ // initJobqRollup mounts once and is a no-op on later calls; the
+ // mount's own effect handles the actual fetch.
+ initJobqRollup();
renderContainers(s);
// Sync the derived approvals + questions stores from the
// snapshot, then render. Live `*_added` / `*_resolved` events
diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json
index 05d490e9..ba195859 100644
--- a/frontend/packages/shared/package.json
+++ b/frontend/packages/shared/package.json
@@ -29,7 +29,9 @@
"./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.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": [
"src/"
diff --git a/frontend/packages/shared/src/jobq-rollup/JobqRollup.tsx b/frontend/packages/shared/src/jobq-rollup/JobqRollup.tsx
new file mode 100644
index 00000000..2f946b58
--- /dev/null
+++ b/frontend/packages/shared/src/jobq-rollup/JobqRollup.tsx
@@ -0,0 +1,100 @@
+// JobqRollup.tsx — , a compact one-line banner over
+// GET .../api/jobq/rollup's pre-tallied `Vec`
+// (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) —
+// ``. 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([]);
+
+ 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 (
+
+
◐{' '}
+
build queue — {parts.join(' · ')}{' '}
+ {queueHref && (
+
view queue →
+ )}
+
+ );
+}
+
+// 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) { props = { ...props, ...next }; draw(); },
+ };
+}
diff --git a/frontend/packages/shared/src/jobq-rollup/jobq-rollup.css b/frontend/packages/shared/src/jobq-rollup/jobq-rollup.css
new file mode 100644
index 00000000..530c8f5a
--- /dev/null
+++ b/frontend/packages/shared/src/jobq-rollup/jobq-rollup.css
@@ -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; }
diff --git a/frontend/packages/swarm-ui/src/pages/JobsPage.css b/frontend/packages/swarm-ui/src/pages/JobsPage.css
index beb79c0a..579a8824 100644
--- a/frontend/packages/swarm-ui/src/pages/JobsPage.css
+++ b/frontend/packages/swarm-ui/src/pages/JobsPage.css
@@ -1,7 +1,9 @@
-/* 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). */
+/* JobsPage — wraps the shared JobqGraph + JobqRollup components pointed
+ at the swarm-controller's own /api/jobq/{graph,rollup} endpoints (same
+ wire shapes hive-c0re's dashboard consumes, see hive-jobq-wire's
+ README). Styles are @hive/shared's own CSS files, @import'ed here
+ rather than from the component files themselves — 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";
+@import "@hive/shared/jobq-rollup.css";
diff --git a/frontend/packages/swarm-ui/src/pages/JobsPage.tsx b/frontend/packages/swarm-ui/src/pages/JobsPage.tsx
index 0645a566..3e95b00b 100644
--- a/frontend/packages/swarm-ui/src/pages/JobsPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/JobsPage.tsx
@@ -7,13 +7,19 @@
// 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.
+//
+// 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 { JobqRollup } from '@hive/shared/jobq-rollup.js';
import { Panel } from '../ui/panel/Panel.js';
import './JobsPage.css';
export function JobsPage() {
return (
+
);