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

View file

@ -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. */

View file

@ -97,6 +97,13 @@
<!-- Swarm / hive identity headline. Populated by refreshState from
hive_name + swarm_name; stays hidden when neither is set. -->
<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">
<p class="meta">loading…</p>
</div>

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'));

View file

@ -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

View file

@ -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/"

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

View file

@ -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 (
<Panel title="jobs">
<JobqRollup endpoint="/api/jobq/rollup" />
<JobqGraph endpoint="/api/jobq/graph" />
</Panel>
);