Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
iris
e455964cb3 dashboard: the SW4RM tab is the agents tab
The swarm lives in the swarm UI; this tab lists the hive's agents. The tab
is 4G3NTS at #agents, its pane and count pill follow, and the module that
renders the containers is agents.js. Comments and docs that named SW4RM or
swarm.js follow.
2026-10-15 09:50:49 +02:00
iris
ee5c057d8f dashboard: the build pages are tabs of the dashboard
The build lifecycle used to be its own page with three sub-tabs. They are
now dashboard tabs (rebuild, meta, buildlogs) beside SW4RM and
P3RM1SS10NS: builds.js is a module tabs.js imports, so the dashboard's one
tab strip, /api/state snapshot and event stream (plus the meta_inputs_changed
and meta_update_running kinds) serve them, and the page, its bundle entry,
stylesheet and home tile are gone. The SW4RM banner's "view queue" link
points at the #rebuild tab, and the rebuild tab carries a count pill of the
rebuilds in flight.

An empty tab-count pill drew a "0": its display beat the UA [hidden] rule.
2026-10-15 09:50:49 +02:00
29 changed files with 448 additions and 517 deletions

View file

@ -127,7 +127,7 @@ Two things live in the `agent-configs` Forgejo organization:
[How a hive reads the forge](#how-a-hive-reads-the-forge)). [How a hive reads the forge](#how-a-hive-reads-the-forge)).
- The dashboard links each container's deployed config commit to this - The dashboard links each container's deployed config commit to this
config repo on the swarm's forge (`swarm.forge.publicUrl`; `null` means no config repo on the swarm's forge (`swarm.forge.publicUrl`; `null` means no
link), so operators can go straight from the SW4RM tab into link), so operators can go straight from the 4G3NTS tab into
the rendered repo without an extra `git` step. the rendered repo without an extra `git` step.
The `hive-forge` CLI (separate workspace crate, see The `hive-forge` CLI (separate workspace crate, see

View file

@ -33,7 +33,7 @@ swarm controller and hive-c0re each build their own graph on it.
Two views, one per graph, drawn by the same component: the swarm UI's Two views, one per graph, drawn by the same component: the swarm UI's
`/jobs` page shows the swarm controller's graph, and the hive dashboard's `/jobs` page shows the swarm controller's graph, and the hive dashboard's
**BU1LDS** page (R3BU1LD QU3U3 — see **R3BU1LD QU3U3** tab (see
[`web-ui/dashboard.md`](../web-ui/dashboard.md)) shows that hive's. Each [`web-ui/dashboard.md`](../web-ui/dashboard.md)) shows that hive's. Each
**row** is one job; the rows nested under it are that job's steps, in **row** is one job; the rows nested under it are that job's steps, in
order (occasionally a couple run side by side). A step shows one of: order (occasionally a couple run side by side). A step shows one of:

View file

@ -11,21 +11,23 @@ what you actually do here.
## Where things are ## Where things are
Everything starts at the **H0M3 hub**, served at `/` — a grid of tiles Everything starts at the **H0M3 hub**, served at `/` — a grid of tiles
linking to every surface (Dashboard, Flow, Logs, Builds, Stats, linking to every surface (Dashboard, Flow, Logs, Stats,
Settings, Core). Every page links back to H0M3, so you're Settings, Core). Every page links back to H0M3, so you're
never more than one step from the hub. never more than one step from the hub.
The **dashboard** itself (`/dashboard.html`) is where you'll spend most The **dashboard** itself (`/dashboard.html`) is where you'll spend most
of your time. It's a single page with exactly two tabs: of your time. It's a single page with five tabs:
- **SW4RM** — every agent, live. This is the default tab and the one - **4G3NTS** — every agent, live. This is the default tab and the one
you'll check most. you'll check most.
- **R3BU1LD QU3U3**, **M3T4 1NPUTS** and **BUILD L0GS** — the build
lifecycle: the rebuild queue with a live log of whatever is building,
the meta flake inputs you can bump, and the build log history.
- **P3RM1SS10NS** — what tools and system-level access each agent has. - **P3RM1SS10NS** — what tools and system-level access each agent has.
Everything else lives on its own page instead, all reachable from the Everything else lives on its own page instead, all reachable from the
H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across
the whole swarm), **Stats** (`/stats.html`, swarm-wide usage stats), the whole swarm), **Stats** (`/stats.html`, swarm-wide usage stats), and
**Builds** (`/builds.html`, the rebuild queue and build history), and
**Core** (`/core.html`, tombstones and container resource use). **Core** (`/core.html`, tombstones and container resource use).
Each agent also has its own page — a full terminal view of that Each agent also has its own page — a full terminal view of that
@ -35,7 +37,7 @@ if the gateway isn't in front).
## The things you'll actually do ## The things you'll actually do
**Check on an agent.** SW4RM shows every container as a row: name, **Check on an agent.** 4G3NTS shows every container as a row: name,
whether it's running, what it's currently doing (a live status pill — whether it's running, what it's currently doing (a live status pill —
`rebuilding…`, `starting…`, or similar — while something's in flight), `rebuilding…`, `starting…`, or similar — while something's in flight),
and quick links (stats, screen, forge profile). Select the name to open and quick links (stats, screen, forge profile). Select the name to open
@ -47,12 +49,12 @@ repo. Review and merge them on the forge; the merge deploys the change.
See [`config-changes.md`](../agent-lifecycle/config-changes.md#config-changes). See [`config-changes.md`](../agent-lifecycle/config-changes.md#config-changes).
**Start, stop, restart, or rebuild an agent.** Select one or more **Start, stop, restart, or rebuild an agent.** Select one or more
agents on SW4RM (select the icon) and use the selection bar, or use the agents on 4G3NTS (select the icon) and use the selection bar, or use the
per-agent `⋮` menu on a single row. Rebuilding re-applies that agent's per-agent `⋮` menu on a single row. Rebuilding re-applies that agent's
current config; use it whenever an agent current config; use it whenever an agent
shows as "needs update." shows as "needs update."
**Watch a build.** BU1LDS shows the rebuild queue live, plus a **Watch a build.** The dashboard's R3BU1LD QU3U3 tab shows the rebuild queue live, plus a
streaming log of whatever's currently building. Useful right after streaming log of whatever's currently building. Useful right after
merging a config change or bumping a flake input. merging a config change or bumping a flake input.

View file

@ -10,8 +10,8 @@ matrix accounts, the controller's job graph — are on the
hive-c0re serves the dashboard at `/dashboard.html` (with the home page at `/`). hive-c0re serves the dashboard at `/dashboard.html` (with the home page at `/`).
It has a fixed chrome header at the top and a `<main>` that shows exactly It has a fixed chrome header at the top and a `<main>` that shows exactly
one tab pane at a time. The URL hash (`#swarm`, one tab pane at a time. The URL hash (`#agents`, `#rebuild`, `#meta`,
`#permissions`) drives which pane is `#buildlogs`, `#permissions`) drives which pane is
active; hash changes don't reload the page. FL0W and the other active; hash changes don't reload the page. FL0W and the other
standalone pages are reachable from the H0M3 hub at `/`, not standalone pages are reachable from the H0M3 hub at `/`, not
from the dashboard tab strip. from the dashboard tab strip.
@ -19,17 +19,18 @@ from the dashboard tab strip.
**Chrome header** (fixed, overlays the active tab pane): **Chrome header** (fixed, overlays the active tab pane):
- **← home back-link**: top-left of the chrome, links to the H0M3 hub - **← home back-link**: top-left of the chrome, links to the H0M3 hub
at `/`. Every surface links back to the hub rather than to each other. at `/`. Every surface links back to the hub rather than to each other.
- **Tab strip**: `◆ SW4RM ◆`, `◆ P3RM1SS10NS ◆`. In-page tabs only — - **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`,
`◆ BUILD L0GS ◆`, `◆ P3RM1SS10NS ◆`. In-page tabs only —
the SYST3M panels live on the the SYST3M panels live on the
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
live on their own pages too, all reachable from the live on their own pages too, all reachable from the
H0M3 hub (not the tab strip). Count pill on SW4RM H0M3 hub (not the tab strip). Count pills on 4G3NTS
(container count). (stale containers) and R3BU1LD QU3U3 (rebuilds in flight).
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`) - **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
— sits below the tab strip. — sits below the tab strip.
- **Server-warnings banner** — a generic, sticky top-of-page strip shown - **Server-warnings banner** — a generic, sticky top-of-page strip shown
on **every** page (dashboard + every stand-alone page — FL0W, on **every** page (dashboard + every stand-alone page — FL0W,
H0M3, C0R3, BU1LDS, ST4TS), injected at the top H0M3, C0R3, ST4TS), injected at the top
of `<body>` by `renderServerWarnings` in `common.js`. Driven by of `<body>` by `renderServerWarnings` in `common.js`. Driven by
`state.server_warnings` — a list of `{ kind, level, message }` — and `state.server_warnings` — a list of `{ kind, level, message }` — and
coloured by `level` (`warn` amber / `crit` red). hive-c0re owns the coloured by `level` (`warn` amber / `crit` red). hive-c0re owns the
@ -55,7 +56,7 @@ The FL0W page uses a slim header (a `← home` back-link + the page
title) rather than the dashboard tab strip — it's a standalone surface, title) rather than the dashboard tab strip — it's a standalone surface,
not a tab pane. not a tab pane.
## SW4RM tab ## 4G3NTS tab
**C0NTAINERS** — live containers as a flat list, one row per **C0NTAINERS** — live containers as a flat list, one row per
container, sorted alphabetically by agent name; every container the container, sorted alphabetically by agent name; every container the
@ -73,13 +74,13 @@ agent is stale.
Passive / rare-interaction state, reached from the **Core** tile on the Passive / rare-interaction state, reached from the **Core** tile on the
H0M3 hub (served at H0M3 hub (served at
`/core.html`), with the same minimal chrome as `/builds.html`: a `← home` `/core.html`), with minimal chrome: a `← home` back-link and a single pane,
back-link and a single pane, **K3PT ST4T3**. The page is its own esbuild **K3PT ST4T3**. The page is its own esbuild bundle (`core.js`) that
bundle (`core.js`) that cold-loads `/api/state` and subscribes to cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for
`/api/dashboard/stream` for `tombstones_changed`, `capabilities_changed`, `tombstones_changed`, `capabilities_changed`, and `tool_groups_changed`
and `tool_groups_changed` (the latter two re-render the stale-perms (the latter two re-render the stale-perms sub-section when permission
sub-section when permission data changes). (Rebuild queue and meta inputs data changes). (Rebuild queue and meta inputs are dashboard tabs — see
live on the BU1LDS page — see below.) below.)
Per-container CPU, memory and disk load isn't shown in the hive UI. The Per-container CPU, memory and disk load isn't shown in the hive UI. The
same figures go out as OTEL metrics and appear in Grafana (see same figures go out as OTEL metrics and appear in Grafana (see
@ -111,21 +112,23 @@ autorefreshes on `capabilities_changed` + `tool_groups_changed` SSE
events. Each ghost agent gets a `✕ clear perms` button events. Each ghost agent gets a `✕ clear perms` button
(`DELETE /api/permissions/{name}`). (`DELETE /api/permissions/{name}`).
## BU1LDS page (`/builds.html`) ## Build tabs (`#rebuild`, `#meta`, `#buildlogs`)
The build lifecycle hub — rebuild queue, live build log, meta inputs, The build lifecycle — rebuild queue, live build log, meta inputs, and
and build log history on one page. Standalone page reached from the build log history — is three tabs of the dashboard: **R3BU1LD QU3U3**
**Builds** tile on the H0M3 hub, same minimal chrome as `/core.html`. (`#rebuild`), **M3T4 1NPUTS** (`#meta`) and **BUILD L0GS**
Three sub-tabs: **R3BU1LD QU3U3** (default), **M3T4 1NPUTS**, (`#buildlogs`). They live in `builds.js`, which `tabs.js` imports: the
**BUILD L0GS**. Its own esbuild bundle (`builds.js`); cold-loads dashboard's one tab strip, `/api/state` snapshot and
`/api/state` and subscribes to `/api/dashboard/stream` for `/api/dashboard/stream` subscription serve them, with
`rebuild_queue_changed`, `meta_inputs_changed`, `meta_update_running`. `rebuild_queue_changed`, `meta_inputs_changed` and `meta_update_running`
The SW4RM tab (`/dashboard.html`) does **not** read this endpoint at on the stream alongside the dashboard's other kinds. The count pill on
all, by design — the swarm view stays independent of job-queue R3BU1LD QU3U3 is the number of rebuilds in flight.
internals. Its per-agent pending badges are transient-only, and The 4G3NTS tab does **not** read the graph endpoint, by design — the swarm
its queue-summary banner reads the much narrower `GET /api/jobq/rollup` view stays independent of job-queue internals. Its per-agent pending
instead (see Container row, below) — a handful of pre-tallied counts, badges are transient-only, and its queue-summary banner reads the much
not the graph. narrower `GET /api/jobq/rollup` instead (see Container row, below) — a
handful of pre-tallied counts, not the graph. The banner's "view queue →"
link goes to the `#rebuild` tab.
**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
@ -394,7 +397,7 @@ re-renders the terminal row. The operator addresses the root agent as `@root`.
## H0M3 page (`/`) ## H0M3 page (`/`)
The H0M3 hub is the primary landing page (served at `/` by default). A The H0M3 hub is the primary landing page (served at `/` by default). A
responsive grid of link tiles — Dashboard, Flow, Builds, Stats, responsive grid of link tiles — Dashboard, Flow, Stats,
Core, API — each pointing to their respective Core, API — each pointing to their respective
surfaces, all unconditionally shown (no gating). The page is a pure surfaces, all unconditionally shown (no gating). The page is a pure
portal with no tab-bar or SSE subscriptions. Typography + colours inherit portal with no tab-bar or SSE subscriptions. Typography + colours inherit
@ -411,7 +414,7 @@ the tooltip says "select … for bulk actions" or "deselect … (or press
Esc to clear all)." The `<img>` points at `<url>/icon`; load failure Esc to clear all)." The `<img>` points at `<url>/icon`; load failure
falls back to the dimmed hyperhive mark (`/favicon.svg`). The card falls back to the dimmed hyperhive mark (`/favicon.svg`). The card
body sits to the right with three stacked lines body sits to the right with three stacked lines
(`assets/swarm.js::renderContainers`). (`assets/agents.js::renderContainers`).
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
**Icon layout + load strategy:** the `<img>` is absolutely **Icon layout + load strategy:** the `<img>` is absolutely
@ -580,10 +583,10 @@ 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 R3BU1LD QU3U3 tab). The shared `JobqRollup` Preact component
(`@hive/shared/jobq-rollup.js` — the same one swarm-ui's `/jobs` page (`@hive/shared/jobq-rollup.js` — the same one swarm-ui's `/jobs` page
mounts, pointed at swarm-controller's own rollup endpoint instead), mounts, pointed at swarm-controller's own rollup endpoint instead),
mounted once into `#jobq-rollup-section` by `swarm.js::initJobqRollup` mounted once into `#jobq-rollup-section` by `agents.js::initJobqRollup`
and refreshed via its own handle rather than being re-rendered by and refreshed via its own handle rather than being re-rendered by
`renderContainers`. Reads `GET /api/jobq/rollup` `renderContainers`. Reads `GET /api/jobq/rollup`
(`hive-jobq-wire::state_rollup`) — `Vec<{ state, nodes, roots }>`, (`hive-jobq-wire::state_rollup`) — `Vec<{ state, nodes, roots }>`,
@ -645,7 +648,7 @@ Bulk actions (`R3ST4RT` / `ST0P` / `ST4RT` / `P4US3` / `R3SUM3` /
buttons — see **Container row** above. Selecting an agent's icon buttons — see **Container row** above. Selecting an agent's icon
toggles its selection (an in-memory `Set<name>`); `Esc` or the bar's toggles its selection (an in-memory `Set<name>`); `Esc` or the bar's
`✕ clear` button drops everything. The selection persists across tab `✕ clear` button drops everything. The selection persists across tab
switches in-memory — the bar just hides on non-SW4RM tabs since other switches in-memory — the bar just hides on non-4G3NTS tabs since other
tabs don't show the agent cards needed to cross-reference. tabs don't show the agent cards needed to cross-reference.
When the operator selects one or more agents (via the icon), a sticky When the operator selects one or more agents (via the icon), a sticky
@ -711,11 +714,11 @@ below — some endpoints aren't in it yet.
404 when the row is missing (vacuum-reaped or stale id). 404 when the row is missing (vacuum-reaped or stale id).
- `GET /api/build-logs/id/{id}/stream` — live SSE stream of - `GET /api/build-logs/id/{id}/stream` — live SSE stream of
stdout+stderr for an in-progress build (newline-delimited JSON stdout+stderr for an in-progress build (newline-delimited JSON
frames). Used by the expanding live-row view in BU1LDS. The frames). Used by the expanding live-row view in the BUILD L0GS tab. The
stream closes when the build finishes or errors. stream closes when the build finishes or errors.
- `GET /api/build-logs/id/{id}/raw` — raw concatenated - `GET /api/build-logs/id/{id}/raw` — raw concatenated
stdout+stderr as `text/plain`. Used by the `↓ raw` / `↓ download stdout+stderr as `text/plain`. Used by the `↓ raw` / `↓ download
raw` links in BU1LDS. Works on both finished and in-progress raw` links in the build tabs. Works on both finished and in-progress
build logs. build logs.
- `GET /static/marked.js` serves the vendored `marked` bundle used - `GET /static/marked.js` serves the vendored `marked` bundle used
for markdown previews. for markdown previews.
@ -854,10 +857,10 @@ payload):
full snapshot of destroyed-but-state-kept agents. Emitted on full snapshot of destroyed-but-state-kept agents. Emitted on
destroy, purge, and revive. C0R3 K3PT ST4T3 tab subscribes. destroy, purge, and revive. C0R3 K3PT ST4T3 tab subscribes.
- `meta_inputs_changed` (seq, inputs: `Vec<MetaInput>`) — full - `meta_inputs_changed` (seq, inputs: `Vec<MetaInput>`) — full
snapshot of `meta/flake.lock` inputs. BU1LDS M3T4 1NPUTS tab snapshot of `meta/flake.lock` inputs. The M3T4 1NPUTS tab
subscribes; same shape as `GET /api/meta-inputs`. subscribes; same shape as `GET /api/meta-inputs`.
- `meta_update_running` (running: bool) — emitted when a - `meta_update_running` (running: bool) — emitted when a
`nix flake update` ripple starts or completes. BU1LDS M3T4 1NPUTS `nix flake update` ripple starts or completes. The M3T4 1NPUTS
tab uses this to show/hide the "⏳ meta-update running" banner. tab uses this to show/hide the "⏳ meta-update running" banner.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->

View file

@ -20,7 +20,7 @@
CSS bundle per page (`colors.css` + `theme.css` + `common.css`, CSS bundle per page (`colors.css` + `theme.css` + `common.css`,
loaded by every page, plus a page-specific bundle — loaded by every page, plus a page-specific bundle —
`dashboard.css` / `flow.css` / `home.css` / `dashboard.css` / `flow.css` / `home.css` /
`stats.css` / `core.css` / `builds.css`); `stats.css` / `core.css`);
`common.css` inlines `@hive/shared`'s `common.css` inlines `@hive/shared`'s
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` + `base.css` + `terminal.css` + `tabs.css` + `chrome.css` +
`pill.css` via esbuild's `@import` resolution. `pill.css` via esbuild's `@import` resolution.
@ -208,7 +208,7 @@ re-render), call `refreshState()`. State shapes live in
`hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and `hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and
`web_ui/state.rs::StateSnapshot` — when `web_ui/state.rs::StateSnapshot` — when
adding state fields, plumb through the snapshot struct and the adding state fields, plumb through the snapshot struct and the
relevant domain module (`swarm.js`, `permissions.js`, etc.) render function. relevant domain module (`agents.js`, `permissions.js`, etc.) render function.
**Focus preservation:** `refreshState` checks whether **Focus preservation:** `refreshState` checks whether
`document.activeElement` sits inside one of the managed sections `document.activeElement` sits inside one of the managed sections

View file

@ -30,7 +30,7 @@ layers per-agent additions on top of the default agent dist.
output is reproducible without network access at build time. output is reproducible without network access at build time.
- **esbuild**: `agent` and `swarm-ui` are Preact apps; `dashboard` is a - **esbuild**: `agent` and `swarm-ui` are Preact apps; `dashboard` is a
vanilla-JS + custom-element MPA with a few Preact-rendered pages vanilla-JS + custom-element MPA with a few Preact-rendered pages
(`builds.js`, `swarm.js`); `shared` holds both kinds of module. No (`builds.js`, `agents.js`, imported by the dashboard entry); `shared` holds both kinds of module. No
framework runtime beyond Preact where it's used. Each workspace's framework runtime beyond Preact where it's used. Each workspace's
`build.mjs` is a short esbuild script. `build.mjs` is a short esbuild script.
- **Single-PR migration**: see the tracked design proposal and - **Single-PR migration**: see the tracked design proposal and

View file

@ -1,5 +1,5 @@
// esbuild build for @hive/dashboard: one JS+CSS bundle per page (H0M3 at // esbuild build for @hive/dashboard: one JS+CSS bundle per page (H0M3 at
// `/`, dashboard/flow/core/builds at their own // `/`, dashboard/flow/core/stats at their own
// `.html`), each named after its own entry point below — see the // `.html`), each named after its own entry point below — see the
// `entryPoints`/`for` lists for the exact map, not repeated here to // `entryPoints`/`for` lists for the exact map, not repeated here to
// avoid this comment drifting out of sync with the real build steps. // avoid this comment drifting out of sync with the real build steps.
@ -38,7 +38,6 @@ await build({
src("home.js"), src("home.js"),
src("stats.js"), src("stats.js"),
src("core.js"), src("core.js"),
src("builds.js"),
], ],
outdir: staticDir(""), outdir: staticDir(""),
bundle: true, bundle: true,
@ -57,7 +56,7 @@ await build({
// 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 // `@hive/shared/jobq-graph.js` resolves to a real `.jsx` file
// (`JobqGraph.jsx`), pulled in transitively by `builds.js` — esbuild // (`JobqGraph.jsx`), pulled in transitively by `tabs.js` via `builds.js` — esbuild
// already picks the `jsx` loader for `.jsx` by extension, this just // already picks the `jsx` loader for `.jsx` by extension, this just
// sets the transform mode to match swarm-ui's (which also authors // 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 // this file). No other entry here uses JSX today; this doesn't turn
@ -103,7 +102,6 @@ for (const entry of [
"home.css", "home.css",
"stats.css", "stats.css",
"core.css", "core.css",
"builds.css",
]) { ]) {
await build({ await build({
entryPoints: [src(entry)], entryPoints: [src(entry)],
@ -120,7 +118,6 @@ for (const html of [
"flow.html", "flow.html",
"stats.html", "stats.html",
"core.html", "core.html",
"builds.html",
]) { ]) {
copyFileSync(src(html), dist(html)); copyFileSync(src(html), dist(html));
} }

View file

@ -1,6 +1,6 @@
// hive-agent-menu.js — <hive-agent-menu>, the per-agent "⋮" context-menu // hive-agent-menu.js — <hive-agent-menu>, the per-agent "⋮" context-menu
// custom element. One instance per agent card, appended after // custom element. One instance per agent card, appended after
// .card-body in the container row's flex layout (swarm.js's // .card-body in the container row's flex layout (agents.js's
// buildContainerLi via buildAgentMenu). // buildContainerLi via buildAgentMenu).
// //
// Deliberately a *light-DOM* element with no shadow root of its own: all // Deliberately a *light-DOM* element with no shadow root of its own: all
@ -66,7 +66,7 @@ async function agentMenuPost(actionPath, name, body, flags) {
class HiveAgentMenu extends HTMLElement { class HiveAgentMenu extends HTMLElement {
connectedCallback() { connectedCallback() {
// Same reconnect-without-detach hazard as <hive-menu> (see its // Same reconnect-without-detach hazard as <hive-menu> (see its
// connectedCallback comment) — swarm.js's row cache can move an // connectedCallback comment) — agents.js's row cache can move an
// already-built <li> subtree without a real detach. Without this // already-built <li> subtree without a real detach. Without this
// guard a reconnect would append a second <hive-menu> on top of the // guard a reconnect would append a second <hive-menu> on top of the
// first, doubling the dropdown. // first, doubling the dropdown.

View file

@ -0,0 +1,987 @@
// 4G3NTS (containers) domain — extracted from tabs.js.
// Agent topology, container-row rendering, selection bar, and all
// live-update apply handlers for container-state, transient, and
// job-queue-rollup ops. See docs/web-ui/dashboard.md::Container row for the
// rendering contract.
import { $, form, fmtAgeSecs } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
import { h, render } from "preact";
import { JobqRollup } from "@hive/shared/jobq-rollup.js";
import { containersState } from "./state.js";
import { closeAllMenus } from "@hive/shared/hive-menu.js";
import "./agent-menu/hive-agent-menu.js"; // registers <hive-agent-menu> — side-effect import
// Context-window badge thresholds. Preferred source is each container's
// `context_window_tokens` from /api/state (the real window for the model
// it last ran on) — thresholds are then 75% / 50% of it, matching the
// harness compaction watermarks (compact at 75%, auto-reset at 50%). The
// fixed token constants are the fallback for when that field is absent
// (agent has no turns yet, or no per-model config matched the model).
const CTX_WARN_FRACTION = 0.75; // >= this share of the window -> red
const CTX_CAUTION_FRACTION = 0.5; // >= this share of the window -> yellow
const CTX_WARN_TOKENS = 150_000; // fallback red threshold (~= 75% of 200k)
const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (~= 50% of 200k)
// ─── module-level state ─────────────────────────────────────────────────────
// Keyed container row cache. Maps agent name -> { el: <li>, fingerprint }.
// Allows renderContainers to skip rebuilding rows whose displayed state
// hasn't changed — prevents full-wipe flicker + avoids redundant async
// dashboard-state fetches on every SSE event.
const containerRowCache = new Map();
// Derived transient state — cold-loaded from /api/state.transients,
// then mutated live by `transient_set` / `transient_cleared`. Keyed
// by agent name -> Map<transient_kind, since_unix>: an agent can hold
// several pills at once (every *running* node naming the agent lights
// one now, not just a curated "worth surfacing" subset — see
// docs/web-ui/dashboard.md::Container row), keyed the same way the backend's
// own tombstone map is, `(agent, label)`, so a clear only removes the
// specific pill it names rather than guessing. `since_unix` is
// wall-clock so the elapsed-seconds badge ticks without polling.
const transientsState = new Map();
// In-memory set of selected agent logical names backing the sticky
// #selection-bar. See docs/web-ui/dashboard.md::Selection bar for the
// interaction model (icon-click toggle, Esc/clear button drop,
// tab-gated visibility).
const selectionState = new Set();
// ─── job queue rollup (queue-summary banner only) ──────────────────────────
//
// The ONE piece of job-queue-derived state on this page, and deliberately
// narrow — mara: "agents.js should not need to pull in the jobq to do its
// job." Rendering is owned by the shared `JobqRollup` Preact component
// (same one swarm-ui's /jobs page mounts, self-fetching
// GET /api/jobq/rollup); this file just calls `render(h(...))` 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) — 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() {
const root = $("jobq-rollup-section");
if (!root) return;
render(
h(JobqRollup, {
endpoint: "/api/jobq/rollup",
queueHref: "#rebuild",
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
// carries no `queue` field this page reads, same "something changed,
// go refetch" treatment builds.js gives the R3BU1LD QU3U3 tab's JobqGraph.
export function applyRebuildQueueChanged() {
jobqRollupToken += 1;
initJobqRollup();
}
// ─── transients ─────────────────────────────────────────────────────────────
export function syncTransientsFromSnapshot(s) {
transientsState.clear();
const nowUnix = Math.floor(Date.now() / 1000);
for (const t of s.transients || []) {
// Snapshot is a flat list — multiple rows CAN share the same
// `name` (one row per running node) — so group into this agent's
// kind map rather than overwrite.
let byKind = transientsState.get(t.name);
if (!byKind) {
byKind = new Map();
transientsState.set(t.name, byKind);
}
// Snapshot ships `secs` (server-computed); reconstruct an
// approximate since_unix so the live ticker keeps progressing
// without surprising jumps when the next snapshot lands.
byKind.set(t.kind, t.since_unix ?? nowUnix - (t.secs || 0));
}
}
export function applyTransientSet(ev) {
let byKind = transientsState.get(ev.name);
if (!byKind) {
byKind = new Map();
transientsState.set(ev.name, byKind);
}
byKind.set(ev.transient_kind, ev.since_unix);
renderContainersFromState();
}
export function applyTransientCleared(ev) {
const byKind = transientsState.get(ev.name);
if (!byKind || !byKind.delete(ev.transient_kind)) return;
// Drop the now-empty outer entry too — keeps `transientsState.size`
// meaning "agents with at least one pill" (checked at the
// no-containers-and-no-transients empty-state gate below).
if (byKind.size === 0) transientsState.delete(ev.name);
renderContainersFromState();
}
// Re-render using the last cached snapshot (containers come from
// /api/state, transients overlay from the derived map). The snapshot
// is stashed on window.__hyperhive_state by refreshState; on cold
// load before the first snapshot we just skip.
export function renderContainersFromState() {
const s = window.__hyperhive_state;
if (s) renderContainers(s);
}
// ─── container-state apply ──────────────────────────────────────────────────
export function applyContainerStateChanged(ev) {
if (!ev.container || !ev.container.name) return;
containersState.set(ev.container.name, ev.container);
renderContainersFromState();
}
export function applyContainerRemoved(ev) {
if (containersState.delete(ev.name)) renderContainersFromState();
}
// ─── selection ──────────────────────────────────────────────────────────────
function toggleSelection(name) {
if (selectionState.has(name)) selectionState.delete(name);
else selectionState.add(name);
renderContainersFromState();
}
function clearSelection() {
if (selectionState.size === 0) return;
selectionState.clear();
renderContainersFromState();
}
// Esc clears the current selection (operator escape hatch — mirrors
// the side-panel close pattern). Ignored when an editable element
// has focus so typing in compose / answer isn't intercepted.
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
if (!selectionState.size) return;
const a = document.activeElement;
if (
a &&
(a.isContentEditable ||
a.tagName === "INPUT" ||
a.tagName === "TEXTAREA" ||
a.tagName === "SELECT")
)
return;
e.preventDefault();
clearSelection();
});
document.addEventListener("click", (e) => {
if (e.target && e.target.closest("#selection-clear")) {
clearSelection();
}
});
// ─── per-agent context menu ──────────────────────────────────────────────────
// Three-dot (⋮) button on each agent card for quick single-agent
// lifecycle actions without needing to select first. State-aware:
// restart/stop only shown when running, start only shown when
// stopped; rebuild + destroy/purge always shown.
// The button is CSS-invisible until the row is hovered (or menu is
// open) so it doesn't clutter quiet rows.
// Rendering + agent-specific interaction lives in the <hive-agent-menu>
// custom element (./agent-menu/hive-agent-menu.js, imported above for its
// customElements.define side effect); the generic dropdown mechanics
// (open/close, positioning, singleton coordination — closed via
// `closeAllMenus` below) live in the shared `<hive-menu>` component it
// composes internally (@hive/shared/hive-menu.js). This is a thin
// wrapper matching <hive-dialog>'s `._opts`-before-append convention,
// since a custom element created via `document.createElement` can't
// take constructor args.
function buildAgentMenu(c, forgeBase) {
const menu = document.createElement("hive-agent-menu");
menu._opts = { c, forgeBase };
return menu;
}
// ─── port conflicts ──────────────────────────────────────────────────────────
// Re-derive port conflicts from the live containers map. Mirrors the
// server-side `build_port_conflicts` so the banner reacts to event
// updates instead of waiting for a /api/state refetch.
function derivePortConflicts(containers) {
const byPort = new Map();
for (const c of containers) {
if (!byPort.has(c.port)) byPort.set(c.port, []);
byPort.get(c.port).push(c.name);
}
const out = [];
for (const [port, agents] of byPort) {
if (agents.length > 1) {
agents.sort();
out.push({ port, agents });
}
}
out.sort((a, b) => a.port - b.port);
return out;
}
// ─── container ordering ──────────────────────────────────────────────────────
// Flat alphabetical sort — see docs/web-ui/dashboard.md::C0NTAINERS.
function sortedContainerRows(containers) {
// Close any open context menu before replacing the DOM tree — the
// previous dropdown element would otherwise be a stale reference.
closeAllMenus();
return [...containers].sort((a, b) => a.name.localeCompare(b.name));
}
// ─── container row ───────────────────────────────────────────────────────────
// Serialise the visible state of a container row into a stable string
// for change-detection. Includes everything that affects what the row
// renders — container fields, derived pending/selection state, and
// link-base context. The async dashboard-state (nav strip, ctx badge,
// status text) is intentionally excluded: it populates in-place and is
// preserved when a row is reused.
function containerRowFingerprint(
c,
pending,
opRunning,
selected,
gatewayLinks,
hostname,
) {
return JSON.stringify({
running: c.running,
failed: c.failed,
paused: c.paused,
needs_login: c.needs_login,
needs_update: c.needs_update,
active_model: c.active_model,
port: c.port,
pending,
opRunning,
selected,
gatewayLinks,
hostname,
});
}
// Build a single container-row <li> from scratch. Extracted so
// renderContainers can call this only for rows whose fingerprint
// changed (keyed cache), skipping the build + async dashboard-state
// fetch for stable rows.
function buildContainerLi(c, opts) {
const { pending, opRunning, selected, url, containerBase, forgeBase, s } =
opts;
// A single `pending-running` class covers any active transient
// badge state (see the pending-badge derivation comment in
// renderContainers).
const li = el("li", {
class:
"container-row" +
(opRunning ? " pending-running" : "") +
(selected ? " selected" : ""),
});
// Agent icon: 5em square wrapper with an absolutely-positioned
// <img> + fire-and-forget load with /favicon.svg fallback. The
// wrapper doubles as the selection toggle (click / keyboard).
// See docs/web-ui/dashboard.md::Container row for the layout + load-strategy
// rationale.
const iconImg = el("img", { class: "container-icon-img", alt: "" });
const icon = el(
"div",
{
class: "container-icon",
role: "button",
tabindex: "0",
"aria-pressed": selected ? "true" : "false",
title: selected
? `deselect ${c.name} (or press Esc to clear all)`
: `select ${c.name} for bulk actions`,
},
iconImg,
);
icon.addEventListener("click", (e) => {
e.preventDefault();
toggleSelection(c.name);
});
icon.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleSelection(c.name);
}
});
if (c.running) {
iconImg.src = `${url}icon`;
iconImg.addEventListener("error", () => {
if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s
iconImg.dataset.fallback = "1";
icon.classList.add("icon-unreachable");
iconImg.src = "/favicon.svg";
});
} else {
// Container stopped — skip the doomed `${url}icon` fetch and
// go straight to the dimmed hyperhive mark. Avoids a noisy
// failed request in the console + the brief broken-image flash.
icon.classList.add("icon-unreachable");
iconImg.src = "/favicon.svg";
}
// Card body: the three stacked content lines, right of the icon.
const body = el("div", { class: "card-body" });
// -- identity -------------------------------------------------------
const head = el("div", { class: "head" });
head.append(
el(
"a",
{ class: "name", href: url, target: "_blank", rel: "noopener" },
c.name,
),
);
// Icon-only nav strip — populated async from the agent's own
// `GET /api/dashboard-state` (via gateway when enabled, direct
// TCP otherwise). The agent is the single source of truth for its
// link list: stats / screen (GUI agents only — c0re's disk-based
// fallback cannot detect this) / forge profile / agent-configs /
// extras. DOM-built — link strings come from the agent's process
// and must never reach the HTML parser.
const navStrip = el("span", { class: "nav-strip" });
head.append(navStrip);
if (c.running) {
// Fetch the lean dashboard-state snapshot from the agent directly.
// Populates: nav strip links (including the screen link that
// c0re's disk-based build cannot detect), rate_limited badge,
// ctx-window badge, and self-reported status text.
// Fails gracefully when the agent is starting up or the gateway
// is not yet routing to it — badges simply don't appear.
// Only runs when the row is first built (fingerprint changed) —
// reused rows keep their previously-fetched nav strip + badges.
fetch(`${containerBase}/api/dashboard-state`)
.then((r) => (r.ok ? r.json() : null))
.then((ds) => {
if (!ds) return;
// -- nav strip -----------------------------------------------
if (Array.isArray(ds.links)) {
for (const lnk of ds.links) {
// No public forge URL to link to — hide rather than build
// a broken/guessed href (see forgeBase above).
if (lnk.kind === "forge" && !forgeBase) continue;
const href =
lnk.kind === "forge"
? forgeBase + (lnk.url || "")
: lnk.kind === "external"
? lnk.url || ""
: /* container */ containerBase + "/" + (lnk.url || "");
const a = el("a", {
class: "nav-link",
href,
target: "_blank",
rel: "noopener",
title: lnk.label || "",
});
// Plain text — agent-controlled strings stay out of innerHTML.
a.textContent = lnk.icon || lnk.label || "";
navStrip.append(a);
}
}
// -- agent-owned status badges --------------------------------
// rate_limited: only show when no other critical badge is
// already shown (pending / not-running already handled sync).
if (ds.rate_limited) {
head.append(
el(
"span",
{
class: "hive-pill-sm badge-rate-limited",
title:
"API rate-limited — harness is parked, will retry automatically",
},
"⊘ rate limited",
),
);
}
// ctx-window badge
if (ds.ctx_tokens != null) {
const k = Math.round(ds.ctx_tokens / 1000);
const win = ds.context_window_tokens;
const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
const caution =
win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
const ctxClass =
ds.ctx_tokens >= warn
? "badge-ctx-warn"
: ds.ctx_tokens >= caution
? "badge-ctx-caution"
: "badge-ctx-ok";
const title =
win != null
? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} ` +
`tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)`
: `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`;
head.append(
el(
"span",
{ class: `hive-pill-sm ${ctxClass}`, title },
`ctx·${k}k`,
),
);
}
// -- agent status text (self-reported via set_status) ---------
if (ds.status_text) {
const nowUnix = Math.floor(Date.now() / 1000);
const ageStr =
ds.status_set_at != null
? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)`
: "";
// Stamp data-set-at so the 30s ticker below keeps the age
// label current even when the row is reused from the cache.
const ageAttrs =
ds.status_set_at != null
? { class: "status-age", "data-set-at": String(ds.status_set_at) }
: { class: "status-age" };
// Icon + message are wrapped together in `.status-msg` so the
// CSS can clamp *just the message* to two lines; the age span is
// a separate flex sibling that is never clipped, so the "(set N
// ago)" stamp stays visible even when the status text is long.
body.append(
el(
"div",
{
class: "agent-status",
title: `agent self-reported status${ageStr}`,
},
el(
"span",
{ class: "status-msg" },
el("span", { class: "status-icon" }, "◈ "),
ds.status_text,
),
el("span", ageAttrs, ageStr),
),
);
}
})
.catch(() => {
/* graceful: agent starting / gateway miss -> no data */
});
}
// Status / runtime badges. Pending transients always win
// (start / stop / restart / rebuild is in progress). Otherwise,
// when the container is stopped, surface a single badge — `✖ gave
// up` (red) when systemd's restart bound was hit (`c.failed`), else
// the plain muted `■ not running`. Both read `running: false`;
// `failed` is what tells "gave up" apart from "stopped on purpose"
// (an absent `failed` — an older backend — is `undefined`, falsy,
// so this degrades to the old single badge). `needs_login` is still
// c0re-owned (reads auth sentinel files on the host). rate_limited /
// ctx / status_text are agent-owned and rendered by the async
// dashboard-state fetch above.
if (pending.length) {
// One badge per pill — an agent can carry several transients at
// once now (see docs/web-ui/dashboard.md::Container row), each rendered
// independently rather than collapsed into one label.
for (const label of pending) {
head.append(
el(
"span",
{ class: "pending-state" },
el("span", { class: "spinner" }, "◐"),
" ",
label + "…",
),
);
}
} else if (!c.running && c.failed) {
head.append(
el(
"span",
{
class: "hive-pill-sm badge-fail",
title:
"container gave up — its unit hit systemd's bounded restart limit and stopped " +
"on its own, not on operator request; start it to bring the harness back up",
},
"✖ gave up",
),
);
} else if (!c.running) {
head.append(
el(
"span",
{
class: "hive-pill-sm badge-muted",
title:
"container is shut down — start it to bring the harness back up",
},
"■ not running",
),
);
} else if (c.needs_login) {
head.append(
el(
"a",
{
class: "hive-pill-sm badge-warn",
href: url,
target: "_blank",
rel: "noopener",
},
"needs login →",
),
);
}
if (c.paused) {
// Paused badge is also a resume button: clicking POSTs /api/resume/{name}
// which removes the marker and flips the badge off via the SSE rescan.
head.append(
form(
"/api/resume/" + c.name,
"hive-pill-sm badge-paused btn-inline",
"⏸ paused",
`resume ${c.name}? the turn loop restarts and drains queued messages.`,
{},
{ noRefresh: true },
),
);
}
if (c.needs_update) {
head.append(
form(
"/api/rebuild/" + c.name,
"hive-pill-sm badge-warn btn-inline",
"needs update ↻",
"rebuild " + c.name + "? hot-reloads the container.",
{},
{ noRefresh: true },
),
);
}
if (c.active_model) {
head.append(
el(
"span",
{
class: "hive-pill-sm badge-model",
title: `active claude model: ${c.active_model}`,
},
c.active_model,
),
);
}
body.append(head);
// Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
// DESTR0Y / PURG3) live on the selection bar, not here — see
// docs/web-ui/dashboard.md::Selection bar. The contextual `needs update ↻`
// chip in the head row stays — it's a state-hint, not an
// action button.
li.append(icon, body, buildAgentMenu(c, forgeBase));
return li;
}
// ─── containers render ───────────────────────────────────────────────────────
export function renderContainers(s) {
const root = $("containers-section");
// #containers-section only exists on /dashboard.html. tabs.js is the
// bundle for that page only (flow.html loads flow.js instead).
// Belt-and-suspenders for any future page that adds tabs.js
// without a #containers-section — matches the
// no-op-when-target-absent convention the other renderers
// follow.
if (!root) return;
// Containers come from the derived map (event-driven) rather than
// `s.containers`; `s` still supplies hostname (for the web-ui
// link) and tombstones/meta_inputs (not event-derived yet). The
// tree builder handles the ordering — we don't pre-sort here.
const containers = Array.from(containersState.values());
const portConflicts = derivePortConflicts(containers);
const anyStale = containers.some((c) => c.needs_update);
// Preserve the keyed container list across the header-section
// rebuild. We wipe the banners/buttons above the list on every
// render (simple), but recycle <li> elements for unchanged rows
// (keyed) to avoid full-DOM thrash and redundant async fetches.
const existingUl = root.querySelector("ul.containers");
root.replaceChildren();
// Port-hash collisions: rename one of the listed agents and
// rebuild. The banner sits above the agent list so it's the
// first thing the operator sees when something's wedged.
if (portConflicts.length) {
const banner = el(
"hive-warn",
{ level: "error" },
el("strong", {}, "⚠ port collision"),
" — ",
);
const groups = portConflicts
.map((c) => `:${c.port} (${c.agents.join(" + ")})`)
.join("; ");
banner.append(groups + ". rename one of each and ↻ R3BU1LD.");
root.append(banner);
}
if (anyStale) {
root.append(
form(
"/api/update-all",
"btn-rebuild",
"↻ UPD4TE 4LL",
"rebuild every stale container?",
{},
{ noRefresh: true },
),
);
}
// Queue-summary banner: see initJobqRollup, above.
if (!containers.length && !transientsState.size) {
root.append(el("p", { class: "empty" }, "no managed containers"));
return;
}
// Drop stale selections (agent destroyed while selected). Defensive —
// the action bar would otherwise loop POST against a gone agent.
const liveNames = new Set(containers.map((c) => c.name));
for (const n of Array.from(selectionState)) {
if (!liveNames.has(n)) selectionState.delete(n);
}
const hostname = (s && s.hostname) || window.location.hostname;
// When hive-gateway is in front of the dashboard, build same-origin
// `/agent/<name>/` URLs instead of the direct `http://<host>:<port>/`
// TCP fallback — the gateway proxies the prefix to the per-agent
// harness (unix-domain via `agent-sockets.json`, or a computed TCP
// loopback port while the socket marker is absent). See
// `docs/web-ui/dashboard.md::Container row` + `docs/networking/gateway.md::Vhost map`.
const gatewayLinks = !!(s && s.gateway_enabled);
// Forge public URL: state.forge_public_url (set by the NixOS module
// from services.hyperhive.forge.publicUrl) or null — never guessed
// from "<hostname>:3000". `null` propagates down to
// buildContainerLi/buildAgentMenu, which already hide forge links
// rather than emit a possibly-wrong one.
const forgeBase = (s && s.forge_public_url) || null;
const ul = existingUl ?? el("ul", { class: "containers" });
const rows = sortedContainerRows(containers);
// Build the ordered list of <li> elements, reusing cached rows
// whose displayed state hasn't changed.
const orderedLis = [];
for (const c of rows) {
const url = gatewayLinks
? `/agent/${encodeURIComponent(c.name)}/`
: `http://${hostname}:${c.port}/`;
// Container nav-strip base: gateway prefix or direct TCP.
const containerBase = gatewayLinks
? `/agent/${encodeURIComponent(c.name)}`
: `http://${hostname}:${c.port}`;
// Pending-badge derivation: transients only — "what is running,"
// full stop (mara, on review: "remove the per agent pending stuff -
// only show what is running"; rendered one badge per pill — mara,
// earlier: "show all running nodes that name the agent"). See
// docs/web-ui/dashboard.md::Container row.
const transientKindsMap = transientsState.get(c.name);
// Sorted for stable badge order across renders — Map iteration
// order is insertion order, which shifts as pills clear/re-add.
const pending = transientKindsMap
? Array.from(transientKindsMap.keys()).sort()
: [];
const opRunning = pending.length > 0;
const selected = selectionState.has(c.name);
const fp = containerRowFingerprint(
c,
pending,
opRunning,
selected,
gatewayLinks,
hostname,
);
const cached = containerRowCache.get(c.name);
let li;
if (cached && cached.fingerprint === fp) {
// Row unchanged — reuse the existing DOM node. The async
// dashboard-state (nav strip, ctx badge, status text) stays
// intact from the previous build, avoiding a redundant fetch.
li = cached.el;
} else {
li = buildContainerLi(c, {
pending,
opRunning,
selected,
url,
containerBase,
forgeBase,
s,
});
containerRowCache.set(c.name, { el: li, fingerprint: fp });
}
orderedLis.push(li);
}
// Remove cache entries for agents that no longer exist.
for (const [name, entry] of containerRowCache) {
if (!liveNames.has(name)) {
entry.el.remove();
containerRowCache.delete(name);
}
}
// Apply correct DOM order without a full wipe. insertBefore is a
// no-op when the node is already at position i, so stable sections
// of the list cause zero layout work.
for (let i = 0; i < orderedLis.length; i++) {
if (ul.children[i] !== orderedLis[i]) {
ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
}
}
// Trim any excess children (defensive — shouldn't happen after the
// cache-removal pass above, but keeps the ul length exact).
while (ul.children.length > orderedLis.length) ul.lastChild.remove();
root.append(ul);
renderSelectionBar(containers);
}
// ─── selection bar ───────────────────────────────────────────────────────────
// Sticky-bottom strip; visible when >=1 agent selected on the 4G3NTS
// tab. See docs/web-ui/dashboard.md::Selection bar for the interaction model
// and the per-action availability rules (disabled-with-tooltip for
// actions that don't apply to the full selection). Actions POST per
// agent in a loop (endpoints are individually idempotent /
// event-covered, so no new bulk backend wire is needed).
export function renderSelectionBar(containers) {
const bar = $("selection-bar");
if (!bar) return;
const countSpan = $("selection-count");
const namesSpan = $("selection-names");
const actions = $("selection-actions");
if (!countSpan || !namesSpan || !actions) return;
const selected = containers.filter((c) => selectionState.has(c.name));
// Tab-gate: bar only renders on 4G3NTS (the only tab with agent
// cards to cross-reference). Selection state lives in-memory and
// the bar reappears on return to 4G3NTS if still non-empty.
const onAgentsTab =
(document.body.dataset.activeTab || "agents") === "agents";
if (!selected.length || !onAgentsTab) {
bar.hidden = true;
document.body.classList.remove("has-selection");
return;
}
bar.hidden = false;
document.body.classList.add("has-selection");
countSpan.textContent =
selected.length === 1
? "1 agent selected"
: selected.length + " agents selected";
namesSpan.textContent = "· " + selected.map((c) => c.name).join(", ");
// Recompute action availability + tooltips per render. Each
// action declares which agents it CAN'T run on; the bar disables
// the button and surfaces the offending names in the tooltip.
actions.replaceChildren();
const allRunning = selected.every((c) => c.running);
const allStopped = selected.every((c) => !c.running);
// Pause/resume is orthogonal to running (see hive-agent-menu.js's own
// comment on the per-agent version of these two actions) — a paused
// stopped agent boots paused, a paused running agent keeps its
// container but drives no turns. Same all-or-nothing enablement rule
// as every other bulk action here: mixed pause state disables the
// button rather than silently no-op'ing on part of the selection.
const allUnpaused = selected.every((c) => !c.paused);
const allPaused = selected.every((c) => c.paused);
const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name);
const runningNames = selected.filter((c) => c.running).map((c) => c.name);
const pausedNames = selected.filter((c) => c.paused).map((c) => c.name);
const unpausedNames = selected.filter((c) => !c.paused).map((c) => c.name);
function why(label, blockers) {
if (!blockers.length) return null;
return `${label} not available — ${blockers.join(", ")} ${blockers.length === 1 ? "is" : "are"} blocking it`;
}
addBulkButton(actions, "btn-restart", "↺ R3ST4RT", allRunning, selected, {
action: "/api/restart/",
confirm: (names) =>
`restart ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})?`,
graceful: true,
gracefulLabel:
"restart gracefully — let each agent finish its turn and flush state before the container restarts",
disabledTitle: why(
"↺ R3ST4RT",
stoppedNames.map((n) => `\`${n}\` is stopped`),
),
});
addBulkButton(actions, "btn-stop", "■ ST0P", allRunning, selected, {
action: "/api/kill/",
confirm: (names) =>
`stop ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})?`,
confirmLabel: "■ stop",
graceful: true,
disabledTitle: why(
"■ ST0P",
stoppedNames.map((n) => `\`${n}\` is already stopped`),
),
});
addBulkButton(actions, "btn-start", "▶ ST4RT", allStopped, selected, {
action: "/api/start/",
confirm: (names) =>
`start ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})?`,
disabledTitle: why(
"▶ ST4RT",
runningNames.map((n) => `\`${n}\` is already running`),
),
});
addBulkButton(actions, "btn-pause", "⏸ P4US3", allUnpaused, selected, {
action: "/api/pause/",
confirm: (names) =>
`pause ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? parks the turn loop — inbox messages queue unacked.`,
disabledTitle: why(
"⏸ P4US3",
pausedNames.map((n) => `\`${n}\` is already paused`),
),
});
addBulkButton(actions, "btn-resume", "▶ R3SUM3", allPaused, selected, {
action: "/api/resume/",
confirm: (names) =>
`resume ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? the turn loop restarts and drains queued messages.`,
disabledTitle: why(
"▶ R3SUM3",
unpausedNames.map((n) => `\`${n}\` is not paused`),
),
});
addBulkButton(actions, "btn-rebuild", "↻ R3BU1LD", true, selected, {
action: "/api/rebuild/",
confirm: (names) =>
`rebuild ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? hot-reloads each container.`,
});
addBulkButton(actions, "btn-destroy", "DESTR0Y", true, selected, {
action: "/api/destroy/",
confirm: (names) =>
`destroy ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? containers are removed; state + creds kept.`,
});
addBulkButton(actions, "btn-destroy", "PURG3", true, selected, {
action: "/api/destroy/",
body: { purge: "on" },
confirm: (names) =>
`PURGE ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? containers, config history, claude creds, and notes are all WIPED. no undo.`,
});
}
function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
const names = selected.map((c) => c.name);
const btn = el(
"button",
{
type: "button",
class: "btn " + btnClass,
},
label,
);
if (!enabled) {
btn.disabled = true;
if (opts.disabledTitle) btn.title = opts.disabledTitle;
}
btn.addEventListener("click", async () => {
if (btn.disabled) return;
const msg = opts.confirm(names);
let graceful = false;
if (msg) {
const r = await themedConfirm({
message: msg,
danger: true,
confirmLabel: opts.confirmLabel || "confirm",
checkboxes: opts.graceful
? [
{
name: "graceful",
label:
opts.gracefulLabel ||
"stop gracefully — let each agent finish its turn and flush state before the container stops",
},
]
: [],
});
if (!r) return;
graceful = !!r.graceful;
}
btn.disabled = true;
const original = btn.innerHTML;
btn.innerHTML = '<span class="spinner">◐</span> ' + label;
const failures = [];
// Sequential POSTs to keep server-side serialisation predictable
// (rebuild_queue dedups but other endpoints don't); the loop is
// short — bulk selections are typically a handful of agents.
//
// `opts.action` is a path prefix and the agent name gets appended
// (lifecycle endpoints: /start/<name>, /rebuild/<name>). `opts.body`
// is a static object applied to every POST.
for (const name of names) {
const body = new URLSearchParams(opts.body || {});
const url =
opts.action +
encodeURIComponent(name) +
(graceful ? "?graceful=true" : "");
try {
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
redirect: "manual",
});
const ok =
resp.ok ||
resp.type === "opaqueredirect" ||
(resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => "");
failures.push(
`${name}: http ${resp.status}${text ? " — " + text.slice(0, 200) : ""}`,
);
}
} catch (err) {
failures.push(`${name}: ${err}`);
}
}
btn.disabled = false;
btn.innerHTML = original;
if (failures.length) {
themedToast(
`${label} completed with ${failures.length} failure${failures.length === 1 ? "" : "s"}:\n\n` +
failures.join("\n"),
{ type: "error", duration: 0 },
);
}
// Container-lifecycle events (ContainerStateChanged /
// ContainerRemoved / RebuildQueueChanged) flow over the existing
// SSE channel and update the derived stores live — no manual
// refresh needed.
});
parent.append(btn);
}
// ─── tickers ─────────────────────────────────────────────────────────────────
// 30s ticker for agent status-age chips. Renderers stamp `data-set-at`
// (unix seconds) on the `.status-age` span. Keyed container rows persist
// across re-renders, so without this ticker the "(set N ago)" label would
// become stale as time passes. 30s granularity matches fmtAgeSecs precision
// (sub-minute values round to seconds, coarser above that).
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
document.querySelectorAll(".status-age[data-set-at]").forEach((node) => {
const setAt = Number(node.dataset.setAt);
if (!Number.isFinite(setAt) || setAt === 0) return;
node.textContent = ` (set ${fmtAgeSecs(now - setAt)} ago)`;
});
}, 30_000);

