Watch
0
0
Fork
You've already forked hyperhive
0

dashboard: serve the dashboard at /, fold kept state in as a tab, add a pages menu

Drop the H0M3 tile hub and the core page. The dashboard is now the index;
kept state is its fifth tab. A pages menu in the chrome links flow, stats,
API docs and the swarm UI. The swarm apex URL comes from
services.hyperhive.swarm.domain via HIVE_SWARM_PUBLIC_URL and the new
swarm_public_url field of the state snapshot.
This commit is contained in:
iris 2026-10-16 14:03:31 +02:00 • committed by mara
commit 0622736b30
35 changed files with 661 additions and 986 deletions

View file

@ -10,24 +10,25 @@ 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 **dashboard**, served at `/`. A pages menu
linking to every surface (Dashboard, Flow, Logs, Stats, (the `☰` button at the left of the tab strip) links to the other
Settings, Core). Every page links back to H0M3, so you're surfaces — Flow, Stats, the API docs and the swarm UI — and each of
never more than one step from the hub. those pages links back to the dashboard.
The **dashboard** itself (`/dashboard.html`) is where you'll spend most The dashboard is where you'll spend most of your time. It's a single
of your time. It's a single page with four tabs: page with five tabs:
- **4G3NTS** — 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 - **R3BU1LD QU3U3**, **M3T4 1NPUTS** and **BUILD L0GS** — the build
lifecycle: the rebuild queue with a live log of whatever is building, lifecycle: the rebuild queue with a live log of whatever is building,
the meta flake inputs you can bump, and the build log history. the meta flake inputs you can bump, and the build log history.
- **K3PT ST4T3** — kept state from agents with no container
(tombstones), with a purge button per entry.
Everything else lives on its own page instead, all reachable from the Two things live on their own page instead, reached from the pages
H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across menu: **Flow** (`/flow.html`, the raw live message stream across the
the whole swarm), **Stats** (`/stats.html`, swarm-wide usage stats), and whole swarm) and **Stats** (`/stats.html`, swarm-wide usage stats).
**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
agent's session, reachable by selecting its name anywhere in the agent's session, reachable by selecting its name anywhere in the

View file

