Compare commits

...
Author SHA1 Message Date
iris
075d1f63e3 apply the same render(h(...)) simplification to JobqGraph
mara, follow-up on PR#3365: "follow up with the same for jobq". Drops
mountJobqGraph the same way mountJobqRollup was dropped - builds.js
now calls render(h(JobqGraph, {...}), root) directly, split into
mountRebuildQueue() (the one-time replaceChildren() + first render,
called once at page init) and renderRebuildQueue() (the render-only
path the rebuild_queue_changed handler and the cancel flow reuse,
bumping a module-level jobqGraphToken instead of holding a mount
handle). The split matters here specifically: repeating
replaceChildren() on every refresh would wipe Preact's own tracked
children out from under its diffing instead of letting it update them
minimally - JobqRollup's simpler version didn't need this since it
only ever renders into its own dedicated section once per app
lifetime's worth of state, but the rebuild queue refreshes on every
`rebuild_queue_changed` tick.

Re-verified: npm run build (whole workspace) + swarm-ui typecheck
clean (JobqGraph is also used via JSX on swarm-ui's /jobs page,
untouched by this), comment-block + issue-ref lints clean, headless-
chromium screenshot of builds.html's R3BU1LD QU3U3 tab against a
mocked /api/jobq/graph payload - tree, filter checkboxes, and cancel
buttons all render identically to before.
2026-08-16 22:08:38 +02:00
iris
5c9d89dc64 drop the mountJobqRollup wrapper, render(h(...)) directly
mara, on review: expected the plain Preact pattern (render(h(Widget,
props), container), call again to update) rather than a custom
mountX() returning {refresh(), update()}. Preact's own render is
already the re-render/diff entry point, so the wrapper was indirection
this component didn't need - swarm.js (plain .js, no JSX pragma
required for h()/render() either) now calls render(h(JobqRollup,
{...refreshToken}), root) directly, bumping a module-level token to
force a refetch instead of holding a mount handle.

JobqGraph/mountJobqGraph (a separate, already-merged component) is
untouched - out of scope for this PR, flagged as a possible follow-up
if she wants the same simplification there.

Re-verified: npm run build (whole workspace) + swarm-ui typecheck
clean, comment-block + issue-ref lints clean, re-screenshotted the
dashboard SW4RM tab against the same mocked payload - identical
render, spinner now visibly mid-rotation in the frame (confirms the
animation is live, not just present in markup).
2026-08-16 22:08:38 +02:00
iris
348b2f96da give the rollup glyph back its spin animation
The extraction dropped the old banner's `spinner` class on the glyph
span (`el('span', { class: 'glyph spinner' }, ...)`) - JobqRollup.tsx
rendered a static `.jqr-glyph` with no animation rule anywhere.
`.spinner` (shared/base.css) is already imported by both consumers, so
just reuse it on the glyph rather than duplicating the keyframes.

argus caught this on review - a single-frame screenshot can't tell a
frozen spinner from a missing one, which is exactly why it slipped
past the PR's visual verification.
2026-08-16 22:08:38 +02:00
iris
1111577c91 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.
2026-08-16 22:08:38 +02:00
12 changed files with 233 additions and 137 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