View file

@ -1,139 +0,0 @@
/* ─── /builds.html — BU1LDS page ────────────────────────────────────
The build lifecycle hub: rebuild queue + live log, meta inputs, and
build log history on one page.
Imports system-sections.css for rebuild queue + meta inputs + live-log
component styles; the build-logs-* rules for the BUILD L0GS list are
below. */
@import "./system-sections.css";
@import "@hive/shared/jobq-graph.css";
body.builds-shell {
margin: 0;
padding: 0;
}
.builds-tabbar {
flex: 1;
}
.builds-main {
padding: 1.2em 1.5em 2em;
}
.builds-pane[hidden] {
display: none;
}
.logs-toolbar {
display: flex;
gap: 0.6em;
align-items: center;
margin-bottom: 0.9em;
flex-wrap: wrap;
}
/* ─── build-log list ──────────────────────────────────────────────
Scrollable list of log header rows; clicking a row expands full
stdout+stderr inline. */
.build-logs-list {
list-style: none;
padding: 0;
margin: 0;
overflow-y: auto;
flex: 1 1 0;
min-height: 0;
}
.build-logs-item {
border-bottom: 1px solid var(--border);
padding: 0.15em 0;
}
.build-logs-row-btn {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
align-items: baseline;
width: 100%;
background: none;
border: none;
color: var(--fg);
font-family: inherit;
font-size: 0.85em;
text-align: left;
padding: 0.25em 0.4em;
cursor: pointer;
border-radius: 3px;
}
.build-logs-row-btn:hover {
background: var(--bg-elev);
}
.build-logs-kind {
font-weight: 600;
}
.build-logs-age {
font-size: 0.88em;
}
.build-logs-cmdline {
color: var(--muted);
font-size: 0.82em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 40em;
}
.build-logs-detail {
padding: 0 0.4em 0.4em;
}
.build-logs-output {
margin: 0.3em 0 0;
padding: 0.4em 0.6em;
background: var(--crust);
color: var(--fg);
border: 1px solid var(--purple-dim);
font-size: 0.82em;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
max-height: 30em;
overflow-y: auto;
}
.build-logs-dl {
color: var(--muted);
font-size: 0.8em;
text-decoration: none;
border: 1px solid var(--purple-dim);
padding: 0.1em 0.4em;
border-radius: 2px;
margin-top: 0.3em;
display: none;
}
.build-logs-dl:not([hidden]) {
display: inline-block;
}
.build-logs-dl:hover {
color: var(--fg);
border-color: var(--purple-dim);
}
.build-logs-live-badge {
margin-bottom: 0.4em;
}
@keyframes live-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
.build-logs-live-badge.badge-running {
animation: live-pulse 1.4s ease-in-out infinite;
}
.build-logs-runtime {
font-size: 0.85em;
color: var(--muted);
}