@ -71,7 +71,7 @@ at a given viewport width. Two columns:
- **Inbox / todos pills** (`📬 inbox · N` / `📋 todos · N`): hidden - **Inbox / todos pills** (`📬 inbox · N` / `📋 todos · N`): hidden
when empty; selecting it opens the matching flyout in the side panel. when empty; selecting it opens the matching flyout in the side panel.
- **Links badge** (`🔗`): opens a popover listing `↑ dashboard` - **Links badge** (`🔗`): opens a popover listing `↑ dashboard`
(back-link to the host dashboard, `${dashboardBase}dashboard.html`) (back-link to the host dashboard, `${dashboardBase}`)
followed by this agent's `agent_links()`-sourced navigation (stats, followed by this agent's `agent_links()`-sourced navigation (stats,
screen when VNC is enabled, forge profile + config-repo mirror when screen when VNC is enabled, forge profile + config-repo mirror when
the agent has a forge account, any `services.hyperhive.agent.dashboardLinks` the agent has a forge account, any `services.hyperhive.agent.dashboardLinks`

View file

@ -8,29 +8,32 @@ surfaces — the agent roster, agent creation, linking external forge and
matrix accounts, the controller's job graph — are on the matrix accounts, the controller's job graph — are on the
[swarm UI](../swarm/ui.md). [swarm UI](../swarm/ui.md).
hive-c0re serves the dashboard at `/dashboard.html` (with the home page at `/`). hive-c0re serves the dashboard 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 (`#agents`, `#rebuild`, `#meta`, one tab pane at a time. The URL hash (`#agents`, `#rebuild`, `#meta`,
`#buildlogs`, `#permissions`) drives which pane is `#buildlogs`, `#kept`) 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 ST4TS
standalone pages are reachable from the H0M3 hub at `/`, not are standalone pages reached from the pages menu, not from the
from the dashboard tab strip. 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 - **Pages menu**: a `☰` button at the left of the chrome (`links-menu.js`)
at `/`. Every surface links back to the hub rather than to each other. opening a popover with links to **flow** (`/flow.html`), **stats**
(`/stats.html`), **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 `/`.
- **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`, - **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`,
`◆ BUILD L0GS ◆`. In-page tabs only — `◆ BUILD L0GS ◆`, `◆ K3PT ST4T3 ◆`. In-page tabs only — FL0W / ST4TS
the SYST3M panels live on the live on their own pages, reached from the pages menu. Count pills on
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS 4G3NTS (stale containers), R3BU1LD QU3U3 (rebuilds in flight) and
live on their own pages too, all reachable from the K3PT ST4T3 (kept-state entries).
H0M3 hub (not the tab strip). Count pills on 4G3NTS
(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 (the dashboard + the stand-alone FL0W
H0M3, C0R3, ST4TS), injected at the top and ST4TS pages), 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
@ -52,7 +55,7 @@ from the dashboard tab strip.
to `<swarm> / <hive> // h1ve-c0re` once `hive_name` / `swarm_name` to `<swarm> / <hive> // h1ve-c0re` once `hive_name` / `swarm_name`
arrive in the state snapshot. arrive in the state snapshot.
The FL0W page uses a slim header (a `← home` back-link + the page The FL0W page uses a slim header (a `← dashboard` 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.
@ -70,15 +73,12 @@ conflicting agent.
`↻ UPD4TE 4LL` button appears above the containers list when any `↻ UPD4TE 4LL` button appears above the containers list when any
agent is stale. agent is stale.
## C0R3 page (`/core.html`) ## K3PT ST4T3 tab
Passive / rare-interaction state, reached from the **Core** tile on the Passive / rare-interaction state. `kept.js` renders it from the
H0M3 hub (served at `/api/state` snapshot and keeps it live from the `tombstones_changed`
`/core.html`), with minimal chrome: a `← home` back-link and a single pane, stream event. (Rebuild queue and meta inputs are dashboard tabs too —
**K3PT ST4T3**. The page is its own esbuild bundle (`core.js`) that see below.)
cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for
`tombstones_changed`. (Rebuild queue and meta inputs are dashboard
tabs — see 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
@ -220,8 +220,8 @@ entry with that id pre-expanded.
Hive-wide turn statistics, aggregated across every agent's Hive-wide turn statistics, aggregated across every agent's
`hyperhive-turn-stats.sqlite` for the selected window. Not a dashboard `hyperhive-turn-stats.sqlite` for the selected window. Not a dashboard
tab — a standalone page reached from the **Stats** tile on the H0M3 tab — a standalone page reached from the dashboard's pages menu,
hub, same minimal chrome as `/flow.html`. Distinct from same minimal chrome as `/flow.html`. Distinct from
each agent's own `/stats` page (which carries the per-agent trend each agent's own `/stats` page (which carries the per-agent trend
charts): ST4TS is the swarm-level rollup. charts): ST4TS is the swarm-level rollup.
@ -261,9 +261,9 @@ prices. Models not covered fall back to hive-c0re's built-in estimate.
## FL0W page (`/flow.html`) ## FL0W page (`/flow.html`)
A dedicated full-page terminal (not a tab pane — a separate HTML A dedicated full-page terminal (not a tab pane — a separate HTML
page). Slim chrome: a `← home` back-link to the H0M3 hub, the FL0W page). Slim chrome: a `← dashboard` back-link, the FL0W
title, and the agent-filter select (see below). No dashboard tab title, and the agent-filter select (see below). No dashboard tab
strip — FL0W is a standalone surface reached from the hub. strip — FL0W is a standalone surface reached from the pages menu.
**MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`. **MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`.
Cold load backfills the last ~200 messages from `/api/dashboard/history`; Cold load backfills the last ~200 messages from `/api/dashboard/history`;
@ -294,17 +294,6 @@ Tab/Enter to confirm; `@*` broadcasts). `POST /api/op-send` drops
`{from:"operator", to, body}` into the broker; the resulting SSE frame `{from:"operator", to, body}` into the broker; the resulting SSE frame
re-renders the terminal row. The operator addresses the root agent as `@root`. re-renders the terminal row. The operator addresses the root agent as `@root`.
## H0M3 page (`/`)
The H0M3 hub is the primary landing page (served at `/` by default). A
responsive grid of link tiles — Dashboard, Flow, Stats,
Core, API — each pointing to their respective
surfaces, all unconditionally shown (no gating). The page is a pure
portal with no tab-bar or SSE subscriptions. Typography + colours inherit
from the shared theme (Catppuccin Mocha via `common.css` + `theme.css`).
`home.js` fills the swarm/hive identity line at the top. All dashboard
sub-pages include a `← Home` back-link for navigation.
## Container row ## Container row
A full-height **square agent icon** (5em, capped) on the left. The A full-height **square agent icon** (5em, capped) on the left. The
@ -579,7 +568,7 @@ selected) before the bar appears.
## Dashboard endpoints ## Dashboard endpoints
Also browsable interactively at `/api/docs` (a Swagger UI, linked from Also browsable interactively at `/api/docs` (a Swagger UI, linked from
the H0M3 hub's **API** tile), with the raw spec at `/api/openapi.json`. 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 It's a growing supplement, not yet a full replacement for the list
below — some endpoints aren't in it yet. below — some endpoints aren't in it yet.
@ -703,7 +692,7 @@ payload):
batch (`reminder_scheduler`). batch (`reminder_scheduler`).
- `tombstones_changed` (seq, tombstones: `Vec<TombstoneView>`) — - `tombstones_changed` (seq, tombstones: `Vec<TombstoneView>`) —
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. The 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. The 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`.

View file

@ -19,11 +19,9 @@ repeated here.
visual noise — the base16 palette has multiple accent slots visual noise — the base16 palette has multiple accent slots
(`--purple`, `--cyan`, `--pink`, …); use more than one where it adds (`--purple`, `--cyan`, `--pink`, …); use more than one where it adds
distinction, not decoration for its own sake. distinction, not decoration for its own sake.
- **Whimsy** — small, delightful touches are welcome (the per-hive - **Whimsy** — small, delightful touches are welcome (the matrix-rain
dashboard's home-page matrix-rain background, `packages/dashboard/src/ background, `src/shell/MatrixRain.tsx`, is the reference example).
home.js`, is the reference example — currently in the dashboard Whimsy still has to clear the accessibility bar below (motion,
package, not swarm-ui itself, but the pattern it sets applies here
too). Whimsy still has to clear the accessibility bar below (motion,
in particular). swarm-ui's own reference example: `Panel`'s optional in particular). swarm-ui's own reference example: `Panel`'s optional
`icon` prop (`src/ui/panel/Panel.tsx`), a small emoji glyph in a `icon` prop (`src/ui/panel/Panel.tsx`), a small emoji glyph in a
panel's header, chosen per panel with no default — grew out of a panel's header, chosen per panel with no default — grew out of a
@ -49,12 +47,10 @@ home.js`, is the reference example — currently in the dashboard
**General rule, not case-by-case: every non-essential CSS animation **General rule, not case-by-case: every non-essential CSS animation
gates on `prefers-reduced-motion` and pauses when its tab/section is gates on `prefers-reduced-motion` and pauses when its tab/section is
hidden.** The dashboard's matrix-rain background (`packages/dashboard/ hidden.** The matrix-rain background (`src/shell/MatrixRain.tsx`) is the
src/home.js`/`.css`) is the existing reference implementation of this existing reference implementation of this pattern — it does both today,
pattern — genuinely does both today, confirmed by reading it — even confirmed by reading it; every future swarm-ui animation follows the
though it lives in the dashboard package rather than swarm-ui; every same pattern, not just whimsy pieces.
future swarm-ui animation follows the same pattern, not just whimsy
pieces.
<!-- vale write-good.Passive = YES --> <!-- vale write-good.Passive = YES -->

View file

@ -19,8 +19,8 @@
from `@hive/shared` (was `hive-fr0nt`); the dashboard ships one from `@hive/shared` (was `hive-fr0nt`); the dashboard ships one
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` /
`stats.css` / `core.css`); `stats.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.

View file

@ -31,7 +31,7 @@ export interface MetaNavProps {
links: AgentLink[]; links: AgentLink[];
forgePublicUrl: string | null; forgePublicUrl: string | null;
/** Absolute base URL of the host dashboard (`resolveDashboardBase`) /** Absolute base URL of the host dashboard (`resolveDashboardBase`)
* — the `🧭 dashboard` item links to `${dashboardBase}dashboard.html`. */ * — the `🧭 dashboard` item links to `${dashboardBase}`. */
dashboardBase: string; dashboardBase: string;
} }
@ -86,7 +86,7 @@ export function MetaNav({
<div class="meta-nav-popover" role="menu" aria-label="agent links"> <div class="meta-nav-popover" role="menu" aria-label="agent links">
<a <a
class="meta-nav-item" class="meta-nav-item"
href={`${dashboardBase}dashboard.html`} href={dashboardBase}
target="_blank" target="_blank"
rel="noopener" rel="noopener"
role="menuitem" role="menuitem"

View file

@ -507,15 +507,10 @@ window.Chart = Chart;
// When accessed via hive-gateway the page lives at `/agent/<name>/` // When accessed via hive-gateway the page lives at `/agent/<name>/`
// on the same origin as the dashboard. Detect via path prefix // on the same origin as the dashboard. Detect via path prefix
// rather than the direct port (which is unreachable or wrong scheme // rather than the direct port (which is unreachable or wrong scheme
// behind HTTPS TLS termination). The dashboard SPA lives at // behind HTTPS TLS termination). The dashboard SPA is the `/` root.
// `dashboard.html` — the `/` root now serves the H0M3 menu hub.
dl.href = window.location.pathname.startsWith("/agent/") dl.href = window.location.pathname.startsWith("/agent/")
? window.location.origin + "/dashboard.html" ? window.location.origin + "/"
: "http://" + : "http://" + window.location.hostname + ":" + s.dashboard_port + "/";
window.location.hostname +
":" +
s.dashboard_port +
"/dashboard.html";
} catch (_) { } catch (_) {
/* non-fatal */ /* non-fatal */
} }

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 (the
// `/`, dashboard/flow/core/stats at their own // dashboard at `/`, flow/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.
@ -9,10 +9,7 @@
// module into each bundle rather than emitting a shared chunk (no // module into each bundle rather than emitting a shared chunk (no
// `splitting: true`). The Rust binary mounts `dist/` as a // `splitting: true`). The Rust binary mounts `dist/` as a
// `tower_http::ServeDir` fallback (`append_index_html_on_directories` // `tower_http::ServeDir` fallback (`append_index_html_on_directories`
// serves index.html at /); the dashboard SPA lives at /dashboard.html // serves index.html at /); the dashboard SPA is that index.html.
// (a plain file, not a /dashboard/ prefix) so it never shadows the
// exact-match /dashboard/stream + /dashboard/history SSE routes
// registered before the ServeDir fallback.
import { build } from "esbuild"; import { build } from "esbuild";
import { mkdirSync, copyFileSync, rmSync } from "node:fs"; import { mkdirSync, copyFileSync, rmSync } from "node:fs";
@ -32,13 +29,7 @@ mkdirSync(staticDir(""), { recursive: true });
// follow-up once asset sizes warrant it). esbuild writes each entry // follow-up once asset sizes warrant it). esbuild writes each entry
// to `static/<name>.js` based on the entryPoint basename. // to `static/<name>.js` based on the entryPoint basename.
await build({ await build({
entryPoints: [ entryPoints: [src("tabs.js"), src("flow.js"), src("stats.js")],
src("tabs.js"),
src("flow.js"),
src("home.js"),
src("stats.js"),
src("core.js"),
],
outdir: staticDir(""), outdir: staticDir(""),
bundle: true, bundle: true,
format: "esm", format: "esm",
@ -99,9 +90,7 @@ for (const entry of [
"common.css", "common.css",
"dashboard.css", "dashboard.css",
"flow.css", "flow.css",
"home.css",
"stats.css", "stats.css",
"core.css",
]) { ]) {
await build({ await build({
entryPoints: [src(entry)], entryPoints: [src(entry)],
@ -112,13 +101,7 @@ for (const entry of [
}); });
} }
for (const html of [ for (const html of ["index.html", "flow.html", "stats.html"]) {
"index.html",
"dashboard.html",
"flow.html",
"stats.html",
"core.html",
]) {
copyFileSync(src(html), dist(html)); copyFileSync(src(html), dist(html));
} }

View file

@ -60,7 +60,7 @@ const selectionState = new Set();
// //
// Rendered into #jobq-rollup-section, a sibling of #containers-section // Rendered into #jobq-rollup-section, a sibling of #containers-section
// kept OUTSIDE that section's per-render `replaceChildren()` wipe (see // kept OUTSIDE that section's per-render `replaceChildren()` wipe (see
// dashboard.html) — a section that gets wiped on every container-state // index.html) — a section that gets wiped on every container-state
// tick would defeat the component owning its own fetch lifecycle. // tick would defeat the component owning its own fetch lifecycle.
let jobqRollupToken = 0; let jobqRollupToken = 0;
@ -587,7 +587,7 @@ function buildContainerLi(c, opts) {
export function renderContainers(s) { export function renderContainers(s) {
const root = $("containers-section"); const root = $("containers-section");
// #containers-section only exists on /dashboard.html. tabs.js is the // #containers-section only exists on the dashboard. tabs.js is the
// bundle for that page only (flow.html loads flow.js instead). // bundle for that page only (flow.html loads flow.js instead).
// Belt-and-suspenders for any future page that adds tabs.js // Belt-and-suspenders for any future page that adds tabs.js
// without a #containers-section — matches the // without a #containers-section — matches the

View file

@ -1,4 +1,4 @@
// The build lifecycle tabs of /dashboard.html — rebuild queue, live build // The build lifecycle tabs of the dashboard — rebuild queue, live build
// log, meta inputs, and build log history. Not an entry point: tabs.js // log, meta inputs, and build log history. Not an entry point: tabs.js
// imports it, owns the tab strip and the dashboard event stream, and calls // imports it, owns the tab strip and the dashboard event stream, and calls
// the exports below. Three tabs, the panes' ids matching the tab ids: // the exports below. Three tabs, the panes' ids matching the tab ids:
@ -168,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 `dashboard.html` — // placeholder `#rebuild-queue-section` starts with in `index.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

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
(agents.js/core.js/builds.js). Used on container rows and build-log (agents.js/kept.js/builds.js). Used on container rows and build-log
rows. */ rows. */
.badge-warn { .badge-warn {
color: var(--amber); color: var(--amber);
@ -561,12 +561,12 @@ hive-side-panel .md td {
} }
/* ─── page-content gutter ───────────────────────────────────────────── /* ─── page-content gutter ─────────────────────────────────────────────
Inverted-gutter layout: the dashboard + H0M3 keep `<body>` full-bleed Inverted-gutter layout: the dashboard keeps `<body>` full-bleed
and wrap their padded content in a single `.page-content` div that and wraps their padded content in a single `.page-content` div that
carries the horizontal gutter. Full-width chrome (the tab strip, the carries the horizontal gutter. Full-width chrome (the tab strip, the
server-warnings banner, the footer) then spans edge-to-edge for free, server-warnings banner, the footer) then spans edge-to-edge for free,
with no negative-margin breakout hacks. Only matches on pages that opt with no negative-margin breakout hacks. Only matches on pages that opt
in by adding the wrapper (dashboard.html, index.html); FL0W / ST4TS in by adding the wrapper (index.html); FL0W / ST4TS
are full-bleed with their own `.<page>-main` padding. */ are full-bleed with their own `.<page>-main` padding. */
.page-content { .page-content {
padding: 0 1.5em; padding: 0 1.5em;
@ -590,7 +590,7 @@ hive-side-panel .md td {
by common.js (renderServerWarnings) on every page. One row per warning; by common.js (renderServerWarnings) on every page. One row per warning;
colour comes from the per-warning `level` (warn = amber, crit = red). colour comes from the per-warning `level` (warn = amber, crit = red).
Stickiness lives on the `.sticky-top` wrapper, not here. The bar spans Stickiness lives on the `.sticky-top` wrapper, not here. The bar spans
the full width edge-to-edge; the dashboard + H0M3 are full-bleed at the the full width edge-to-edge; the dashboard is full-bleed at the
body level (their 1.5em gutter lives on an inner `.page-content` body level (their 1.5em gutter lives on an inner `.page-content`
wrapper), so the banner is full-width for free. */ wrapper), so the banner is full-width for free. */
.server-warnings[hidden] { .server-warnings[hidden] {

View file

@ -1,4 +1,4 @@
// Shared dashboard helpers used by both dashboard.html (./tabs.js) and // Shared dashboard helpers used by both index.html (./tabs.js) and
// flow.html (./flow.js): pure DOM helpers, the side-panel singleton, // flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
// and the path-link / file-preview infrastructure for the side panel. // and the path-link / file-preview infrastructure for the side panel.
@ -564,7 +564,7 @@ export function appendLinkified(parent, text, refs) {
// ─── server warnings banner ────────────────────────────────────────── // ─── server warnings banner ──────────────────────────────────────────
// A generic top-of-page banner shown on every page (dashboard + the // A generic top-of-page banner shown on every page (dashboard + the
// stand-alone FL0W / H0M3 pages). The backend decides what to // stand-alone FL0W / ST4TS pages). The backend decides what to
// warn about — `/api/state.server_warnings` is a list of // warn about — `/api/state.server_warnings` is a list of
// `{ kind, level, message }` — and this just renders it, coloured by // `{ kind, level, message }` — and this just renders it, coloured by
// `level` (`warn` amber / `crit` red). Adding a new system warning is a // `level` (`warn` amber / `crit` red). Adding a new system warning is a
@ -575,8 +575,8 @@ export function appendLinkified(parent, text, refs) {
// banner above the page's chrome (tab bar / page header), so the banner // banner above the page's chrome (tab bar / page header), so the banner
// stacks with the chrome instead of being overlaid by it (two separate // stacks with the chrome instead of being overlaid by it (two separate
// `top:0` stickies would otherwise collide). Built once by wrapping the // `top:0` stickies would otherwise collide). Built once by wrapping the
// page's existing chrome element; pages without a chrome (e.g. the H0M3 // page's existing chrome element; pages without a chrome (none today)
// hub) get a banner-only sticky region at the top of <body>. // get a banner-only sticky region at the top of <body>.
function ensureStickyTop() { function ensureStickyTop() {
let top = document.querySelector(".sticky-top"); let top = document.querySelector(".sticky-top");
if (top) return top; if (top) return top;

View file

@ -1,66 +0,0 @@
/* ─── /core.html — C0R3 page ───────────────────────────────────────
Kept state (tombstones) and stale permission entries. Section rules
imported from system-sections.css (shared with dashboard.css). */
@import "./system-sections.css";
body.core-shell {
margin: 0;
padding: 0;
}
.core-main {
padding: 1.2em 1.5em 2em;
}
/* ─── K3PT ST4T3 container cards ────────────────────────────────────
core.html doesn't load dashboard.css (that's the operator SPA),
so the .container-row card styles aren't inherited. Redefine them
here so tombstone entries look like proper cards (similar to agent
cards on the main dashboard) rather than bare list items. */
.containers {
list-style: none;
padding: 0;
margin: 0.5em 0 0;
display: flex;
flex-direction: column;
gap: 0.5em;
}
.container-row {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.55em 0.8em 0.5em;
}
.container-row.tombstone {
border-style: dashed;
background: color-mix(in srgb, var(--bg-elev) 35%, transparent);
opacity: 0.85;
}
.container-row .head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5em;
margin-bottom: 0.4em;
}
.container-row .head .name {
font-size: 1.05em;
font-weight: bold;
}
.container-row.tombstone .head .name {
color: var(--muted);
}
.container-row .head .meta {
margin-left: auto;
font-size: 0.88em;
color: var(--muted);
}
.container-row .actions {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.container-row .actions form.inline {
display: inline-block;
margin: 0;
}

View file

@ -1,40 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // C0R3</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/core.css" />
</head>
<body class="core-shell">
<!-- C0R3: the host/coordinator surface. Standalone page (served at
/core.html) carved out of the dashboard's old SYST3M tab so the
dashboard tab strip stays lean: kept state (tombstones). Rebuild
queue + meta inputs are tabs of /dashboard.html. The section <div>
id (tombstones-section) matches what core.js's renderers target. Hive
infra containers (`hive-forge`, `hive-gateway`, `hive-matrix`) have
no dashboard panel — `hivectl stop`/`start`/`restart` is the only
control surface. -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<span class="page-title">K3PT ST4T3</span>
</header>
<main class="core-main">
<!-- tombstones-section shows destroyed agents (purge button). -->
<p class="meta">
kept state from previously tombstoned agents — recreating an agent with
the same name reuses it.
</p>
<div id="tombstones-section">
<p class="meta">loading…</p>
</div>
</main>
<script type="module" src="/static/core.js" defer></script>
</body>
</html>

View file

@ -1,156 +0,0 @@
// C0R3 page entry (/core.html). The host/coordinator surface, its own
// standalone page so the dashboard tab strip stays lean: kept state
// (tombstones).
//
// 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
// channel the dashboard uses), maintaining its own copy of the tombstones
// state.
import { $, form, openStream, initServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
// ─── derived state (own copies; this bundle has its own runtime) ──────────
let tombstonesState = [];
function syncFromSnapshot(s) {
tombstonesState = (s.tombstones || []).slice();
}
// ─── kept state (tombstones) ──────────────────────────────────────────────
function renderTombstones(s) {
const root = $("tombstones-section");
if (!root) return;
root.replaceChildren();
if (!s.tombstones || !s.tombstones.length) {
root.append(el("p", { class: "empty" }, "no kept state — clean"));
return;
}
const fmtBytes = (n) => {
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
};
const fmtAgeDays = (ts) => {
if (!ts) return "?";
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
if (d <= 0) return "today";
if (d === 1) return "1 day ago";
return d + " days ago";
};
// Only shown alongside actual rows — a caveat over an empty list is noise.
// Wording is deliberately about what the list *is* rather than what it
// isn't: nothing records a destroy, so "container absent" is the only thing
// the backend can actually tell.
const warn = el("hive-warn", { level: "warning" });
warn.append(
el("strong", {}, "shows every agent whose container is absent"),
" — not only destroyed ones. An agent part-way through being spawned " +
"looks identical here, because nothing records a destroy. Check it is " +
"really gone before you PURG3.",
);
root.append(warn);
const ul = el("ul", { class: "containers" });
for (const t of s.tombstones) {
const li = el("li", { class: "container-row tombstone" });
const head = el("div", { class: "head" });
head.append(
el("span", { class: "name" }, t.name),
// Was `destroyed`, which the backend cannot actually know — see the
// caveat above. `offline` is what the absence of a container proves.
el("span", { class: "hive-pill-sm badge-muted" }, "offline"),
);
if (t.has_creds)
head.append(
el("span", { class: "hive-pill-sm badge-muted" }, "creds kept"),
);
head.append(
el(
"span",
{ class: "meta" },
`${fmtBytes(t.state_bytes)} · ${fmtAgeDays(t.last_seen)}`,
),
);
li.append(head);
const actions = el("div", { class: "actions" });
actions.append(
form(
"/api/purge-tombstone/" + t.name,
"btn-destroy",
"PURG3",
"PURGE " +
t.name +
"? config history, claude creds, " +
"and notes are all WIPED. no undo.",
{},
{ noRefresh: true },
),
);
li.append(actions);
ul.append(li);
}
root.append(ul);
}
// ─── render-all (cold load + any full re-render) ──────────────────────────
function renderAll() {
renderTombstones({ tombstones: tombstonesState });
}
// ─── live SSE handlers ───────────────────────────────────────────────────
const SSE_HANDLERS = {
tombstones_changed(ev) {
tombstonesState = (ev.tombstones || []).slice();
renderTombstones({ tombstones: tombstonesState });
},
};
// ─── boot ─────────────────────────────────────────────────────────────────
// Re-fetch /api/state, re-sync the derived state, re-render. Used for the
// cold load and as the post-submit refresh for `data-async` forms whose
// mutation doesn't arrive via an SSE event (e.g. the meta-update / spawn /
// purge actions that opt out of `data-no-refresh`).
async function refreshState() {
try {
const resp = await fetch("/api/state");
if (resp.ok) syncFromSnapshot(await resp.json());
} catch {
// best-effort: the page keeps its last-rendered state
}
renderAll();
}
async function init() {
initServerWarnings();
// `data-async` form submit interceptor — without this the meta-update /
// cancel / respawn / purge buttons POST natively and the browser navigates
// to the bare `ok` response page.
bindAsyncForms(() => refreshState());
await refreshState();
// kinds= matches SSE_HANDLERS below verbatim — narrows the stream down
// to the kinds this page acts on. This page was one
// of 4 unfiltered `/api/dashboard/stream` subscribers before this
// (subscription discipline, part 1 of the dashboard-event-stream-
// split issue).
const es = openStream("/api/dashboard/stream?kinds=tombstones_changed");
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();

View file

@ -3,9 +3,7 @@
component rules that this file depends on. */ component rules that this file depends on. */
/* SYST3M panel rules (rebuild queue / meta inputs / kept state) /* SYST3M panel rules (rebuild queue / meta inputs / kept state)
live in system-sections.css, shared with /core.html. A few are still live in system-sections.css. */
referenced from the dashboard (`.rebuild-queue`), so the dashboard
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"; @import "@hive/shared/jobq-graph.css";
@ -29,7 +27,7 @@ body.dashboard-shell {
} }
.dashboard-chrome { .dashboard-chrome {
/* Home back-link + tab strip share one row (flex). The chrome owns the /* Pages menu + tab strip share one row (flex). The chrome owns the
horizontal gutter and the single full-width bottom divider; its horizontal gutter and the single full-width bottom divider; its
children drop their own side padding / border so they sit inline. */ children drop their own side padding / border so they sit inline. */
display: flex; display: flex;
@ -45,20 +43,64 @@ body.dashboard-shell {
padding: 0.3em 1em; padding: 0.3em 1em;
margin: 0 0 1em; margin: 0 0 1em;
} }
/* ← home back-link to the H0M3 hub. Small purple link top-left of the /* Pages menu (links-menu.js): one icon button at the left of the chrome
chrome, mirroring the .page-back treatment the standalone pages use that opens a popover of the standalone pages and the swarm UI. Same
(now shared from @hive/shared/chrome.css) so the navigation reads shape as the swarm UI's LinksMenu — quiet until hovered or open. The
consistently. The dashboard keeps this richer sticky chrome of its explicit `[hidden]` rule is needed because `display: flex` on the
own rather than folding into the simple page header. */ popover would otherwise beat the UA `[hidden]` style. */
.dash-home-back { .links-menu {
position: relative;
flex: none; flex: none;
}
.links-menu-button {
display: flex;
align-items: center;
justify-content: center;
width: 2em;
height: 2em;
padding: 0;
border: 1px solid transparent;
border-radius: 0.4em;
background: none;
color: var(--purple); color: var(--purple);
font-size: 1em;
line-height: 1;
cursor: pointer;
}
.links-menu-button:hover,
.links-menu-button[aria-expanded="true"] {
border-color: var(--border);
background: var(--bg-elev);
}
.links-menu-popover {
position: absolute;
top: calc(100% + 0.4em);
left: 0;
z-index: 10;
display: flex;
flex-direction: column;
min-width: 10em;
padding: 0.4em;
border: 1px solid var(--border);
border-radius: 0.5em;
background: var(--bg-elev);
box-shadow: 0 0.25em 0.75em rgba(0, 0, 0, 0.3);
}
.links-menu-popover[hidden] {
display: none;
}
.links-menu-item {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.4em 0.6em;
border-radius: 0.35em;
color: var(--fg);
text-decoration: none; text-decoration: none;
font-size: 0.82em;
white-space: nowrap; white-space: nowrap;
} }
.dash-home-back:hover { .links-menu-item:hover {
text-decoration: underline; background: var(--bg);
} }
.banner-thin { .banner-thin {
text-align: center; text-align: center;
@ -77,7 +119,7 @@ body.dashboard-shell {
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; align-items: center;
gap: 0.2em; gap: 0.2em;
/* On one row with the home link now; the chrome owns the gutter + /* On one row with the pages menu; the chrome owns the gutter +
the bottom divider, so no outer padding / border here. */ the bottom divider, so no outer padding / border here. */
} }
/* Shares the flat-pill aesthetic of the shared `.hive-tab` (sub-tabs on /* Shares the flat-pill aesthetic of the shared `.hive-tab` (sub-tabs on

View file

@ -1,260 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // h1ve-c0re</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/dashboard.css" />
</head>
<body class="dashboard-shell">
<!-- Sticky chrome — just the tab strip. The "WE ARE THE WIRED"
slug lives at the page footer below `<main>`; chrome is
navigation only. Tabs route via the URL hash so F5 / back-
button / shared links keep you on the same view. JS owns
the show/hide. -->
<header class="dashboard-chrome">
<!-- Back to the H0M3 menu hub (served at /). Sub-pages and the
dashboard all link back to the landing page rather than to each
other — H0M3 is the single navigation hub. -->
<a class="dash-home-back" href="/" title="back to the H0M3 menu"
>← home</a
>
<nav class="tabbar" id="tabbar" role="tablist">
<a
class="tab"
id="tab-agents"
href="#agents"
role="tab"
aria-controls="tab-pane-agents"
data-tab="agents"
>
<span class="tab-label">◆ 4G3NTS ◆</span>
<span class="tab-count" id="tab-count-agents" hidden></span>
</a>
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
reached from the H0M3 hub — keeps the dashboard tab strip lean.
The build lifecycle is three tabs here (builds.js): the rebuild
queue with its live log, the meta flake inputs, and the build log
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>
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
the H0M3 hub — the hive-wide rollup is a standalone read-only
view, not part of the operational tab strip. -->
<!-- (Peer hives are no longer a tab — they render as a headline
under 4G3NTS. See tab-pane-agents.) -->
<!-- FL0W and the other standalone pages are reachable from the
H0M3 hub (served at /), not from the dashboard tab strip —
the strip holds only in-page tabs. -->
</nav>
</header>
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
visible by toggling the `hidden` attribute, resolved from the URL
hash (default 4G3NTS). Panes start `hidden` to avoid a flash before
the script runs. -->
<!-- Padded content wrapper: carries the 1.5em side gutter
(.page-content, common.css) while <body> stays full-bleed so the
sticky chrome above + the footer below span the full width. -->
<div class="page-content">
<main class="dashboard-main">
<!-- 4G3NTS: the agents. Container cards (the central thing
the operator looks at) and rebuild queue / cascade visualisation
that drives them. No inline `C0NTAINERS` h2 heading + divider
— the tab label 4G3NTS already says it. -->
<section
class="tab-pane"
id="tab-pane-agents"
data-tab-pane="agents"
hidden
role="tabpanel"
aria-labelledby="tab-agents"
>
<!-- Swarm / hive identity headline. Populated by refreshState from
hive_name + swarm_name; stays hidden when neither is set. -->
<h2 id="swarm-identity" hidden></h2>
<!-- JobqRollup mount point, kept outside #containers-section
deliberately — that section is wiped + rebuilt on every
container-list render (see agents.js::renderContainers), which
would tear down and remount the Preact tree on every
container-state tick. Mounted once by agents.js::initJobqRollup,
refreshed via its own handle rather than by re-rendering. -->
<div id="jobq-rollup-section"></div>
<div id="containers-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- 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. -->
<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>
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
(`/stats.html`), reached from the H0M3 hub. The markup + the
hive-stats render JS moved there; when tabs.js boots on the
dashboard the renderers are simply gone (no stats tab to
activate). -->
<!-- FL0W: lives on its own page now (`/flow.html`). The
message-flow + inbox + compose DOM only exists there — when
tabs.js boots on this page the corresponding renderers
no-op silently (each guard is `if (!el) return`). -->
</main>
</div>
<!-- /.page-content -->
<footer>
<pre class="banner banner-thin">
░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre
>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p>
▲△▲
<a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a>
▲△▲ hive-c0re on this host ▲△▲
</p>
</footer>
<!-- Slide-in detail panel (clicked file previews, applied config)
is a <hive-side-panel> element
(@hive/shared/side-panel.js) — the Panel singleton in common.js
creates + appends it to <body> lazily on first use, so nothing
needs to be pre-declared here. -->
<!-- Selection action bar. Sticky-bottom strip that slides into
view when one or more agent cards is selected (click the icon
to toggle). See docs/web-ui/dashboard.md::Selection bar for the bulk
action gating + clear semantics. -->
<div
id="selection-bar"
class="selection-bar"
hidden
role="toolbar"
aria-label="bulk agent actions"
>
<span class="selection-count" id="selection-count"></span>
<span class="selection-names" id="selection-names"></span>
<span class="selection-actions" id="selection-actions"></span>
<button
type="button"
class="btn selection-clear"
id="selection-clear"
title="clear selection (esc)"
>
✕ clear
</button>
</div>
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
flow.html has its own flow.js bundle. esbuild folds
@hive/shared/terminal.js and the marked npm package into
tabs.js. -->
<script type="module" src="/static/tabs.js" defer></script>
</body>
</html>

View file

@ -11,12 +11,11 @@
<link rel="stylesheet" href="/static/flow.css" /> <link rel="stylesheet" href="/static/flow.css" />
</head> </head>
<body class="flow-shell"> <body class="flow-shell">
<!-- Minimal chrome: just a back link to the H0M3 hub (served at /). <!-- Minimal chrome: just a back link to the dashboard (served at /).
No full tabbar — the flow page is a dedicated full-viewport No full tabbar — the flow page is a dedicated full-viewport
terminal surface; navigating back to the menu is the only chrome terminal surface; navigating back is the only chrome needed. -->
needed. Pages link back to H0M3, not the dashboard. -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← dashboard</a>
<span class="page-title">FL0W</span> <span class="page-title">FL0W</span>
<select <select
id="flow-agent-filter" id="flow-agent-filter"

View file

@ -5,7 +5,7 @@
// //
// Does NOT contain the dashboard's tab renderers, mutation-event // Does NOT contain the dashboard's tab renderers, mutation-event
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by // dispatchers, or refreshState — that's `./tabs.js`, loaded only by
// /dashboard.html. The flow page runs purely on the broker stream + an // the dashboard. The flow page runs purely on the broker stream + an
// initial /api/state fetch (compose autocomplete needs the live // initial /api/state fetch (compose autocomplete needs the live
// container list). // container list).
@ -24,7 +24,7 @@ import { epochSec } from "./util.js";
// ─── local containers cache (for compose autocomplete) ────────────────── // ─── local containers cache (for compose autocomplete) ──────────────────
// The compose box's @-mention completion suggests known agent names. // The compose box's @-mention completion suggests known agent names.
// /dashboard.html (tabs.js) maintains the canonical `containersState` // the dashboard (tabs.js) maintains the canonical `containersState`
// from /api/state + SSE; here we keep a small local mirror updated // from /api/state + SSE; here we keep a small local mirror updated
// by the same `container_state_changed` / `container_removed` events // by the same `container_state_changed` / `container_removed` events
// the dashboard would handle. // the dashboard would handle.
@ -261,7 +261,7 @@ import { epochSec } from "./util.js";
// Drop every other mutation kind silently — without this // Drop every other mutation kind silently — without this
// they'd fall through to the terminal module's default // they'd fall through to the terminal module's default
// renderer and clutter the log with JSON dumps. The dashboard // renderer and clutter the log with JSON dumps. The dashboard
// tabs handle these on /dashboard.html via tabs.js. // tabs handle these on the dashboard via tabs.js.
_default: () => {}, _default: () => {},
}, },
// Re-sync the local containers cache on every SSE (re)connect. // Re-sync the local containers cache on every SSE (re)connect.

View file

@ -1,94 +0,0 @@
/* H0M3 — the menu-hub page. Minimal portal styling: a banner
+ a responsive grid of link tiles. Colours come from theme.css (the
shared palette); base typography from common.css. When the shared
page-chrome component lands (a later refactor step) the banner + tile
styling can fold into it. */
body.home-shell {
/* Full-bleed body; the 1.5em side gutter lives on the inner
`.page-content` wrapper (common.css) so the server-warnings banner
spans full width. `padding-bottom` keeps breathing room at the foot. */
margin: 0;
padding-bottom: 2em;
min-height: 100vh;
}
/* Matrix-rain backdrop (driven by home.js). Fixed, full-viewport, dimmed,
and behind every other element (z-index below the auto-stacked content).
Decorative only — pointer-events off so it never intercepts clicks. The
low opacity keeps the menu tiles legible over it; glyph + fade colours
are themed from the stylix palette in JS. */
#matrix-rain {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
z-index: -1;
opacity: 0.16;
pointer-events: none;
}
.home-header {
text-align: center;
padding: 1.5em 0 0.5em;
}
.home-header .banner {
font-size: 1.05em;
}
.home-main {
max-width: 60em;
margin: 1.5em auto 0;
}
.home-menu {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16em, 1fr));
gap: 1em;
}
.home-tile {
display: flex;
flex-direction: column;
gap: 0.35em;
padding: 1.1em 1.2em;
border: 1px solid var(--purple-dim);
border-radius: 6px;
background: color-mix(in srgb, var(--bg-elev) 55%, transparent);
text-decoration: none;
color: var(--fg);
transition:
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.home-tile:hover {
border-color: var(--purple);
background: color-mix(in srgb, var(--purple) 6%, transparent);
box-shadow: 0 -2px 14px -6px
color-mix(in srgb, var(--purple) 50%, transparent);
}
/* Icon + label share the top row of each tile; the description sits
below. The icon is decorative (aria-hidden) — the label carries the
accessible name. */
.home-tile-head {
display: flex;
align-items: center;
gap: 0.55em;
}
.home-tile-icon {
font-size: 1.25em;
line-height: 1;
flex: none;
}
.home-tile-label {
font-weight: bold;
letter-spacing: 0.08em;
color: var(--purple);
}
.home-tile-desc {
font-size: 0.85em;
color: var(--muted);
}

View file

@ -1,145 +0,0 @@
// H0M3 page script. The page is static markup; this only does
// a few small things off a single `/api/state` read:
// 1. fill the swarm/hive identity line when configured;
// 2. render the shared server-warnings banner (top of every page).
// No SSE — a portal doesn't need live updates.
//
// It also drives the decorative matrix-rain backdrop (#matrix-rain) —
// see startMatrixRain() at the foot.
import { renderServerWarnings } from "./common.js";
const $ = (id) => document.getElementById(id);
async function init() {
let state;
try {
const resp = await fetch("/api/state");
if (!resp.ok) return;
state = await resp.json();
} catch {
return; // best-effort: the tiles still work without it
}
renderServerWarnings(state.server_warnings);
const ident = $("hive-identity");
if (ident && (state.swarm_name || state.hive_name)) {
const parts = [state.swarm_name, state.hive_name].filter(Boolean);
ident.textContent = parts.join(" / ");
ident.hidden = false;
}
// Rev line: absent when the flake ref isn't a local path pin (a bare
// `github:` url has no on-disk rev to canonicalize) — hidden rather
// than showing a placeholder in that case.
//
// `current_flake_rev` canonicalizes to a nix store path
// (/nix/store/<hash>-<pname>) — the part that actually varies between
// builds is the hash right after `/nix/store/`, not the trailing
// `-<pname>` (constant across revs). Slice the hash out of the front
// rather than truncating from the tail, so two different builds don't
// render as the same string. Falls back to a plain head-slice for a
// non-store path (e.g. a bare local dir during dev).
const rev = $("hive-rev");
if (rev && state.hyperhive_rev) {
const storeMatch = state.hyperhive_rev.match(/^\/nix\/store\/([^-]+)/);
const short = storeMatch
? storeMatch[1].slice(0, 12)
: state.hyperhive_rev.slice(0, 12);
rev.textContent = `rev ${short}`;
rev.title = state.hyperhive_rev;
rev.hidden = false;
}
}
init();
// ── matrix-rain backdrop ─────────────────────────────────────────────
// Classic falling-glyph "digital rain" on the #matrix-rain canvas, purely
// decorative. Colours are resolved from the live stylix palette (--green
// glyphs, --bg fade-trail) so a theme swap re-colours it; the CSS already
// dims it + parks it behind the content. Stepped via setInterval (the
// steppy cadence is the look and costs little CPU); paused while the tab is
// hidden, and skipped entirely under prefers-reduced-motion.
function startMatrixRain() {
const canvas = $("matrix-rain");
if (!canvas || !canvas.getContext) return;
if (
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
)
return;
const ctx = canvas.getContext("2d");
// getComputedStyle on a custom property returns its *declared* value
// (e.g. "var(--base0B)") unresolved, so resolve to a concrete rgb() by
// probing a real `color` computation instead.
function resolveColor(varName, fallback) {
const probe = document.createElement("span");
probe.style.color = `var(${varName})`;
probe.style.display = "none";
document.body.appendChild(probe);
const c = getComputedStyle(probe).color;
probe.remove();
return /^rgb/.test(c) ? c : fallback;
}
const glyphColor = resolveColor("--green", "rgb(166, 227, 161)");
// Fade-trail = the bg at low alpha. Pull r,g,b numerically rather than
// string-rewriting the rgb() — robust to whatever rgb()/rgba() spacing
// getComputedStyle hands back, so the trail always fades (never fills
// solid) even if the format shifts.
const bgRGB = resolveColor("--bg", "rgb(30, 30, 46)").match(
/(\d+)[^\d]+(\d+)[^\d]+(\d+)/,
);
const fadeColor = bgRGB
? `rgba(${bgRGB[1]}, ${bgRGB[2]}, ${bgRGB[3]}, 0.09)`
: "rgba(30, 30, 46, 0.09)";
const GLYPHS = "アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦";
const CELL = 16; // glyph cell size (px)
let cols = 0;
let drops = [];
function resize() {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
cols = Math.max(1, Math.ceil(canvas.width / CELL));
drops = Array.from({ length: cols }, () => Math.floor(Math.random() * -40));
ctx.font = `${CELL}px monospace`; // canvas reset on resize clears this
}
function tick() {
// Translucent bg wash leaves a fading trail behind each glyph.
ctx.fillStyle = fadeColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = glyphColor;
for (let i = 0; i < cols; i++) {
const ch = GLYPHS[(Math.random() * GLYPHS.length) | 0];
ctx.fillText(ch, i * CELL, drops[i] * CELL);
if (drops[i] * CELL > canvas.height && Math.random() > 0.975)
drops[i] = 0;
drops[i]++;
}
}
let timer = null;
const play = () => {
if (!timer) timer = setInterval(tick, 55);
};
const pause = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
resize();
window.addEventListener("resize", resize);
document.addEventListener("visibilitychange", () =>
document.hidden ? pause() : play(),
);
play();
}
startMatrixRain();

View file

@ -3,90 +3,277 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // h0m3</title> <title>hyperhive // h1ve-c0re</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" /> <link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" /> <link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" /> <link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/home.css" /> <link rel="stylesheet" href="/static/dashboard.css" />
</head> </head>
<body class="home-shell"> <body class="dashboard-shell">
<!-- Decorative matrix-rain backdrop: a dimmed full-viewport canvas of <!-- Sticky chrome — just the tab strip. The "WE ARE THE WIRED"
falling glyphs behind everything (home.js drives it). aria-hidden + slug lives at the page footer below `<main>`; chrome is
pointer-events:none so it's purely cosmetic; glyph/bg colours are navigation only. Tabs route via the URL hash so F5 / back-
read from the stylix palette at runtime so a theme swap re-colours button / shared links keep you on the same view. JS owns
it, and it's disabled under prefers-reduced-motion. --> the show/hide. -->
<canvas id="matrix-rain" aria-hidden="true"></canvas> <header class="dashboard-chrome">
<!-- Pages menu: the standalone pages (flow, stats, API docs) and the
swarm UI. Filled by links-menu.js from /api/state. The pages link
back to `/`, which is this dashboard. -->
<div class="links-menu" id="links-menu"></div>
<nav class="tabbar" id="tabbar" role="tablist">
<a
class="tab"
id="tab-agents"
href="#agents"
role="tab"
aria-controls="tab-pane-agents"
data-tab="agents"
>
<span class="tab-label">◆ 4G3NTS ◆</span>
<span class="tab-count" id="tab-count-agents" hidden></span>
</a>
<!-- The build lifecycle is three tabs here (builds.js): the rebuild
queue with its live log, the meta flake inputs, and the build log
history. Kept state (kept.js) is the fourth. -->
<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>
<a
class="tab"
id="tab-kept"
href="#kept"
role="tab"
aria-controls="tab-pane-kept"
data-tab="kept"
>
<span class="tab-label">◆ K3PT ST4T3 ◆</span>
<span class="tab-count" id="tab-count-kept" hidden></span>
</a>
<!-- H0M3: the menu hub. A plain grid of links to every top-level <!-- (Peer hives are no longer a tab — they render as a headline
surface. This is the page served at `/` — the landing page — with under 4G3NTS. See tab-pane-agents.) -->
the dashboard relocated to /dashboard.html. No tabbar / SSE —
it's a static portal. --> <!-- FL0W and ST4TS are standalone pages reached from the pages
menu at the left of this strip, not tabs — the strip holds only
in-page tabs. -->
</nav>
</header>
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
visible by toggling the `hidden` attribute, resolved from the URL
hash (default 4G3NTS). Panes start `hidden` to avoid a flash before
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
server-warnings banner spans the full width. --> sticky chrome above + the footer below span the full width. -->
<div class="page-content"> <div class="page-content">
<header class="home-header"> <main class="dashboard-main">
<p class="banner-thin" id="hive-identity" hidden></p> <!-- 4G3NTS: the agents. Container cards (the central thing
<pre class="banner"> the operator looks at) and rebuild queue / cascade visualisation
░▒▓█▓▒░ ░▒▓█▓▒░ H Y P E R H I V E · H0M3 ░▒▓█▓▒░ ░▒▓█▓▒░</pre that drives them. No inline `C0NTAINERS` h2 heading + divider
— the tab label 4G3NTS already says it. -->
<section
class="tab-pane"
id="tab-pane-agents"
data-tab-pane="agents"
hidden
role="tabpanel"
aria-labelledby="tab-agents"
> >
<p class="banner-thin" id="hive-rev" hidden></p> <!-- Swarm / hive identity headline. Populated by refreshState from
</header> hive_name + swarm_name; stays hidden when neither is set. -->
<h2 id="swarm-identity" hidden></h2>
<!-- JobqRollup mount point, kept outside #containers-section
deliberately — that section is wiped + rebuilt on every
container-list render (see agents.js::renderContainers), which
would tear down and remount the Preact tree on every
container-state tick. Mounted once by agents.js::initJobqRollup,
refreshed via its own handle rather than by re-rendering. -->
<div id="jobq-rollup-section"></div>
<div id="containers-section">
<p class="meta">loading…</p>
</div>
</section>
<main class="home-main"> <!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
<nav class="home-menu" aria-label="hyperhive surfaces"> first-spawns. Rendered from GET /api/jobq/graph by the
<a class="home-tile" href="/dashboard.html"> `JobqGraph` Preact component (@hive/shared/jobq-graph.js);
<span class="home-tile-head"> `rebuild_queue_changed` over /api/dashboard/stream is the
<span class="home-tile-icon" aria-hidden="true">🖥</span> refresh trigger and carries no payload of its own. -->
<span class="home-tile-label">Dashboard</span> <section
</span> class="tab-pane"
<span class="home-tile-desc">containers · builds</span> id="tab-pane-rebuild"
</a> 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>
<a class="home-tile" href="/flow.html"> <!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
<span class="home-tile-head"> <section
<span class="home-tile-icon" aria-hidden="true">📡</span> class="tab-pane"
<span class="home-tile-label">Flow</span> id="tab-pane-meta"
</span> data-tab-pane="meta"
<span class="home-tile-desc">live all-agents message firehose</span> hidden
</a> 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>
<a class="home-tile" href="/stats.html"> <!-- BUILD L0GS: all-agents build log history. Click a row to expand
<span class="home-tile-head"> stdout + stderr. Live builds stream in real time. Lazy-loaded the
<span class="home-tile-icon" aria-hidden="true">📊</span> first time the tab is shown; auto-refreshes when
<span class="home-tile-label">Stats</span> rebuild_queue_changed fires. Deep-link: ?id=N#buildlogs. -->
</span> <section
<span class="home-tile-desc" class="tab-pane"
>hive-wide turn stats · cost · model mix</span id="tab-pane-buildlogs"
> data-tab-pane="buildlogs"
</a> 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>
<a class="home-tile" href="/core.html"> <!-- K3PT ST4T3: kept state from agents whose container is absent,
<span class="home-tile-head"> with a purge button per row. Rendered by kept.js; refreshed by the
<span class="home-tile-icon" aria-hidden="true">⬡</span> `tombstones_changed` stream event. -->
<span class="home-tile-label">Core</span> <section
</span> class="tab-pane"
<span class="home-tile-desc">kept state</span> id="tab-pane-kept"
</a> data-tab-pane="kept"
hidden
role="tabpanel"
aria-labelledby="tab-kept"
>
<p class="meta">
kept state from previously tombstoned agents — recreating an agent
with the same name reuses it.
</p>
<div id="tombstones-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- API tile: the OpenAPI spec + Swagger UI are always served by <!-- ST4TS and FL0W live on their own pages (`/stats.html`,
hive-c0re itself (docs/web-ui/dashboard.md::Dashboard `/flow.html`). Their DOM only exists there — when tabs.js boots
endpoints), so this tile is never gated/hidden. --> on this page the corresponding renderers no-op silently (each
<a class="home-tile" href="/api/docs"> guard is `if (!el) return`). -->
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🧬</span>
<span class="home-tile-label">API</span>
</span>
<span class="home-tile-desc"
>interactive OpenAPI spec (Swagger UI)</span
>
</a>
</nav>
</main> </main>
</div> </div>
<!-- /.page-content --> <!-- /.page-content -->
<script type="module" src="/static/home.js" defer></script> <footer>
<pre class="banner banner-thin">
░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre
>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p>
▲△▲
<a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a>
▲△▲ hive-c0re on this host ▲△▲
</p>
<!-- Short build id of the running hive; filled from hyperhive_rev. -->
<p class="meta" id="hive-rev" hidden></p>
</footer>
<!-- Slide-in detail panel (clicked file previews, applied config)
is a <hive-side-panel> element
(@hive/shared/side-panel.js) — the Panel singleton in common.js
creates + appends it to <body> lazily on first use, so nothing
needs to be pre-declared here. -->
<!-- Selection action bar. Sticky-bottom strip that slides into
view when one or more agent cards is selected (click the icon
to toggle). See docs/web-ui/dashboard.md::Selection bar for the bulk
action gating + clear semantics. -->
<div
id="selection-bar"
class="selection-bar"
hidden
role="toolbar"
aria-label="bulk agent actions"
>
<span class="selection-count" id="selection-count"></span>
<span class="selection-names" id="selection-names"></span>
<span class="selection-actions" id="selection-actions"></span>
<button
type="button"
class="btn selection-clear"
id="selection-clear"
title="clear selection (esc)"
>
✕ clear
</button>
</div>
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
flow.html has its own flow.js bundle. esbuild folds
@hive/shared/terminal.js and the marked npm package into
tabs.js. -->
<script type="module" src="/static/tabs.js" defer></script>
</body> </body>
</html> </html>

View file

@ -0,0 +1,102 @@
// K3PT ST4T3 tab of the dashboard: kept state from agents whose container
// is absent (tombstones), with a purge button per row. Owns its slice of
// state like agents.js / builds.js do; tabs.js feeds it the /api/state
// snapshot and the `tombstones_changed` stream event.
import { $, form } from "./common.js";
import { el } from "@hive/shared/dom.js";
let tombstonesState = [];
const fmtBytes = (n) => {
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
};
const fmtAgeDays = (ts) => {
if (!ts) return "?";
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
if (d <= 0) return "today";
if (d === 1) return "1 day ago";
return d + " days ago";
};
/** Number of kept-state entries, for the tab's count pill. */
export function keptCount() {
return tombstonesState.length;
}
export function syncKeptFromSnapshot(s) {
tombstonesState = (s.tombstones || []).slice();
}
export function renderKept() {
const root = $("tombstones-section");
if (!root) return;
root.replaceChildren();
if (!tombstonesState.length) {
root.append(el("p", { class: "empty" }, "no kept state — clean"));
return;
}
// Only shown alongside actual rows — a caveat over an empty list is noise.
// Wording is deliberately about what the list *is* rather than what it
// isn't: nothing records a destroy, so "container absent" is the only thing
// the backend can actually tell.
const warn = el("hive-warn", { level: "warning" });
warn.append(
el("strong", {}, "shows every agent whose container is absent"),
" — not only destroyed ones. An agent part-way through being spawned " +
"looks identical here, because nothing records a destroy. Check it is " +
"really gone before you PURG3.",
);
root.append(warn);
const ul = el("ul", { class: "containers" });
for (const t of tombstonesState) {
const li = el("li", { class: "container-row tombstone" });
const head = el("div", { class: "head" });
head.append(
el("span", { class: "name" }, t.name),
// `offline` is what the absence of a container proves; `destroyed`
// is something the backend cannot know — see the caveat above.
el("span", { class: "hive-pill-sm badge-muted" }, "offline"),
);
if (t.has_creds)
head.append(
el("span", { class: "hive-pill-sm badge-muted" }, "creds kept"),
);
head.append(
el(
"span",
{ class: "meta" },
`${fmtBytes(t.state_bytes)} · ${fmtAgeDays(t.last_seen)}`,
),
);
li.append(head);
const actions = el("div", { class: "actions" });
actions.append(
form(
"/api/purge-tombstone/" + t.name,
"btn-destroy",
"PURG3",
"PURGE " +
t.name +
"? config history, claude creds, " +
"and notes are all WIPED. no undo.",
{},
{ noRefresh: true },
),
);
li.append(actions);
ul.append(li);
}
root.append(ul);
}
/** `tombstones_changed` stream event: replace the list and re-render. */
export function applyTombstonesChanged(ev) {
tombstonesState = (ev.tombstones || []).slice();
renderKept();
}

View file

@ -0,0 +1,99 @@
// Pages menu in the dashboard chrome: one button that opens a popover
// linking the standalone pages (flow, stats, API docs) and the swarm UI.
// Modelled on the swarm UI's `LinksMenu`. The swarm entry only exists
// when `/api/state` carries `swarm_public_url`, so a hive without a
// configured swarm domain shows no dead link.
import { $ } from "./common.js";
import { el } from "@hive/shared/dom.js";
/** Menu entries for a state snapshot. `external` opens in a new tab. */
export function menuLinks(state) {
const links = [
{ icon: "📡", label: "flow", url: "/flow.html" },
{ icon: "📊", label: "stats", url: "/stats.html" },
{ icon: "🧬", label: "api docs", url: "/api/docs/" },
];
if (state && state.swarm_public_url) {
links.push({
icon: "🐝",
label: "swarm",
url: state.swarm_public_url,
external: true,
});
}
return links;
}
let wired = false;
function closeMenu(root) {
const pop = root.querySelector(".links-menu-popover");
const btn = root.querySelector(".links-menu-button");
if (pop) pop.hidden = true;
if (btn) btn.setAttribute("aria-expanded", "false");
}
function wire(root) {
if (wired) return;
wired = true;
root.querySelector(".links-menu-button").addEventListener("click", () => {
const pop = root.querySelector(".links-menu-popover");
const btn = root.querySelector(".links-menu-button");
pop.hidden = !pop.hidden;
btn.setAttribute("aria-expanded", String(!pop.hidden));
});
// Outside click / Escape close the popover.
document.addEventListener("pointerdown", (e) => {
if (!root.contains(e.target)) closeMenu(root);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeMenu(root);
});
}
/** (Re)render the menu into `#links-menu`. Safe to call on every state
* refresh: the open/closed state lives on the popover element, which is
* kept; only its items are rebuilt. */
export function renderLinksMenu(state) {
const root = $("links-menu");
if (!root) return;
if (!root.firstChild) {
root.append(
el(
"button",
{
type: "button",
class: "links-menu-button",
"aria-haspopup": "true",
"aria-expanded": "false",
"aria-label": "pages",
title: "pages",
},
"☰",
),
el("div", { class: "links-menu-popover", role: "menu", hidden: "" }),
);
}
const pop = root.querySelector(".links-menu-popover");
pop.replaceChildren(
...menuLinks(state).map((link) => {
const attrs = {
class: "links-menu-item",
href: link.url,
role: "menuitem",
};
if (link.external) {
attrs.target = "_blank";
attrs.rel = "noreferrer";
}
return el(
"a",
attrs,
el("span", { "aria-hidden": "true" }, link.icon),
link.label,
);
}),
);
wire(root);
}

View file

@ -13,12 +13,12 @@
<body class="stats-shell"> <body class="stats-shell">
<!-- Minimal chrome: back link + title. Same pattern as flow.html — <!-- Minimal chrome: back link + title. Same pattern as flow.html —
no full dashboard tabbar. Back link points to the no full dashboard tabbar. Back link points to the
H0M3 hub (served at /), not the dashboard. --> dashboard (served at /). -->
<header class="page-header"> <header class="page-header">
<a class="page-back" href="/">← home</a> <a class="page-back" href="/">← dashboard</a>
<span class="page-title">ST4TS</span> <span class="page-title">ST4TS</span>
<!-- Window selector lives in the header so it behaves like a tab strip <!-- Window selector lives in the header so it behaves like a tab strip.
(matches the sub-tab strips on /core.html). Hash-routed via createTabStrip. --> Hash-routed via createTabStrip. -->
<nav class="hive-stats-windows" id="hive-stats-windows" role="tablist"> <nav class="hive-stats-windows" id="hive-stats-windows" role="tablist">
<button type="button" class="btn" data-tab="1h">1h</button> <button type="button" class="btn" data-tab="1h">1h</button>
<button type="button" class="btn" data-tab="4h">4h</button> <button type="button" class="btn" data-tab="4h">4h</button>

View file

@ -1,6 +1,6 @@
/* ─── SYST3M/BU1LDS section panels — shared by dashboard and /core.html ─── /* ─── SYST3M/BU1LDS section panels — used by the dashboard ───
Rebuild queue, meta inputs and kept-state panels. Rebuild queue, meta inputs and kept-state panels.
Imported by core.css and dashboard.css. */ Imported by dashboard.css. */
/* ─── meta inputs panel ────────────────────────────────────────────── */ /* ─── meta inputs panel ────────────────────────────────────────────── */
.meta-inputs { .meta-inputs {

View file

@ -1,4 +1,4 @@
// /dashboard.html entry point: tab renderers + tab routing + refreshState. // the dashboard entry point: tab renderers + tab routing + refreshState.
// Reads /api/state on cold load and after every async-form submit; live // Reads /api/state on cold load and after every async-form submit; live
// updates run through `applyXxx` mutation handlers triggered by the // updates run through `applyXxx` mutation handlers triggered by the
// dashboard event stream (subscribed via `openStream` from common.js). See // dashboard event stream (subscribed via `openStream` from common.js). See
@ -7,7 +7,7 @@
// Pure helpers (DOM, side panel, path linkification) // Pure helpers (DOM, side panel, path linkification)
// live in `./common.js`; the flow-page surface (broker // live in `./common.js`; the flow-page surface (broker
// terminal, @-mention composer) lives in `./flow.js` — `flow.html` and // terminal, @-mention composer) lives in `./flow.js` — `flow.html` and
// `dashboard.html` each load their own bundle. // `index.html` each load their own bundle.
// 4G3NTS (containers) domain lives in `./agents.js`. // 4G3NTS (containers) domain lives in `./agents.js`.
import { marked } from "marked"; import { marked } from "marked";
@ -36,6 +36,13 @@ import {
rebuildCount, rebuildCount,
syncBuildsFromSnapshot, syncBuildsFromSnapshot,
} from "./builds.js"; } from "./builds.js";
import {
applyTombstonesChanged,
keptCount,
renderKept,
syncKeptFromSnapshot,
} from "./kept.js";
import { renderLinksMenu } from "./links-menu.js";
// mdNode (in common.js) reads `window.marked` for the markdown side // mdNode (in common.js) reads `window.marked` for the markdown side
// panel preview path. Set it here on the dashboard entry so file // panel preview path. Set it here on the dashboard entry so file
@ -44,8 +51,8 @@ window.marked = marked;
(() => { (() => {
// ─── async forms ──────────────────────────────────────────────────────── // ─── async forms ────────────────────────────────────────────────────────
// Shared `data-async` submit interceptor (now in common.js so /core.html's // Shared `data-async` submit interceptor (in common.js, shared with the other
// bundle gets it too). On success it re-runs refreshState unless the form // pages' bundles). On success it re-runs refreshState unless the form
// opts out via `data-no-refresh` (its mutation arrives via an SSE event). // opts out via `data-no-refresh` (its mutation arrives via an SSE event).
bindAsyncForms(() => refreshState()); bindAsyncForms(() => refreshState());
@ -95,6 +102,22 @@ window.marked = marked;
}); });
} }
// Rev line in the footer: absent when the flake ref isn't a local path
// pin (a bare `github:` url has no on-disk rev to canonicalize), so it
// stays hidden rather than showing a placeholder. `hyperhive_rev`
// canonicalizes to a nix store path (/nix/store/<hash>-<pname>); the
// part that varies between builds is the hash, so slice that out rather
// than truncating from the tail. Falls back to a plain head-slice for a
// non-store path (e.g. a bare local dir during dev).
function renderHiveRev(rev) {
const node = $("hive-rev");
if (!node || !rev) return;
const storeMatch = rev.match(/^\/nix\/store\/([^-]+)/);
node.textContent = `rev ${(storeMatch ? storeMatch[1] : rev).slice(0, 12)}`;
node.title = rev;
node.hidden = false;
}
async function refreshState() { async function refreshState() {
// Don't yank the form out from under the operator. Try again // Don't yank the form out from under the operator. Try again
// shortly on the next tick; eventually they'll blur and the // shortly on the next tick; eventually they'll blur and the
@ -144,7 +167,11 @@ window.marked = marked;
// mount's own effect handles the actual fetch. // mount's own effect handles the actual fetch.
initJobqRollup(); initJobqRollup();
syncBuildsFromSnapshot(s); syncBuildsFromSnapshot(s);
syncKeptFromSnapshot(s);
renderContainers(s); renderContainers(s);
renderKept();
renderLinksMenu(s);
renderHiveRev(s.hyperhive_rev);
restoreOpenDetails(openDetails); restoreOpenDetails(openDetails);
// No periodic refresh timer. Phase 6 covers every container // No periodic refresh timer. Phase 6 covers every container
// mutation with `ContainerStateChanged` / `ContainerRemoved` // mutation with `ContainerStateChanged` / `ContainerRemoved`
@ -181,7 +208,7 @@ window.marked = marked;
// lost during the disconnect window (same pattern as flow.js's // lost during the disconnect window (same pattern as flow.js's
// onStreamOpen). // onStreamOpen).
// //
// Both /dashboard.html and /flow.html subscribe to `/api/dashboard/stream` // Both the dashboard and /flow.html subscribe to `/api/dashboard/stream`
// and filter client-side — the dashboard ignores broker traffic // and filter client-side — the dashboard ignores broker traffic
// and FL0W ignores mutation events. // and FL0W ignores mutation events.
const MUTATION_HANDLERS = { const MUTATION_HANDLERS = {
@ -198,6 +225,7 @@ window.marked = marked;
}, },
meta_inputs_changed: onMetaInputsChanged, meta_inputs_changed: onMetaInputsChanged,
meta_update_running: onMetaUpdateRunning, meta_update_running: onMetaUpdateRunning,
tombstones_changed: applyTombstonesChanged,
}; };
(function bindDashboardStream() { (function bindDashboardStream() {
// Route through the SharedWorker so all open hyperhive tabs on the // Route through the SharedWorker so all open hyperhive tabs on the
@ -208,12 +236,12 @@ window.marked = marked;
// fallback when SharedWorker isn't supported. // fallback when SharedWorker isn't supported.
// //
// kinds= matches MUTATION_HANDLERS verbatim, so the stream carries // kinds= matches MUTATION_HANDLERS verbatim, so the stream carries
// only the 7 wire kinds this page acts on. // only the 8 wire kinds this page acts on.
const es = openStream( const es = openStream(
"/api/dashboard/stream?kinds=" + "/api/dashboard/stream?kinds=" +
"transient_set,transient_cleared," + "transient_set,transient_cleared," +
"container_state_changed,container_removed,rebuild_queue_changed," + "container_state_changed,container_removed,rebuild_queue_changed," +
"meta_inputs_changed,meta_update_running", "meta_inputs_changed,meta_update_running,tombstones_changed",
); );
es.onmessage = (e) => { es.onmessage = (e) => {
let ev; let ev;
@ -245,11 +273,11 @@ window.marked = marked;
})(); })();
// ─── tab routing ─────────────────────────────────────────────────────── // ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` // Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` / `#kept`
// activate the matching pane on the dashboard. Empty // activate the matching pane on the dashboard. Empty
// hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs — // hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs —
// they're separate pages (`/flow.html`, `/stats.html`) reached from // they're separate pages (`/flow.html`, `/stats.html`) reached from
// the H0M3 hub. Tab routing only // the pages menu (links-menu.js). Tab routing only
// applies when the tab DOM is present (e.g. not on the flow page // applies when the tab DOM is present (e.g. not on the flow page
// itself, where these elements don't exist and the loop no-ops). // itself, where these elements don't exist and the loop no-ops).
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the // The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
@ -299,6 +327,8 @@ window.marked = marked;
setTabCount("agents", stale); setTabCount("agents", stale);
// R3BU1LD QU3U3 — rebuilds in flight. // R3BU1LD QU3U3 — rebuilds in flight.
setTabCount("rebuild", rebuildCount()); setTabCount("rebuild", rebuildCount());
// K3PT ST4T3 — entries of kept state.
setTabCount("kept", keptCount());
} }
// Poll the state stores on a 1s tick to keep the pill counts in // Poll the state stores on a 1s tick to keep the pill counts in
// sync. The state stores are mutated synchronously by every SSE // sync. The state stores are mutated synchronously by every SSE

View file

@ -1,7 +1,7 @@
/* ─── page chrome: back-link header ──────────────────────────────── /* ─── page chrome: back-link header ────────────────────────────────
The minimal sticky "frosted bar" header used by every standalone page The minimal sticky "frosted bar" header used by every standalone page
reached from the H0M3 hub — /flow, /stats, /core. A back reached from the dashboard's pages menu — /flow, /stats. A back
link to H0M3 on the left, then either a page title or (on /core) a link to the dashboard on the left, then a page title and, on /stats, a
sub-tab nav. The operator dashboard uses its own richer sub-tab nav. The operator dashboard uses its own richer
sticky chrome (`.dashboard-chrome`); these classes cover the simple sticky chrome (`.dashboard-chrome`); these classes cover the simple
pages. pages.

View file

@ -9,8 +9,8 @@
// The list is entirely server-driven (see swarm-controller's // The list is entirely server-driven (see swarm-controller's
// `services.hyperhive.swarm.controller.links` option) — 0 entries // `services.hyperhive.swarm.controller.links` option) — 0 entries
// hides the button rather than showing an empty popover, same "don't // hides the button rather than showing an empty popover, same "don't
// render a dead affordance" rule the old dashboard's H0M3 tiles follow // render a dead affordance" rule the dashboard's pages menu follows for
// for Forge/Matrix. // its swarm entry.
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import { LinkIcon } from "@hive/shared/icons.js"; import { LinkIcon } from "@hive/shared/icons.js";
import "./LinksMenu.css"; import "./LinksMenu.css";

View file

@ -5,8 +5,7 @@
// Stepped on an interval (the steppy cadence is the look, and it costs // Stepped on an interval (the steppy cadence is the look, and it costs
// little), paused while the tab is hidden, and stopped (and cleared) when // little), paused while the tab is hidden, and stopped (and cleared) when
// motion is reduced: the settings menu's `data-motion` override wins over // motion is reduced: the settings menu's `data-motion` override wins over
// `prefers-reduced-motion`, and is followed live. The same effect as the hive dashboard's home // `prefers-reduced-motion`, and is followed live.
// page, which is plain JS over a fixed canvas.
import { useEffect, useRef } from "preact/hooks"; import { useEffect, useRef } from "preact/hooks";
import { import {
CELL, CELL,

View file

@ -87,6 +87,11 @@ pub(super) struct StateSnapshot {
/// which is only right by accident on deployments that aren't /// which is only right by accident on deployments that aren't
/// plain localhost. /// plain localhost.
forge_public_url: Option<String>, forge_public_url: Option<String>,
/// Browser URL of the swarm UI (the swarm apex domain). Sourced from
/// `HIVE_SWARM_PUBLIC_URL`, which the c0re NixOS module sets from
/// `services.hyperhive.swarm.domain`. `None` when the domain is unset —
/// the dashboard's pages menu then omits the swarm entry.
swarm_public_url: Option<String>,
/// Human name of this single-host hive instance (e.g. `"pr1ma"`). /// Human name of this single-host hive instance (e.g. `"pr1ma"`).
/// Sourced from `HYPERHIVE_HIVE_NAME` env var, set by the c0re /// Sourced from `HYPERHIVE_HIVE_NAME` env var, set by the c0re
/// NixOS module from `services.hyperhive.hiveName`. `None` when /// NixOS module from `services.hyperhive.hiveName`. `None` when
@ -219,6 +224,9 @@ pub(super) async fn api_state(
forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL") forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL")
.ok() .ok()
.filter(|s| !s.is_empty()), .filter(|s| !s.is_empty()),
swarm_public_url: std::env::var("HIVE_SWARM_PUBLIC_URL")
.ok()
.filter(|s| !s.is_empty()),
hive_name: std::env::var("HYPERHIVE_HIVE_NAME") hive_name: std::env::var("HYPERHIVE_HIVE_NAME")
.ok() .ok()
.filter(|s| !s.is_empty()), .filter(|s| !s.is_empty()),

View file

@ -208,6 +208,13 @@ in
# forge links rather than emitting one it can't justify. # forge links rather than emitting one it can't justify.
HIVE_FORGE_PUBLIC_URL = config.services.hyperhive.swarm.forge.publicUrl; HIVE_FORGE_PUBLIC_URL = config.services.hyperhive.swarm.forge.publicUrl;
} }
// lib.optionalAttrs (config.services.hyperhive.swarm.domain != null) {
# Browser URL of the swarm UI, which answers on the swarm apex domain
# (see ../swarm-ui.nix). The dashboard links to it from its pages menu.
# Absent when the domain is unset, so the link is hidden rather than
# guessed.
HIVE_SWARM_PUBLIC_URL = "https://${config.services.hyperhive.swarm.domain}/";
}
// lib.optionalAttrs (config.services.hyperhive.swarm.matrix.gatewayHost != null) { // lib.optionalAttrs (config.services.hyperhive.swarm.matrix.gatewayHost != null) {
# Browser-facing matrix GUI (fluffychat) URL — the gateway # Browser-facing matrix GUI (fluffychat) URL — the gateway
# vhost (`gatewayHost`, `chat.<swarm-domain>` by default). Surfaced via the daemon's `Urls` # vhost (`gatewayHost`, `chat.<swarm-domain>` by default). Surfaced via the daemon's `Urls`

View file

@ -116,13 +116,13 @@ in
example = "https://forge.example.com"; example = "https://forge.example.com";
description = '' description = ''
Browser-facing forge URL the dashboard uses to build clickable Browser-facing forge URL the dashboard uses to build clickable
forge links (the H0M3 Forge tile, per-agent-row forge links) — forge links (the per-agent-row forge links) —
sourced into every agent container + hive-c0re as sourced into every agent container + hive-c0re as
`HIVE_FORGE_PUBLIC_URL`. `HIVE_FORGE_PUBLIC_URL`.
Defaults to `https://''${cfg.domain}`, the gateway vhost. When Defaults to `https://''${cfg.domain}`, the gateway vhost. When
`null`, the dashboard **hides** forge links rather than `null`, the dashboard **hides** forge links rather than
guessing one — see `docs/web-ui/dashboard.md::H0M3 page` for guessing one — see `docs/web-ui/dashboard.md::Container row` for
the rationale (a link built from the operator's own browser the rationale (a link built from the operator's own browser
hostname + a container port is only an accident away from hostname + a container port is only an accident away from
wrong on any deployment that isn't plain localhost). wrong on any deployment that isn't plain localhost).

View file

@ -192,7 +192,7 @@ let
# files — hive-c0re serves none of this as a fallback. # files — hive-c0re serves none of this as a fallback.
# `= /api/docs` (no trailing slash) redirects to `/api/docs/`: nginx # `= /api/docs` (no trailing slash) redirects to `/api/docs/`: nginx
# owns that redirect because hive-c0re has no route for it, and the # owns that redirect because hive-c0re has no route for it, and the
# H0M3 hub's own `/api/docs` link (no trailing slash) would 404 # the dashboard's own `/api/docs` links (no trailing slash) would 404
# without it. # without it.
swaggerUiLocations = { swaggerUiLocations = {
"= /api/docs" = { "= /api/docs" = {

View file

@ -15,11 +15,10 @@
# #
# $out/dashboard/ the hive-c0re dashboard assets (full layout in # $out/dashboard/ the hive-c0re dashboard assets (full layout in
# frontend/packages/dashboard/build.mjs): # frontend/packages/dashboard/build.mjs):
# index.html (H0M3 hub, served at /) dashboard.html (operator SPA, # index.html (operator SPA, served at /) flow.html
# served at /dashboard.html) flow.html
# stats.html favicon.svg # stats.html favicon.svg
# static/{home,tabs,flow,stats,stream-worker}.js{,.map} # static/{tabs,flow,stats,stream-worker}.js{,.map}
# static/{colors,theme,common,home,dashboard,flow,stats}.css # static/{colors,theme,common,dashboard,flow,stats}.css
# $out/agent/ the per-agent default UI (layered with # $out/agent/ the per-agent default UI (layered with
# hyperhive.frontend.extraFiles at activation time) # hyperhive.frontend.extraFiles at activation time)
# index.html stats.html screen.html # index.html stats.html screen.html