Compare commits

..
12 changed files with 137 additions and 233 deletions

View file

@ -986,21 +986,17 @@ 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). The shared `JobqRollup` Preact component BU1LDS page's R3BU1LD QU3U3). Reads `GET /api/jobq/rollup`
(`@hive/shared/jobq-rollup.js` — the same one swarm-ui's `/jobs` page (`hive-jobq-wire::state_rollup`, `jobqRollupState` in `swarm.js`) —
mounts, pointed at swarm-controller's own rollup endpoint instead), `Vec<{ state, nodes, roots }>`, every lifecycle state present in a
mounted once into `#jobq-rollup-section` by `swarm.js::initJobqRollup` fixed order, zero counts included — rather than the full
and refreshed via its own handle rather than being re-rendered by `/api/jobq/graph` tree: `running` sums the `Running` and `Finishing`
`renderContainers`. Reads `GET /api/jobq/rollup` entries' `roots` (`Finishing` = own work done, subtree still going,
(`hive-jobq-wire::state_rollup`) — `Vec<{ state, nodes, roots }>`, still in flight), `queued` reads the `Pending` entry's `roots`.
every lifecycle state present in a fixed order, zero counts included — `roots` specifically, not `nodes` — the banner means *N whole
rather than the full `/api/jobq/graph` tree: `running` sums the operations*, not raw steps (one rebuild is ~7 nodes but 1 root);
`Running` and `Finishing` entries' `roots` (`Finishing` = own work `nodes` exists on the same endpoint for a consumer that wants
done, subtree still going, still in flight), `queued` reads the step-level counts instead, unused here.
`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,10 +14,9 @@ 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 { JobqGraph } from '@hive/shared/jobq-graph.js'; import { mountJobqGraph } from '@hive/shared/jobq-graph.js';
// ─── derived state ─────────────────────────────────────────────────────────── // ─── derived state ───────────────────────────────────────────────────────────
let metaInputsState = []; let metaInputsState = [];
@ -27,7 +26,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 jobqGraphToken = 0; let jobqGraphHandle = null;
function syncFromSnapshot(s) { function syncFromSnapshot(s) {
metaInputsState = (s.meta_inputs || []).slice(); metaInputsState = (s.meta_inputs || []).slice();
@ -128,40 +127,27 @@ function renderMetaInputs(s) {
} }
// ─── rebuild queue ──────────────────────────────────────────────────────────── // ─── rebuild queue ────────────────────────────────────────────────────────────
// R3BU1LD QU3U3 is `JobqGraph` (Preact, rendered via plain `render(h(...))` // R3BU1LD QU3U3 is `JobqGraph` (Preact, mounted imperatively — see
// — no JSX pragma needed for either call, no mount wrapper: `render` is // mountJobqGraph's own doc comment in @hive/shared/jobq-graph.js for why
// already the re-render/diff entry point, per mara on review) directly — // this file has no JSX pipeline) directly — no hand-rolled tree/roll-up
// no hand-rolled tree/roll-up rendering here anymore. The component owns // rendering here anymore. The component owns fetching GET /api/jobq/graph
// fetching GET /api/jobq/graph and its own refetch (bump `jobqGraphToken` // and its own refetch (`.refresh()` on the mount handle); this page just
// and render again); this page just reads its `onUpdate` callback to keep // reads its `onUpdate` callback to keep `jobqNodes` (the flat array) in
// `jobqNodes` (the flat array) in sync for the two things the generic // sync for the two things the generic view doesn't render itself: the
// view doesn't render itself: the count-pill and the live-log panel below. // 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();
renderRebuildQueue(); jobqGraphHandle = mountJobqGraph(root, {
}
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();
@ -179,12 +165,12 @@ function renderRebuildQueue() {
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 bumps jobqGraphToken and re-renders on that tick. // already calls jobqGraphHandle.refresh() 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 ─────────────────────────────────────────────────
@ -485,8 +471,7 @@ 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.
jobqGraphToken += 1; if (jobqGraphHandle) jobqGraphHandle.refresh();
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,7 +7,6 @@
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
@ -457,9 +456,30 @@ 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 moved to the shared /* Build-queue summary banner on the SW4RM tab: one compact line
`JobqRollup` Preact component (`.jqr-*` classes, imported above) when the rebuild queue has active work, with a link to the full queue on
mounted by swarm.js::initJobqRollup into #jobq-rollup-section. */ 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; }
/* .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,13 +97,6 @@
<!-- 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,8 +9,6 @@ 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';
@ -64,39 +62,35 @@ 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). Rendering itself later // generic graph was itself judged a stopgap). Now that the dedicated
// moved out to the shared `JobqRollup` Preact component (same one // rollup endpoint exists (hive-jobq-wire::state_rollup, served at
// swarm-ui's /jobs page mounts), which owns its own fetch of // GET /api/jobq/rollup), the banner reads *that* instead — a handful of
// GET /api/jobq/rollup — this file just calls Preact's own // pre-tallied counts, not the graph.
// `render(h(...))` directly (no `mountX()` wrapper: `render` is let jobqRollupState = [];
// 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;
export function initJobqRollup() { // Fetches the rollup fresh and re-renders. Called on cold load (see
const root = $('jobq-rollup-section'); // tabs.js's refreshState) and whenever `rebuild_queue_changed` fires
if (!root) return; // (applyRebuildQueueChanged below) — a payload-less push trigger by
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 own JobqGraph mount // go refetch" treatment builds.js already gives its 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() {
jobqRollupToken += 1; refreshJobqRollup();
initJobqRollup();
} }
// ─── transients ───────────────────────────────────────────────────────────── // ─── transients ─────────────────────────────────────────────────────────────
@ -633,9 +627,31 @@ export function renderContainers(s) {
)); ));
} }
// Queue-summary banner lives outside this section now — see // Queue-summary banner: one compact line above the container list when
// #jobq-rollup-section / initJobqRollup, mounted once rather than // the job queue has active work, linking to the full queue on the
// rebuilt on every render this function does. // 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 →'),
));
}
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 {
initJobqRollup, syncTransientsFromSnapshot, refreshJobqRollup, syncTransientsFromSnapshot,
applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved, applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved,
applyTransientSet, applyTransientCleared, applyTransientSet, applyTransientCleared,
renderContainers, renderContainersFromState, renderContainers, renderContainersFromState,
@ -232,12 +232,13 @@ 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). Its // (per-agent card badges are transient-only — see swarm.js).
// own `JobqRollup` mount self-fetches GET /api/jobq/rollup — not // Self-fetches GET /api/jobq/rollup — not read off `s` (this
// read off `s` (this page's snapshot carries no jobq field). // page's snapshot carries no jobq field) — fire-and-forget:
// initJobqRollup mounts once and is a no-op on later calls; the // renderContainers below runs off whatever jobqRollupState
// mount's own effect handles the actual fetch. // already holds, and refreshJobqRollup's own re-render catches
initJobqRollup(); // up once the fetch resolves.
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,9 +29,7 @@
"./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,10 +17,11 @@
// 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.
// //
// JSX (swarm-ui) — `<JobqGraph endpoint="..." cancellable // Two ways to use this: JSX (swarm-ui, or any dashboard page that
// onCancel={...} onUpdate={...} />`. Or plain `render(h(JobqGraph, // renders it directly) — `<JobqGraph endpoint="..." cancellable
// props), container)` (dashboard/src/builds.js, no JSX pragma needed) // onCancel={...} onUpdate={...} />`. Or imperative mount
// — call again with a bumped `refreshToken` to refetch. // (dashboard/src/builds.js, plain `.js` — mounting needs no JSX pragma)
// — `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
@ -28,6 +29,7 @@
// 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
@ -231,8 +233,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 an // change identity so the effect below re-runs, giving a host (or
// explicit "refetch now" lever (bump it and re-render) without an // `mountJobqGraph`) an explicit "refetch now" lever 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>>(
@ -292,3 +294,19 @@ 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

@ -1,90 +0,0 @@
// 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

@ -1,25 +0,0 @@
/* 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,9 +1,7 @@
/* JobsPage wraps the shared JobqGraph + JobqRollup components pointed /* JobsPage wraps the shared JobqGraph component pointed at the
at the swarm-controller's own /api/jobq/{graph,rollup} endpoints (same swarm-controller's own /api/jobq/graph endpoint (same wire shape
wire shapes hive-c0re's dashboard consumes, see hive-jobq-wire's hive-c0re's dashboard consumes, see hive-jobq-wire's README). Styles
README). Styles are @hive/shared's own CSS files, @import'ed here are @hive/shared's jobq-graph.css, @import'ed here rather than from
rather than from the component files themselves see JobqGraph.tsx's the component file itself see JobqGraph.tsx's own comment for why
own comment for why that split exists (esbuild's loader map is global that split exists (esbuild's loader map is global per bundle call). */
per bundle call). */
@import "@hive/shared/jobq-graph.css"; @import "@hive/shared/jobq-graph.css";
@import "@hive/shared/jobq-rollup.css";

View file

@ -7,19 +7,13 @@
// 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>
); );