View file

@ -1,101 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // BU1LDS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/builds.css" />
</head>
<body class="builds-shell">
<!-- BU1LDS: the build lifecycle hub — rebuild queue, live build log,
meta inputs, and build log history on one page. Same minimal
chrome as /core.html — a `← home` back-link + a <hive-tab-strip>
sub-tab nav. Default sub-tab is the rebuild queue. -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip
class="hive-tabbar builds-tabbar"
id="builds-tabbar"
prefix="builds"
role="tablist"
></hive-tab-strip>
</header>
<main class="builds-main">
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
first-spawns. Rendered from GET /api/jobq/graph by the
`JobqGraph` Preact component (@hive/shared/jobq-graph.js);
`rebuild_queue_changed` over /api/dashboard/stream is the
refresh trigger and carries no payload of its own. Default tab. -->
<section
class="builds-pane"
id="builds-pane-rebuild"
data-tab-pane="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>
<div id="rebuild-queue-section">
<p class="meta">loading…</p>
</div>
<!-- Live build log of the currently-running rebuild (one runs at a
time). Managed by renderRebuildLiveLog in builds.js, separate from
rebuild-queue-section so the queue's row re-render never disturbs
the open SSE stream. -->
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
</section>
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
<section
class="builds-pane"
id="builds-pane-meta"
data-tab-pane="meta"
role="tabpanel"
aria-labelledby="builds-tab-meta"
>
<p class="meta">
select inputs to <code>nix flake update</code> in <code>/meta/</code>.
selected agents rebuild in sequence after the lock bump; the
submitting agent learns each outcome via the usual
<code>rebuilt</code> system event.
</p>
<div id="meta-inputs-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- BUILD L0GS: all-agents build log history.
Click a row to expand stdout + stderr. Live builds stream in real
time. Lazy-loaded on first tab activation; auto-refreshes when
rebuild_queue_changed fires. Deep-link: ?id=N#buildlogs. -->
<section
class="builds-pane"
id="builds-pane-buildlogs"
data-tab-pane="buildlogs"
role="tabpanel"
aria-labelledby="builds-tab-buildlogs"
>
<p class="meta">
all-agents build log history. click a row to expand stdout + stderr.
live builds stream in real time.
</p>
<div class="logs-toolbar">
<button type="button" class="btn btn-restart" id="build-refresh">
↻ refresh
</button>
</div>
<div id="build-list"><p class="meta">loading…</p></div>
</section>
</main>
<script type="module" src="/static/builds.js" defer></script>
</body>
</html>

