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/builds.js b/frontend/packages/dashboard/src/builds.js
index 16b37662..7e8f9358 100644
--- a/frontend/packages/dashboard/src/builds.js
+++ b/frontend/packages/dashboard/src/builds.js
@@ -14,9 +14,10 @@ import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } fro
import { el } from '@hive/shared/dom.js';
import { bindAsyncForms } from '@hive/shared/forms.js';
import { themedConfirm } from '@hive/shared/modal.js';
+import { h, render } from 'preact';
import { fmtAgo, fmtDuration, truncate } from './util.js';
import '@hive/shared/hive-tab-strip.js';
-import { mountJobqGraph } from '@hive/shared/jobq-graph.js';
+import { JobqGraph } from '@hive/shared/jobq-graph.js';
// ─── derived state ───────────────────────────────────────────────────────────
let metaInputsState = [];
@@ -26,7 +27,7 @@ let metaUpdateRunning = false;
// the tree itself; this flat array exists only for the two things it
// doesn't render: the count-pill and the live-log panel.
let jobqNodes = [];
-let jobqGraphHandle = null;
+let jobqGraphToken = 0;
function syncFromSnapshot(s) {
metaInputsState = (s.meta_inputs || []).slice();
@@ -127,27 +128,40 @@ function renderMetaInputs(s) {
}
// ─── rebuild queue ────────────────────────────────────────────────────────────
-// R3BU1LD QU3U3 is `JobqGraph` (Preact, mounted imperatively — see
-// mountJobqGraph's own doc comment in @hive/shared/jobq-graph.js for why
-// this file has no JSX pipeline) directly — no hand-rolled tree/roll-up
-// rendering here anymore. The component owns fetching GET /api/jobq/graph
-// and its own refetch (`.refresh()` on the mount handle); this page just
-// reads its `onUpdate` callback to keep `jobqNodes` (the flat array) in
-// sync for the two things the generic view doesn't render itself: the
-// count-pill and the live-log panel below.
+// R3BU1LD QU3U3 is `JobqGraph` (Preact, rendered via plain `render(h(...))`
+// — no JSX pragma needed for either call, no mount wrapper: `render` is
+// already the re-render/diff entry point, per mara on review) directly —
+// no hand-rolled tree/roll-up rendering here anymore. The component owns
+// fetching GET /api/jobq/graph and its own refetch (bump `jobqGraphToken`
+// and render again); this page just reads its `onUpdate` callback to keep
+// `jobqNodes` (the flat array) in sync for the two things the generic
+// view doesn't render itself: the count-pill and the live-log panel below.
//
// Cancel is the one action this page *does* wire up: the `cancellable`
// prop turns on the component's own per-node cancel button, which calls
// `onCancel(id)` rather than posting anything — the endpoint
// (`/api/rebuild-queue/{id}/cancel`) is this page's domain concept, not
// the generic component's.
+// Split from `renderRebuildQueue` below: `replaceChildren()` only
+// belongs on the *first* render, to clear the static "loading…"
+// placeholder `#rebuild-queue-section` starts with in `builds.html` —
+// Preact doesn't know that placeholder is there, so a later render
+// call must NOT repeat `replaceChildren()` (it would wipe Preact's own
+// tracked children out from under its diffing rather than let it
+// update them minimally).
function mountRebuildQueue() {
const root = $('rebuild-queue-section');
if (!root) return;
root.replaceChildren();
- jobqGraphHandle = mountJobqGraph(root, {
+ renderRebuildQueue();
+}
+function renderRebuildQueue() {
+ const root = $('rebuild-queue-section');
+ if (!root) return;
+ render(h(JobqGraph, {
endpoint: '/api/jobq/graph',
cancellable: true,
+ refreshToken: jobqGraphToken,
onUpdate: (nodes) => {
jobqNodes = nodes || [];
renderRebuildLiveLog();
@@ -165,12 +179,12 @@ function mountRebuildQueue() {
if (!r.ok) throw new Error('http ' + r.status);
// No manual refresh: cancel flips node state, which fires
// rebuild_queue_changed over SSE — the existing handler below
- // already calls jobqGraphHandle.refresh() on that tick.
+ // already bumps jobqGraphToken and re-renders on that tick.
} catch (err) {
console.error('cancel failed', err);
}
},
- });
+ }), root);
}
// ─── running-rebuild live log ─────────────────────────────────────────────────
@@ -471,7 +485,8 @@ const SSE_HANDLERS = {
// queue snapshot on the wire (tabs.js/SW4RM still consumes it for the
// swarm badges, untouched by this), this page just treats it as a
// refetch trigger. `JobqGraph` owns the actual fetch.
- if (jobqGraphHandle) jobqGraphHandle.refresh();
+ jobqGraphToken += 1;
+ renderRebuildQueue();
// Auto-refresh build log list when the queue changes and BUILD L0GS is active.
if (buildTabs && buildTabs.active() === 'buildlogs') {
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
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..6c4d2e3a 100644
--- a/frontend/packages/dashboard/src/swarm.js
+++ b/frontend/packages/dashboard/src/swarm.js
@@ -9,6 +9,8 @@ import {
} from './common.js';
import { el } from '@hive/shared/dom.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
+import { h, render } from 'preact';
+import { JobqRollup } from '@hive/shared/jobq-rollup.js';
import {
containersState, questionsState,
} from './state.js';
@@ -62,35 +64,39 @@ 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 calls Preact's own
+// `render(h(...))` directly (no `mountX()` wrapper: `render` is
+// already the re-render/diff entry point, per mara on review) and
+// bumps a refresh token to force a refetch.
+//
+// Rendered 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) — rendering into a
+// section that gets wiped on every container-state tick would defeat
+// the component owning its own fetch lifecycle.
+let jobqRollupToken = 0;
-// 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
+export function initJobqRollup() {
+ const root = $('jobq-rollup-section');
+ if (!root) return;
+ render(h(JobqRollup, {
+ endpoint: '/api/jobq/rollup',
+ queueHref: '/builds.html',
+ refreshToken: jobqRollupToken,
+ }), root);
+}
+// 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();
+ jobqRollupToken += 1;
+ initJobqRollup();
}
// ─── 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-graph/JobqGraph.tsx b/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx
index 3c549f83..8c335ba4 100644
--- a/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx
+++ b/frontend/packages/shared/src/jobq-graph/JobqGraph.tsx
@@ -17,11 +17,10 @@
// needing the raw list (a count badge, a live-log panel) without its
// own parallel fetch.
//
-// Two ways to use this: JSX (swarm-ui, or any dashboard page that
-// renders it directly) — ``. Or imperative mount
-// (dashboard/src/builds.js, plain `.js` — mounting needs no JSX pragma)
-// — `mountJobqGraph(container, props)` returns `{ refresh(), update(props) }`.
+// JSX (swarm-ui) — ``. Or plain `render(h(JobqGraph,
+// props), container)` (dashboard/src/builds.js, no JSX pragma needed)
+// — call again with a bumped `refreshToken` to refetch.
//
// Styles live in `@hive/shared/jobq-graph.css`, `@import`ed from a
// page/component CSS file rather than imported here — dashboard bundles
@@ -29,7 +28,6 @@
// loader is `text` (for unrelated shadow-DOM components' CSS-as-string
// needs), and that loader is global per call, not per-module.
-import { h, render } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
// Mirrors `hive_jobq_wire::StateSchema` verbatim (variant names, no
@@ -233,8 +231,8 @@ export interface JobqGraphProps {
}
// `refreshToken` is not read anywhere in the body — its only job is to
-// change identity so the effect below re-runs, giving a host (or
-// `mountJobqGraph`) an explicit "refetch now" lever without an
+// change identity so the effect below re-runs, giving a host an
+// explicit "refetch now" lever (bump it and re-render) without an
// imperative ref into this component.
export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }: JobqGraphProps) {
const [selectedStates, setSelectedStates] = useState>(
@@ -294,19 +292,3 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
);
}
-
-// Imperative mount helper for a call site that isn't itself a JSX file
-// (dashboard/src/builds.js — plain `.js`, no per-file JSX pragma to
-// write `` inline). Returns a handle: `.refresh()`
-// (re-fetch with the current props) and `.update(props)` (merge new
-// props — e.g. a different `endpoint` — and re-render).
-export function mountJobqGraph(container: Element, initialProps: JobqGraphProps) {
- let props = initialProps;
- let token = 0;
- const draw = () => render(h(JobqGraph, { ...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/JobqRollup.tsx b/frontend/packages/shared/src/jobq-rollup/JobqRollup.tsx
new file mode 100644
index 00000000..85c6dea6
--- /dev/null
+++ b/frontend/packages/shared/src/jobq-rollup/JobqRollup.tsx
@@ -0,0 +1,90 @@
+// 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).
+//
+// 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) — ``. 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([]);
+
+ 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 →
+ )}
+
+ );
+}
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 (
+
);