@ -14,9 +14,10 @@ import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } fro
import { el } from '@hive/shared/dom.js'; import { el } from '@hive/shared/dom.js';
import { bindAsyncForms } from '@hive/shared/forms.js'; import { bindAsyncForms } from '@hive/shared/forms.js';
import { themedConfirm } from '@hive/shared/modal.js'; import { themedConfirm } from '@hive/shared/modal.js';
import { h, render } from 'preact';
import { fmtAgo, fmtDuration, truncate } from './util.js'; import { fmtAgo, fmtDuration, truncate } from './util.js';
import '@hive/shared/hive-tab-strip.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 ─────────────────────────────────────────────────────────── // ─── derived state ───────────────────────────────────────────────────────────
let metaInputsState = []; let metaInputsState = [];
@ -26,7 +27,7 @@ let metaUpdateRunning = false;
// the tree itself; this flat array exists only for the two things it // the tree itself; this flat array exists only for the two things it
// doesn't render: the count-pill and the live-log panel. // doesn't render: the count-pill and the live-log panel.
let jobqNodes = []; let jobqNodes = [];
let jobqGraphHandle = null; let jobqGraphToken = 0;
function syncFromSnapshot(s) { function syncFromSnapshot(s) {
metaInputsState = (s.meta_inputs || []).slice(); metaInputsState = (s.meta_inputs || []).slice();
@ -127,27 +128,40 @@ function renderMetaInputs(s) {
} }
// ─── rebuild queue ──────────────────────────────────────────────────────────── // ─── rebuild queue ────────────────────────────────────────────────────────────
// R3BU1LD QU3U3 is `JobqGraph` (Preact, mounted imperatively — see // R3BU1LD QU3U3 is `JobqGraph` (Preact, rendered via plain `render(h(...))`
// mountJobqGraph's own doc comment in @hive/shared/jobq-graph.js for why // — no JSX pragma needed for either call, no mount wrapper: `render` is
// this file has no JSX pipeline) directly — no hand-rolled tree/roll-up // already the re-render/diff entry point, per mara on review) directly —
// rendering here anymore. The component owns fetching GET /api/jobq/graph // no hand-rolled tree/roll-up rendering here anymore. The component owns
// and its own refetch (`.refresh()` on the mount handle); this page just // fetching GET /api/jobq/graph and its own refetch (bump `jobqGraphToken`
// reads its `onUpdate` callback to keep `jobqNodes` (the flat array) in // and render again); this page just reads its `onUpdate` callback to keep
// sync for the two things the generic view doesn't render itself: the // `jobqNodes` (the flat array) in sync for the two things the generic
// count-pill and the live-log panel below. // 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` // 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 // prop turns on the component's own per-node cancel button, which calls
// `onCancel(id)` rather than posting anything — the endpoint // `onCancel(id)` rather than posting anything — the endpoint
// (`/api/rebuild-queue/{id}/cancel`) is this page's domain concept, not // (`/api/rebuild-queue/{id}/cancel`) is this page's domain concept, not
// the generic component's. // 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() { function mountRebuildQueue() {
const root = $('rebuild-queue-section'); const root = $('rebuild-queue-section');
if (!root) return; if (!root) return;
root.replaceChildren(); root.replaceChildren();
jobqGraphHandle = mountJobqGraph(root, { renderRebuildQueue();
}
function renderRebuildQueue() {
const root = $('rebuild-queue-section');
if (!root) return;
render(h(JobqGraph, {
endpoint: '/api/jobq/graph', endpoint: '/api/jobq/graph',
cancellable: true, cancellable: true,
refreshToken: jobqGraphToken,
onUpdate: (nodes) => { onUpdate: (nodes) => {
jobqNodes = nodes || []; jobqNodes = nodes || [];
renderRebuildLiveLog(); renderRebuildLiveLog();
@ -165,12 +179,12 @@ function mountRebuildQueue() {
if (!r.ok) throw new Error('http ' + r.status); if (!r.ok) throw new Error('http ' + r.status);
// No manual refresh: cancel flips node state, which fires // No manual refresh: cancel flips node state, which fires
// rebuild_queue_changed over SSE — the existing handler below // 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) { } catch (err) {
console.error('cancel failed', err); console.error('cancel failed', err);
} }
}, },
}); }), root);
} }
// ─── running-rebuild live log ───────────────────────────────────────────────── // ─── running-rebuild live log ─────────────────────────────────────────────────
@ -471,7 +485,8 @@ const SSE_HANDLERS = {
// queue snapshot on the wire (tabs.js/SW4RM still consumes it for the // 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 // swarm badges, untouched by this), this page just treats it as a
// refetch trigger. `JobqGraph` owns the actual fetch. // 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. // Auto-refresh build log list when the queue changes and BUILD L0GS is active.
if (buildTabs && buildTabs.active() === 'buildlogs') { if (buildTabs && buildTabs.active() === 'buildlogs') {
if (buildRefreshTimer) clearTimeout(buildRefreshTimer); if (buildRefreshTimer) clearTimeout(buildRefreshTimer);

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,8 @@ 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 { h, render } from 'preact';
import { JobqRollup } from '@hive/shared/jobq-rollup.js';
import { import {
containersState, questionsState, containersState, questionsState,
} from './state.js'; } from './state.js';
@ -62,35 +64,39 @@ 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 calls Preact's own
let jobqRollupState = []; // `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 export function initJobqRollup() {
// tabs.js's refreshState) and whenever `rebuild_queue_changed` fires const root = $('jobq-rollup-section');
// (applyRebuildQueueChanged below) — a payload-less push trigger by 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 // 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(); jobqRollupToken += 1;
initJobqRollup();
} }
// ─── 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

@ -17,11 +17,10 @@
// needing the raw list (a count badge, a live-log panel) without its // needing the raw list (a count badge, a live-log panel) without its
// own parallel fetch. // own parallel fetch.
// //
// Two ways to use this: JSX (swarm-ui, or any dashboard page that // JSX (swarm-ui) — `<JobqGraph endpoint="..." cancellable
// renders it directly) — `<JobqGraph endpoint="..." cancellable // onCancel={...} onUpdate={...} />`. Or plain `render(h(JobqGraph,
// onCancel={...} onUpdate={...} />`. Or imperative mount // props), container)` (dashboard/src/builds.js, no JSX pragma needed)
// (dashboard/src/builds.js, plain `.js` — mounting needs no JSX pragma) // — call again with a bumped `refreshToken` to refetch.
// — `mountJobqGraph(container, props)` returns `{ refresh(), update(props) }`.
// //
// Styles live in `@hive/shared/jobq-graph.css`, `@import`ed from a // Styles live in `@hive/shared/jobq-graph.css`, `@import`ed from a
// page/component CSS file rather than imported here — dashboard bundles // 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 // loader is `text` (for unrelated shadow-DOM components' CSS-as-string
// needs), and that loader is global per call, not per-module. // needs), and that loader is global per call, not per-module.
import { h, render } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks'; import { useState, useEffect, useCallback } from 'preact/hooks';
// Mirrors `hive_jobq_wire::StateSchema` verbatim (variant names, no // 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 // `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 // change identity so the effect below re-runs, giving a host an
// `mountJobqGraph`) an explicit "refetch now" lever without an // explicit "refetch now" lever (bump it and re-render) without an
// imperative ref into this component. // imperative ref into this component.
export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }: JobqGraphProps) { export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }: JobqGraphProps) {
const [selectedStates, setSelectedStates] = useState<Set<NodeState>>( const [selectedStates, setSelectedStates] = useState<Set<NodeState>>(
@ -294,19 +292,3 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
</div> </div>
); );
} }
// 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 `<JobqGraph .../>` 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<JobqGraphProps>) { props = { ...props, ...next }; draw(); },
};
}

View file

@ -0,0 +1,90 @@
// 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).
//
// 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) — `<JobqRollup endpoint="..." queueHref="..." />`. 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<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 spinner"></span>{' '}
<strong>build queue</strong> {parts.join(' · ')}{' '}
{queueHref && (
<a class="jqr-link" href={queueHref}>view queue </a>
)}
</div>
);
}

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