View file

@ -1,23 +1,17 @@
// BU1LDS page entry (/builds.html). The build lifecycle hub — rebuild // The build lifecycle tabs of /dashboard.html — rebuild queue, live build
// queue, live build log, meta inputs, and build log history on one page. // log, meta inputs, and build log history. Not an entry point: tabs.js
// Three sub-tabs via <hive-tab-strip> (default = Rebuild Queue): // imports it, owns the tab strip and the dashboard event stream, and calls
// - R3BU1LD QU3U3: pending + running rebuilds + live build log inline // the exports below. Three tabs, the panes' ids matching the tab ids:
// - M3T4 1NPUTS: nix flake input selector + meta-update trigger // - rebuild (R3BU1LD QU3U3): pending + running rebuilds + live build log
// - BUILD L0GS: all-agents build log history (lazy-loaded on tab show) // - meta (M3T4 1NPUTS): nix flake input selector + meta-update
// - buildlogs (BUILD L0GS): all-agents build log history, lazy-loaded
// the first time the tab is shown
import { import { $, fmtAgeSecs, openBuildLogStream } from "./common.js";
$,
fmtAgeSecs,
openStream,
openBuildLogStream,
initServerWarnings,
} from "./common.js";
import { el } from "@hive/shared/dom.js"; import { el } from "@hive/shared/dom.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 { 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 { JobqGraph } from "@hive/shared/jobq-graph.js"; import { JobqGraph } from "@hive/shared/jobq-graph.js";
// ─── derived state ─────────────────────────────────────────────────────────── // ─── derived state ───────────────────────────────────────────────────────────
@ -174,7 +168,7 @@ function renderMetaInputs(s) {
// the generic component's. // the generic component's.
// Split from `renderRebuildQueue` below: `replaceChildren()` only // Split from `renderRebuildQueue` below: `replaceChildren()` only
// belongs on the *first* render, to clear the static "loading…" // belongs on the *first* render, to clear the static "loading…"
// placeholder `#rebuild-queue-section` starts with in `builds.html` — // placeholder `#rebuild-queue-section` starts with in `dashboard.html` —
// Preact doesn't know that placeholder is there, so a later render // Preact doesn't know that placeholder is there, so a later render
// call must NOT repeat `replaceChildren()` (it would wipe Preact's own // call must NOT repeat `replaceChildren()` (it would wipe Preact's own
// tracked children out from under its diffing rather than let it // tracked children out from under its diffing rather than let it
@ -196,7 +190,6 @@ function renderRebuildQueue() {
onUpdate: (nodes) => { onUpdate: (nodes) => {
jobqNodes = nodes || []; jobqNodes = nodes || [];
renderRebuildLiveLog(); renderRebuildLiveLog();
updateRebuildCount();
}, },
onCancel: async (id) => { onCancel: async (id) => {
const node = jobqNodes.find((n) => n.id === id); const node = jobqNodes.find((n) => n.id === id);
@ -384,33 +377,18 @@ function renderRebuildLiveLog() {
} }
// ─── rebuild-queue count pill ───────────────────────────────────────────────── // ─── rebuild-queue count pill ─────────────────────────────────────────────────
function updateRebuildCount() { // The queue's in-flight count, for the R3BU1LD QU3U3 tab's pill (tabs.js
const pill = $("builds-tab-count-rebuild"); // reads it on its refresh tick). Pending/Running/Finishing = in flight
if (!pill) return; // (Finishing = own work done, a sub-node still running — still counts).
// Pending/Running/Finishing = in flight (Finishing = own work done, a // Root nodes only: each is one queue entry.
// sub-node still running — still counts). Root nodes only: each is one export function rebuildCount() {
// queue entry. return jobqNodes.filter(
const n = jobqNodes.filter(
(n) => (n) =>
n.parent == null && n.parent == null &&
(n.state === "Pending" || (n.state === "Pending" ||
n.state === "Running" || n.state === "Running" ||
n.state === "Finishing"), n.state === "Finishing"),
).length; ).length;
if (n > 0) {
pill.textContent = String(n);
pill.hidden = false;
} else {
pill.hidden = true;
}
}
// ─── render-all (cold load + any full re-render) ──────────────────────────────
function renderAll() {
renderMetaInputs({ meta_inputs: metaInputsState });
// Rebuild-queue rendering + the count-pill/live-log it drives all happen
// off JobqGraph's own onUpdate callback (see mountRebuildQueue) —
// nothing to render here directly.
} }
// ─── BUILD L0GS tab ─────────────────────────────────────────────────────────── // ─── BUILD L0GS tab ───────────────────────────────────────────────────────────
@ -419,10 +397,7 @@ function renderAll() {
// 2s) when rebuild_queue_changed fires while the BUILD L0GS tab is active. // 2s) when rebuild_queue_changed fires while the BUILD L0GS tab is active.
// Deep-link: ?id=N#buildlogs auto-expands the target row. // Deep-link: ?id=N#buildlogs auto-expands the target row.
const buildList = $("build-list");
const buildRefresh = $("build-refresh");
let buildLogsLoaded = false; let buildLogsLoaded = false;
let buildTabs; // set in init
function fmtTs(unixSecs) { function fmtTs(unixSecs) {
if (!unixSecs) return ""; if (!unixSecs) return "";
@ -431,6 +406,7 @@ function fmtTs(unixSecs) {
} }
async function fetchBuild() { async function fetchBuild() {
const buildList = $("build-list");
if (!buildList) return; if (!buildList) return;
buildList.replaceChildren(); buildList.replaceChildren();
buildList.append(el("p", { class: "meta" }, "loading…")); buildList.append(el("p", { class: "meta" }, "loading…"));
@ -612,93 +588,54 @@ async function fetchBuild() {
} }
} }
if (buildRefresh) buildRefresh.addEventListener("click", fetchBuild); // ─── entry points for tabs.js ─────────────────────────────────────────────────
// Everything below is called by tabs.js, which owns the tab strip, the
// `/api/state` snapshot and the dashboard event stream.
// ─── SSE handlers ───────────────────────────────────────────────────────────── /** Mount the rebuild queue and wire the build-log refresh button. Called
let buildRefreshTimer = null; * once, after the tab strip exists. The queue self-fetches on mount
const SSE_HANDLERS = { * (JobqGraph's own effect), unlike the meta inputs, which wait for the
rebuild_queue_changed() { * snapshot. */
// No `ev.queue` payload read anymore — this event still carries its own export function initBuilds() {
// queue snapshot on the wire (tabs.js/SW4RM still consumes it for the const refresh = $("build-refresh");
// swarm badges, untouched by this), this page just treats it as a if (refresh) refresh.addEventListener("click", fetchBuild);
// refetch trigger. `JobqGraph` owns the actual fetch.
jobqGraphToken += 1;
renderRebuildQueue();
// Auto-refresh build log list when the queue changes and BUILD L0GS is active.
if (buildTabs && buildTabs.active() === "buildlogs") {
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
buildRefreshTimer = setTimeout(fetchBuild, 2000);
}
},
meta_inputs_changed(ev) {
metaInputsState = (ev.inputs || []).slice();
renderMetaInputs({ meta_inputs: metaInputsState });
},
meta_update_running(ev) {
metaUpdateRunning = !!ev.running;
renderMetaInputs({ meta_inputs: metaInputsState });
},
};
// ─── boot ─────────────────────────────────────────────────────────────────────
async function refreshState() {
try {
const resp = await fetch("/api/state");
if (resp.ok) syncFromSnapshot(await resp.json());
} catch {
// best-effort
}
renderAll();
}
async function init() {
initServerWarnings();
bindAsyncForms(() => refreshState());
// Self-fetches on mount (JobqGraph's own effect) — no explicit initial
// fetch needed here, unlike meta inputs below.
mountRebuildQueue(); mountRebuildQueue();
}
buildTabs = document.getElementById("builds-tabbar").configure({ /** The meta inputs, from an `/api/state` snapshot. */
tabs: [ export function syncBuildsFromSnapshot(s) {
{ syncFromSnapshot(s);
id: "rebuild", renderMetaInputs({ meta_inputs: metaInputsState });
label: "R3BU1LD QU3U3", }
badgeId: "builds-tab-count-rebuild",
},
{ id: "meta", label: "M3T4 1NPUTS" },
{ id: "buildlogs", label: "BUILD L0GS" },
],
defaultId: "rebuild",
onShow: (id) => {
if (id === "buildlogs" && !buildLogsLoaded) {
buildLogsLoaded = true;
fetchBuild();
}
},
});
await refreshState(); /** A tab was shown: BUILD L0GS loads its list the first time. */
export function onBuildsTabShown(tab) {
// kinds= matches SSE_HANDLERS below verbatim — narrows this from all if (tab === "buildlogs" && !buildLogsLoaded) {
// 13 wire kinds down to the 3 this page acts on. This page was one buildLogsLoaded = true;
// of 4 unfiltered `/api/dashboard/stream` subscribers before this fetchBuild();
// (subscription discipline, part 1 of the dashboard-event-stream-
// split issue).
const es = openStream(
"/api/dashboard/stream?kinds=rebuild_queue_changed,meta_inputs_changed,meta_update_running",
);
if (es) {
es.onmessage = (e) => {
let ev;
try {
ev = JSON.parse(e.data);
} catch {
return;
}
const h = SSE_HANDLERS[ev.kind];
if (h) h(ev);
};
} }
} }
init(); let buildRefreshTimer = null;
/** `rebuild_queue_changed`: a payload-less refetch trigger — `JobqGraph`
* owns the actual fetch. */
export function onRebuildQueueChanged() {
jobqGraphToken += 1;
renderRebuildQueue();
// Auto-refresh the build log list while BUILD L0GS is the active tab.
if (document.body.dataset.activeTab === "buildlogs") {
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
buildRefreshTimer = setTimeout(fetchBuild, 2000);
}
}
export function onMetaInputsChanged(ev) {
metaInputsState = (ev.inputs || []).slice();
renderMetaInputs({ meta_inputs: metaInputsState });
}
export function onMetaUpdateRunning(ev) {
metaUpdateRunning = !!ev.running;
renderMetaInputs({ meta_inputs: metaInputsState });
}

View file

@ -84,7 +84,7 @@ code {
Semantic colour variants only — shape comes from `hive-pill-sm` Semantic colour variants only — shape comes from `hive-pill-sm`
(shared with the agent UI's pill/chip family, @hive/shared/pill.css), (shared with the agent UI's pill/chip family, @hive/shared/pill.css),
carried directly in the markup alongside each of these classnames carried directly in the markup alongside each of these classnames
(swarm.js/core.js/builds.js). Used on container rows and build-log (agents.js/core.js/builds.js). Used on container rows and build-log
rows. */ rows. */
.badge-warn { .badge-warn {
color: var(--amber); color: var(--amber);

View file

@ -292,7 +292,7 @@ export function openStream(url) {
} }
// Stream a build log into a <pre>, returning the EventSource. Used by the // Stream a build log into a <pre>, returning the EventSource. Used by the
// BU1LDS page's BUILD L0GS tab. Appends `stdout_append` then // BUILD L0GS tab. Appends `stdout_append` then
// `stderr_append` (one `--- stderr ---` separator) frames from // `stderr_append` (one `--- stderr ---` separator) frames from
// `GET /api/build-logs/id/{id}/stream`; auto-scrolls to the bottom unless the operator scrolled up; on the terminal `done` frame // `GET /api/build-logs/id/{id}/stream`; auto-scrolls to the bottom unless the operator scrolled up; on the terminal `done` frame
// closes the stream and calls `onDone(status)`; on a non-transient error // closes the stream and calls `onDone(status)`; on a non-transient error

View file

@ -1,7 +1,6 @@
/* ─── /core.html — C0R3 page ─────────────────────────────────────── /* ─── /core.html — C0R3 page ───────────────────────────────────────
Kept state (tombstones) and stale permission entries. Section rules Kept state (tombstones) and stale permission entries. Section rules
imported from system-sections.css (shared with dashboard.css + imported from system-sections.css (shared with dashboard.css). */
builds.css). */
@import "./system-sections.css"; @import "./system-sections.css";
body.core-shell { body.core-shell {

View file

@ -14,7 +14,8 @@
<!-- C0R3: the host/coordinator surface. Standalone page (served at <!-- C0R3: the host/coordinator surface. Standalone page (served at
/core.html) carved out of the dashboard's old SYST3M tab so the /core.html) carved out of the dashboard's old SYST3M tab so the
dashboard tab strip stays lean: kept state (tombstones) and the dashboard tab strip stays lean: kept state (tombstones) and the
stale permission entries. The section <div> ids (tombstones-section, stale permission entries. Rebuild queue + meta inputs are tabs of
/dashboard.html. The section <div> ids (tombstones-section,
tombstones-stale-perms) match what core.js's renderers target. Hive tombstones-stale-perms) match what core.js's renderers target. Hive
infra containers (`hive-forge`, `hive-gateway`, `hive-matrix`) have infra containers (`hive-forge`, `hive-gateway`, `hive-matrix`) have
no dashboard panel — `hivectl stop`/`start`/`restart` is the only no dashboard panel — `hivectl stop`/`start`/`restart` is the only

View file

@ -2,7 +2,8 @@
// standalone page so the dashboard tab strip stays lean: kept state // standalone page so the dashboard tab strip stays lean: kept state
// (tombstones) and the stale permission entries. // (tombstones) and the stale permission entries.
// //
// This page is its own esbuild bundle with its own runtime: it cold-loads // Rebuild queue + meta inputs are tabs of /dashboard.html, not here. This
// page is its own esbuild bundle with its own runtime: it cold-loads
// /api/state and subscribes to /dashboard/stream (the same broker event // /api/state and subscribes to /dashboard/stream (the same broker event
// channel the dashboard uses), maintaining its own copy of the tombstones // channel the dashboard uses), maintaining its own copy of the tombstones
// state. // state.

View file

@ -8,6 +8,7 @@
pulls them in here. */ pulls them in here. */
@import "./system-sections.css"; @import "./system-sections.css";
@import "@hive/shared/jobq-rollup.css"; @import "@hive/shared/jobq-rollup.css";
@import "@hive/shared/jobq-graph.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
@ -125,10 +126,15 @@ body.dashboard-shell {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-weight: bold; font-weight: bold;
} }
/* `display` above beats the UA's `[hidden]` rule, so an empty count (the
4G3NTS and R3BU1LD QU3U3 pills at zero) would otherwise draw a "0". */
.tab-count[hidden] {
display: none;
}
/* Tab pane visibility — createTabStrip (@hive/shared/tabs.js) sets the /* Tab pane visibility — createTabStrip (@hive/shared/tabs.js) sets the
`hidden` attribute on every inactive pane (resolved from the URL hash; `hidden` attribute on every inactive pane (resolved from the URL hash;
default — no hash — is SW4RM). Only the active pane stays visible. */ default — no hash — is 4G3NTS). Only the active pane stays visible. */
.tab-pane[hidden] { .tab-pane[hidden] {
display: none; display: none;
} }
@ -316,7 +322,7 @@ hive-agent-menu {
--menu-btn-opacity: 1; --menu-btn-opacity: 1;
} }
/* Card actions dim while a row has any pending-state badge (transient- /* Card actions dim while a row has any pending-state badge (transient-
driven only now — see swarm.js's pending-badge derivation comment; driven only now — see agents.js's pending-badge derivation comment;
there is no separate queued-but-not-running row state to tell apart there is no separate queued-but-not-running row state to tell apart
from this one anymore). */ from this one anymore). */
.container-row.pending-running .actions { .container-row.pending-running .actions {
@ -437,12 +443,12 @@ hive-agent-menu {
shared <hive-warn> component — amber/no-pulse for shared <hive-warn> component — amber/no-pulse for
the standing caveat (a pulsing banner that's always present just the standing caveat (a pulsing banner that's always present just
teaches you to stop seeing it), `severity="red" pulse` for the port teaches you to stop seeing it), `severity="red" pulse` for the port
collision (an incident, not a caveat). See core.js/swarm.js and collision (an incident, not a caveat). See core.js/agents.js and
hive-warn.css for the actual styling now. */ hive-warn.css for the actual styling now. */
/* Build-queue summary banner on the SW4RM tab moved to the shared /* Build-queue summary banner on the 4G3NTS tab moved to the shared
`JobqRollup` Preact component (`.jqr-*` classes, imported above) — `JobqRollup` Preact component (`.jqr-*` classes, imported above) —
mounted by swarm.js::initJobqRollup into #jobq-rollup-section. */ mounted by agents.js::initJobqRollup into #jobq-rollup-section. */
.pending-state { .pending-state {
color: var(--amber); color: var(--amber);
@ -772,3 +778,120 @@ body.dashboard-shell.has-selection {
/* ST4TS moved to its own page (`/stats.html`): the /* ST4TS moved to its own page (`/stats.html`): the
window selector / summary chips / bars live in stats.css, and the window selector / summary chips / bars live in stats.css, and the
shared `.hive-stats-table` moved to common.css. */ shared `.hive-stats-table` moved to common.css. */
/* ─── build tabs (builds.js) ────────────────────────────────────────
R3BU1LD QU3U3 and M3T4 1NPUTS are styled by system-sections.css and
jobq-graph.css; the BUILD L0GS list rules are below. */
.logs-toolbar {
display: flex;
gap: 0.6em;
align-items: center;
margin-bottom: 0.9em;
flex-wrap: wrap;
}
/* ─── build-log list ──────────────────────────────────────────────
Scrollable list of log header rows; clicking a row expands full
stdout+stderr inline. */
.build-logs-list {
list-style: none;
padding: 0;
margin: 0;
overflow-y: auto;
flex: 1 1 0;
min-height: 0;
}
.build-logs-item {
border-bottom: 1px solid var(--border);
padding: 0.15em 0;
}
.build-logs-row-btn {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
align-items: baseline;
width: 100%;
background: none;
border: none;
color: var(--fg);
font-family: inherit;
font-size: 0.85em;
text-align: left;
padding: 0.25em 0.4em;
cursor: pointer;
border-radius: 3px;
}
.build-logs-row-btn:hover {
background: var(--bg-elev);
}
.build-logs-kind {
font-weight: 600;
}
.build-logs-age {
font-size: 0.88em;
}
.build-logs-cmdline {
color: var(--muted);
font-size: 0.82em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 40em;
}
.build-logs-detail {
padding: 0 0.4em 0.4em;
}
.build-logs-output {
margin: 0.3em 0 0;
padding: 0.4em 0.6em;
background: var(--crust);
color: var(--fg);
border: 1px solid var(--purple-dim);
font-size: 0.82em;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
max-height: 30em;
overflow-y: auto;
}
.build-logs-dl {
color: var(--muted);
font-size: 0.8em;
text-decoration: none;
border: 1px solid var(--purple-dim);
padding: 0.1em 0.4em;
border-radius: 2px;
margin-top: 0.3em;
display: none;
}
.build-logs-dl:not([hidden]) {
display: inline-block;
}
.build-logs-dl:hover {
color: var(--fg);
border-color: var(--purple-dim);
}
.build-logs-live-badge {
margin-bottom: 0.4em;
}
@keyframes live-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
.build-logs-live-badge.badge-running {
animation: live-pulse 1.4s ease-in-out infinite;
}
.build-logs-runtime {
font-size: 0.85em;
color: var(--muted);
}

View file

@ -26,20 +26,51 @@
<nav class="tabbar" id="tabbar" role="tablist"> <nav class="tabbar" id="tabbar" role="tablist">
<a <a
class="tab" class="tab"
id="tab-swarm" id="tab-agents"
href="#swarm" href="#agents"
role="tab" role="tab"
aria-controls="tab-pane-swarm" aria-controls="tab-pane-agents"
data-tab="swarm" data-tab="agents"
> >
<span class="tab-label">◆ SW4RM ◆</span> <span class="tab-label">◆ 4G3NTS ◆</span>
<span class="tab-count" id="tab-count-swarm" hidden></span> <span class="tab-count" id="tab-count-agents" hidden></span>
</a> </a>
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"), <!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
reached from the H0M3 hub — keeps the dashboard tab strip lean. reached from the H0M3 hub — keeps the dashboard tab strip lean.
The rebuild-queue STATE still lives in tabs.js (it drives the The build lifecycle is three tabs here (builds.js): the rebuild
"building…" badges on SW4RM agent cards); only the SYST3M tab + queue with its live log, the meta flake inputs, and the build log
its section renderers moved out. --> history. -->
<a
class="tab"
id="tab-rebuild"
href="#rebuild"
role="tab"
aria-controls="tab-pane-rebuild"
data-tab="rebuild"
>
<span class="tab-label">◆ R3BU1LD QU3U3 ◆</span>
<span class="tab-count" id="tab-count-rebuild" hidden></span>
</a>
<a
class="tab"
id="tab-meta"
href="#meta"
role="tab"
aria-controls="tab-pane-meta"
data-tab="meta"
>
<span class="tab-label">◆ M3T4 1NPUTS ◆</span>
</a>
<a
class="tab"
id="tab-buildlogs"
href="#buildlogs"
role="tab"
aria-controls="tab-pane-buildlogs"
data-tab="buildlogs"
>
<span class="tab-label">◆ BUILD L0GS ◆</span>
</a>
<!-- P3RM1SS10NS: per-agent capability grants and tool-group <!-- P3RM1SS10NS: per-agent capability grants and tool-group
assignments. Both tables are fetched on tab activation. --> assignments. Both tables are fetched on tab activation. -->
<a <a
@ -58,7 +89,7 @@
view, not part of the operational tab strip. --> view, not part of the operational tab strip. -->
<!-- (Peer hives are no longer a tab — they render as a headline <!-- (Peer hives are no longer a tab — they render as a headline
under SW4RM. See tab-pane-swarm.) --> under 4G3NTS. See tab-pane-agents.) -->
<!-- FL0W and the other standalone pages are reachable from the <!-- FL0W and the other standalone pages are reachable from the
H0M3 hub (served at /), not from the dashboard tab strip — H0M3 hub (served at /), not from the dashboard tab strip —
@ -68,33 +99,33 @@
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one <!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
visible by toggling the `hidden` attribute, resolved from the URL visible by toggling the `hidden` attribute, resolved from the URL
hash (default SW4RM). Panes start `hidden` to avoid a flash before hash (default 4G3NTS). Panes start `hidden` to avoid a flash before
the script runs. --> the script runs. -->
<!-- Padded content wrapper: carries the 1.5em side gutter <!-- Padded content wrapper: carries the 1.5em side gutter
(.page-content, common.css) while <body> stays full-bleed so the (.page-content, common.css) while <body> stays full-bleed so the
sticky chrome above + the footer below span the full width. --> sticky chrome above + the footer below span the full width. -->
<div class="page-content"> <div class="page-content">
<main class="dashboard-main"> <main class="dashboard-main">
<!-- SW4RM: the swarm itself. Container cards (the central thing <!-- 4G3NTS: the agents. Container cards (the central thing
the operator looks at) and rebuild queue / cascade visualisation the operator looks at) and rebuild queue / cascade visualisation
that drives them. No inline `C0NTAINERS` h2 heading + divider that drives them. No inline `C0NTAINERS` h2 heading + divider
— the tab label SW4RM already says it. --> — the tab label 4G3NTS already says it. -->
<section <section
class="tab-pane" class="tab-pane"
id="tab-pane-swarm" id="tab-pane-agents"
data-tab-pane="swarm" data-tab-pane="agents"
hidden hidden
role="tabpanel" role="tabpanel"
aria-labelledby="tab-swarm" aria-labelledby="tab-agents"
> >
<!-- 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 <!-- JobqRollup mount point, kept outside #containers-section
deliberately — that section is wiped + rebuilt on every deliberately — that section is wiped + rebuilt on every
container-list render (see swarm.js::renderContainers), which container-list render (see agents.js::renderContainers), which
would tear down and remount the Preact tree on every would tear down and remount the Preact tree on every
container-state tick. Mounted once by swarm.js::initJobqRollup, container-state tick. Mounted once by agents.js::initJobqRollup,
refreshed via its own handle rather than by re-rendering. --> refreshed via its own handle rather than by re-rendering. -->
<div id="jobq-rollup-section"></div> <div id="jobq-rollup-section"></div>
<div id="containers-section"> <div id="containers-section">
@ -102,9 +133,77 @@
</div> </div>
</section> </section>
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page): <!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
kept state and stale permissions. Reached first-spawns. Rendered from GET /api/jobq/graph by the
from the H0M3 hub. --> `JobqGraph` Preact component (@hive/shared/jobq-graph.js);
`rebuild_queue_changed` over /api/dashboard/stream is the
refresh trigger and carries no payload of its own. -->
<section
class="tab-pane"
id="tab-pane-rebuild"
data-tab-pane="rebuild"
hidden
role="tabpanel"
aria-labelledby="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>
<div id="rebuild-queue-section">
<p class="meta">loading…</p>
</div>
<!-- Live build log of the currently-running rebuild (one runs at a
time). Managed by renderRebuildLiveLog in builds.js, separate from
rebuild-queue-section so the queue's row re-render never disturbs
the open poll. -->
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
</section>
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
<section
class="tab-pane"
id="tab-pane-meta"
data-tab-pane="meta"
hidden
role="tabpanel"
aria-labelledby="tab-meta"
>
<p class="meta">
select inputs to <code>nix flake update</code> in
<code>/meta/</code>. selected agents rebuild in sequence after the
lock bump; the submitting agent learns each outcome via the usual
<code>rebuilt</code> system event.
</p>
<div id="meta-inputs-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- BUILD L0GS: all-agents build log history. Click a row to expand
stdout + stderr. Live builds stream in real time. Lazy-loaded the
first time the tab is shown; auto-refreshes when
rebuild_queue_changed fires. Deep-link: ?id=N#buildlogs. -->
<section
class="tab-pane"
id="tab-pane-buildlogs"
data-tab-pane="buildlogs"
hidden
role="tabpanel"
aria-labelledby="tab-buildlogs"
>
<p class="meta">
all-agents build log history. click a row to expand stdout + stderr.
live builds stream in real time.
</p>
<div class="logs-toolbar">
<button type="button" class="btn btn-restart" id="build-refresh">
↻ refresh
</button>
</div>
<div id="build-list"><p class="meta">loading…</p></div>
</section>
<!-- P3RM1SS10NS: per-agent capability grants + tool-group <!-- P3RM1SS10NS: per-agent capability grants + tool-group
assignments. Both tables are column-driven from the backend assignments. Both tables are column-driven from the backend

View file

@ -42,7 +42,7 @@
<span class="home-tile-label">Dashboard</span> <span class="home-tile-label">Dashboard</span>
</span> </span>
<span class="home-tile-desc" <span class="home-tile-desc"
>containers · permissions · system</span >containers · builds · permissions</span
> >
</a> </a>
@ -54,16 +54,6 @@
<span class="home-tile-desc">live all-agents message firehose</span> <span class="home-tile-desc">live all-agents message firehose</span>
</a> </a>
<a class="home-tile" href="/builds.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔨</span>
<span class="home-tile-label">Builds</span>
</span>
<span class="home-tile-desc"
>rebuild queue · meta inputs · build logs</span
>
</a>
<a class="home-tile" href="/stats.html"> <a class="home-tile" href="/stats.html">
<span class="home-tile-head"> <span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📊</span> <span class="home-tile-icon" aria-hidden="true">📊</span>

Some files were not shown because too many files have changed in this diff Show more