Watch
0
0
Fork
You've already forked hyperhive
0

docs: sort the top-level directories into five sections by what the reader wants

Every docs directory moves under 1-getting-started, 2-handbook,
4-implementation or 5-project; the options reference (section 3) comes from
the website and has no directory here. trust-boundary stays whole under the
handbook for now. web-ui, swarm and tools split between handbook and
implementation, and the command-line references move to
2-handbook/reference. Links, path references in code and nix, and the docs
index follow the moves; no page content changes.
This commit is contained in:
iris 2026-11-06 18:30:12 +01:00 • committed by mara
commit 6739bb903d
3 changed files with 736 additions and 701 deletions

View file

@ -0,0 +1,115 @@
# Dashboard & Web UI
The per-hive dashboard: container state and the rebuild
queue for one hive. For swarm-wide day to day, see
[the swarm UI](../swarm/ui.md) instead. If you want implementation
detail — wire formats, DOM structure, event plumbing — see [Dashboard
layout](dashboard.md), [Per-agent page](agent.md), [Shape](../../4-implementation/web-ui/shape.md),
and [CSS theme variables](../../4-implementation/web-ui/css-vars.md) below; this page only covers
what you actually do here.
## Where things are
Everything starts at the **dashboard**, served at `/`. A pages menu
(the link icon beside the profile avatar, at the right of the tab strip) links to the other
surfaces — the API docs and the swarm UI.
The dashboard is where you'll spend most of your time. It's a single
page with four tabs:
- **4G3NTS** — every agent, live. This is the default tab and the one
you'll check most.
- **R3BU1LD QU3U3** — the rebuild queue. A build's output and history are on the
swarm's `builds` Grafana board.
- **K3PT ST4T3** — kept state from agents with no container
(tombstones), with a purge button per entry.
Each agent also has its own page — a full terminal view of that
agent's session, reachable by selecting its name anywhere in the
dashboard, or directly at `/agent/<name>/` (or `http://<host>:<port>/`
if the gateway isn't in front).
## The things you'll actually do
**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 —
`rebuilding…`, `starting…`, or similar — while something's in flight),
and quick links (stats, screen, forge profile). Select the name to open
its terminal and watch it work in real time.
**Review a config change.** Agent config changes (new packages, env
vars, MCP servers) are pull requests on the agent's `agent-configs`
repo. Review and merge them on the forge; the merge deploys the change.
See [`config-changes.md`](../agent-lifecycle/config-changes.md#config-changes).
**Start, stop, restart, or rebuild an agent.** Select one or more
agents on 4G3NTS (select the icon) and use the selection bar, or use the
per-agent `⋮` menu on a single row. Rebuilding downloads and applies the closure the swarm
declares for the agent again. The hive applies a changed declaration itself,
so use it to retry one that failed.
**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
merging a config change or bumping a flake input.
**Grant or revoke a permission.** Not on the hive dashboard: an agent's
permissions live in the swarm. Set them with `swarmctl agent permissions`,
or on the swarm UI's create-agent screen when creating the agent; see
[`process/conventions.md`](../../5-project/process/conventions.md#permissions).
**Read an agent's logs.** Logs aren't in the hive dashboard. Every
hive ships its agents', daemons' and infra containers' journals to the
swarm's VictoriaLogs; read them in Grafana's **Explore** — see
[`swarm/services.md`](../swarm/services.md#logs-victorialogs).
**Set up a schedule.** Scheduled prompts are swarm-level: set them with
`swarmctl schedule` ([`swarm/schedules.md`](../swarm/schedules.md)) or
in the schedules card on the swarm UI's messages tab ([`swarm/ui.md`](../swarm/ui.md)).
## More depth
Both the dashboard and the per-agent pages are SPAs sharing one
skeleton: `GET /` returns a static shell, `/api/state` returns JSON,
JS renders — no full-page reloads.
- **[Dashboard layout](dashboard.md)** — every tab and standalone page,
in full implementation detail: endpoint shapes, event wiring, exact
badge-derivation rules.
- **[Per-agent page](agent.md)** — the per-agent terminal,
side panel, actions, and per-agent endpoints.
- **[Shape (shared by both)](../../4-implementation/web-ui/shape.md)** — the SPA skeleton, SSE
multiplexing, terminal pane, listener bind, per-agent relative
paths, `data-async` form pattern, atomic repaint.
- **[CSS theme variables](../../4-implementation/web-ui/css-vars.md)** — the Catppuccin Mocha custom
properties declared once in `base.css` and the rule that per-page
stylesheets reference (never redeclare) them.
- **[swarm-ui design guide](../../5-project/web-ui/design-guide.md)** — visual language,
motion, theming, error-UX, and component-first principles for the
swarm-level Preact app specifically (not this page's dashboard/agent
UIs). `/components` on a running swarm-ui is the companion living
demo of every building block it references.
### Implementation reading paths
Task-oriented jumps straight to the relevant section, for when you're
touching the code rather than using the UI:
- **"How does the dashboard SPA stay live without polling?"** →
[`shape.md`](../../4-implementation/web-ui/shape.md) (SSE multiplexing, Worker-death self-heal,
atomic repaint).
- **"What does a container row contain?"** →
[`dashboard.md`](dashboard.md) (Container row, Selection bar).
- **"What endpoints does the dashboard expose?"** →
[`dashboard.md`](dashboard.md) (Dashboard endpoints, Dashboard event
channel).
- **"How does the per-agent terminal render tool calls?"** →
[`terminal-rendering.md`](../../4-implementation/web-ui/terminal-rendering.md) (full row taxonomy
and dispatch walkthrough); for a high-level summary see
[`agent.md`](agent.md) (Per-stream rendering).
- **"What actions does the agent page offer?"** →
[`agent.md`](agent.md) (Actions).
- **"What are the per-agent HTTP endpoints?"** →
[`agent.md`](agent.md) (Per-agent endpoints).
- **"Which CSS variable to use / where are colours defined?"** →
[`css-vars.md`](../../4-implementation/web-ui/css-vars.md) (Palette, single-source `base.css`
rule).

View file

@ -0,0 +1,425 @@
# Per-agent page
> Part of [Web UI](README.md). See also:
> [Shape (shared)](../../4-implementation/web-ui/shape.md) · [Dashboard layout](dashboard.md)
Two fixed-position layers frame a full-viewport terminal: a header and
scrollable main content — plus a slide-in side panel for flyouts and long
content. The page has no message box: to write to an agent, use the swarm
UI's terminal.
## Header
Preact component tree (`Header.tsx` + `StatusChips.tsx` +
`MetaNav.tsx` + `HeaderPill.tsx`, wired together in `Root.tsx`) — see
`frontend/packages/agent/src/components/`. This section describes the
rendered component tree: every element is Preact component output.
**Fixed-overlay header** (`<header class="agent-header">`): frosted
glass — `backdrop-filter: blur` lets scrolled terminal rows show
through. Measures its own rendered height via `ResizeObserver`
(`Header.tsx`) and writes it to a CSS custom property the content
below reads for its offset, so content never overlaps the header
regardless of how many rows of badges/pills wrap onto an extra line
at a given viewport width. Two columns:
<!-- vale write-good.Passive = NO -->
- **Agent icon** (`<img class="agent-icon">`): fixed-size square
identity anchor — `width: 5em; height: 5em` with explicit pixel
sizing so the `<img>`'s intrinsic (large) dimensions don't push
the parent flex container open via `align-items: stretch`-driven
height feedback. Falls back to the dimmed hyperhive mark
(`/favicon.svg`) on load error (`/icon` 404s when the agent has no
`services.hyperhive.agent.icon` override — there is no server-side default image).
- **Main column** (`.agent-header-main`, `Header.tsx`): two rows —
title (`◆ <label> ◆`) and, when set, the "swarm / hive" identity
line. Nothing else lives here; both rows are short, fixed-shape text
that doesn't wrap in practice, so this column's height stays stable
regardless of how many badges or pills are showing (a design
correction mid-review — the first pass put the variable-width
content here instead, which is exactly the kind of thing the
`ResizeObserver` measurement above exists to catch even when a
layout choice reintroduces it).
- **Pills cluster** (`.agent-header-pills`, right-aligned): status
badges (`StatusChips.tsx`) and flyout triggers together in one row,
so the header reads as one identity zone + one status/actions zone:
- **Alive badge**: `● alive` (green) / `⊘ rate limited` (red) /
`◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`.
- **State badge**: `💤 idle` / `🧠 thinking` / `📦 compacting` /
`○ offline` / `… booting` + age suffix.
- **Model and effort badges** (`model · <name>`, `effort · <level>`):
display only. Each tooltip says where the value comes from
(`state.model_source` / `state.effort_source`: set on the swarm,
agent.nix, an existing `hyperhive-model` / `hyperhive-effort` file, or
the built-in default); the model's also says what the last turn
resolved it to. Both are set on the swarm: the swarm UI's agents page,
or `swarmctl agent inference set`
([`inference.md`](../../4-implementation/swarm/inference.md)).
- On an ACP agent each badge shows the session's own value when it
doesn't offer the requested one. The effort badge is hidden
(`state.effort` is `null`) until the first turn has attached a
session, when the model offers no effort levels, and while a newly
set model waits for the next turn, since the levels come with the
model.
- **Ctx / cost badges**: `ctx · 142k` (last inference's prompt size,
tooltip shows % of context window) / `cost · 1.3M` (cumulative
tokens billed across every inference in the last turn).
- **Pause badge** (`⏸ pause` / `▶ resume`): toggles via the same
hive-c0re-owned `/api/pause/<name>` / `/api/resume/<name>`
endpoints the dashboard uses (pausing needs a write this
unprivileged process can't make directly).
- **Inbox / todos pills** (`📬 inbox · N` / `📋 todos · N`): hidden
when empty; selecting it opens the matching flyout in the side panel.
- **Profile avatar**: the signed-in user's initial, opening a popover with
their name and links to authelia's settings and sign-out pages, as on the
dashboard (`AgentUserMenu.tsx`). It shows only behind the gateway, where
the page shares the dashboard's origin and session; opened on the agent's
own port there is no sign-in and no avatar. The name comes from the
dashboard's `/api/whoami`, authelia's URL from the dashboard's
`/api/state`.
- **Links badge** (`🔗`): opens a popover listing `↑ dashboard`
(back-link to the host dashboard, `${dashboardBase}`)
followed by this agent's `agent_links()`-sourced navigation (stats,
screen when VNC is enabled, forge profile + config-repo mirror when
the agent has a forge account, any `services.hyperhive.agent.dashboardLinks`
extras) — all as real `<a>` elements, not a `Dropdown`-style
command list, so ctrl/middle-click and "copy link address" keep
working. Each `AgentLink.kind` resolves differently: `container` →
same-origin path; `forge` → `state.forge_public_url + url`, and
hive-agent drops the link entirely when that's unset (never guessed from
`<host>:3000`); `external` → already absolute. Same source
(`GET /api/state`'s `links` field) also feeds
`DashboardState.links` for the dashboard card's icon strip —
`agent_links()` in hive-agent is the single source of truth for
both. The dashboard link lives in this 🔗 popover. `cancel turn`,
`new session` and `log out` are rows of the state badge's menu, each
with a click-again confirm (see Actions below).
<!-- vale write-good.Passive = YES -->
Values throughout come from `GET /api/state`'s cold-load snapshot,
kept in sync afterwards by the SSE stream (see Live view below) —
`context_window_tokens` for the ctx badge tooltip, `qualified_label`
(the hive-qualified `name@domain` form, used for the browser tab title
so two tabs from different hives are distinguishable; the header's own
`◆ <label> ◆` stays short).
## Main content
**Main content** (`<main class="agent-main">`): fills the viewport
and scrolls behind the fixed header + footer.
<!-- vale write-good.Passive = NO -->
- `#status` overlay: empty when online; shows the login form / OAuth
URL when `status` is `needs_login_*`. The OAuth code input is
`type="password"` with a `👁 reveal` toggle that flips it back to
`text` on press so the operator can sanity-check the paste before
submit — avoids accidental on-screen token exposure to
shoulder-surfers or screenshots. `autocomplete="one-time-code"`
is the semantic value for OAuth codes (per WHATWG): browsers may
silently ignore `autocomplete="off"` on `type="password"`, but
browsers honour `one-time-code` and suppress the "save password
for this site?" prompt that would otherwise fire on submit.
- Terminal-wrap: live event tail (sticky-bottom autoscroll +
`↓ N new` pill when not at bottom). The pill is **anchored in
`.agent-main`**, not in `log.parentElement = .terminal-wrap`:
`.terminal-wrap` applies `backdrop-filter: blur` for the frost
effect, which creates a CSS stacking context — anchoring the
pill inside that context would confine its `z-index` below the
fixed header in the root stacking context, and it'd never
float. `.agent-main` has no backdrop-filter (no stacking-context
creators), so the pill's `z-index` reaches the root and properly
composites above the header. Geometry is unchanged —
`.agent-main` and `.terminal-wrap` both `inset: 0` fill the same
area.
<!-- vale write-good.Passive = YES -->
## Side panel
**Side panel** (slide-in from right): singleton shared with the
dashboard's side panel shape. Carries inbox and todos flyouts (opened
via the header pills) as well as long content (file previews, diffs,
journald logs). Inbox flyout: unread messages addressed to this agent
(`acked_at IS NULL`, newest-first, up to 30); reply messages indented
with `↳ reply ·` in amber. A `✓ mark all read` button appears in the
flyout header when the inbox is non-empty; selecting it confirms then
POSTs cross-origin to the core dashboard's `POST
/api/agent/{name}/mark-all-read`, which acks all pending messages for
this agent so the harness won't receive wake-prompts for them. A `{
marked: N }` pill surfaces the count. After the drain the inbox list
empties on reload (the filter is `acked_at IS NULL`, so drained
messages disappear). Todos flyout ("loose-ends v2"): the harness-local
todos other subsystems push at this agent (`GET /api/todos`) — matrix,
forge, and bash are the built-in producers, but any user-configured MCP
server can push its own via the same in-agent socket. Each row shows the
producing subsystem, an
optional source label, a summary, and age. A checkbox per row plus a
select-all / select-none / `✓ mark done` bulk row above the list POSTs
the checked ids to `POST /api/todos/mark-done`, which dismisses them
from the harness-local store (same effect as `cancel_loose_end(kind:
"todo")`, just from the web UI instead of the agent's own tool calls).
If the todo store errors or doesn't answer for any id, the endpoint returns
500 naming how many ids failed, and the panel shows `failed: …` instead of
`✓ marked done`.
## Live view
Each agent runs an `events::Bus`: a `tokio::sync::broadcast<LiveEvent>`
plus a sqlite-backed history at `/state/hyperhive-events.sqlite`.
The harness emits `TurnStart { from, body, unread }`,
`Stream(value)` (one per parsed stream-json line), `Note`,
`TurnEnd { ok, note }`. Each event also carries a `ts` (unix
seconds) — a flattened sibling of the event tag on both the live
SSE frame and the replayed history rows. The web UI:
- fetches `GET /events/history` on page load and replays the last
2000 events (oldest first);
- then subscribes to `GET /events/stream` (SSE) for live tail;
- shows a granular state badge above the terminal, driven
authoritatively from `/api/state.turn_state`. SSE turn_start /
turn_end still flip the badge instantly between renders;
- terminal-themed: phosphor mauve glow, Crust bg,
backdrop-filter blur, row fade-in slide-up.
The backfill/live-tail dedupe, sticky-bottom autoscroll, and "↓ N
new" pill are the shared terminal-pane mechanics described in
[Shape](../../4-implementation/web-ui/shape.md#shared-terminal-pane) — this page's log is one
instance of that same factory.
Per-stream rendering (see [`docs/4-implementation/web-ui/terminal-rendering.md`](../../4-implementation/web-ui/terminal-rendering.md) for
the full row taxonomy and dispatch logic):
- `Stream` `tool_use` →
- `Write` / `Edit`: collapsed `<details>` with a +/- diff body
(`-` lines from `input.old_string`, `+` lines from
`input.new_string` or every line of `input.content`).
Summary carries the path + line counts.
- others (`Read /path`, `Bash $ cmd`, `mcp__hyperhive__send →
operator: "..."`, etc.): flat one-line per-tool format with
per-tool salient-arg extraction (`fmtToolUse`).
- `Stream` `tool_result` short → flat `← ...`; long → collapsed
`<details>` `▸ ← Nl · headline` (select to expand full body).
- `Stream` `thinking` → `.thinking` row with a `💭 thinking …`
indicator.
- `Stream` `system` → handled by subtype: `plugin_install` and
`compact_boundary` emit muted notes; `commands_changed` emits an
expandable details row listing slash commands; `thinking_tokens`
updates a single in-place `🧠` counter; `init`, `result`, and
`rate_limit_event` drop silently (noise / used elsewhere); other
subtypes → muted `⚙ <subtype>` note.
- `Note` → `· text`.
- `TurnStart` → `◆ TURN ← <from>` with the wake-prompt body; a
muted `· HH:MM:SS` time suffix from the event `ts`.
- `TurnEnd` → `✓ turn ok` / `✗ turn fail — note`, with a
`· HH:MM:SS · <duration>` suffix (duration = end − the paired
turn-start), triggers a `refreshState()`. The time suffix is
guarded on a numeric `ts`, so rows degrade cleanly when absent.
## Actions
The state badge's menu carries the actions on the agent's claude:
- `cancel turn` — `POST /api/cancel` → SIGINT to the turn's claude and every
command it started (its process group), emits a Note. On an ACP agent
it sends the agent `session/cancel` instead, and SIGINT if the turn
hasn't ended 10s later. Offered while the state is `thinking`.
- `new session` — `POST /api/new-session` (confirms first).
Arms a one-shot on the Bus; next turn runs without
`--continue`, dropping the resume session entirely.
- `log out` — `POST /api/logout` (confirms first). Wipes OAuth
credential files, parks the agent in `needs_login`, and preserves
session history (`~/.claude/projects/`).
`POST /api/compact` sets the deferred `Bus::request_compact()` flag and
returns immediately. The harness runs the `/compact` at the next turn
boundary (end of the in-flight turn, or `turn::run_pending_compact` when
idle); output streams into the live panel, which ends with a `/compact done`
or `/compact failed: …` note. It works mid-turn, not only when idle. Nothing
on this page calls it now.
## Per-agent endpoints
Successful POSTs return 200 (no 303 redirects). Error responses
use semantic status codes: **400** for missing/malformed input
(`body` required, unknown model name, unrecognised effort level),
**409** for retryable state conflicts (turn in flight when
`/compact` fires), **500** only for genuine server/transport
failures. The matching
mutations fire `LiveEvent` variants on the per-agent bus, so the
client doesn't refetch `/api/state` on submit — the SSE stream
delivers the new state faster anyway. Only the login flow still
polls (session output streams in updates that aren't event-
shaped).
- `POST /login/{start,code,cancel}` — claude OAuth login flow.
Start/cancel emit `LiveEvent::StatusChanged` to flip the
badge to/from `needs_login_in_progress`.
- `POST /api/cancel` — SIGINT the in-flight claude turn and the
commands it started. Emits a `LiveEvent::Note`.
- `POST /api/compact` — run `/compact` on the persistent session
(same MCP config + system prompt + allowed tools as a normal
turn — only the stdin payload differs). Flips state to
`Compacting` via `Bus::set_state`, which emits
`TurnStateChanged`.
- `POST /api/new-session` — arm a one-shot for the next turn to
drop `--continue`. Emits a `LiveEvent::Note`.
- `POST /api/logout` — three-step teardown that re-uses the
existing `wait_for_login` resumption path:
1. SIGINT any running claude (matches `/api/cancel`'s pattern —
idempotent no-op when nothing is running) so the credential
wipe doesn't race a mid-API-call turn.
2. Delete only the OAuth credential files
(`.credentials.json` + `mcp-needs-auth-cache.json` under
`~/.claude/`). **Preserves** session history files
(`projects/<hash>/*.jsonl`), sessions, shell-snapshots,
plans, settings, telemetry, and the dir itself, so
`claude --continue` keeps working after a fresh login.
⚠️ Deleting the whole `~/.claude/` directory instead breaks
session continuity — logout must stay narrowed to just the
credential files.
3. Flip `LoginState::NeedsLogin` + emit a `LiveEvent::Note`
naming the files it just deleted, then emit
`needs_login_idle`. The turn-loop's next iteration parks
into `wait_for_login`, which snapshots the credential dir
(now missing the wiped files) and resumes when a fresh
credentials file appears via the dashboard's `/login/code`
flow (the same mtime-resumption path manual re-login uses).
Always returns 200 with a body describing what happened — it
folds per-file errors into the response + the Note so the
operator sees them in the live panel rather than as an HTTP
error. Missing files (already logged out) count as
idempotent.
- `GET /api/state` — cold-load snapshot (`StateSnapshot`) consumed by
the frontend (`useAgentState.ts`) on page load and, on a poll, while
`status === 'needs_login_in_progress'`. Includes `turn_state`,
`context_window_tokens`, `qualified_label`,
`model`, `model_source`, `effort`, `effort_source`, `links`, and
other fields described inline throughout this document. All
subsequent state updates arrive via SSE.
- `GET /api/dashboard-state` — lean snapshot of agent-owned fields
fetched once per running agent by the dashboard's container row to
get fresh values without relying on hive-c0re's periodic file-reads.
Returns `{ status_text?, status_set_at?, ctx_tokens?, context_window_tokens,
rate_limited, links }`. Only called when the container is running;
skipped (muted badges) when stopped. Also accessible via the gateway
at `/agent/<name>/api/dashboard-state`.
- `GET /api/todos` — harness-local todos snapshot (loose-ends v2)
consumed by the todos flyout (`refreshTodos` / `buildTodosList`).
See the todos-flyout paragraph above for the producer/subsystem
model and the bulk mark-done row.
- `GET /api/stats?window=24h|7d|30d|all` — time-bucketed turn analytics
`Snapshot` consumed by the `/stats` page. `all` ranges from the
earliest recorded turn with an adaptive bucket width.
- `GET /icon` — agent's icon as `image/svg+xml`. Returns
`/etc/hyperhive/icon.svg` (set via `services.hyperhive.agent.icon` in `agent.nix`)
when present, otherwise **404** — there is no server-side default.
Consumers (dashboard container row, this page's own header icon)
hit `/icon` optimistically and fall back client-side on load failure
to the frontend-bundled `/favicon.svg` rather than probing first.
- `GET /events/history` — replay buffer for the terminal.
- `GET /screen` — VNC viewer page (minimal RFB-over-WebSocket
renderer — deliberately thin, just enough to display the
desktop + forward pointer + keyboard. A production-grade viewer
would vendor noVNC; this file ships the minimal in-tree variant).
Only accessible when `services.hyperhive.agent.gui.enable = true` in the agent's
`agent.nix`; the harness shows a 🖥 screen link in the state row
when `gui_vnc_port` is present. A `← agent` back-link in the page
header returns to the main per-agent page. Controls: `⤢ fit` CSS-downscales
the canvas to the window via `relayoutCanvas()` setting explicit
pixel dimensions on the canvas — *not* CSS `max-width/max-height`,
because a flex item's automatic minimum size (`min-width: auto`
resolves to the canvas's intrinsic framebuffer resolution) silently
clamps `max-*` back up, making fit mode a no-op that just centred +
clipped the oversized canvas. The fit-mode rules pin the canvas
with `flex: none; min-width: 0; min-height: 0` so the JS-set size
sticks. `⤡ match size` sends an RFB `SetDesktopSize` request so the
server (weston) changes its real output resolution to the window
dimensions; enabled once the server advertises the
`ExtendedDesktopSize` pseudo-encoding (`-308` rect in the header).
Fit-mode state persists in `localStorage` (`screen-fit`); default
is on. `sendPointer` rescales pointer coordinates so presses
land on the right pixel regardless of CSS scale.
- `GET /screen/ws` — raw RFB byte relay: proxies WebSocket
frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
Transparent to any RFB variant. VNC port comes from the
`HIVE_GUI_VNC_PORT` env var (a fixed port set on the harness
service when `services.hyperhive.agent.gui.enable`; see `weston-vnc.nix`).
- `GET|POST /extra/<name>/…` — **extra web proxies** declared via
`services.hyperhive.agent.extraWebProxies` in `agent.nix` (serialised to the
`HIVE_EXTRA_WEB_PROXIES` env var as a JSON object
`{"<name>": "<upstream_url>"}`). Each entry mounts a transparent
reverse-proxy at `/extra/<name>/` that forwards every request (method,
headers, body) to the configured upstream, strips hop-by-hop headers
on both sides (repeated response headers such as two `Set-Cookie` lines
are all forwarded), and buffers the full response body (MVP — SSE
connections will appear as one large response rather than streaming).
The `/extra/` namespace ensures user-declared proxies can never
conflict with native agent endpoints. Upstream values are either an
`http(s)://` URL (forwarded via `reqwest`) or a Unix domain socket,
spelled `unix:<path>` (for example `unix:/run/myapp/http.sock`) — dialed
directly with a raw HTTP/1.1 client per request, since `reqwest` has
no UDS transport. Implemented in `web_ui/proxy.rs`.
Bus events (new vocabulary on `/events/stream`):
- `status_changed { status }` — `online` / `rate_limited` /
`needs_login_idle` / `needs_login_in_progress`. Drives the
alive-badge. The harness sets `rate_limited` when it detects a
429 response and clears it when the retry sleep expires.
- `model_changed { model }` — drives the model chip. The harness emits it
when it applies or clears a swarm value.
- `effort_changed { effort }` — drives the effort chip. The harness
emits it when it applies or clears a swarm value.
- `token_usage_changed { ctx: TokenUsage, cost: TokenUsage }`
— drives the ctx + cost badges. Emitted from
`Bus::record_turn_usage` at turn-end; `ctx` is the last
inference's usage (current context size), `cost` is the
cumulative across every inference (the `result` event's
totals).
- `turn_state_changed { state, since_unix }` — drives the
state badge (`idle`/`thinking`/`compacting`).
## Stats page
`GET /stats` is a separate per-agent page (served by the
harness, linked from the per-agent page's `📊 stats →` and from
each dashboard container row). A `← live` back-link in the page
header returns to the main per-agent page. Turn analytics, read-only, from
`/state/hyperhive-turn-stats.sqlite`. `GET /api/stats?window=
24h|7d|30d|all` returns a time-bucketed `Snapshot`; the page renders
it with Chart.js (bundled into `stats.js` via esbuild — no CDN
dependency). Charts: turns, duration (p50 · p95 · avg), context
tokens, token cost per bucket, a **turns-by-model** stacked bar
(model choice drives token cost, so it sits directly under the
cost chart), doughnuts for tool / wake-source / result mix, and a
**result-trend** stacked bar — per-bucket `result_counts` so
error / rate-limit / compaction outcomes are visible over time
(the doughnut shows only the window total).
A **favorite tools** doughnut shows the most-run shell commands —
normalised `bash_commands` heads written per bash task by the
hive-bash-daemon capture: the basename of the *first real command*,
looking past `cd repo &&` prefixes, env-assignments, and
prefix-runners like `sudo` / `env` (so `cd /repo && cargo build`
records `cargo`, not `cd`). Read via `bash_breakdown`; the card
stays hidden until the agent has run a bash command (a missing
`bash_commands` table degrades to an empty list), so it never
renders an empty chart.
A summary chip row carries window totals, plus two
token-efficiency chips derived from the bucket sums: **cache
hit-rate** (`cache_read` over all input-side tokens) and
**tokens/turn**. When `reminder_stats` is present (fetched via
`ReminderRollup` RPC and merged into the snapshot in
`web_ui/stats.rs::api_stats`) three more chips appear: **reminders
scheduled / delivered / pending** for the window. When the
per-session capture has data, a **first-turn ctx** chip shows the
input tokens of the most recent fresh claude session's first turn —
a proxy for system-prompt + AGENTS.md sprawl (a fresh session's first
turn pays the full static prefix uncached). It's derived in `stats.rs`
(`first_turn_ctx`: the first turn, `ORDER BY started_at LIMIT 1`, of
the latest `sessions` row in the window) and stays out of the JSON
until the `sessions` / `turn_stats.session_id` capture has rows — so
the chip stays hidden on older dbs (inert-until-capture).
`stats.rs` opens the sqlite db read-only and degrades to an
empty snapshot on any error — the page is decorative, never
authoritative.

View file

@ -0,0 +1,528 @@
# Dashboard layout
> Part of [Web UI](README.md). See also:
> [Shape (shared)](../../4-implementation/web-ui/shape.md) · [Per-agent page](agent.md)
This page covers the per-hive UI that hive-c0re serves. Swarm-wide
surfaces — the agent roster, agent creation, linking external forge and
matrix accounts, the controller's job graph — are on the
[swarm UI](../swarm/ui.md).
hive-c0re serves the dashboard at `/`.
It has a fixed chrome header at the top and a `<main>` that shows exactly
one tab pane at a time. The URL hash (`#agents`, `#rebuild`, `#meta`,
`#kept`) drives which pane is
active; hash changes don't reload the page.
**Chrome header** (fixed, overlays the active tab pane):
- **Pages menu**: a link icon at the right end of the chrome, beside the
profile avatar (`links-menu.js`, `@hive/shared`'s `LinksMenu`, the same
component the swarm UI uses for its service links), opening a popover with links to **api docs**
(`/api/docs/`) and **swarm** (the swarm
UI at the swarm apex domain, opened in a new tab). The swarm entry
comes from `state.swarm_public_url`, which hive-c0re reads from
`HIVE_SWARM_PUBLIC_URL` (set from `services.hyperhive.swarm.domain`);
with no domain set there is no swarm entry. The standalone pages link back to `/`.
- **Profile menu**: the signed-in user's avatar at the right end of the
chrome (`user-menu.js`, the same component as the swarm UI's and the agent
page's). It opens a popover with the user's name and links to authelia's
settings and sign-out pages. The name comes from `/api/whoami`, which the
dashboard's nginx vhost proxies to authelia, and the links from
`state.authelia_public_url`, which hive-c0re reads from
`HIVE_AUTHELIA_PUBLIC_URL` (set from `services.hyperhive.swarm.authelia.url`).
If the name doesn't load the avatar shows a question mark, and without the URL the
popover has no links.
- **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`,
`◆ K3PT ST4T3 ◆`.
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
— sits below the tab strip.
- **No warning banner.** No page shows hive-level warnings (host disk
pressure, failing background sweeps, forge boot failures, agents that
need a claude login). They live in a push-based registry
(`hive-c0re`'s `warnings.rs`): any subsystem raises or clears its own
entry through an RAII guard, and each raise logs one
`hive warning raised` line. The operator sees the registry as metrics and
Grafana alerts: see
[Hive-scoped metrics](../../4-implementation/scheduler/observability.md#hive-scoped-metrics-hive-c0re).
A container crash is a swarm-level Grafana alert on the crash watcher's
own log line, so a hive with no otel export has no operator-visible
warning signal beyond `/health/ready`.
- **Browser tab title** — `hyperhive // h1ve-c0re` by default; updated
to `<swarm> / <hive> // h1ve-c0re` once `hive_name` / `swarm_name`
arrive in the state snapshot.
## 4G3NTS tab
**C0NTAINERS** — live containers as a flat list, one row per
container, sorted alphabetically by agent name; every container the
rescan reports gets a row. Pulsing red banner at the top
of this section if any two sub-agents hash to the same port
(`port_conflicts` from `/api/state`): the operator must rename
one of them and rebuild. `lifecycle::{spawn,rebuild}` also
preflight this and refuse with a clear error message naming the
conflicting agent.
## K3PT ST4T3 tab
Passive / rare-interaction state. `kept.js` renders it from the
`/api/state` snapshot and keeps it live from the `tombstones_changed`
stream event. (The rebuild queue and build logs are dashboard tabs too —
see below.)
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
[observability](../../4-implementation/scheduler/observability.md)). An agent's CPU and memory
caps live per agent in the swarm UI.
**K3PT ST4T3** — agents with kept state and **no container** (size + age +
claude-creds badge). One action: `PURG3` (wipes the state
dir; `POST /api/purge-tombstone/{name}`).
⚠️ **Not only *destroyed* agents.** Nothing records a destroy: `Provision`
writes every definition-side artifact (state subvolume,
meta registration, topology entry) *before* the container exists,
and that artifact set survives `lifecycle::destroy`. An agent
part-way through a spawn is byte-identical on disk to a tombstone, and
the pane lists both. The pane carries a standing warning to that effect, and
the row badge says `offline` rather than `destroyed` — the absence of a
container is all hive-c0re can actually prove. Fixing it properly
needs a recorded destroy — **deferred** to the swarm-controller /
snapshot-storage rework where the problem changes shape.
## Build tab (`#rebuild`)
The rebuild queue is the **R3BU1LD QU3U3** tab (`#rebuild`). It lives in `builds.js`, which `tabs.js` imports: the
dashboard's one tab strip, `/api/state` snapshot and
`/api/dashboard/stream` subscription serve them, with
`rebuild_queue_changed` on the stream alongside the dashboard's other
kinds.
The 4G3NTS tab does **not** read the graph endpoint, by design — the swarm
view stays independent of job-queue internals. Its per-agent pending
badges are transient-only, and its queue-summary banner reads the much
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
operations: rebuilds and first-spawns. One
operation runs at a time; the worker drains FIFO. **Is a mounted
`JobqGraph`** (the shared generic graph-viewer Preact component,
`@hive/shared/jobq-graph.js` — the same one swarm-ui uses) —
`builds.js` doesn't render the queue itself; it just mounts the component
(`mountJobqGraph(container, props)`, since this page has no JSX
pipeline) with an `onUpdate` callback to drive the two things below it
that the generic view doesn't show. The component owns fetching, cold
and live: `GET /api/jobq/graph` on mount, and a refetch via the mount
handle's `.refresh()` on every `rebuild_queue_changed` SSE tick (that
event is a bare `{ seq }` trigger carrying no queue payload; both
dashboard pages treat the tick as a pure refetch trigger).
Each row is one root graph node (`parent: null`); a multi-step op's
per-agent subgraphs and sub-steps render as nodes within that one
entry (structural `parent` edges define the tree; sibling order is
array order, unchanged from the wire). A row shows a state glyph (`⏸`
pending / `▶` running / `◐` finishing — own work done, a sub-node
still running / `✔` done / `✖` failed / `⊘` cancelled / `·` skipped)
and each step's own label/agent. A `Node`-kind dep on a sibling shows
as a plain "waits on: `<label>`" text line under the row rather than a
gutter rail — a rail breaks visually whenever a nested subtree sits
between the two related rows, since a text line needs no continuous
vertical space to draw.
`builds.js` mounts the component with `cancellable` set, which turns on
a per-node cancel button (`✕`) on any non-terminal row — the button
calls the mount's `onCancel(id)` callback, and the page does the actual
`POST /api/rebuild-queue/{id}/cancel`, matching that callback to its
own domain concept (the component knows nothing about that endpoint).
**Rows carry no source chip, kind label, timing, or build-log
deep-link** — the generic graph wire doesn't carry those fields, and
rows deliberately present exactly what the endpoint provides rather
than reconstructing chrome hive-c0re doesn't send. Settled entries
render their **full step tree**, not just a bare summary — the wire
doesn't filter `Done` nodes out.
**State filter.** A row of per-state checkboxes above
the tree — one per lifecycle state, matching the row glyphs — lets the
operator narrow which root groups render; unchecking a state re-fetches
`GET /api/jobq/graph?states=<checked, comma-joined>` rather than
hiding rows client-side, so the `onUpdate` callback's node list (and
everything downstream of it) only
ever sees what's actually shown. Filtering is by a **root's own**
state, which is already its subtree's rolled-up answer, so the filter
keeps or drops a whole group, never splitting mid-tree. Default selection is
every state **except** `Done`/`Skipped` — a fresh queue view leads
with what's still moving or needs attention, not the settled tail;
builds.js omits the `states` param entirely (identical request to
before this filter existed) when the operator has checked every state. Server-side: the query
narrows [`Queue::graph_snapshot`]'s already-bounded (`MAX_HISTORY_DAGS`)
root set — the history cap and the state filter are independent
concerns, so a narrow filter never reaches further back in time to
compensate.
A build's output and history aren't on this page. hive-c0re logs every container build
to the journal, and the swarm's `builds` Grafana board reads those lines: the builds with
their outcome and runtime, and the output of the one you pick.
## Container row
A full-height **square agent icon** (5em, capped) on the left. The
icon is the **selection toggle**: select (or Enter/Space) adds/removes
the agent from the selection set; `aria-pressed` reflects the state;
the tooltip says "select … for bulk actions" or "deselect … (or press
Esc to clear all)." The `<img>` points at `<url>/icon`; load failure
falls back to the dimmed hyperhive mark (`/favicon.svg`). The card
body sits to the right with three stacked lines
(`assets/agents.js::renderContainers`).
<!-- vale write-good.Passive = NO -->
**Icon layout + load strategy:** the `<img>` is absolutely
positioned (`inset: 0`) inside the `.container-icon` wrapper —
the wrapper is the flex child and sizes itself via `width: 5em` +
`aspect-ratio: 1`, the `<img>` is out of flow so its load state
(pending, loaded, broken) can never contribute intrinsic size or
reflow the row. Without that, the row would briefly grow as the
image's natural dimensions arrived, then snap back on
`object-fit: contain`. The load itself is fire-and-forget: the
dashboard doesn't pre-check whether the agent is reachable, it
just lets the `<img>` try and listens for an `error` event. On
failure the handler swaps the `src` to `/favicon.svg` (served by
the dashboard itself, always reachable) and adds the
`icon-unreachable` class for the dimmed look. When the dashboard
already knows the container is stopped (`ContainerView.running = false`)
the fallback fires immediately, skipping the doomed `<url>/icon`
fetch entirely.
<!-- vale write-good.Passive = YES -->
<!-- vale write-good.Passive = NO -->
**Line 1** — agent name (link → new tab), m1nd/ag3nt chip, an
**icon-only nav strip** plus live agent-owned state, all populated
async from a single `GET /api/dashboard-state` call to the agent's
own hive-agent. The response (`DashboardState`) carries: `links` (nav
strip entries — `📊 stats`, `🖥 screen` when GUI is enabled, `⬡ forge
profile`, `↳ agent-configs mirror`, plus any agent-declared
`dashboardLinks` extras), `status_text` / `status_set_at` (agent
self-reported status — the `(set N ago)` chip carries a `data-set-at`
stamp and ticks every 30s to stay fresh across the long-lived keyed row
cache), `rate_limited`, `ctx_tokens` / `context_window_tokens`
(context-window badge data). hive-agent is the single source
of truth for all of these. The dashboard resolves each `AgentLink.kind`
against a per-agent base URL depending on whether hive-gateway is in
front (`StateSnapshot.gateway_enabled`, sourced from the
`HIVE_GATEWAY_ENABLED` env). The c0re NixOS module sets that env
unconditionally — the gateway always runs; the flag stays so the
dashboard doesn't have to learn that. Gateway-on: base URL is
`/agent/<name>` (same origin, gateway proxies to the
per-agent harness — TCP or unix-domain depending on the agent's
`HIVE_WEB_SOCKET` opt-in, see
`docs/4-implementation/networking/gateway.md::Per-agent unix-socket upstream`).
Gateway-off is the flag-absent fallback (a dashboard served outside a
hyperhive deploy): base URL is
`http://<host>:<container.port>` (direct TCP). Forge
links resolve against `state.forge_public_url` (sourced from
`services.hyperhive.swarm.forge.publicUrl`) and disappear entirely when
that's unset — never guessed from `<host>:3000`. External links are
already absolute. The same base URL drives the primary agent-name
link + favicon fetch, so the whole row routes through the gateway
as a unit.
<!-- vale write-good.Passive = YES -->
<!-- vale write-good.Passive = NO -->
**When the container is stopped** (`ContainerView.running = false`),
the dashboard skips the async `dashboard-state` fetch entirely (the agent
web server is down), so a single badge replaces the badge chain,
the nav strip is empty, and status text / rate-limited / ctx
badges disappear. The agent icon goes straight to the dimmed
`/favicon.svg` fallback instead of attempting a doomed load from the
container's URL. Static fields — `pending_reminders`, `parent`, `config` link — remain visible
regardless of run state.
<!-- vale write-good.Passive = YES -->
**Line 2** — status badges only (no per-card action buttons — actions
live in the **selection bar** or the **per-agent `⋮` menu**, see
below).
### Status badges
**Which single badge:** `ContainerView.failed`
(systemd `ActiveState=failed` — the unit exhausted its bounded
restarts and gave up on its own) draws a red `✖ gave up` badge;
otherwise a plain muted `■ not running` — a container an operator
stopped deliberately. Both states read `running: false`; `failed` is
the orthogonal fact (a fourth one alongside `paused`/
`needs_login`, same "independent flags, no state machine" shape —
see `ContainerView`'s own doc comment) that tells them apart. An
older hive-c0re without the field serves `failed: undefined`, which
reads falsy — degrades cleanly to the single `not running` badge.
When the container is running, status badges follow — `⊘ rate
limited` (red, while the harness parks itself after a 429), `needs
login` — plus **one `◐ pending-state…` pill per
active transient** (replaces buttons during operator-initiated
start / stop / restart / rebuild / destroy). An agent can carry
**multiple transients at once** — for example a lease-exempt `prebuild`
running alongside a `stop_for_update` on the same agent — and each
renders as its own independent badge instead of collapsing
into one label, matching the existing multi-badge convention this
line already uses for `paused`/model/ctx.
Any pending badge means the row is actually **running** something
right now (see **Pending-badge derivation** below); every row
carrying ≥1 badge keeps the amber row tint AND draws a **rotating
amber ring** around the agent icon.
**Pending-badge derivation:** transients only (`transientsState`,
keyed `agent -> Map<kind, since_unix>`) — a transient is **derived
from a job-queue node currently `Running`** against that agent, not
declared per request, so its label follows the operation as it
progresses (a rebuild reads `stop_for_update`, then `swap`, then
`reconcile` rather than one constant `rebuilding` for its whole
life). Two consequences for anything rendering it:
- The label vocabulary is **open** — it's the node's own wire tag
(`NodeKind`'s `&str` conversion, the same strings the graph wire's node
labels carry), not a fixed set. Treat it as an opaque display
string; don't switch on specific values.
- It's **not** exclusively operator-initiated, and **not** limited
to rebuild-shaped work — `running_transients()` in hive-c0re is
a status-only test (any `Running` node whose payload names a
non-empty agent lights a pill), so work the operator never
triggered (a crash-recover rebuild, a boot-sweep rebuild, a
lease-exempt `prebuild`) lights the same mechanism.
Ops with no queue node behind them (destroy, migration) supply
their own label directly via `TransientSet`/`TransientCleared`
events carrying no backing node at all.
**Queued (not-yet-started) work shows nothing on the card.** Only
running work gets a per-agent pending badge, by design — the swarm
view stays independent of job-queue internals. The queue-summary
banner below is the only queued-work indicator on this tab, and it
reads the narrow `/api/jobq/rollup` endpoint rather than the full
graph.
**Active model badge** (`model · <name>`, blue) appears when the
container is running and the harness has persisted a model name (the
`active_model` field of `hyperhive-harness.json`, the consolidated
harness state file in the agent's state dir). Read by hive-c0re's
`ContainerView` (`read_active_model`); absent until the agent has
completed at least one turn, and hive-c0re suppresses stale values
for stopped containers.
**`ctx · Nk` chip** shows the agent's last-turn context size,
populated from `DashboardState.ctx_tokens` (absent until the
agent has completed at least one turn). The chip colour (green /
yellow / red) keys off `DashboardState.context_window_tokens`
(the real context window for the model the agent last ran on,
authoritative from the agent side); the badge goes yellow ≥ 50%
and red ≥ 75% of that window, matching the harness compaction
watermarks. When the window value is absent the badge falls back
to fixed 100k / 150k thresholds.
**Per-agent `⋮` overflow menu** — a `⋮` button appears on the right
edge of each container row. Selecting it opens a small dropdown with
per-agent actions and navigation links. Contents:
- `↺ R3ST4RT` (running agents only) / `■ ST0P` (running only) /
`▶ ST4RT` (stopped only) — single-agent run-state toggles.
Identical to the bulk actions on the selection bar but operate
on one agent without requiring a selection.
- `↻ R3BU1LD` — always available; queues a rebuild for this agent.
- `DESTR0Y` / `PURG3` — destructive, each prompts for confirmation
via the themed dialog (see **Themed dialogs** below).
**Build-queue summary banner** — when the job queue has any active
work, a compact amber banner sits above the container list: `◐ build
queue — N running · M queued — view queue →` (the link goes to the
R3BU1LD QU3U3 tab). The shared `JobqRollup` Preact component
(`@hive/shared/jobq-rollup.js` — the same one swarm-ui's `/jobs` page
mounts, pointed at swarm-controller's own rollup endpoint instead),
mounted once into `#jobq-rollup-section` by `agents.js::initJobqRollup`
and refreshed via its own handle rather than being re-rendered by
`renderContainers`. Reads `GET /api/jobq/rollup`
(`hive-jobq-wire::state_rollup`) — `Vec<{ state, nodes, roots }>`,
every lifecycle state present in a fixed order, zero counts included —
rather than the full `/api/jobq/graph` tree: `running` sums the
`Running` and `Finishing` entries' `roots` (`Finishing` = own work
done, subtree still going, still in flight), `queued` reads the
`Pending` entry's `roots`. `roots` specifically, not `nodes` — the
banner means *N whole operations*, not raw steps (one rebuild is ~7
nodes but 1 root); `nodes` exists on the same endpoint for a consumer
that wants step-level counts instead, unused here.
### Themed dialogs
All confirmations and transient error notices use an in-app themed
dialog system (`@hive/shared/modal.js`) rather than the browser's
native `confirm()` / `alert()` chrome, so they match the Catppuccin
palette and the OS can't style them away. Two primitives, both built
on the `openDialog` core:
- `themedConfirm({ message, danger, confirmLabel, checkboxes })`
— a modal confirm that resolves to `null` on cancel or an object
of checkbox states on confirm. Destructive actions pass
`danger: true` (the confirm button turns red and the cancel
button takes focus). Selecting the backdrop and `Esc` both cancel.
- `themedToast(message, { type, ... })` — a non-blocking toast (top-right,
`info` / `error` / `ok`) for transient validation + action
failures, so an error doesn't lock the operator behind a modal.
Single-action errors autodismiss; bulk / partial-failure
summaries are sticky (select to dismiss) so they aren't missed.
Every destructive run-state action (`ST0P`, `R3ST4RT`, `R3BU1LD`,
`DESTR0Y`, `PURG3`) routes through `themedConfirm`, on both
the per-agent `⋮` menu and the bulk selection bar.
**Graceful stop** — the `■ ST0P` confirm dialog (per-agent and
bulk) carries a `stop gracefully — let the agent finish its turn
and flush state before the container stops` checkbox. When ticked,
the action POSTs `/api/kill/<name>?graceful=true` (the bulk path
appends the flag per-agent); unticked is the instant hard stop
(`/api/kill/<name>` with no query). hive-c0re enqueues a
`Signal` → `Drain` job-queue node pair (`NodeKind::Signal` /
`NodeKind::Drain`): `Signal` sets the graceful-stop fence and kicks
the harness so it runs one stop-checkpoint turn (so the agent can
flush `/state`); `Drain` awaits the harness clearing that fence,
bounded by a 3-minute timeout (`GRACEFUL_STOP_TIMEOUT`) that
resolves either way and falls back to the downstream mechanical
stop. The quiescing progress surfaces through the same
rebuild-queue pending-badge mechanism the card already reads for a
rebuild, driven by the `Signal`/`Drain` pair rather than a nix
build. (The `hivectl stop --graceful` CLI flag enqueues the same
`Signal`/`Drain` pair, so the dashboard and CLI paths behave
identically.)
## Selection bar
Bulk actions (`R3ST4RT` / `ST0P` / `ST4RT` / `P4US3` / `R3SUM3` /
`R3BU1LD` / `DESTR0Y` / `PURG3`) live here rather than as per-card
buttons — see **Container row** above. Selecting an agent's icon
toggles its selection (an in-memory `Set<name>`); `Esc` or the bar's
`✕ clear` button drops everything. The selection persists across tab
switches in-memory — the bar just hides on non-4G3NTS tabs since other
tabs don't show the agent cards needed to cross-reference.
When the operator selects one or more agents (via the icon), a sticky
frosted-mauve bar slides up from the bottom of the viewport
(`#selection-bar`, `position: fixed; bottom: 0`). It shows:
<!-- vale write-good.Passive = NO -->
- **Count + names** — "N agents selected · name1, name2, …"
- **Bulk action buttons** — only enabled when ALL selected agents
support the action; greyed out with a tooltip naming the blockers
when the selection is mixed:
- `↺ R3ST4RT` — running agents only
- `■ ST0P` — running agents only
- `▶ ST4RT` — stopped agents only
- `⏸ P4US3` — unpaused agents only (orthogonal to running/stopped —
see **Container row**'s own pause/resume note)
- `▶ R3SUM3` — paused agents only
- `↻ R3BU1LD` — always available
- `DESTR0Y` / `PURG3` — always available
<!-- vale write-good.Passive = YES -->
That list is the whole set — the bar offers no other bulk action.
- **`✕ clear`** button + `Esc` key clear the entire selection.
Every render prunes stale selections (agents destroyed while
selected) before the bar appears.
## Dashboard endpoints
Also browsable interactively at `/api/docs` (a Swagger UI, linked from
the pages menu's **api docs** entry), with the raw spec at `/api/openapi.json`.
It's a growing supplement, not yet a full replacement for the list
below — some endpoints aren't in it yet.
- `POST /api/{rebuild,kill,restart,start,destroy}/{name}` — lifecycle.
`destroy` accepts `purge=on` to also wipe state dirs.
- `POST /api/purge-tombstone/{name}` — wipe a tombstone's state dirs.
- `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry.
Refuses `Running` / terminal-state entries (in-flight
rebuilds can't be safely interrupted). Always 200; body is
`{"cancelled": true}` on a successful flip or
`{"cancelled": false}` when the entry wasn't in `Queued` state.
- `POST /api/agent/{name}/mark-all-read` — ack all pending broker
messages for `{name}`. Backfills `delivered_at` for rows
not yet delivered and sets `acked_at = now`. Returns
`{ "marked": N }`. Agent name validated against
`[a-z0-9_-]`, 1-63 chars; 400 on bad input.
- `GET /api/build-logs/{agent}?limit=N` — most-recent build log
headers for one agent, newest first. Returns
`Vec<BuildLogHeader>` (JSON): `id`, `agent`, `kind`, `cmdline`,
`started_at`, `finished_at`, `status` (`"ok"` / `"fail"` /
`null` while in-progress). `limit` defaults to 10, server-side
cap at 50. Agent name validated (`[a-z0-9_-]`, 1-63 chars).
- `GET /api/build-logs/id/{id}` — full build log by id. Returns
`BuildLogFull` (JSON): all header fields plus `stdout` and
`stderr` as plain text (newline-terminated lines, utf-8). HTTP
404 when the row is missing (vacuum-reaped or stale id).
- `GET /api/build-logs/id/{id}/stream` — live SSE stream of
stdout+stderr for an in-progress build (newline-delimited JSON
frames). The
stream closes when the build finishes or errors.
- `GET /api/build-logs/id/{id}/raw` — raw concatenated
stdout+stderr as `text/plain`. Works on both finished and in-progress
build logs.
- `GET /api/reminders` — list pending reminders for the
dashboard's queued-reminders panel.
- `POST /api/cancel-reminder/{id}` — hard-delete a pending reminder.
- `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery
failed (clears the failure state so the scheduler retries).
- `GET /api/dashboard/stream` — unified live event channel:
broker `sent` / `delivered`, plus the mutation events listed
below. Each frame carries `seq`.
<!-- vale write-good.Passive = YES -->
## Dashboard event channel
Wire vocabulary on `/api/dashboard/stream` (kind tag is in the JSON
payload):
- `sent` / `delivered` — broker traffic, mirrored from the
intra-process channel by a forwarder task. Both carry `id: i64`
(the broker row id) and `in_reply_to: Option<i64>` for thread
rendering.
- `transient_set` (name, transient_kind, since_unix) /
`transient_cleared` (name) — lifecycle action spinners. The
client ticks the elapsed-seconds badge off `since_unix`
client-side, no polling. `transient_kind` is an **open**
display string (the running node's own tag), not a fixed
enum — render it, don't branch on it.
- `container_state_changed` (container: ContainerView) /
`container_removed` (name) — per-row container mutations,
emitted by `Coordinator::rescan_containers_and_emit` from
the job queue's own node execution (`job_queue::exec`, for example
after a rebuild's stop/swap/start steps or a destroy's teardown
step) — and from the 10s
`crash_watch` poll. Client upserts/removes by name and
reads the pending overlay from `transientsState` since the
payload doesn't carry it.
- `rebuild_queue_changed` (seq) — **payload-free trigger**, fired on
every queue mutation (enqueue, state transition, dedup collapse,
terminal-history trim). Unlike the snapshot events below it ships
no state at all: the client re-fetches `GET /api/jobq/graph`, which
is where it reads the queue from cold too.
- `reminders_changed` (seq, reminders: `Vec<PendingReminder>`) —
full snapshot of all pending reminders. Emitted after every
reminder mutation: agent `remind` calls (`agent_server`),
operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end`
with Reminder kind, and the scheduler tick after each delivery
batch (`reminder_scheduler`).
- `tombstones_changed` (seq, tombstones: `Vec<TombstoneView>`) —
full snapshot of destroyed-but-state-kept agents. Emitted on
destroy, purge, and revive. The K3PT ST4T3 tab subscribes.
<!-- vale write-good.Passive = NO -->
`/api/state` is **only fetched on cold-load and on the few
forms that mutate non-event-derived state** (PURG3, since
tombstones aren't event-shaped yet). Every other section — transients,
containers —
derives from `/api/dashboard/stream` after the initial snapshot,
maintaining its own client-side store and applying events on
top.
<!-- vale write-good.Passive = YES -->
Generalised form helper: `form[data-confirm="…"]` pops
`confirm()` before submit.