From 1111577c91315c7d8d229ab4e35f8db8165f2882 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 16 Aug 2026 21:21:12 +0200 Subject: [PATCH] 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 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. --- docs/web-ui/dashboard.md | 26 +++-- frontend/packages/dashboard/src/dashboard.css | 28 +---- .../packages/dashboard/src/dashboard.html | 7 ++ frontend/packages/dashboard/src/swarm.js | 82 ++++++-------- frontend/packages/dashboard/src/tabs.js | 15 ++- frontend/packages/shared/package.json | 4 +- .../shared/src/jobq-rollup/JobqRollup.tsx | 100 ++++++++++++++++++ .../shared/src/jobq-rollup/jobq-rollup.css | 25 +++++ .../packages/swarm-ui/src/pages/JobsPage.css | 14 +-- .../packages/swarm-ui/src/pages/JobsPage.tsx | 6 ++ 10 files changed, 208 insertions(+), 99 deletions(-) create mode 100644 frontend/packages/shared/src/jobq-rollup/JobqRollup.tsx create mode 100644 frontend/packages/shared/src/jobq-rollup/jobq-rollup.css 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 @@ + +

loading…

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 ( + );