Compare commits
17 changed files with 339 additions and 375 deletions
|
|
@ -228,17 +228,16 @@ not the graph.
|
||||||
**R3BU1LD QU3U3** — pending, in-flight, and recently-settled container
|
**R3BU1LD QU3U3** — pending, in-flight, and recently-settled container
|
||||||
operations: rebuilds, meta-update cascades, and first-spawns. One
|
operations: rebuilds, meta-update cascades, and first-spawns. One
|
||||||
operation runs at a time; the worker drains FIFO. **Is a mounted
|
operation runs at a time; the worker drains FIFO. **Is a mounted
|
||||||
`JobqGraph`** (the shared generic graph-viewer Preact component,
|
`<hive-jobq-graph endpoint="/api/jobq/graph">`** (the shared generic
|
||||||
`@hive/shared/jobq-graph.js` — the same one swarm-ui uses) —
|
graph-viewer component, `@hive/shared/jobq-graph.js`) — `builds.js`
|
||||||
`builds.js` does not render the queue itself; it just mounts the component
|
does not render the queue itself; it just mounts the element and
|
||||||
(`mountJobqGraph(container, props)`, since this page has no JSX
|
listens for its `hive-jobq-graph-update` event to drive the two things
|
||||||
pipeline) with an `onUpdate` callback to drive the two things below it
|
below it that the generic view doesn't show. The component owns
|
||||||
that the generic view doesn't show. The component owns fetching, cold
|
fetching, cold and live: `GET /api/jobq/graph` on mount, and
|
||||||
and live: `GET /api/jobq/graph` on mount, and a refetch via the mount
|
`.refresh()` on every `rebuild_queue_changed` SSE tick (that event is a
|
||||||
handle's `.refresh()` on every `rebuild_queue_changed` SSE tick (that
|
bare `{ seq }` trigger — it carried a typed queue snapshot until every
|
||||||
event is a bare `{ seq }` trigger — it carried a typed queue snapshot
|
consumer had moved to the generic endpoint, and now carries none; both
|
||||||
until every consumer had moved to the generic endpoint, and now carries
|
dashboard pages treat the tick as a pure refetch trigger).
|
||||||
none; both dashboard pages treat the tick as a pure refetch trigger).
|
|
||||||
|
|
||||||
Each row is one root graph node (`parent: null`); a multi-step op's
|
Each row is one root graph node (`parent: null`); a multi-step op's
|
||||||
per-agent subgraphs and sub-steps render as nodes within that one
|
per-agent subgraphs and sub-steps render as nodes within that one
|
||||||
|
|
@ -251,10 +250,10 @@ as a plain "waits on: `<label>`" text line under the row rather than a
|
||||||
gutter rail — a rail breaks visually whenever a nested subtree sits
|
gutter rail — a rail breaks visually whenever a nested subtree sits
|
||||||
between the two related rows, since a text line needs no continuous
|
between the two related rows, since a text line needs no continuous
|
||||||
vertical space to draw.
|
vertical space to draw.
|
||||||
`builds.js` mounts the component with `cancellable` set, which turns on
|
`builds.js` mounts the element with `cancellable` set, which turns on
|
||||||
a per-node cancel button (`✕`) on any non-terminal row — the button
|
a per-node cancel button (`✕`) on any non-terminal row — the button
|
||||||
calls the mount's `onCancel(id)` callback, and the page does the actual
|
dispatches `hive-jobq-graph-cancel`, and the page does the actual
|
||||||
`POST /api/rebuild-queue/{id}/cancel`, matching that callback to its
|
`POST /api/rebuild-queue/{id}/cancel`, matching the wire event as its
|
||||||
own domain concept (the component knows nothing about that endpoint).
|
own domain concept (the component knows nothing about that endpoint).
|
||||||
**Rows carry no source chip, kind label, timing, or build-log
|
**Rows carry no source chip, kind label, timing, or build-log
|
||||||
deep-link** — the generic graph wire doesn't carry those fields, and
|
deep-link** — the generic graph wire doesn't carry those fields, and
|
||||||
|
|
@ -267,7 +266,7 @@ does not filter `Done` nodes out.
|
||||||
the tree — one per lifecycle state, matching the row glyphs — lets the
|
the tree — one per lifecycle state, matching the row glyphs — lets the
|
||||||
operator narrow which root groups render; unchecking a state re-fetches
|
operator narrow which root groups render; unchecking a state re-fetches
|
||||||
`GET /api/jobq/graph?states=<checked, comma-joined>` rather than
|
`GET /api/jobq/graph?states=<checked, comma-joined>` rather than
|
||||||
hiding rows client-side, so the `onUpdate` callback's node list (and
|
hiding rows client-side, so `hive-jobq-graph-update`'s node list (and
|
||||||
everything downstream of it — the count pill, the live-log panel) only
|
everything downstream of it — the count pill, the live-log panel) only
|
||||||
ever sees what's actually shown. Filtering is by a **root's own**
|
ever sees what's actually shown. Filtering is by a **root's own**
|
||||||
state, which is already its subtree's rolled-up answer, so a group is
|
state, which is already its subtree's rolled-up answer, so a group is
|
||||||
|
|
@ -285,16 +284,16 @@ Below the queue, a **live build-log panel** (`#rebuild-live-log`,
|
||||||
`renderRebuildLiveLog`) shows the currently-running rebuild's output
|
`renderRebuildLiveLog`) shows the currently-running rebuild's output
|
||||||
inline — collapsible, with a live/ok/fail badge and a `↓ raw`
|
inline — collapsible, with a live/ok/fail badge and a `↓ raw`
|
||||||
download. It's keyed to the first `Running` node (in wire order)
|
download. It's keyed to the first `Running` node (in wire order)
|
||||||
whose `payload.data.build_log_id` is set — read from the `onUpdate`
|
whose `payload.data.build_log_id` is set — read from the
|
||||||
callback's node list, same source as the count pill, no separate
|
`hive-jobq-graph-update` event's node list, same source as the count
|
||||||
fetch — and **polls** `GET /api/build-log/{id}` every 2s
|
pill, no separate fetch — and **polls** `GET /api/build-log/{id}`
|
||||||
(`fetchAndRenderLiveLog` / `liveLogPollTimer`); not an `EventSource`
|
every 2s (`fetchAndRenderLiveLog` / `liveLogPollTimer`); not an
|
||||||
(that's the BUILD L0GS tab's own per-row expand view below — `GET
|
`EventSource` (that's the BUILD L0GS tab's own per-row expand view
|
||||||
/api/build-logs/id/{id}/stream`, real SSE, replays accumulated output
|
below — `GET /api/build-logs/id/{id}/stream`, real SSE, replays
|
||||||
on connect — a separate mechanism). The live-log panel lives in its
|
accumulated output on connect — a separate mechanism). The live-log
|
||||||
own container outside `#rebuild-queue-section` so the mounted
|
panel lives in its own container outside `#rebuild-queue-section` so
|
||||||
`JobqGraph`'s own re-renders never disturb the open poll; it hides
|
the mounted `<hive-jobq-graph>`'s own re-renders never disturb the
|
||||||
when nothing is building.
|
open poll; it hides when nothing is building.
|
||||||
|
|
||||||
**M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can
|
**M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can
|
||||||
selectively `nix flake update`, rendered as an indented tree:
|
selectively `nix flake update`, rendered as an indented tree:
|
||||||
|
|
|
||||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
|
|
@ -1000,16 +1000,12 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hive/shared": "*",
|
"@hive/shared": "*",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
"marked": "18.0.6",
|
"marked": "18.0.6"
|
||||||
"preact": "10.29.8"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/shared": {
|
"packages/shared": {
|
||||||
"name": "@hive/shared",
|
"name": "@hive/shared",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0"
|
||||||
"dependencies": {
|
|
||||||
"preact": "10.29.8"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"packages/swarm-ui": {
|
"packages/swarm-ui": {
|
||||||
"name": "@hive/swarm-ui",
|
"name": "@hive/swarm-ui",
|
||||||
|
|
|
||||||
|
|
@ -83,14 +83,6 @@ await build({
|
||||||
// page-stylesheet bundling below (`loader: { '.css': 'css' }`), which
|
// page-stylesheet bundling below (`loader: { '.css': 'css' }`), which
|
||||||
// runs as its own esbuild invocation over different entry points.
|
// runs as its own esbuild invocation over different entry points.
|
||||||
loader: { '.css': 'text' },
|
loader: { '.css': 'text' },
|
||||||
// `@hive/shared/jobq-graph.js` resolves to a real `.jsx` file
|
|
||||||
// (`JobqGraph.jsx`), pulled in transitively by `builds.js` — esbuild
|
|
||||||
// already picks the `jsx` loader for `.jsx` by extension, this just
|
|
||||||
// sets the transform mode to match swarm-ui's (which also authors
|
|
||||||
// this file). No other entry here uses JSX today; this doesn't turn
|
|
||||||
// any plain `.js` file into one, `.js` still parses as plain JS.
|
|
||||||
jsx: 'automatic',
|
|
||||||
jsxImportSource: 'preact',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stream-worker entry (#448). Lives in a separate bundle: SharedWorker
|
// Stream-worker entry (#448). Lives in a separate bundle: SharedWorker
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hive/shared": "*",
|
"@hive/shared": "*",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
"marked": "18.0.6",
|
"marked": "18.0.6"
|
||||||
"preact": "10.29.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
(`.builds-shell` body); only the build-logs-* component rules are used. */
|
(`.builds-shell` body); only the build-logs-* component rules are used. */
|
||||||
@import "./system-sections.css";
|
@import "./system-sections.css";
|
||||||
@import "./logs.css";
|
@import "./logs.css";
|
||||||
@import "@hive/shared/jobq-graph.css";
|
|
||||||
|
|
||||||
body.builds-shell {
|
body.builds-shell {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,10 @@
|
||||||
<main class="builds-main">
|
<main class="builds-main">
|
||||||
|
|
||||||
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
|
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
|
||||||
first-spawns. Rendered from GET /api/jobq/graph by the
|
first-spawns. Rendered from GET /api/jobq/graph by
|
||||||
`JobqGraph` Preact component (@hive/shared/jobq-graph.js);
|
<hive-jobq-graph>; `rebuild_queue_changed` over
|
||||||
`rebuild_queue_changed` over /api/dashboard/stream is the
|
/api/dashboard/stream is the refresh trigger and carries no
|
||||||
refresh trigger and carries no payload of its own. Default tab. -->
|
payload of its own. Default tab. -->
|
||||||
<section class="builds-pane" id="builds-pane-rebuild" data-tab-pane="rebuild"
|
<section class="builds-pane" id="builds-pane-rebuild" data-tab-pane="rebuild"
|
||||||
role="tabpanel" aria-labelledby="builds-tab-rebuild">
|
role="tabpanel" aria-labelledby="builds-tab-rebuild">
|
||||||
<p class="meta">pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.</p>
|
<p class="meta">pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.</p>
|
||||||
|
|
|
||||||
|
|
@ -16,17 +16,18 @@ import { bindAsyncForms } from '@hive/shared/forms.js';
|
||||||
import { themedConfirm } from '@hive/shared/modal.js';
|
import { themedConfirm } from '@hive/shared/modal.js';
|
||||||
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 '@hive/shared/jobq-graph.js';
|
||||||
|
|
||||||
// ─── derived state ───────────────────────────────────────────────────────────
|
// ─── derived state ───────────────────────────────────────────────────────────
|
||||||
let metaInputsState = [];
|
let metaInputsState = [];
|
||||||
let metaUpdateRunning = false;
|
let metaUpdateRunning = false;
|
||||||
// Kept in sync from JobqGraph's `onUpdate` callback (see mountRebuildQueue
|
// Kept in sync from <hive-jobq-graph>'s `hive-jobq-graph-update` event
|
||||||
// below) — the component owns fetching GET /api/jobq/graph and renders
|
// (see mountJobqGraph below) — the component owns fetching GET
|
||||||
// the tree itself; this flat array exists only for the two things it
|
// /api/jobq/graph and renders the tree itself; this flat array exists only
|
||||||
// doesn't render: the count-pill and the live-log panel.
|
// for the two things it doesn't render: the count-pill and the live-log
|
||||||
|
// panel.
|
||||||
let jobqNodes = [];
|
let jobqNodes = [];
|
||||||
let jobqGraphHandle = null;
|
let jobqGraphEl = null;
|
||||||
|
|
||||||
function syncFromSnapshot(s) {
|
function syncFromSnapshot(s) {
|
||||||
metaInputsState = (s.meta_inputs || []).slice();
|
metaInputsState = (s.meta_inputs || []).slice();
|
||||||
|
|
@ -127,50 +128,48 @@ function renderMetaInputs(s) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── rebuild queue ────────────────────────────────────────────────────────────
|
// ─── rebuild queue ────────────────────────────────────────────────────────────
|
||||||
// R3BU1LD QU3U3 is `JobqGraph` (Preact, mounted imperatively — see
|
// R3BU1LD QU3U3 is <hive-jobq-graph> directly (mara: "replace the build
|
||||||
// mountJobqGraph's own doc comment in @hive/shared/jobq-graph.js for why
|
// queue tab with this component") — no hand-rolled tree/roll-up rendering
|
||||||
// this file has no JSX pipeline) directly — no hand-rolled tree/roll-up
|
// here anymore. The component owns fetching GET /api/jobq/graph and its
|
||||||
// rendering here anymore. The component owns fetching GET /api/jobq/graph
|
// own refresh(); this page just listens for its `hive-jobq-graph-update`
|
||||||
// and its own refetch (`.refresh()` on the mount handle); this page just
|
// event to keep `jobqNodes` (the flat array) in sync for the two things
|
||||||
// reads its `onUpdate` callback to keep `jobqNodes` (the flat array) in
|
// the generic view doesn't render itself: the count-pill and the live-log
|
||||||
// sync for the two things the generic view doesn't render itself: the
|
// 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
|
||||||
// prop turns on the component's own per-node cancel button, which calls
|
// `cancellable` attribute turns on the component's own per-node cancel
|
||||||
// `onCancel(id)` rather than posting anything — the endpoint
|
// button, which dispatches `hive-jobq-graph-cancel` rather than posting
|
||||||
// (`/api/rebuild-queue/{id}/cancel`) is this page's domain concept, not
|
// anything — the endpoint (`/api/rebuild-queue/{id}/cancel`) is this
|
||||||
// the generic component's.
|
// page's domain concept, not the generic component's.
|
||||||
function mountRebuildQueue() {
|
function mountJobqGraph() {
|
||||||
const root = $('rebuild-queue-section');
|
const root = $('rebuild-queue-section');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
root.replaceChildren();
|
root.replaceChildren();
|
||||||
jobqGraphHandle = mountJobqGraph(root, {
|
jobqGraphEl = el('hive-jobq-graph', { endpoint: '/api/jobq/graph', cancellable: '' });
|
||||||
endpoint: '/api/jobq/graph',
|
jobqGraphEl.addEventListener('hive-jobq-graph-update', (e) => {
|
||||||
cancellable: true,
|
jobqNodes = e.detail.nodes || [];
|
||||||
onUpdate: (nodes) => {
|
renderRebuildLiveLog();
|
||||||
jobqNodes = nodes || [];
|
updateRebuildCount();
|
||||||
renderRebuildLiveLog();
|
|
||||||
updateRebuildCount();
|
|
||||||
},
|
|
||||||
onCancel: async (id) => {
|
|
||||||
const node = jobqNodes.find((n) => n.id === id);
|
|
||||||
const label = node ? node.payload.label : 'node ' + id;
|
|
||||||
if (!(await themedConfirm({
|
|
||||||
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
|
|
||||||
danger: true, confirmLabel: '✕ cancel',
|
|
||||||
}))) return;
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/rebuild-queue/' + id + '/cancel', { method: 'POST' });
|
|
||||||
if (!r.ok) throw new Error('http ' + r.status);
|
|
||||||
// No manual refresh: cancel flips node state, which fires
|
|
||||||
// rebuild_queue_changed over SSE — the existing handler below
|
|
||||||
// already calls jobqGraphHandle.refresh() on that tick.
|
|
||||||
} catch (err) {
|
|
||||||
console.error('cancel failed', err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
jobqGraphEl.addEventListener('hive-jobq-graph-cancel', async (e) => {
|
||||||
|
const { id } = e.detail;
|
||||||
|
const node = jobqNodes.find((n) => n.id === id);
|
||||||
|
const label = node ? node.payload.label : 'node ' + id;
|
||||||
|
if (!(await themedConfirm({
|
||||||
|
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
|
||||||
|
danger: true, confirmLabel: '✕ cancel',
|
||||||
|
}))) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/rebuild-queue/' + id + '/cancel', { method: 'POST' });
|
||||||
|
if (!r.ok) throw new Error('http ' + r.status);
|
||||||
|
// No manual refresh: cancel flips node state, which fires
|
||||||
|
// rebuild_queue_changed over SSE — the existing handler below
|
||||||
|
// already calls jobqGraphEl.refresh() on that tick.
|
||||||
|
} catch (err) {
|
||||||
|
console.error('cancel failed', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.append(jobqGraphEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── running-rebuild live log ─────────────────────────────────────────────────
|
// ─── running-rebuild live log ─────────────────────────────────────────────────
|
||||||
|
|
@ -306,8 +305,8 @@ function updateRebuildCount() {
|
||||||
function renderAll() {
|
function renderAll() {
|
||||||
renderMetaInputs({ meta_inputs: metaInputsState });
|
renderMetaInputs({ meta_inputs: metaInputsState });
|
||||||
// Rebuild-queue rendering + the count-pill/live-log it drives all happen
|
// Rebuild-queue rendering + the count-pill/live-log it drives all happen
|
||||||
// off JobqGraph's own onUpdate callback (see mountRebuildQueue) —
|
// off <hive-jobq-graph>'s own hive-jobq-graph-update event (see
|
||||||
// nothing to render here directly.
|
// mountJobqGraph) — nothing to render here directly.
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── BUILD L0GS tab ───────────────────────────────────────────────────────────
|
// ─── BUILD L0GS tab ───────────────────────────────────────────────────────────
|
||||||
|
|
@ -470,8 +469,8 @@ const SSE_HANDLERS = {
|
||||||
// No `ev.queue` payload read anymore — this event still carries its own
|
// No `ev.queue` payload read anymore — this event still carries its own
|
||||||
// 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. <hive-jobq-graph> owns the actual fetch.
|
||||||
if (jobqGraphHandle) jobqGraphHandle.refresh();
|
if (jobqGraphEl) jobqGraphEl.refresh();
|
||||||
// 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);
|
||||||
|
|
@ -502,9 +501,9 @@ async function refreshState() {
|
||||||
async function init() {
|
async function init() {
|
||||||
initServerWarnings();
|
initServerWarnings();
|
||||||
bindAsyncForms(() => refreshState());
|
bindAsyncForms(() => refreshState());
|
||||||
// Self-fetches on mount (JobqGraph's own effect) — no explicit initial
|
// Self-fetches on mount (<hive-jobq-graph>'s own connectedCallback) —
|
||||||
// fetch needed here, unlike meta inputs below.
|
// no explicit initial fetch needed here, unlike meta inputs below.
|
||||||
mountRebuildQueue();
|
mountJobqGraph();
|
||||||
|
|
||||||
buildTabs = document.getElementById('builds-tabbar').configure({
|
buildTabs = document.getElementById('builds-tabbar').configure({
|
||||||
tabs: [
|
tabs: [
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,8 @@ let jobqRollupState = [];
|
||||||
// (applyRebuildQueueChanged below) — a payload-less push trigger by
|
// (applyRebuildQueueChanged below) — 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 it for
|
||||||
// handle's .refresh().
|
// <hive-jobq-graph>.refresh().
|
||||||
// Best-effort: a failed fetch leaves the previous snapshot in place
|
// Best-effort: a failed fetch leaves the previous snapshot in place
|
||||||
// rather than wiping the banner on a network blip.
|
// rather than wiping the banner on a network blip.
|
||||||
export async function refreshJobqRollup() {
|
export async function refreshJobqRollup() {
|
||||||
|
|
|
||||||
|
|
@ -311,8 +311,8 @@ window.marked = marked;
|
||||||
// handled on /core.html now (the SYST3M panels moved there).
|
// handled on /core.html now (the SYST3M panels moved there).
|
||||||
// rebuild_queue_changed: refreshes the SW4RM queue-summary banner
|
// rebuild_queue_changed: refreshes the SW4RM queue-summary banner
|
||||||
// (see swarm.js) — a payload-less push trigger, same treatment
|
// (see swarm.js) — a payload-less push trigger, same treatment
|
||||||
// /builds.html gives it for its JobqGraph mount handle's .refresh()
|
// /builds.html gives it for <hive-jobq-graph>.refresh() (its own
|
||||||
// (its own separate subscription).
|
// separate subscription).
|
||||||
rebuild_queue_changed: applyRebuildQueueChanged,
|
rebuild_queue_changed: applyRebuildQueueChanged,
|
||||||
schedules_changed: applySchedulesChanged,
|
schedules_changed: applySchedulesChanged,
|
||||||
capabilities_changed: applyCapabilitiesChanged,
|
capabilities_changed: applyCapabilitiesChanged,
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,6 @@
|
||||||
"description": "Shared frontend modules used by both the dashboard and the per-agent UI: terminal log pane, Catppuccin palette, base typography. Imported by sibling workspaces; not bundled standalone.",
|
"description": "Shared frontend modules used by both the dashboard and the per-agent UI: terminal log pane, Catppuccin palette, base typography. Imported by sibling workspaces; not bundled standalone.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.js",
|
"main": "./src/index.js",
|
||||||
"dependencies": {
|
|
||||||
"preact": "10.29.8"
|
|
||||||
},
|
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.js",
|
".": "./src/index.js",
|
||||||
"./terminal.js": "./src/terminal/terminal.js",
|
"./terminal.js": "./src/terminal/terminal.js",
|
||||||
|
|
@ -28,8 +25,7 @@
|
||||||
"./hive-menu.js": "./src/hive-menu/hive-menu.js",
|
"./hive-menu.js": "./src/hive-menu/hive-menu.js",
|
||||||
"./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.jsx",
|
"./jobq-graph.js": "./src/jobq-graph/hive-jobq-graph.js"
|
||||||
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css"
|
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"src/"
|
"src/"
|
||||||
|
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
// JobqGraph.jsx — <JobqGraph>, a Preact component rendering any
|
|
||||||
// hive_jobq graph from the wire shape GET /api/jobq/graph serves (any
|
|
||||||
// endpoint serving `Vec<hive_jobq_wire::GraphNode>` works — see
|
|
||||||
// hive-jobq-wire's README). Renders an indented state tree:
|
|
||||||
// `payload.label` verbatim, `payload.data` as a generic key/value list.
|
|
||||||
// Light DOM, shared by the dashboard and swarm-ui — both esbuild
|
|
||||||
// configs run `jsx: 'automatic', jsxImportSource: 'preact'`, so this
|
|
||||||
// file is real JSX in either build, not hand-written `h()` calls.
|
|
||||||
//
|
|
||||||
// `cancellable` adds a per-node cancel button calling `onCancel(id)`
|
|
||||||
// directly (a plain prop). `onUpdate(nodes)` fires after every fetch,
|
|
||||||
// for a host needing the raw list (a count badge, a live-log panel)
|
|
||||||
// without its own parallel fetch.
|
|
||||||
//
|
|
||||||
// Two ways to use this: JSX (swarm-ui, or any future dashboard page
|
|
||||||
// that renders it directly) — `<JobqGraph endpoint="..." cancellable
|
|
||||||
// onCancel={...} onUpdate={...} />`, a normal component. Or imperative
|
|
||||||
// mount (dashboard/src/builds.js, which stays plain `.js` — a `.jsx`
|
|
||||||
// call site still needs a JSX-aware file, mounting doesn't) —
|
|
||||||
// `mountJobqGraph(container, props)` returns a `{ refresh(),
|
|
||||||
// update(props) }` handle.
|
|
||||||
//
|
|
||||||
// Styles live in `@hive/shared/jobq-graph.css`, `@import`ed from a
|
|
||||||
// page/component CSS file rather than imported here. This one stays
|
|
||||||
// necessary regardless of the JSX question above: dashboard bundles
|
|
||||||
// every page entry (including this component, pulled in transitively)
|
|
||||||
// through one esbuild call whose `.css` loader is `text` — a handful of
|
|
||||||
// shadow-DOM components (modal.js, hive-btn.js) need their CSS as a
|
|
||||||
// literal string to inject into a shadow root, and esbuild's loader map
|
|
||||||
// is global per call, not per-module. Importing `.css` here would
|
|
||||||
// silently pick up that `text` loader too and bind a useless string
|
|
||||||
// instead of applying styles, so this file imports no CSS at all;
|
|
||||||
// each consumer's own page/component CSS `@import`s it instead.
|
|
||||||
|
|
||||||
import { h, render } from 'preact';
|
|
||||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
|
||||||
|
|
||||||
const STATE_GLYPH = {
|
|
||||||
Pending: '⏸',
|
|
||||||
Running: '▶',
|
|
||||||
Finishing: '◐',
|
|
||||||
Done: '✔',
|
|
||||||
Failed: '✖',
|
|
||||||
Cancelled: '⊘',
|
|
||||||
Skipped: '·',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Declaration order doubles as render order for the filter checkboxes —
|
|
||||||
// matches `hive_jobq_wire::ALL_STATES` on the wire, so the row reads in
|
|
||||||
// the same lifecycle order the rollup endpoint counts in.
|
|
||||||
const ALL_STATES = Object.keys(STATE_GLYPH);
|
|
||||||
|
|
||||||
// Product call: "default selection filters out skipped and done."
|
|
||||||
const DEFAULT_HIDDEN_STATES = new Set(['Done', 'Skipped']);
|
|
||||||
|
|
||||||
// Non-terminal states a cancel button makes sense on. Finishing is
|
|
||||||
// included — "own logic done, children still running" is still a subtree
|
|
||||||
// worth stopping early.
|
|
||||||
const CANCELLABLE_STATES = new Set(['Pending', 'Running', 'Finishing']);
|
|
||||||
|
|
||||||
// Build a parent/child tree from the flat wire array. `parent` (structural
|
|
||||||
// grouping) defines tree shape. Sibling order follows array order, which
|
|
||||||
// is already root-then-subtree per root per `GraphWire::wire_snapshot`'s
|
|
||||||
// own doc contract — no reordering; a dependency is named in text (see
|
|
||||||
// `_waitsOn` below), not implied by render position.
|
|
||||||
//
|
|
||||||
// Also resolves each node's `Node`-kind deps to the referenced node's own
|
|
||||||
// label, once, so rendering doesn't need a second lookup pass. Looked up
|
|
||||||
// globally (`byId`, every node in this snapshot), not scoped to siblings —
|
|
||||||
// a dep is expected to always name a sibling, but resolving globally means
|
|
||||||
// a label still shows correctly even if that expectation is ever wrong,
|
|
||||||
// instead of silently dropping the edge. A dep naming an id outside this
|
|
||||||
// snapshot (a filtered view) or a `Resource`-kind dep has nothing to point
|
|
||||||
// at and is simply not listed.
|
|
||||||
function buildTree(nodes) {
|
|
||||||
const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }]));
|
|
||||||
const roots = [];
|
|
||||||
for (const n of byId.values()) {
|
|
||||||
const p = n.parent != null ? byId.get(n.parent) : null;
|
|
||||||
if (p) p._children.push(n);
|
|
||||||
else roots.push(n);
|
|
||||||
}
|
|
||||||
for (const n of byId.values()) {
|
|
||||||
n._waitsOn = (n.deps || [])
|
|
||||||
.filter((d) => d.kind === 'Node')
|
|
||||||
.map((d) => byId.get(d.id))
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((dep) => dep.payload.label);
|
|
||||||
}
|
|
||||||
return roots;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `payload.data` is an opaque JSON value from the host's `WireNode::data`
|
|
||||||
// — render it as a generic key/value list when it's a plain object (the
|
|
||||||
// only shape a host is expected to send; anything else falls back to a
|
|
||||||
// single stringified row rather than silently dropping it).
|
|
||||||
function DataList({ data }) {
|
|
||||||
if (data == null) return null;
|
|
||||||
const isPlainObject = typeof data === 'object' && !Array.isArray(data);
|
|
||||||
const entries = isPlainObject ? Object.entries(data) : [['data', data]];
|
|
||||||
if (!entries.length) return null;
|
|
||||||
return (
|
|
||||||
<dl class="jg-data">
|
|
||||||
{entries.map(([k, v]) => (
|
|
||||||
<>
|
|
||||||
<dt key={k + '-dt'}>{k}</dt>
|
|
||||||
<dd key={k + '-dd'}>{typeof v === 'string' ? v : JSON.stringify(v)}</dd>
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NodeView({ n, cancellable, onCancel }) {
|
|
||||||
const glyph = STATE_GLYPH[n.state] || '?';
|
|
||||||
const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
|
|
||||||
return (
|
|
||||||
<div class="jg-node">
|
|
||||||
<div class="jg-row">
|
|
||||||
<span class={'jg-state jg-state-' + n.state.toLowerCase()}
|
|
||||||
title={n.state + (n.error ? ' — ' + n.error : '')}>
|
|
||||||
{glyph}
|
|
||||||
</span>
|
|
||||||
{' '}
|
|
||||||
<span class="jg-label">{n.payload.label}</span>
|
|
||||||
{showCancel && (
|
|
||||||
<button type="button" class="jg-cancel-btn" title={'cancel ' + n.payload.label}
|
|
||||||
onClick={() => onCancel && onCancel(n.id)}>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{n._waitsOn && n._waitsOn.length > 0 && (
|
|
||||||
<div class="jg-waits-on">waits on: {n._waitsOn.join(', ')}</div>
|
|
||||||
)}
|
|
||||||
<DataList data={n.payload.data} />
|
|
||||||
{n.error && <pre class="jg-error">{n.error}</pre>}
|
|
||||||
{n._children.map((child) => (
|
|
||||||
<NodeView key={child.id} n={child} cancellable={cancellable} onCancel={onCancel} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterBar({ selectedStates, onToggle }) {
|
|
||||||
return (
|
|
||||||
<div class="jg-filter">
|
|
||||||
{ALL_STATES.map((state) => {
|
|
||||||
const id = 'jg-filter-' + state.toLowerCase();
|
|
||||||
return (
|
|
||||||
<label key={state} for={id} class={'jg-filter-label jg-state-' + state.toLowerCase()}>
|
|
||||||
<input type="checkbox" id={id} checked={selectedStates.has(state)}
|
|
||||||
onChange={() => onToggle(state)} />
|
|
||||||
{' '}{STATE_GLYPH[state] + ' ' + state}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// `endpoint` plus the current filter selection as a `states=` query
|
|
||||||
// param — omitted entirely when every state is checked, so the
|
|
||||||
// unfiltered default case sends the exact same request as before this
|
|
||||||
// filter existed.
|
|
||||||
function fetchUrl(endpoint, selectedStates) {
|
|
||||||
if (!endpoint) return null;
|
|
||||||
if (selectedStates.size >= ALL_STATES.length) return endpoint;
|
|
||||||
const url = new URL(endpoint, window.location.origin);
|
|
||||||
url.searchParams.set('states', Array.from(selectedStates).join(','));
|
|
||||||
return url.pathname + url.search;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `refreshToken` is not read anywhere in the body — its only job is to
|
|
||||||
// change identity so the effect below re-runs, giving a host (or
|
|
||||||
// `mountJobqGraph`) an explicit "refetch now" lever without an
|
|
||||||
// imperative ref into this component.
|
|
||||||
export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }) {
|
|
||||||
const [selectedStates, setSelectedStates] = useState(
|
|
||||||
() => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))),
|
|
||||||
);
|
|
||||||
const [nodes, setNodes] = useState(null); // null = loading, [] = empty-but-loaded
|
|
||||||
const [error, setError] = useState(null);
|
|
||||||
|
|
||||||
const toggleState = useCallback((state) => {
|
|
||||||
setSelectedStates((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(state)) next.delete(state); else next.add(state);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const url = fetchUrl(endpoint, selectedStates);
|
|
||||||
if (!url) return undefined;
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const r = await fetch(url);
|
|
||||||
if (!r.ok) throw new Error('http ' + r.status);
|
|
||||||
const data = await r.json();
|
|
||||||
if (cancelled) return;
|
|
||||||
setNodes(data);
|
|
||||||
setError(null);
|
|
||||||
if (onUpdate) onUpdate(data);
|
|
||||||
} catch (err) {
|
|
||||||
if (cancelled) return;
|
|
||||||
setError(String(err));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedStates is a Set;
|
|
||||||
// its *contents* are what should retrigger the fetch, not its identity, and the
|
|
||||||
// string form below already changes identity exactly when contents do.
|
|
||||||
}, [endpoint, Array.from(selectedStates).sort().join(','), refreshToken]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="jg-root">
|
|
||||||
<FilterBar selectedStates={selectedStates} onToggle={toggleState} />
|
|
||||||
<div class="jg-body">
|
|
||||||
{error ? (
|
|
||||||
<p class="jg-error-msg">fetch failed: {error}</p>
|
|
||||||
) : nodes === null ? (
|
|
||||||
<p class="jg-empty">loading…</p>
|
|
||||||
) : !nodes.length ? (
|
|
||||||
<p class="jg-empty">empty</p>
|
|
||||||
) : (
|
|
||||||
buildTree(nodes).map((root) => (
|
|
||||||
<NodeView key={root.id} n={root} cancellable={cancellable} onCancel={onCancel} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</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, initialProps) {
|
|
||||||
let props = initialProps;
|
|
||||||
let token = 0;
|
|
||||||
const draw = () => render(h(JobqGraph, { ...props, refreshToken: token }), container);
|
|
||||||
draw();
|
|
||||||
return {
|
|
||||||
refresh() { token += 1; draw(); },
|
|
||||||
update(next) { props = { ...props, ...next }; draw(); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
/* jobq-graph.css — styles for the `JobqGraph` Preact component
|
/* hive-jobq-graph.css — shadow-scoped styles for <hive-jobq-graph>. Theme
|
||||||
(`./JobqGraph.jsx`), rendered into light DOM under `.jg-root`.
|
custom properties (--fg, --red, ...) pierce the shadow boundary by
|
||||||
`@import` this from a page-level CSS file (dashboard) or a
|
inheritance and are used directly; only plain class rules live here,
|
||||||
component-level one (swarm-ui), matching how every other
|
same split every other shadow-DOM component (<hive-dialog>, ...) uses. */
|
||||||
`@hive/shared` stylesheet is consumed — see JobqGraph.jsx's top comment
|
|
||||||
for why this file is never imported from JS. Theme custom properties
|
|
||||||
(--fg, --red, ...) are plain inherited custom properties here, same as
|
|
||||||
any other light-DOM rule. */
|
|
||||||
|
|
||||||
.jg-root {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
240
frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js
Normal file
240
frontend/packages/shared/src/jobq-graph/hive-jobq-graph.js
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
// hive-jobq-graph.js — <hive-jobq-graph>, a shadow-DOM custom element that
|
||||||
|
// renders any hive_jobq graph generically from the wire shape served by
|
||||||
|
// GET /api/jobq/graph (or any endpoint serving the same
|
||||||
|
// `Vec<hive_jobq_wire::GraphNode>` shape — see hive-jobq-wire's README).
|
||||||
|
// Renders each root + its subtree as an indented tree: state glyph,
|
||||||
|
// `payload.label` verbatim, and `payload.data` (if present) as a generic
|
||||||
|
// key/value list — this element never branches on what a label or a data
|
||||||
|
// key means, matching the "opaque payload" contract the wire type
|
||||||
|
// documents. A consumer wanting domain-specific rendering (an agent chip,
|
||||||
|
// a build-log link, ...) does its own thing on top; this is the generic
|
||||||
|
// floor every jobq gets for free.
|
||||||
|
//
|
||||||
|
// `Node`-kind dep edges get a plain "waits on: <label>" text line — see
|
||||||
|
// `buildTree`'s `_waitsOn` resolution. `cancellable` attribute adds a
|
||||||
|
// per-node cancel button dispatching `hive-jobq-graph-cancel` instead of
|
||||||
|
// POSTing anything itself — see `CANCELLABLE_STATES` + the click-delegate
|
||||||
|
// in `connectedCallback` below for both. A per-state checkbox row above
|
||||||
|
// the tree re-fetches `endpoint` with `?states=<checked>` on toggle — see
|
||||||
|
// `_buildFilterBar`/`_fetchUrl` below and docs/web-ui/dashboard.md's
|
||||||
|
// R3BU1LD QU3U3 section for the full filter rationale.
|
||||||
|
//
|
||||||
|
// Usage: <hive-jobq-graph endpoint="/api/jobq/graph"></hive-jobq-graph> —
|
||||||
|
// self-fetches on connect. `.refresh()` re-fetches + re-renders;
|
||||||
|
// `.render(nodes)` renders host-pushed data directly, no fetch. Every
|
||||||
|
// render dispatches a bubbling/composed `hive-jobq-graph-update` event
|
||||||
|
// (`detail: { nodes }`) so a host needing the raw list (a count badge, a
|
||||||
|
// live-log panel) listens instead of running its own parallel fetch.
|
||||||
|
//
|
||||||
|
// Shadow DOM + own styles: theme custom properties (--fg, --red, ...)
|
||||||
|
// pierce the shadow boundary by inheritance; only plain class rules local.
|
||||||
|
|
||||||
|
import { el } from '../dom.js';
|
||||||
|
import { attachShadowCss } from '../shadow-css.js';
|
||||||
|
import graphCss from './hive-jobq-graph.css';
|
||||||
|
|
||||||
|
const STATE_GLYPH = {
|
||||||
|
Pending: '⏸',
|
||||||
|
Running: '▶',
|
||||||
|
Finishing: '◐',
|
||||||
|
Done: '✔',
|
||||||
|
Failed: '✖',
|
||||||
|
Cancelled: '⊘',
|
||||||
|
Skipped: '·',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Declaration order doubles as render order for the filter checkboxes —
|
||||||
|
// matches `hive_jobq_wire::ALL_STATES` on the wire, so the row reads in the
|
||||||
|
// same lifecycle order the rollup endpoint counts in.
|
||||||
|
const ALL_STATES = Object.keys(STATE_GLYPH);
|
||||||
|
|
||||||
|
// Product call: "default selection filters out skipped and done."
|
||||||
|
const DEFAULT_HIDDEN_STATES = new Set(['Done', 'Skipped']);
|
||||||
|
|
||||||
|
// Non-terminal states a cancel button makes sense on. Finishing is
|
||||||
|
// included — "own logic done, children still running" is still a subtree
|
||||||
|
// worth stopping early.
|
||||||
|
const CANCELLABLE_STATES = new Set(['Pending', 'Running', 'Finishing']);
|
||||||
|
|
||||||
|
// Build a parent/child tree from the flat wire array. `parent` (structural
|
||||||
|
// grouping) defines tree shape. Sibling order follows array order, which is
|
||||||
|
// already root-then-subtree per root per `GraphWire::wire_snapshot`'s own
|
||||||
|
// doc contract — no reordering; a dependency is named in text (see
|
||||||
|
// `_waitsOn` below), not implied by render position.
|
||||||
|
//
|
||||||
|
// Also resolves each node's `Node`-kind deps to the referenced node's own
|
||||||
|
// label, once, so rendering doesn't need a second lookup pass. Looked up
|
||||||
|
// globally (`byId`, every node in this snapshot), not scoped to siblings —
|
||||||
|
// a dep is expected to always name a sibling, but resolving globally means
|
||||||
|
// a label still shows correctly even if that expectation is ever wrong,
|
||||||
|
// instead of silently dropping the edge. A dep naming an id outside this
|
||||||
|
// snapshot (a filtered view) or a `Resource`-kind dep has nothing to point
|
||||||
|
// at and is simply not listed.
|
||||||
|
function buildTree(nodes) {
|
||||||
|
const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }]));
|
||||||
|
const roots = [];
|
||||||
|
for (const n of byId.values()) {
|
||||||
|
const p = n.parent != null ? byId.get(n.parent) : null;
|
||||||
|
if (p) p._children.push(n);
|
||||||
|
else roots.push(n);
|
||||||
|
}
|
||||||
|
for (const n of byId.values()) {
|
||||||
|
n._waitsOn = (n.deps || [])
|
||||||
|
.filter((d) => d.kind === 'Node')
|
||||||
|
.map((d) => byId.get(d.id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((dep) => dep.payload.label);
|
||||||
|
}
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `payload.data` is an opaque JSON value from the host's `WireNode::data`
|
||||||
|
// — render it as a generic key/value list when it's a plain object (the
|
||||||
|
// only shape a host is expected to send; anything else falls back to a
|
||||||
|
// single stringified row rather than silently dropping it).
|
||||||
|
function renderDataList(data) {
|
||||||
|
if (data == null) return null;
|
||||||
|
const isPlainObject = typeof data === 'object' && !Array.isArray(data);
|
||||||
|
const entries = isPlainObject ? Object.entries(data) : [['data', data]];
|
||||||
|
if (!entries.length) return null;
|
||||||
|
const dl = el('dl', { class: 'jg-data' });
|
||||||
|
for (const [k, v] of entries) {
|
||||||
|
dl.append(
|
||||||
|
el('dt', {}, k),
|
||||||
|
el('dd', {}, typeof v === 'string' ? v : JSON.stringify(v)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return dl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(n, cancellable) {
|
||||||
|
const glyph = STATE_GLYPH[n.state] || '?';
|
||||||
|
const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
|
||||||
|
const row = el('div', { class: 'jg-row' },
|
||||||
|
el('span', {
|
||||||
|
class: 'jg-state jg-state-' + n.state.toLowerCase(),
|
||||||
|
title: n.state + (n.error ? ' — ' + n.error : ''),
|
||||||
|
}, glyph),
|
||||||
|
' ',
|
||||||
|
el('span', { class: 'jg-label' }, n.payload.label),
|
||||||
|
showCancel
|
||||||
|
? el('button', {
|
||||||
|
type: 'button', class: 'jg-cancel-btn', 'data-cancel-id': String(n.id),
|
||||||
|
title: 'cancel ' + n.payload.label,
|
||||||
|
}, '✕')
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
const wrap = el('div', { class: 'jg-node' }, row);
|
||||||
|
if (n._waitsOn && n._waitsOn.length) {
|
||||||
|
wrap.append(el('div', { class: 'jg-waits-on' }, 'waits on: ' + n._waitsOn.join(', ')));
|
||||||
|
}
|
||||||
|
const data = renderDataList(n.payload.data);
|
||||||
|
if (data) wrap.append(data);
|
||||||
|
if (n.error) wrap.append(el('pre', { class: 'jg-error' }, n.error));
|
||||||
|
for (const child of n._children) wrap.append(renderNode(child, cancellable));
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HiveJobqGraph extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
// Reconnect-without-detach guard — same hazard <hive-menu>/
|
||||||
|
// <hive-agent-menu> hit when a row cache moves an already-built
|
||||||
|
// element without a real detach.
|
||||||
|
if (this._root) return;
|
||||||
|
// Set before the first `refresh()` call below, so the very first fetch
|
||||||
|
// already carries the default filter rather than flashing every state
|
||||||
|
// and re-fetching a moment later.
|
||||||
|
this._selectedStates = new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s)));
|
||||||
|
this._root = attachShadowCss(this, graphCss);
|
||||||
|
this._filterBar = this._buildFilterBar();
|
||||||
|
this._body = el('div', { class: 'jg-body' });
|
||||||
|
this._root.append(this._filterBar, this._body);
|
||||||
|
// One delegated listener rather than a per-button one — cancel buttons
|
||||||
|
// come and go on every re-render, a delegated listener on the stable
|
||||||
|
// container doesn't need rebinding.
|
||||||
|
this._body.addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.jg-cancel-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
this.dispatchEvent(new CustomEvent('hive-jobq-graph-cancel', {
|
||||||
|
detail: { id: Number(btn.dataset.cancelId) },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// One checkbox per `ALL_STATES` entry, pre-ticked per `_selectedStates`.
|
||||||
|
// Built once at connect — toggling a box mutates `_selectedStates` and
|
||||||
|
// re-fetches rather than rebuilding the row, so focus/scroll position in
|
||||||
|
// the row itself is never disturbed by a data refresh.
|
||||||
|
_buildFilterBar() {
|
||||||
|
const bar = el('div', { class: 'jg-filter' });
|
||||||
|
for (const state of ALL_STATES) {
|
||||||
|
const id = 'jg-filter-' + state.toLowerCase();
|
||||||
|
const cb = el('input', { type: 'checkbox', id });
|
||||||
|
cb.checked = this._selectedStates.has(state);
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
if (cb.checked) this._selectedStates.add(state);
|
||||||
|
else this._selectedStates.delete(state);
|
||||||
|
this.refresh();
|
||||||
|
});
|
||||||
|
const label = el('label', { for: id, class: 'jg-filter-label jg-state-' + state.toLowerCase() },
|
||||||
|
cb, ' ', STATE_GLYPH[state] + ' ' + state);
|
||||||
|
bar.append(label);
|
||||||
|
}
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `endpoint` plus the current filter selection as a `states=` query
|
||||||
|
// param — omitted entirely when every state is checked, so the
|
||||||
|
// unfiltered default case sends the exact same request as before this
|
||||||
|
// filter existed.
|
||||||
|
_fetchUrl() {
|
||||||
|
const endpoint = this.getAttribute('endpoint');
|
||||||
|
if (!endpoint) return null;
|
||||||
|
if (!this._selectedStates || this._selectedStates.size >= ALL_STATES.length) return endpoint;
|
||||||
|
const url = new URL(endpoint, window.location.origin);
|
||||||
|
url.searchParams.set('states', Array.from(this._selectedStates).join(','));
|
||||||
|
return url.pathname + url.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch `endpoint` (filtered by the current checkbox selection) and
|
||||||
|
// re-render. Public so a host page can call it on its own refresh
|
||||||
|
// cadence (SSE tick, poll, whatever fits the page) — this element
|
||||||
|
// intentionally owns no transport of its own.
|
||||||
|
async refresh() {
|
||||||
|
const url = this._fetchUrl();
|
||||||
|
if (!url || !this._body) return;
|
||||||
|
let nodes;
|
||||||
|
try {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error('http ' + r.status);
|
||||||
|
nodes = await r.json();
|
||||||
|
} catch (err) {
|
||||||
|
this._body.replaceChildren(el('p', { class: 'jg-error-msg' }, 'fetch failed: ' + err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.render(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a pre-fetched node array directly, bypassing `endpoint` — for a
|
||||||
|
// host that already has the data and doesn't want a redundant fetch.
|
||||||
|
render(nodes) {
|
||||||
|
if (!this._body) return;
|
||||||
|
this._body.replaceChildren();
|
||||||
|
if (!nodes || !nodes.length) {
|
||||||
|
this._body.append(el('p', { class: 'jg-empty' }, 'empty'));
|
||||||
|
} else {
|
||||||
|
const roots = buildTree(nodes);
|
||||||
|
const cancellable = this.hasAttribute('cancellable');
|
||||||
|
for (const root of roots) this._body.append(renderNode(root, cancellable));
|
||||||
|
}
|
||||||
|
this.dispatchEvent(new CustomEvent('hive-jobq-graph-update', {
|
||||||
|
detail: { nodes: nodes || [] },
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('hive-jobq-graph', HiveJobqGraph);
|
||||||
|
|
@ -345,11 +345,10 @@ impl JobQueue {
|
||||||
/// costs nothing at that size.
|
/// costs nothing at that size.
|
||||||
///
|
///
|
||||||
/// A node whose *parent* got filtered out still rides with its original
|
/// A node whose *parent* got filtered out still rides with its original
|
||||||
/// `parent` id — `JobqGraph` (`@hive/shared/jobq-graph.js`, the one
|
/// `parent` id — `<hive-jobq-graph>` (the one consumer) already treats
|
||||||
/// consumer) already treats an unresolvable parent as a new root
|
/// an unresolvable parent as a new root (`buildTree`'s fallback), so a
|
||||||
/// (`buildTree`'s fallback), so a filtered-out ancestor surfaces a
|
/// filtered-out ancestor surfaces a still-matching descendant one level
|
||||||
/// still-matching descendant one level higher rather than hiding or
|
/// higher rather than hiding or orphaning it.
|
||||||
/// orphaning it.
|
|
||||||
///
|
///
|
||||||
/// The projection itself is [`hive_jobq_wire`]'s; all this layer supplies
|
/// The projection itself is [`hive_jobq_wire`]'s; all this layer supplies
|
||||||
/// is *which* nodes to show — see [`visible_roots`] for why the graph
|
/// is *which* nodes to show — see [`visible_roots`] for why the graph
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,7 @@ in
|
||||||
# Same lockfile as `frontend`/`swarm-ui` above — recompute in
|
# Same lockfile as `frontend`/`swarm-ui` above — recompute in
|
||||||
# lockstep with those two whenever `frontend/package-lock.json`
|
# lockstep with those two whenever `frontend/package-lock.json`
|
||||||
# changes (`prefetch-npm-deps frontend/package-lock.json`).
|
# changes (`prefetch-npm-deps frontend/package-lock.json`).
|
||||||
npmDepsHash = "sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI=";
|
npmDepsHash = "sha256-LIwW5Nn9cSqJHCm1czPIVxQdP5OqWk7U/4kvkwaj2ts=";
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ buildNpmPackage {
|
||||||
# Update whenever the lockfile changes. Recompute locally with the
|
# Update whenever the lockfile changes. Recompute locally with the
|
||||||
# same command (`pkgs.prefetch-npm-deps`), or let the build fail
|
# same command (`pkgs.prefetch-npm-deps`), or let the build fail
|
||||||
# and copy the actual hash from the error message.
|
# and copy the actual hash from the error message.
|
||||||
npmDepsHash = "sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI=";
|
npmDepsHash = "sha256-LIwW5Nn9cSqJHCm1czPIVxQdP5OqWk7U/4kvkwaj2ts=";
|
||||||
|
|
||||||
# `npm run build` recurses into all workspaces (`--workspaces
|
# `npm run build` recurses into all workspaces (`--workspaces
|
||||||
# --if-present`). The workspaces' build scripts each run their own
|
# --if-present`). The workspaces' build scripts each run their own
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ buildNpmPackage {
|
||||||
# Recompute with `prefetch-npm-deps frontend/package-lock.json`
|
# Recompute with `prefetch-npm-deps frontend/package-lock.json`
|
||||||
# whenever the lockfile changes — same value as `./frontend.nix`'s
|
# whenever the lockfile changes — same value as `./frontend.nix`'s
|
||||||
# `npmDepsHash`, both hash the one shared lockfile.
|
# `npmDepsHash`, both hash the one shared lockfile.
|
||||||
npmDepsHash = "sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI=";
|
npmDepsHash = "sha256-LIwW5Nn9cSqJHCm1czPIVxQdP5OqWk7U/4kvkwaj2ts=";
|
||||||
|
|
||||||
buildPhase = ''
|
buildPhase = ''
|
||||||
runHook preBuild
|
runHook preBuild
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue