dashboard: the SW4RM tab is the agents tab
The swarm lives in the swarm UI; this tab lists the hive's agents. The tab is 4G3NTS at #agents, its pane and count pill follow, and the module that renders the containers is agents.js. Comments and docs that named SW4RM or swarm.js follow.
This commit is contained in:
parent
ee5c057d8f
commit
e455964cb3
16 changed files with 70 additions and 69 deletions
|
|
@ -127,7 +127,7 @@ Two things live in the `agent-configs` Forgejo organization:
|
||||||
[How a hive reads the forge](#how-a-hive-reads-the-forge)).
|
[How a hive reads the forge](#how-a-hive-reads-the-forge)).
|
||||||
- The dashboard links each container's deployed config commit to this
|
- The dashboard links each container's deployed config commit to this
|
||||||
config repo on the swarm's forge (`swarm.forge.publicUrl`; `null` means no
|
config repo on the swarm's forge (`swarm.forge.publicUrl`; `null` means no
|
||||||
link), so operators can go straight from the SW4RM tab into
|
link), so operators can go straight from the 4G3NTS tab into
|
||||||
the rendered repo without an extra `git` step.
|
the rendered repo without an extra `git` step.
|
||||||
|
|
||||||
The `hive-forge` CLI (separate workspace crate, see
|
The `hive-forge` CLI (separate workspace crate, see
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ never more than one step from the hub.
|
||||||
The **dashboard** itself (`/dashboard.html`) is where you'll spend most
|
The **dashboard** itself (`/dashboard.html`) is where you'll spend most
|
||||||
of your time. It's a single page with five tabs:
|
of your time. It's a single page with five tabs:
|
||||||
|
|
||||||
- **SW4RM** — every agent, live. This is the default tab and the one
|
- **4G3NTS** — every agent, live. This is the default tab and the one
|
||||||
you'll check most.
|
you'll check most.
|
||||||
- **R3BU1LD QU3U3**, **M3T4 1NPUTS** and **BUILD L0GS** — the build
|
- **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,
|
||||||
|
|
@ -37,7 +37,7 @@ if the gateway isn't in front).
|
||||||
|
|
||||||
## The things you'll actually do
|
## The things you'll actually do
|
||||||
|
|
||||||
**Check on an agent.** SW4RM shows every container as a row: name,
|
**Check on an agent.** 4G3NTS shows every container as a row: name,
|
||||||
whether it's running, what it's currently doing (a live status pill —
|
whether it's running, what it's currently doing (a live status pill —
|
||||||
`rebuilding…`, `starting…`, or similar — while something's in flight),
|
`rebuilding…`, `starting…`, or similar — while something's in flight),
|
||||||
and quick links (stats, screen, forge profile). Select the name to open
|
and quick links (stats, screen, forge profile). Select the name to open
|
||||||
|
|
@ -49,7 +49,7 @@ repo. Review and merge them on the forge; the merge deploys the change.
|
||||||
See [`config-changes.md`](../agent-lifecycle/config-changes.md#config-changes).
|
See [`config-changes.md`](../agent-lifecycle/config-changes.md#config-changes).
|
||||||
|
|
||||||
**Start, stop, restart, or rebuild an agent.** Select one or more
|
**Start, stop, restart, or rebuild an agent.** Select one or more
|
||||||
agents on SW4RM (select the icon) and use the selection bar, or use the
|
agents on 4G3NTS (select the icon) and use the selection bar, or use the
|
||||||
per-agent `⋮` menu on a single row. Rebuilding re-applies that agent's
|
per-agent `⋮` menu on a single row. Rebuilding re-applies that agent's
|
||||||
current config; use it whenever an agent
|
current config; use it whenever an agent
|
||||||
shows as "needs update."
|
shows as "needs update."
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ matrix accounts, the controller's job graph — are on the
|
||||||
|
|
||||||
hive-c0re serves the dashboard at `/dashboard.html` (with the home page at `/`).
|
hive-c0re serves the dashboard at `/dashboard.html` (with the home page at `/`).
|
||||||
It has a fixed chrome header at the top and a `<main>` that shows exactly
|
It has a fixed chrome header at the top and a `<main>` that shows exactly
|
||||||
one tab pane at a time. The URL hash (`#swarm`, `#rebuild`, `#meta`,
|
one tab pane at a time. The URL hash (`#agents`, `#rebuild`, `#meta`,
|
||||||
`#buildlogs`, `#permissions`) drives which pane is
|
`#buildlogs`, `#permissions`) drives which pane is
|
||||||
active; hash changes don't reload the page. FL0W and the other
|
active; hash changes don't reload the page. FL0W and the other
|
||||||
standalone pages are reachable from the H0M3 hub at `/`, not
|
standalone pages are reachable from the H0M3 hub at `/`, not
|
||||||
|
|
@ -19,12 +19,12 @@ from the dashboard tab strip.
|
||||||
**Chrome header** (fixed, overlays the active tab pane):
|
**Chrome header** (fixed, overlays the active tab pane):
|
||||||
- **← home back-link**: top-left of the chrome, links to the H0M3 hub
|
- **← home back-link**: top-left of the chrome, links to the H0M3 hub
|
||||||
at `/`. Every surface links back to the hub rather than to each other.
|
at `/`. Every surface links back to the hub rather than to each other.
|
||||||
- **Tab strip**: `◆ SW4RM ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`,
|
- **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`,
|
||||||
`◆ BUILD L0GS ◆`, `◆ P3RM1SS10NS ◆`. In-page tabs only —
|
`◆ BUILD L0GS ◆`, `◆ P3RM1SS10NS ◆`. In-page tabs only —
|
||||||
the SYST3M panels live on the
|
the SYST3M panels live on the
|
||||||
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
|
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
|
||||||
live on their own pages too, all reachable from the
|
live on their own pages too, all reachable from the
|
||||||
H0M3 hub (not the tab strip). Count pills on SW4RM
|
H0M3 hub (not the tab strip). Count pills on 4G3NTS
|
||||||
(stale containers) and R3BU1LD QU3U3 (rebuilds in flight).
|
(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.
|
||||||
|
|
@ -56,7 +56,7 @@ The FL0W page uses a slim header (a `← home` back-link + the page
|
||||||
title) rather than the dashboard tab strip — it's a standalone surface,
|
title) rather than the dashboard tab strip — it's a standalone surface,
|
||||||
not a tab pane.
|
not a tab pane.
|
||||||
|
|
||||||
## SW4RM tab
|
## 4G3NTS tab
|
||||||
|
|
||||||
**C0NTAINERS** — live containers as a flat list, one row per
|
**C0NTAINERS** — live containers as a flat list, one row per
|
||||||
container, sorted alphabetically by agent name; every container the
|
container, sorted alphabetically by agent name; every container the
|
||||||
|
|
@ -123,7 +123,7 @@ dashboard's one tab strip, `/api/state` snapshot and
|
||||||
`rebuild_queue_changed`, `meta_inputs_changed` and `meta_update_running`
|
`rebuild_queue_changed`, `meta_inputs_changed` and `meta_update_running`
|
||||||
on the stream alongside the dashboard's other kinds. The count pill on
|
on the stream alongside the dashboard's other kinds. The count pill on
|
||||||
R3BU1LD QU3U3 is the number of rebuilds in flight.
|
R3BU1LD QU3U3 is the number of rebuilds in flight.
|
||||||
The SW4RM tab does **not** read the graph endpoint, by design — the swarm
|
The 4G3NTS tab does **not** read the graph endpoint, by design — the swarm
|
||||||
view stays independent of job-queue internals. Its per-agent pending
|
view stays independent of job-queue internals. Its per-agent pending
|
||||||
badges are transient-only, and its queue-summary banner reads the much
|
badges are transient-only, and its queue-summary banner reads the much
|
||||||
narrower `GET /api/jobq/rollup` instead (see Container row, below) — a
|
narrower `GET /api/jobq/rollup` instead (see Container row, below) — a
|
||||||
|
|
@ -414,7 +414,7 @@ the tooltip says "select … for bulk actions" or "deselect … (or press
|
||||||
Esc to clear all)." The `<img>` points at `<url>/icon`; load failure
|
Esc to clear all)." The `<img>` points at `<url>/icon`; load failure
|
||||||
falls back to the dimmed hyperhive mark (`/favicon.svg`). The card
|
falls back to the dimmed hyperhive mark (`/favicon.svg`). The card
|
||||||
body sits to the right with three stacked lines
|
body sits to the right with three stacked lines
|
||||||
(`assets/swarm.js::renderContainers`).
|
(`assets/agents.js::renderContainers`).
|
||||||
|
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
**Icon layout + load strategy:** the `<img>` is absolutely
|
**Icon layout + load strategy:** the `<img>` is absolutely
|
||||||
|
|
@ -586,7 +586,7 @@ queue — N running · M queued — view queue →` (the link goes to the
|
||||||
R3BU1LD QU3U3 tab). The shared `JobqRollup` Preact component
|
R3BU1LD QU3U3 tab). The shared `JobqRollup` Preact component
|
||||||
(`@hive/shared/jobq-rollup.js` — the same one swarm-ui's `/jobs` page
|
(`@hive/shared/jobq-rollup.js` — the same one swarm-ui's `/jobs` page
|
||||||
mounts, pointed at swarm-controller's own rollup endpoint instead),
|
mounts, pointed at swarm-controller's own rollup endpoint instead),
|
||||||
mounted once into `#jobq-rollup-section` by `swarm.js::initJobqRollup`
|
mounted once into `#jobq-rollup-section` by `agents.js::initJobqRollup`
|
||||||
and refreshed via its own handle rather than being re-rendered by
|
and refreshed via its own handle rather than being re-rendered by
|
||||||
`renderContainers`. Reads `GET /api/jobq/rollup`
|
`renderContainers`. Reads `GET /api/jobq/rollup`
|
||||||
(`hive-jobq-wire::state_rollup`) — `Vec<{ state, nodes, roots }>`,
|
(`hive-jobq-wire::state_rollup`) — `Vec<{ state, nodes, roots }>`,
|
||||||
|
|
@ -648,7 +648,7 @@ Bulk actions (`R3ST4RT` / `ST0P` / `ST4RT` / `P4US3` / `R3SUM3` /
|
||||||
buttons — see **Container row** above. Selecting an agent's icon
|
buttons — see **Container row** above. Selecting an agent's icon
|
||||||
toggles its selection (an in-memory `Set<name>`); `Esc` or the bar's
|
toggles its selection (an in-memory `Set<name>`); `Esc` or the bar's
|
||||||
`✕ clear` button drops everything. The selection persists across tab
|
`✕ clear` button drops everything. The selection persists across tab
|
||||||
switches in-memory — the bar just hides on non-SW4RM tabs since other
|
switches in-memory — the bar just hides on non-4G3NTS tabs since other
|
||||||
tabs don't show the agent cards needed to cross-reference.
|
tabs don't show the agent cards needed to cross-reference.
|
||||||
|
|
||||||
When the operator selects one or more agents (via the icon), a sticky
|
When the operator selects one or more agents (via the icon), a sticky
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@ re-render), call `refreshState()`. State shapes live in
|
||||||
`hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and
|
`hive-c0re/src/dashboard/state_snapshot.rs::StateSnapshot` and
|
||||||
`web_ui/state.rs::StateSnapshot` — when
|
`web_ui/state.rs::StateSnapshot` — when
|
||||||
adding state fields, plumb through the snapshot struct and the
|
adding state fields, plumb through the snapshot struct and the
|
||||||
relevant domain module (`swarm.js`, `permissions.js`, etc.) render function.
|
relevant domain module (`agents.js`, `permissions.js`, etc.) render function.
|
||||||
|
|
||||||
**Focus preservation:** `refreshState` checks whether
|
**Focus preservation:** `refreshState` checks whether
|
||||||
`document.activeElement` sits inside one of the managed sections
|
`document.activeElement` sits inside one of the managed sections
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ layers per-agent additions on top of the default agent dist.
|
||||||
output is reproducible without network access at build time.
|
output is reproducible without network access at build time.
|
||||||
- **esbuild**: `agent` and `swarm-ui` are Preact apps; `dashboard` is a
|
- **esbuild**: `agent` and `swarm-ui` are Preact apps; `dashboard` is a
|
||||||
vanilla-JS + custom-element MPA with a few Preact-rendered pages
|
vanilla-JS + custom-element MPA with a few Preact-rendered pages
|
||||||
(`builds.js`, `swarm.js`, imported by the dashboard entry); `shared` holds both kinds of module. No
|
(`builds.js`, `agents.js`, imported by the dashboard entry); `shared` holds both kinds of module. No
|
||||||
framework runtime beyond Preact where it's used. Each workspace's
|
framework runtime beyond Preact where it's used. Each workspace's
|
||||||
`build.mjs` is a short esbuild script.
|
`build.mjs` is a short esbuild script.
|
||||||
- **Single-PR migration**: see the tracked design proposal and
|
- **Single-PR migration**: see the tracked design proposal and
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// hive-agent-menu.js — <hive-agent-menu>, the per-agent "⋮" context-menu
|
// hive-agent-menu.js — <hive-agent-menu>, the per-agent "⋮" context-menu
|
||||||
// custom element. One instance per agent card, appended after
|
// custom element. One instance per agent card, appended after
|
||||||
// .card-body in the container row's flex layout (swarm.js's
|
// .card-body in the container row's flex layout (agents.js's
|
||||||
// buildContainerLi via buildAgentMenu).
|
// buildContainerLi via buildAgentMenu).
|
||||||
//
|
//
|
||||||
// Deliberately a *light-DOM* element with no shadow root of its own: all
|
// Deliberately a *light-DOM* element with no shadow root of its own: all
|
||||||
|
|
@ -66,7 +66,7 @@ async function agentMenuPost(actionPath, name, body, flags) {
|
||||||
class HiveAgentMenu extends HTMLElement {
|
class HiveAgentMenu extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
// Same reconnect-without-detach hazard as <hive-menu> (see its
|
// Same reconnect-without-detach hazard as <hive-menu> (see its
|
||||||
// connectedCallback comment) — swarm.js's row cache can move an
|
// connectedCallback comment) — agents.js's row cache can move an
|
||||||
// already-built <li> subtree without a real detach. Without this
|
// already-built <li> subtree without a real detach. Without this
|
||||||
// guard a reconnect would append a second <hive-menu> on top of the
|
// guard a reconnect would append a second <hive-menu> on top of the
|
||||||
// first, doubling the dropdown.
|
// first, doubling the dropdown.
|
||||||
|
|
|
||||||
987
frontend/packages/dashboard/src/agents.js
Normal file
987
frontend/packages/dashboard/src/agents.js
Normal file
|
|
@ -0,0 +1,987 @@
|
||||||
|
// 4G3NTS (containers) domain — extracted from tabs.js.
|
||||||
|
// Agent topology, container-row rendering, selection bar, and all
|
||||||
|
// live-update apply handlers for container-state, transient, and
|
||||||
|
// job-queue-rollup ops. See docs/web-ui/dashboard.md::Container row for the
|
||||||
|
// rendering contract.
|
||||||
|
|
||||||
|
import { $, form, fmtAgeSecs } from "./common.js";
|
||||||
|
import { el } from "@hive/shared/dom.js";
|
||||||
|
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
|
||||||
|
import { h, render } from "preact";
|
||||||
|
import { JobqRollup } from "@hive/shared/jobq-rollup.js";
|
||||||
|
import { containersState } from "./state.js";
|
||||||
|
import { closeAllMenus } from "@hive/shared/hive-menu.js";
|
||||||
|
import "./agent-menu/hive-agent-menu.js"; // registers <hive-agent-menu> — side-effect import
|
||||||
|
|
||||||
|
// Context-window badge thresholds. Preferred source is each container's
|
||||||
|
// `context_window_tokens` from /api/state (the real window for the model
|
||||||
|
// it last ran on) — thresholds are then 75% / 50% of it, matching the
|
||||||
|
// harness compaction watermarks (compact at 75%, auto-reset at 50%). The
|
||||||
|
// fixed token constants are the fallback for when that field is absent
|
||||||
|
// (agent has no turns yet, or no per-model config matched the model).
|
||||||
|
const CTX_WARN_FRACTION = 0.75; // >= this share of the window -> red
|
||||||
|
const CTX_CAUTION_FRACTION = 0.5; // >= this share of the window -> yellow
|
||||||
|
const CTX_WARN_TOKENS = 150_000; // fallback red threshold (~= 75% of 200k)
|
||||||
|
const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (~= 50% of 200k)
|
||||||
|
|
||||||
|
// ─── module-level state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Keyed container row cache. Maps agent name -> { el: <li>, fingerprint }.
|
||||||
|
// Allows renderContainers to skip rebuilding rows whose displayed state
|
||||||
|
// hasn't changed — prevents full-wipe flicker + avoids redundant async
|
||||||
|
// dashboard-state fetches on every SSE event.
|
||||||
|
const containerRowCache = new Map();
|
||||||
|
|
||||||
|
// Derived transient state — cold-loaded from /api/state.transients,
|
||||||
|
// then mutated live by `transient_set` / `transient_cleared`. Keyed
|
||||||
|
// by agent name -> Map<transient_kind, since_unix>: an agent can hold
|
||||||
|
// several pills at once (every *running* node naming the agent lights
|
||||||
|
// one now, not just a curated "worth surfacing" subset — see
|
||||||
|
// docs/web-ui/dashboard.md::Container row), keyed the same way the backend's
|
||||||
|
// own tombstone map is, `(agent, label)`, so a clear only removes the
|
||||||
|
// specific pill it names rather than guessing. `since_unix` is
|
||||||
|
// wall-clock so the elapsed-seconds badge ticks without polling.
|
||||||
|
const transientsState = new Map();
|
||||||
|
|
||||||
|
// In-memory set of selected agent logical names backing the sticky
|
||||||
|
// #selection-bar. See docs/web-ui/dashboard.md::Selection bar for the
|
||||||
|
// interaction model (icon-click toggle, Esc/clear button drop,
|
||||||
|
// tab-gated visibility).
|
||||||
|
const selectionState = new Set();
|
||||||
|
|
||||||
|
// ─── job queue rollup (queue-summary banner only) ──────────────────────────
|
||||||
|
//
|
||||||
|
// The ONE piece of job-queue-derived state on this page, and deliberately
|
||||||
|
// narrow — mara: "agents.js should not need to pull in the jobq to do its
|
||||||
|
// job." Rendering is owned by the shared `JobqRollup` Preact component
|
||||||
|
// (same one swarm-ui's /jobs page mounts, self-fetching
|
||||||
|
// GET /api/jobq/rollup); this file just calls `render(h(...))` and bumps
|
||||||
|
// a refresh token to force a refetch.
|
||||||
|
//
|
||||||
|
// Rendered into #jobq-rollup-section, a sibling of #containers-section
|
||||||
|
// kept OUTSIDE that section's per-render `replaceChildren()` wipe (see
|
||||||
|
// dashboard.html) — a section that gets wiped on every container-state
|
||||||
|
// tick would defeat the component owning its own fetch lifecycle.
|
||||||
|
let jobqRollupToken = 0;
|
||||||
|
|
||||||
|
export function initJobqRollup() {
|
||||||
|
const root = $("jobq-rollup-section");
|
||||||
|
if (!root) return;
|
||||||
|
render(
|
||||||
|
h(JobqRollup, {
|
||||||
|
endpoint: "/api/jobq/rollup",
|
||||||
|
queueHref: "#rebuild",
|
||||||
|
refreshToken: jobqRollupToken,
|
||||||
|
}),
|
||||||
|
root,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Refetches on cold load (tabs.js's refreshState) and whenever
|
||||||
|
// `rebuild_queue_changed` fires — a payload-less push trigger by
|
||||||
|
// design, confirmed with atlas on the jobq-deletion tracker: the event
|
||||||
|
// carries no `queue` field this page reads, same "something changed,
|
||||||
|
// go refetch" treatment builds.js gives the R3BU1LD QU3U3 tab's JobqGraph.
|
||||||
|
export function applyRebuildQueueChanged() {
|
||||||
|
jobqRollupToken += 1;
|
||||||
|
initJobqRollup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── transients ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function syncTransientsFromSnapshot(s) {
|
||||||
|
transientsState.clear();
|
||||||
|
const nowUnix = Math.floor(Date.now() / 1000);
|
||||||
|
for (const t of s.transients || []) {
|
||||||
|
// Snapshot is a flat list — multiple rows CAN share the same
|
||||||
|
// `name` (one row per running node) — so group into this agent's
|
||||||
|
// kind map rather than overwrite.
|
||||||
|
let byKind = transientsState.get(t.name);
|
||||||
|
if (!byKind) {
|
||||||
|
byKind = new Map();
|
||||||
|
transientsState.set(t.name, byKind);
|
||||||
|
}
|
||||||
|
// Snapshot ships `secs` (server-computed); reconstruct an
|
||||||
|
// approximate since_unix so the live ticker keeps progressing
|
||||||
|
// without surprising jumps when the next snapshot lands.
|
||||||
|
byKind.set(t.kind, t.since_unix ?? nowUnix - (t.secs || 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function applyTransientSet(ev) {
|
||||||
|
let byKind = transientsState.get(ev.name);
|
||||||
|
if (!byKind) {
|
||||||
|
byKind = new Map();
|
||||||
|
transientsState.set(ev.name, byKind);
|
||||||
|
}
|
||||||
|
byKind.set(ev.transient_kind, ev.since_unix);
|
||||||
|
renderContainersFromState();
|
||||||
|
}
|
||||||
|
export function applyTransientCleared(ev) {
|
||||||
|
const byKind = transientsState.get(ev.name);
|
||||||
|
if (!byKind || !byKind.delete(ev.transient_kind)) return;
|
||||||
|
// Drop the now-empty outer entry too — keeps `transientsState.size`
|
||||||
|
// meaning "agents with at least one pill" (checked at the
|
||||||
|
// no-containers-and-no-transients empty-state gate below).
|
||||||
|
if (byKind.size === 0) transientsState.delete(ev.name);
|
||||||
|
renderContainersFromState();
|
||||||
|
}
|
||||||
|
// Re-render using the last cached snapshot (containers come from
|
||||||
|
// /api/state, transients overlay from the derived map). The snapshot
|
||||||
|
// is stashed on window.__hyperhive_state by refreshState; on cold
|
||||||
|
// load before the first snapshot we just skip.
|
||||||
|
export function renderContainersFromState() {
|
||||||
|
const s = window.__hyperhive_state;
|
||||||
|
if (s) renderContainers(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── container-state apply ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function applyContainerStateChanged(ev) {
|
||||||
|
if (!ev.container || !ev.container.name) return;
|
||||||
|
containersState.set(ev.container.name, ev.container);
|
||||||
|
renderContainersFromState();
|
||||||
|
}
|
||||||
|
export function applyContainerRemoved(ev) {
|
||||||
|
if (containersState.delete(ev.name)) renderContainersFromState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── selection ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleSelection(name) {
|
||||||
|
if (selectionState.has(name)) selectionState.delete(name);
|
||||||
|
else selectionState.add(name);
|
||||||
|
renderContainersFromState();
|
||||||
|
}
|
||||||
|
function clearSelection() {
|
||||||
|
if (selectionState.size === 0) return;
|
||||||
|
selectionState.clear();
|
||||||
|
renderContainersFromState();
|
||||||
|
}
|
||||||
|
// Esc clears the current selection (operator escape hatch — mirrors
|
||||||
|
// the side-panel close pattern). Ignored when an editable element
|
||||||
|
// has focus so typing in compose / answer isn't intercepted.
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
if (!selectionState.size) return;
|
||||||
|
const a = document.activeElement;
|
||||||
|
if (
|
||||||
|
a &&
|
||||||
|
(a.isContentEditable ||
|
||||||
|
a.tagName === "INPUT" ||
|
||||||
|
a.tagName === "TEXTAREA" ||
|
||||||
|
a.tagName === "SELECT")
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
e.preventDefault();
|
||||||
|
clearSelection();
|
||||||
|
});
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
if (e.target && e.target.closest("#selection-clear")) {
|
||||||
|
clearSelection();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── per-agent context menu ──────────────────────────────────────────────────
|
||||||
|
// Three-dot (⋮) button on each agent card for quick single-agent
|
||||||
|
// lifecycle actions without needing to select first. State-aware:
|
||||||
|
// restart/stop only shown when running, start only shown when
|
||||||
|
// stopped; rebuild + destroy/purge always shown.
|
||||||
|
// The button is CSS-invisible until the row is hovered (or menu is
|
||||||
|
// open) so it doesn't clutter quiet rows.
|
||||||
|
// Rendering + agent-specific interaction lives in the <hive-agent-menu>
|
||||||
|
// custom element (./agent-menu/hive-agent-menu.js, imported above for its
|
||||||
|
// customElements.define side effect); the generic dropdown mechanics
|
||||||
|
// (open/close, positioning, singleton coordination — closed via
|
||||||
|
// `closeAllMenus` below) live in the shared `<hive-menu>` component it
|
||||||
|
// composes internally (@hive/shared/hive-menu.js). This is a thin
|
||||||
|
// wrapper matching <hive-dialog>'s `._opts`-before-append convention,
|
||||||
|
// since a custom element created via `document.createElement` can't
|
||||||
|
// take constructor args.
|
||||||
|
function buildAgentMenu(c, forgeBase) {
|
||||||
|
const menu = document.createElement("hive-agent-menu");
|
||||||
|
menu._opts = { c, forgeBase };
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── port conflicts ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Re-derive port conflicts from the live containers map. Mirrors the
|
||||||
|
// server-side `build_port_conflicts` so the banner reacts to event
|
||||||
|
// updates instead of waiting for a /api/state refetch.
|
||||||
|
function derivePortConflicts(containers) {
|
||||||
|
const byPort = new Map();
|
||||||
|
for (const c of containers) {
|
||||||
|
if (!byPort.has(c.port)) byPort.set(c.port, []);
|
||||||
|
byPort.get(c.port).push(c.name);
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const [port, agents] of byPort) {
|
||||||
|
if (agents.length > 1) {
|
||||||
|
agents.sort();
|
||||||
|
out.push({ port, agents });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort((a, b) => a.port - b.port);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── container ordering ──────────────────────────────────────────────────────
|
||||||
|
// Flat alphabetical sort — see docs/web-ui/dashboard.md::C0NTAINERS.
|
||||||
|
|
||||||
|
function sortedContainerRows(containers) {
|
||||||
|
// Close any open context menu before replacing the DOM tree — the
|
||||||
|
// previous dropdown element would otherwise be a stale reference.
|
||||||
|
closeAllMenus();
|
||||||
|
return [...containers].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── container row ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Serialise the visible state of a container row into a stable string
|
||||||
|
// for change-detection. Includes everything that affects what the row
|
||||||
|
// renders — container fields, derived pending/selection state, and
|
||||||
|
// link-base context. The async dashboard-state (nav strip, ctx badge,
|
||||||
|
// status text) is intentionally excluded: it populates in-place and is
|
||||||
|
// preserved when a row is reused.
|
||||||
|
function containerRowFingerprint(
|
||||||
|
c,
|
||||||
|
pending,
|
||||||
|
opRunning,
|
||||||
|
selected,
|
||||||
|
gatewayLinks,
|
||||||
|
hostname,
|
||||||
|
) {
|
||||||
|
return JSON.stringify({
|
||||||
|
running: c.running,
|
||||||
|
failed: c.failed,
|
||||||
|
paused: c.paused,
|
||||||
|
needs_login: c.needs_login,
|
||||||
|
needs_update: c.needs_update,
|
||||||
|
active_model: c.active_model,
|
||||||
|
port: c.port,
|
||||||
|
pending,
|
||||||
|
opRunning,
|
||||||
|
selected,
|
||||||
|
gatewayLinks,
|
||||||
|
hostname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a single container-row <li> from scratch. Extracted so
|
||||||
|
// renderContainers can call this only for rows whose fingerprint
|
||||||
|
// changed (keyed cache), skipping the build + async dashboard-state
|
||||||
|
// fetch for stable rows.
|
||||||
|
function buildContainerLi(c, opts) {
|
||||||
|
const { pending, opRunning, selected, url, containerBase, forgeBase, s } =
|
||||||
|
opts;
|
||||||
|
// A single `pending-running` class covers any active transient
|
||||||
|
// badge state (see the pending-badge derivation comment in
|
||||||
|
// renderContainers).
|
||||||
|
const li = el("li", {
|
||||||
|
class:
|
||||||
|
"container-row" +
|
||||||
|
(opRunning ? " pending-running" : "") +
|
||||||
|
(selected ? " selected" : ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agent icon: 5em square wrapper with an absolutely-positioned
|
||||||
|
// <img> + fire-and-forget load with /favicon.svg fallback. The
|
||||||
|
// wrapper doubles as the selection toggle (click / keyboard).
|
||||||
|
// See docs/web-ui/dashboard.md::Container row for the layout + load-strategy
|
||||||
|
// rationale.
|
||||||
|
const iconImg = el("img", { class: "container-icon-img", alt: "" });
|
||||||
|
const icon = el(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
class: "container-icon",
|
||||||
|
role: "button",
|
||||||
|
tabindex: "0",
|
||||||
|
"aria-pressed": selected ? "true" : "false",
|
||||||
|
title: selected
|
||||||
|
? `deselect ${c.name} (or press Esc to clear all)`
|
||||||
|
: `select ${c.name} for bulk actions`,
|
||||||
|
},
|
||||||
|
iconImg,
|
||||||
|
);
|
||||||
|
icon.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleSelection(c.name);
|
||||||
|
});
|
||||||
|
icon.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleSelection(c.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (c.running) {
|
||||||
|
iconImg.src = `${url}icon`;
|
||||||
|
iconImg.addEventListener("error", () => {
|
||||||
|
if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s
|
||||||
|
iconImg.dataset.fallback = "1";
|
||||||
|
icon.classList.add("icon-unreachable");
|
||||||
|
iconImg.src = "/favicon.svg";
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Container stopped — skip the doomed `${url}icon` fetch and
|
||||||
|
// go straight to the dimmed hyperhive mark. Avoids a noisy
|
||||||
|
// failed request in the console + the brief broken-image flash.
|
||||||
|
icon.classList.add("icon-unreachable");
|
||||||
|
iconImg.src = "/favicon.svg";
|
||||||
|
}
|
||||||
|
// Card body: the three stacked content lines, right of the icon.
|
||||||
|
const body = el("div", { class: "card-body" });
|
||||||
|
|
||||||
|
// -- identity -------------------------------------------------------
|
||||||
|
const head = el("div", { class: "head" });
|
||||||
|
head.append(
|
||||||
|
el(
|
||||||
|
"a",
|
||||||
|
{ class: "name", href: url, target: "_blank", rel: "noopener" },
|
||||||
|
c.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Icon-only nav strip — populated async from the agent's own
|
||||||
|
// `GET /api/dashboard-state` (via gateway when enabled, direct
|
||||||
|
// TCP otherwise). The agent is the single source of truth for its
|
||||||
|
// link list: stats / screen (GUI agents only — c0re's disk-based
|
||||||
|
// fallback cannot detect this) / forge profile / agent-configs /
|
||||||
|
// extras. DOM-built — link strings come from the agent's process
|
||||||
|
// and must never reach the HTML parser.
|
||||||
|
const navStrip = el("span", { class: "nav-strip" });
|
||||||
|
head.append(navStrip);
|
||||||
|
if (c.running) {
|
||||||
|
// Fetch the lean dashboard-state snapshot from the agent directly.
|
||||||
|
// Populates: nav strip links (including the screen link that
|
||||||
|
// c0re's disk-based build cannot detect), rate_limited badge,
|
||||||
|
// ctx-window badge, and self-reported status text.
|
||||||
|
// Fails gracefully when the agent is starting up or the gateway
|
||||||
|
// is not yet routing to it — badges simply don't appear.
|
||||||
|
// Only runs when the row is first built (fingerprint changed) —
|
||||||
|
// reused rows keep their previously-fetched nav strip + badges.
|
||||||
|
fetch(`${containerBase}/api/dashboard-state`)
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((ds) => {
|
||||||
|
if (!ds) return;
|
||||||
|
// -- nav strip -----------------------------------------------
|
||||||
|
if (Array.isArray(ds.links)) {
|
||||||
|
for (const lnk of ds.links) {
|
||||||
|
// No public forge URL to link to — hide rather than build
|
||||||
|
// a broken/guessed href (see forgeBase above).
|
||||||
|
if (lnk.kind === "forge" && !forgeBase) continue;
|
||||||
|
const href =
|
||||||
|
lnk.kind === "forge"
|
||||||
|
? forgeBase + (lnk.url || "")
|
||||||
|
: lnk.kind === "external"
|
||||||
|
? lnk.url || ""
|
||||||
|
: /* container */ containerBase + "/" + (lnk.url || "");
|
||||||
|
const a = el("a", {
|
||||||
|
class: "nav-link",
|
||||||
|
href,
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noopener",
|
||||||
|
title: lnk.label || "",
|
||||||
|
});
|
||||||
|
// Plain text — agent-controlled strings stay out of innerHTML.
|
||||||
|
a.textContent = lnk.icon || lnk.label || "";
|
||||||
|
navStrip.append(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// -- agent-owned status badges --------------------------------
|
||||||
|
// rate_limited: only show when no other critical badge is
|
||||||
|
// already shown (pending / not-running already handled sync).
|
||||||
|
if (ds.rate_limited) {
|
||||||
|
head.append(
|
||||||
|
el(
|
||||||
|
"span",
|
||||||
|
{
|
||||||
|
class: "hive-pill-sm badge-rate-limited",
|
||||||
|
title:
|
||||||
|
"API rate-limited — harness is parked, will retry automatically",
|
||||||
|
},
|
||||||
|
"⊘ rate limited",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// ctx-window badge
|
||||||
|
if (ds.ctx_tokens != null) {
|
||||||
|
const k = Math.round(ds.ctx_tokens / 1000);
|
||||||
|
const win = ds.context_window_tokens;
|
||||||
|
const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
|
||||||
|
const caution =
|
||||||
|
win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
|
||||||
|
const ctxClass =
|
||||||
|
ds.ctx_tokens >= warn
|
||||||
|
? "badge-ctx-warn"
|
||||||
|
: ds.ctx_tokens >= caution
|
||||||
|
? "badge-ctx-caution"
|
||||||
|
: "badge-ctx-ok";
|
||||||
|
const title =
|
||||||
|
win != null
|
||||||
|
? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} ` +
|
||||||
|
`tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)`
|
||||||
|
: `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`;
|
||||||
|
head.append(
|
||||||
|
el(
|
||||||
|
"span",
|
||||||
|
{ class: `hive-pill-sm ${ctxClass}`, title },
|
||||||
|
`ctx·${k}k`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// -- agent status text (self-reported via set_status) ---------
|
||||||
|
if (ds.status_text) {
|
||||||
|
const nowUnix = Math.floor(Date.now() / 1000);
|
||||||
|
const ageStr =
|
||||||
|
ds.status_set_at != null
|
||||||
|
? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)`
|
||||||
|
: "";
|
||||||
|
// Stamp data-set-at so the 30s ticker below keeps the age
|
||||||
|
// label current even when the row is reused from the cache.
|
||||||
|
const ageAttrs =
|
||||||
|
ds.status_set_at != null
|
||||||
|
? { class: "status-age", "data-set-at": String(ds.status_set_at) }
|
||||||
|
: { class: "status-age" };
|
||||||
|
// Icon + message are wrapped together in `.status-msg` so the
|
||||||
|
// CSS can clamp *just the message* to two lines; the age span is
|
||||||
|
// a separate flex sibling that is never clipped, so the "(set N
|
||||||
|
// ago)" stamp stays visible even when the status text is long.
|
||||||
|
body.append(
|
||||||
|
el(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
class: "agent-status",
|
||||||
|
title: `agent self-reported status${ageStr}`,
|
||||||
|
},
|
||||||
|
el(
|
||||||
|
"span",
|
||||||
|
{ class: "status-msg" },
|
||||||
|
el("span", { class: "status-icon" }, "◈ "),
|
||||||
|
ds.status_text,
|
||||||
|
),
|
||||||
|
el("span", ageAttrs, ageStr),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* graceful: agent starting / gateway miss -> no data */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Status / runtime badges. Pending transients always win
|
||||||
|
// (start / stop / restart / rebuild is in progress). Otherwise,
|
||||||
|
// when the container is stopped, surface a single badge — `✖ gave
|
||||||
|
// up` (red) when systemd's restart bound was hit (`c.failed`), else
|
||||||
|
// the plain muted `■ not running`. Both read `running: false`;
|
||||||
|
// `failed` is what tells "gave up" apart from "stopped on purpose"
|
||||||
|
// (an absent `failed` — an older backend — is `undefined`, falsy,
|
||||||
|
// so this degrades to the old single badge). `needs_login` is still
|
||||||
|
// c0re-owned (reads auth sentinel files on the host). rate_limited /
|
||||||
|
// ctx / status_text are agent-owned and rendered by the async
|
||||||
|
// dashboard-state fetch above.
|
||||||
|
if (pending.length) {
|
||||||
|
// One badge per pill — an agent can carry several transients at
|
||||||
|
// once now (see docs/web-ui/dashboard.md::Container row), each rendered
|
||||||
|
// independently rather than collapsed into one label.
|
||||||
|
for (const label of pending) {
|
||||||
|
head.append(
|
||||||
|
el(
|
||||||
|
"span",
|
||||||
|
{ class: "pending-state" },
|
||||||
|
el("span", { class: "spinner" }, "◐"),
|
||||||
|
" ",
|
||||||
|
label + "…",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (!c.running && c.failed) {
|
||||||
|
head.append(
|
||||||
|
el(
|
||||||
|
"span",
|
||||||
|
{
|
||||||
|
class: "hive-pill-sm badge-fail",
|
||||||
|
title:
|
||||||
|
"container gave up — its unit hit systemd's bounded restart limit and stopped " +
|
||||||
|
"on its own, not on operator request; start it to bring the harness back up",
|
||||||
|
},
|
||||||
|
"✖ gave up",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (!c.running) {
|
||||||
|
head.append(
|
||||||
|
el(
|
||||||
|
"span",
|
||||||
|
{
|
||||||
|
class: "hive-pill-sm badge-muted",
|
||||||
|
title:
|
||||||
|
"container is shut down — start it to bring the harness back up",
|
||||||
|
},
|
||||||
|
"■ not running",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (c.needs_login) {
|
||||||
|
head.append(
|
||||||
|
el(
|
||||||
|
"a",
|
||||||
|
{
|
||||||
|
class: "hive-pill-sm badge-warn",
|
||||||
|
href: url,
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noopener",
|
||||||
|
},
|
||||||
|
"needs login →",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (c.paused) {
|
||||||
|
// Paused badge is also a resume button: clicking POSTs /api/resume/{name}
|
||||||
|
// which removes the marker and flips the badge off via the SSE rescan.
|
||||||
|
head.append(
|
||||||
|
form(
|
||||||
|
"/api/resume/" + c.name,
|
||||||
|
"hive-pill-sm badge-paused btn-inline",
|
||||||
|
"⏸ paused",
|
||||||
|
`resume ${c.name}? the turn loop restarts and drains queued messages.`,
|
||||||
|
{},
|
||||||
|
{ noRefresh: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (c.needs_update) {
|
||||||
|
head.append(
|
||||||
|
form(
|
||||||
|
"/api/rebuild/" + c.name,
|
||||||
|
"hive-pill-sm badge-warn btn-inline",
|
||||||
|
"needs update ↻",
|
||||||
|
"rebuild " + c.name + "? hot-reloads the container.",
|
||||||
|
{},
|
||||||
|
{ noRefresh: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.active_model) {
|
||||||
|
head.append(
|
||||||
|
el(
|
||||||
|
"span",
|
||||||
|
{
|
||||||
|
class: "hive-pill-sm badge-model",
|
||||||
|
title: `active claude model: ${c.active_model}`,
|
||||||
|
},
|
||||||
|
c.active_model,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.append(head);
|
||||||
|
|
||||||
|
// Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
|
||||||
|
// DESTR0Y / PURG3) live on the selection bar, not here — see
|
||||||
|
// docs/web-ui/dashboard.md::Selection bar. The contextual `needs update ↻`
|
||||||
|
// chip in the head row stays — it's a state-hint, not an
|
||||||
|
// action button.
|
||||||
|
|
||||||
|
li.append(icon, body, buildAgentMenu(c, forgeBase));
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── containers render ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function renderContainers(s) {
|
||||||
|
const root = $("containers-section");
|
||||||
|
// #containers-section only exists on /dashboard.html. tabs.js is the
|
||||||
|
// bundle for that page only (flow.html loads flow.js instead).
|
||||||
|
// Belt-and-suspenders for any future page that adds tabs.js
|
||||||
|
// without a #containers-section — matches the
|
||||||
|
// no-op-when-target-absent convention the other renderers
|
||||||
|
// follow.
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
// Containers come from the derived map (event-driven) rather than
|
||||||
|
// `s.containers`; `s` still supplies hostname (for the web-ui
|
||||||
|
// link) and tombstones/meta_inputs (not event-derived yet). The
|
||||||
|
// tree builder handles the ordering — we don't pre-sort here.
|
||||||
|
const containers = Array.from(containersState.values());
|
||||||
|
const portConflicts = derivePortConflicts(containers);
|
||||||
|
const anyStale = containers.some((c) => c.needs_update);
|
||||||
|
|
||||||
|
// Preserve the keyed container list across the header-section
|
||||||
|
// rebuild. We wipe the banners/buttons above the list on every
|
||||||
|
// render (simple), but recycle <li> elements for unchanged rows
|
||||||
|
// (keyed) to avoid full-DOM thrash and redundant async fetches.
|
||||||
|
const existingUl = root.querySelector("ul.containers");
|
||||||
|
root.replaceChildren();
|
||||||
|
|
||||||
|
// Port-hash collisions: rename one of the listed agents and
|
||||||
|
// rebuild. The banner sits above the agent list so it's the
|
||||||
|
// first thing the operator sees when something's wedged.
|
||||||
|
if (portConflicts.length) {
|
||||||
|
const banner = el(
|
||||||
|
"hive-warn",
|
||||||
|
{ level: "error" },
|
||||||
|
el("strong", {}, "⚠ port collision"),
|
||||||
|
" — ",
|
||||||
|
);
|
||||||
|
const groups = portConflicts
|
||||||
|
.map((c) => `:${c.port} (${c.agents.join(" + ")})`)
|
||||||
|
.join("; ");
|
||||||
|
banner.append(groups + ". rename one of each and ↻ R3BU1LD.");
|
||||||
|
root.append(banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anyStale) {
|
||||||
|
root.append(
|
||||||
|
form(
|
||||||
|
"/api/update-all",
|
||||||
|
"btn-rebuild",
|
||||||
|
"↻ UPD4TE 4LL",
|
||||||
|
"rebuild every stale container?",
|
||||||
|
{},
|
||||||
|
{ noRefresh: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue-summary banner: see initJobqRollup, above.
|
||||||
|
|
||||||
|
if (!containers.length && !transientsState.size) {
|
||||||
|
root.append(el("p", { class: "empty" }, "no managed containers"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop stale selections (agent destroyed while selected). Defensive —
|
||||||
|
// the action bar would otherwise loop POST against a gone agent.
|
||||||
|
const liveNames = new Set(containers.map((c) => c.name));
|
||||||
|
for (const n of Array.from(selectionState)) {
|
||||||
|
if (!liveNames.has(n)) selectionState.delete(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = (s && s.hostname) || window.location.hostname;
|
||||||
|
// When hive-gateway is in front of the dashboard, build same-origin
|
||||||
|
// `/agent/<name>/` URLs instead of the direct `http://<host>:<port>/`
|
||||||
|
// TCP fallback — the gateway proxies the prefix to the per-agent
|
||||||
|
// harness (unix-domain via `agent-sockets.json`, or a computed TCP
|
||||||
|
// loopback port while the socket marker is absent). See
|
||||||
|
// `docs/web-ui/dashboard.md::Container row` + `docs/networking/gateway.md::Vhost map`.
|
||||||
|
const gatewayLinks = !!(s && s.gateway_enabled);
|
||||||
|
// Forge public URL: state.forge_public_url (set by the NixOS module
|
||||||
|
// from services.hyperhive.forge.publicUrl) or null — never guessed
|
||||||
|
// from "<hostname>:3000". `null` propagates down to
|
||||||
|
// buildContainerLi/buildAgentMenu, which already hide forge links
|
||||||
|
// rather than emit a possibly-wrong one.
|
||||||
|
const forgeBase = (s && s.forge_public_url) || null;
|
||||||
|
const ul = existingUl ?? el("ul", { class: "containers" });
|
||||||
|
const rows = sortedContainerRows(containers);
|
||||||
|
|
||||||
|
// Build the ordered list of <li> elements, reusing cached rows
|
||||||
|
// whose displayed state hasn't changed.
|
||||||
|
const orderedLis = [];
|
||||||
|
for (const c of rows) {
|
||||||
|
const url = gatewayLinks
|
||||||
|
? `/agent/${encodeURIComponent(c.name)}/`
|
||||||
|
: `http://${hostname}:${c.port}/`;
|
||||||
|
// Container nav-strip base: gateway prefix or direct TCP.
|
||||||
|
const containerBase = gatewayLinks
|
||||||
|
? `/agent/${encodeURIComponent(c.name)}`
|
||||||
|
: `http://${hostname}:${c.port}`;
|
||||||
|
// Pending-badge derivation: transients only — "what is running,"
|
||||||
|
// full stop (mara, on review: "remove the per agent pending stuff -
|
||||||
|
// only show what is running"; rendered one badge per pill — mara,
|
||||||
|
// earlier: "show all running nodes that name the agent"). See
|
||||||
|
// docs/web-ui/dashboard.md::Container row.
|
||||||
|
const transientKindsMap = transientsState.get(c.name);
|
||||||
|
// Sorted for stable badge order across renders — Map iteration
|
||||||
|
// order is insertion order, which shifts as pills clear/re-add.
|
||||||
|
const pending = transientKindsMap
|
||||||
|
? Array.from(transientKindsMap.keys()).sort()
|
||||||
|
: [];
|
||||||
|
const opRunning = pending.length > 0;
|
||||||
|
const selected = selectionState.has(c.name);
|
||||||
|
|
||||||
|
const fp = containerRowFingerprint(
|
||||||
|
c,
|
||||||
|
pending,
|
||||||
|
opRunning,
|
||||||
|
selected,
|
||||||
|
gatewayLinks,
|
||||||
|
hostname,
|
||||||
|
);
|
||||||
|
const cached = containerRowCache.get(c.name);
|
||||||
|
|
||||||
|
let li;
|
||||||
|
if (cached && cached.fingerprint === fp) {
|
||||||
|
// Row unchanged — reuse the existing DOM node. The async
|
||||||
|
// dashboard-state (nav strip, ctx badge, status text) stays
|
||||||
|
// intact from the previous build, avoiding a redundant fetch.
|
||||||
|
li = cached.el;
|
||||||
|
} else {
|
||||||
|
li = buildContainerLi(c, {
|
||||||
|
pending,
|
||||||
|
opRunning,
|
||||||
|
selected,
|
||||||
|
url,
|
||||||
|
containerBase,
|
||||||
|
forgeBase,
|
||||||
|
s,
|
||||||
|
});
|
||||||
|
containerRowCache.set(c.name, { el: li, fingerprint: fp });
|
||||||
|
}
|
||||||
|
orderedLis.push(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove cache entries for agents that no longer exist.
|
||||||
|
for (const [name, entry] of containerRowCache) {
|
||||||
|
if (!liveNames.has(name)) {
|
||||||
|
entry.el.remove();
|
||||||
|
containerRowCache.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply correct DOM order without a full wipe. insertBefore is a
|
||||||
|
// no-op when the node is already at position i, so stable sections
|
||||||
|
// of the list cause zero layout work.
|
||||||
|
for (let i = 0; i < orderedLis.length; i++) {
|
||||||
|
if (ul.children[i] !== orderedLis[i]) {
|
||||||
|
ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trim any excess children (defensive — shouldn't happen after the
|
||||||
|
// cache-removal pass above, but keeps the ul length exact).
|
||||||
|
while (ul.children.length > orderedLis.length) ul.lastChild.remove();
|
||||||
|
|
||||||
|
root.append(ul);
|
||||||
|
renderSelectionBar(containers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── selection bar ───────────────────────────────────────────────────────────
|
||||||
|
// Sticky-bottom strip; visible when >=1 agent selected on the 4G3NTS
|
||||||
|
// tab. See docs/web-ui/dashboard.md::Selection bar for the interaction model
|
||||||
|
// and the per-action availability rules (disabled-with-tooltip for
|
||||||
|
// actions that don't apply to the full selection). Actions POST per
|
||||||
|
// agent in a loop (endpoints are individually idempotent /
|
||||||
|
// event-covered, so no new bulk backend wire is needed).
|
||||||
|
export function renderSelectionBar(containers) {
|
||||||
|
const bar = $("selection-bar");
|
||||||
|
if (!bar) return;
|
||||||
|
const countSpan = $("selection-count");
|
||||||
|
const namesSpan = $("selection-names");
|
||||||
|
const actions = $("selection-actions");
|
||||||
|
if (!countSpan || !namesSpan || !actions) return;
|
||||||
|
|
||||||
|
const selected = containers.filter((c) => selectionState.has(c.name));
|
||||||
|
// Tab-gate: bar only renders on 4G3NTS (the only tab with agent
|
||||||
|
// cards to cross-reference). Selection state lives in-memory and
|
||||||
|
// the bar reappears on return to 4G3NTS if still non-empty.
|
||||||
|
const onAgentsTab =
|
||||||
|
(document.body.dataset.activeTab || "agents") === "agents";
|
||||||
|
if (!selected.length || !onAgentsTab) {
|
||||||
|
bar.hidden = true;
|
||||||
|
document.body.classList.remove("has-selection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bar.hidden = false;
|
||||||
|
document.body.classList.add("has-selection");
|
||||||
|
countSpan.textContent =
|
||||||
|
selected.length === 1
|
||||||
|
? "1 agent selected"
|
||||||
|
: selected.length + " agents selected";
|
||||||
|
namesSpan.textContent = "· " + selected.map((c) => c.name).join(", ");
|
||||||
|
|
||||||
|
// Recompute action availability + tooltips per render. Each
|
||||||
|
// action declares which agents it CAN'T run on; the bar disables
|
||||||
|
// the button and surfaces the offending names in the tooltip.
|
||||||
|
actions.replaceChildren();
|
||||||
|
const allRunning = selected.every((c) => c.running);
|
||||||
|
const allStopped = selected.every((c) => !c.running);
|
||||||
|
// Pause/resume is orthogonal to running (see hive-agent-menu.js's own
|
||||||
|
// comment on the per-agent version of these two actions) — a paused
|
||||||
|
// stopped agent boots paused, a paused running agent keeps its
|
||||||
|
// container but drives no turns. Same all-or-nothing enablement rule
|
||||||
|
// as every other bulk action here: mixed pause state disables the
|
||||||
|
// button rather than silently no-op'ing on part of the selection.
|
||||||
|
const allUnpaused = selected.every((c) => !c.paused);
|
||||||
|
const allPaused = selected.every((c) => c.paused);
|
||||||
|
|
||||||
|
const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name);
|
||||||
|
const runningNames = selected.filter((c) => c.running).map((c) => c.name);
|
||||||
|
const pausedNames = selected.filter((c) => c.paused).map((c) => c.name);
|
||||||
|
const unpausedNames = selected.filter((c) => !c.paused).map((c) => c.name);
|
||||||
|
|
||||||
|
function why(label, blockers) {
|
||||||
|
if (!blockers.length) return null;
|
||||||
|
return `${label} not available — ${blockers.join(", ")} ${blockers.length === 1 ? "is" : "are"} blocking it`;
|
||||||
|
}
|
||||||
|
|
||||||
|
addBulkButton(actions, "btn-restart", "↺ R3ST4RT", allRunning, selected, {
|
||||||
|
action: "/api/restart/",
|
||||||
|
confirm: (names) =>
|
||||||
|
`restart ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})?`,
|
||||||
|
graceful: true,
|
||||||
|
gracefulLabel:
|
||||||
|
"restart gracefully — let each agent finish its turn and flush state before the container restarts",
|
||||||
|
disabledTitle: why(
|
||||||
|
"↺ R3ST4RT",
|
||||||
|
stoppedNames.map((n) => `\`${n}\` is stopped`),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
addBulkButton(actions, "btn-stop", "■ ST0P", allRunning, selected, {
|
||||||
|
action: "/api/kill/",
|
||||||
|
confirm: (names) =>
|
||||||
|
`stop ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})?`,
|
||||||
|
confirmLabel: "■ stop",
|
||||||
|
graceful: true,
|
||||||
|
disabledTitle: why(
|
||||||
|
"■ ST0P",
|
||||||
|
stoppedNames.map((n) => `\`${n}\` is already stopped`),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
addBulkButton(actions, "btn-start", "▶ ST4RT", allStopped, selected, {
|
||||||
|
action: "/api/start/",
|
||||||
|
confirm: (names) =>
|
||||||
|
`start ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})?`,
|
||||||
|
disabledTitle: why(
|
||||||
|
"▶ ST4RT",
|
||||||
|
runningNames.map((n) => `\`${n}\` is already running`),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
addBulkButton(actions, "btn-pause", "⏸ P4US3", allUnpaused, selected, {
|
||||||
|
action: "/api/pause/",
|
||||||
|
confirm: (names) =>
|
||||||
|
`pause ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? parks the turn loop — inbox messages queue unacked.`,
|
||||||
|
disabledTitle: why(
|
||||||
|
"⏸ P4US3",
|
||||||
|
pausedNames.map((n) => `\`${n}\` is already paused`),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
addBulkButton(actions, "btn-resume", "▶ R3SUM3", allPaused, selected, {
|
||||||
|
action: "/api/resume/",
|
||||||
|
confirm: (names) =>
|
||||||
|
`resume ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? the turn loop restarts and drains queued messages.`,
|
||||||
|
disabledTitle: why(
|
||||||
|
"▶ R3SUM3",
|
||||||
|
unpausedNames.map((n) => `\`${n}\` is not paused`),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
addBulkButton(actions, "btn-rebuild", "↻ R3BU1LD", true, selected, {
|
||||||
|
action: "/api/rebuild/",
|
||||||
|
confirm: (names) =>
|
||||||
|
`rebuild ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? hot-reloads each container.`,
|
||||||
|
});
|
||||||
|
addBulkButton(actions, "btn-destroy", "DESTR0Y", true, selected, {
|
||||||
|
action: "/api/destroy/",
|
||||||
|
confirm: (names) =>
|
||||||
|
`destroy ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? containers are removed; state + creds kept.`,
|
||||||
|
});
|
||||||
|
addBulkButton(actions, "btn-destroy", "PURG3", true, selected, {
|
||||||
|
action: "/api/destroy/",
|
||||||
|
body: { purge: "on" },
|
||||||
|
confirm: (names) =>
|
||||||
|
`PURGE ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? containers, config history, claude creds, and notes are all WIPED. no undo.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
|
||||||
|
const names = selected.map((c) => c.name);
|
||||||
|
const btn = el(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
class: "btn " + btnClass,
|
||||||
|
},
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
if (!enabled) {
|
||||||
|
btn.disabled = true;
|
||||||
|
if (opts.disabledTitle) btn.title = opts.disabledTitle;
|
||||||
|
}
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
if (btn.disabled) return;
|
||||||
|
const msg = opts.confirm(names);
|
||||||
|
let graceful = false;
|
||||||
|
if (msg) {
|
||||||
|
const r = await themedConfirm({
|
||||||
|
message: msg,
|
||||||
|
danger: true,
|
||||||
|
confirmLabel: opts.confirmLabel || "confirm",
|
||||||
|
checkboxes: opts.graceful
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: "graceful",
|
||||||
|
label:
|
||||||
|
opts.gracefulLabel ||
|
||||||
|
"stop gracefully — let each agent finish its turn and flush state before the container stops",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
if (!r) return;
|
||||||
|
graceful = !!r.graceful;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
const original = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<span class="spinner">◐</span> ' + label;
|
||||||
|
const failures = [];
|
||||||
|
// Sequential POSTs to keep server-side serialisation predictable
|
||||||
|
// (rebuild_queue dedups but other endpoints don't); the loop is
|
||||||
|
// short — bulk selections are typically a handful of agents.
|
||||||
|
//
|
||||||
|
// `opts.action` is a path prefix and the agent name gets appended
|
||||||
|
// (lifecycle endpoints: /start/<name>, /rebuild/<name>). `opts.body`
|
||||||
|
// is a static object applied to every POST.
|
||||||
|
for (const name of names) {
|
||||||
|
const body = new URLSearchParams(opts.body || {});
|
||||||
|
const url =
|
||||||
|
opts.action +
|
||||||
|
encodeURIComponent(name) +
|
||||||
|
(graceful ? "?graceful=true" : "");
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body,
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
const ok =
|
||||||
|
resp.ok ||
|
||||||
|
resp.type === "opaqueredirect" ||
|
||||||
|
(resp.status >= 200 && resp.status < 400);
|
||||||
|
if (!ok) {
|
||||||
|
const text = await resp.text().catch(() => "");
|
||||||
|
failures.push(
|
||||||
|
`${name}: http ${resp.status}${text ? " — " + text.slice(0, 200) : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
failures.push(`${name}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = original;
|
||||||
|
if (failures.length) {
|
||||||
|
themedToast(
|
||||||
|
`${label} completed with ${failures.length} failure${failures.length === 1 ? "" : "s"}:\n\n` +
|
||||||
|
failures.join("\n"),
|
||||||
|
{ type: "error", duration: 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Container-lifecycle events (ContainerStateChanged /
|
||||||
|
// ContainerRemoved / RebuildQueueChanged) flow over the existing
|
||||||
|
// SSE channel and update the derived stores live — no manual
|
||||||
|
// refresh needed.
|
||||||
|
});
|
||||||
|
parent.append(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── tickers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 30s ticker for agent status-age chips. Renderers stamp `data-set-at`
|
||||||
|
// (unix seconds) on the `.status-age` span. Keyed container rows persist
|
||||||
|
// across re-renders, so without this ticker the "(set N ago)" label would
|
||||||
|
// become stale as time passes. 30s granularity matches fmtAgeSecs precision
|
||||||
|
// (sub-minute values round to seconds, coarser above that).
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
document.querySelectorAll(".status-age[data-set-at]").forEach((node) => {
|
||||||
|
const setAt = Number(node.dataset.setAt);
|
||||||
|
if (!Number.isFinite(setAt) || setAt === 0) return;
|
||||||
|
node.textContent = ` (set ${fmtAgeSecs(now - setAt)} ago)`;
|
||||||
|
});
|
||||||
|
}, 30_000);
|
||||||
|
|
@ -84,7 +84,7 @@ code {
|
||||||
Semantic colour variants only — shape comes from `hive-pill-sm`
|
Semantic colour variants only — shape comes from `hive-pill-sm`
|
||||||
(shared with the agent UI's pill/chip family, @hive/shared/pill.css),
|
(shared with the agent UI's pill/chip family, @hive/shared/pill.css),
|
||||||
carried directly in the markup alongside each of these classnames
|
carried directly in the markup alongside each of these classnames
|
||||||
(swarm.js/core.js/builds.js). Used on container rows and build-log
|
(agents.js/core.js/builds.js). Used on container rows and build-log
|
||||||
rows. */
|
rows. */
|
||||||
.badge-warn {
|
.badge-warn {
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
|
|
|
||||||
|
|
@ -127,14 +127,14 @@ body.dashboard-shell {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
/* `display` above beats the UA's `[hidden]` rule, so an empty count (the
|
/* `display` above beats the UA's `[hidden]` rule, so an empty count (the
|
||||||
SW4RM and R3BU1LD QU3U3 pills at zero) would otherwise draw a "0". */
|
4G3NTS and R3BU1LD QU3U3 pills at zero) would otherwise draw a "0". */
|
||||||
.tab-count[hidden] {
|
.tab-count[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab pane visibility — createTabStrip (@hive/shared/tabs.js) sets the
|
/* Tab pane visibility — createTabStrip (@hive/shared/tabs.js) sets the
|
||||||
`hidden` attribute on every inactive pane (resolved from the URL hash;
|
`hidden` attribute on every inactive pane (resolved from the URL hash;
|
||||||
default — no hash — is SW4RM). Only the active pane stays visible. */
|
default — no hash — is 4G3NTS). Only the active pane stays visible. */
|
||||||
.tab-pane[hidden] {
|
.tab-pane[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
@ -322,7 +322,7 @@ hive-agent-menu {
|
||||||
--menu-btn-opacity: 1;
|
--menu-btn-opacity: 1;
|
||||||
}
|
}
|
||||||
/* Card actions dim while a row has any pending-state badge (transient-
|
/* Card actions dim while a row has any pending-state badge (transient-
|
||||||
driven only now — see swarm.js's pending-badge derivation comment;
|
driven only now — see agents.js's pending-badge derivation comment;
|
||||||
there is no separate queued-but-not-running row state to tell apart
|
there is no separate queued-but-not-running row state to tell apart
|
||||||
from this one anymore). */
|
from this one anymore). */
|
||||||
.container-row.pending-running .actions {
|
.container-row.pending-running .actions {
|
||||||
|
|
@ -443,12 +443,12 @@ hive-agent-menu {
|
||||||
shared <hive-warn> component — amber/no-pulse for
|
shared <hive-warn> component — amber/no-pulse for
|
||||||
the standing caveat (a pulsing banner that's always present just
|
the standing caveat (a pulsing banner that's always present just
|
||||||
teaches you to stop seeing it), `severity="red" pulse` for the port
|
teaches you to stop seeing it), `severity="red" pulse` for the port
|
||||||
collision (an incident, not a caveat). See core.js/swarm.js and
|
collision (an incident, not a caveat). See core.js/agents.js and
|
||||||
hive-warn.css for the actual styling now. */
|
hive-warn.css for the actual styling now. */
|
||||||
|
|
||||||
/* Build-queue summary banner on the SW4RM tab moved to the shared
|
/* Build-queue summary banner on the 4G3NTS tab moved to the shared
|
||||||
`JobqRollup` Preact component (`.jqr-*` classes, imported above) —
|
`JobqRollup` Preact component (`.jqr-*` classes, imported above) —
|
||||||
mounted by swarm.js::initJobqRollup into #jobq-rollup-section. */
|
mounted by agents.js::initJobqRollup into #jobq-rollup-section. */
|
||||||
|
|
||||||
.pending-state {
|
.pending-state {
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,14 @@
|
||||||
<nav class="tabbar" id="tabbar" role="tablist">
|
<nav class="tabbar" id="tabbar" role="tablist">
|
||||||
<a
|
<a
|
||||||
class="tab"
|
class="tab"
|
||||||
id="tab-swarm"
|
id="tab-agents"
|
||||||
href="#swarm"
|
href="#agents"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-controls="tab-pane-swarm"
|
aria-controls="tab-pane-agents"
|
||||||
data-tab="swarm"
|
data-tab="agents"
|
||||||
>
|
>
|
||||||
<span class="tab-label">◆ SW4RM ◆</span>
|
<span class="tab-label">◆ 4G3NTS ◆</span>
|
||||||
<span class="tab-count" id="tab-count-swarm" hidden></span>
|
<span class="tab-count" id="tab-count-agents" hidden></span>
|
||||||
</a>
|
</a>
|
||||||
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
|
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
|
||||||
reached from the H0M3 hub — keeps the dashboard tab strip lean.
|
reached from the H0M3 hub — keeps the dashboard tab strip lean.
|
||||||
|
|
@ -89,7 +89,7 @@
|
||||||
view, not part of the operational tab strip. -->
|
view, not part of the operational tab strip. -->
|
||||||
|
|
||||||
<!-- (Peer hives are no longer a tab — they render as a headline
|
<!-- (Peer hives are no longer a tab — they render as a headline
|
||||||
under SW4RM. See tab-pane-swarm.) -->
|
under 4G3NTS. See tab-pane-agents.) -->
|
||||||
|
|
||||||
<!-- FL0W and the other standalone pages are reachable from the
|
<!-- FL0W and the other standalone pages are reachable from the
|
||||||
H0M3 hub (served at /), not from the dashboard tab strip —
|
H0M3 hub (served at /), not from the dashboard tab strip —
|
||||||
|
|
@ -99,33 +99,33 @@
|
||||||
|
|
||||||
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
|
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
|
||||||
visible by toggling the `hidden` attribute, resolved from the URL
|
visible by toggling the `hidden` attribute, resolved from the URL
|
||||||
hash (default SW4RM). Panes start `hidden` to avoid a flash before
|
hash (default 4G3NTS). Panes start `hidden` to avoid a flash before
|
||||||
the script runs. -->
|
the script runs. -->
|
||||||
<!-- Padded content wrapper: carries the 1.5em side gutter
|
<!-- Padded content wrapper: carries the 1.5em side gutter
|
||||||
(.page-content, common.css) while <body> stays full-bleed so the
|
(.page-content, common.css) while <body> stays full-bleed so the
|
||||||
sticky chrome above + the footer below span the full width. -->
|
sticky chrome above + the footer below span the full width. -->
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<main class="dashboard-main">
|
<main class="dashboard-main">
|
||||||
<!-- SW4RM: the swarm itself. Container cards (the central thing
|
<!-- 4G3NTS: the agents. Container cards (the central thing
|
||||||
the operator looks at) and rebuild queue / cascade visualisation
|
the operator looks at) and rebuild queue / cascade visualisation
|
||||||
that drives them. No inline `C0NTAINERS` h2 heading + divider
|
that drives them. No inline `C0NTAINERS` h2 heading + divider
|
||||||
— the tab label SW4RM already says it. -->
|
— the tab label 4G3NTS already says it. -->
|
||||||
<section
|
<section
|
||||||
class="tab-pane"
|
class="tab-pane"
|
||||||
id="tab-pane-swarm"
|
id="tab-pane-agents"
|
||||||
data-tab-pane="swarm"
|
data-tab-pane="agents"
|
||||||
hidden
|
hidden
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-labelledby="tab-swarm"
|
aria-labelledby="tab-agents"
|
||||||
>
|
>
|
||||||
<!-- Swarm / hive identity headline. Populated by refreshState from
|
<!-- Swarm / hive identity headline. Populated by refreshState from
|
||||||
hive_name + swarm_name; stays hidden when neither is set. -->
|
hive_name + swarm_name; stays hidden when neither is set. -->
|
||||||
<h2 id="swarm-identity" hidden></h2>
|
<h2 id="swarm-identity" hidden></h2>
|
||||||
<!-- JobqRollup mount point, kept outside #containers-section
|
<!-- JobqRollup mount point, kept outside #containers-section
|
||||||
deliberately — that section is wiped + rebuilt on every
|
deliberately — that section is wiped + rebuilt on every
|
||||||
container-list render (see swarm.js::renderContainers), which
|
container-list render (see agents.js::renderContainers), which
|
||||||
would tear down and remount the Preact tree on every
|
would tear down and remount the Preact tree on every
|
||||||
container-state tick. Mounted once by swarm.js::initJobqRollup,
|
container-state tick. Mounted once by agents.js::initJobqRollup,
|
||||||
refreshed via its own handle rather than by re-rendering. -->
|
refreshed via its own handle rather than by re-rendering. -->
|
||||||
<div id="jobq-rollup-section"></div>
|
<div id="jobq-rollup-section"></div>
|
||||||
<div id="containers-section">
|
<div id="containers-section">
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
// 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.
|
// `dashboard.html` each load their own bundle.
|
||||||
// SW4RM (containers) domain lives in `./swarm.js`.
|
// 4G3NTS (containers) domain lives in `./agents.js`.
|
||||||
|
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { $, openStream, renderServerWarnings } from "./common.js";
|
import { $, openStream, renderServerWarnings } from "./common.js";
|
||||||
|
|
@ -33,7 +33,7 @@ import {
|
||||||
applyTransientCleared,
|
applyTransientCleared,
|
||||||
renderContainers,
|
renderContainers,
|
||||||
renderSelectionBar,
|
renderSelectionBar,
|
||||||
} from "./swarm.js";
|
} from "./agents.js";
|
||||||
import {
|
import {
|
||||||
initBuilds,
|
initBuilds,
|
||||||
onBuildsTabShown,
|
onBuildsTabShown,
|
||||||
|
|
@ -126,7 +126,7 @@ window.marked = marked;
|
||||||
window.__hyperhive_state = s;
|
window.__hyperhive_state = s;
|
||||||
renderServerWarnings(s.server_warnings);
|
renderServerWarnings(s.server_warnings);
|
||||||
// Hive identity: render the swarm/hive name as a headline at the
|
// Hive identity: render the swarm/hive name as a headline at the
|
||||||
// top of the SW4RM pane + update the page title once the
|
// top of the 4G3NTS pane + update the page title once the
|
||||||
// server-side display names are known. `hive_name` / `swarm_name`
|
// server-side display names are known. `hive_name` / `swarm_name`
|
||||||
// come from HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env vars
|
// come from HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env vars
|
||||||
// (set by services.hyperhive.{hiveName,swarmName} nix options).
|
// (set by services.hyperhive.{hiveName,swarmName} nix options).
|
||||||
|
|
@ -148,8 +148,8 @@ window.marked = marked;
|
||||||
// `transientsState` + `containersState`, not from `s.*`).
|
// `transientsState` + `containersState`, not from `s.*`).
|
||||||
syncTransientsFromSnapshot(s);
|
syncTransientsFromSnapshot(s);
|
||||||
syncContainersFromSnapshot(s);
|
syncContainersFromSnapshot(s);
|
||||||
// Job-queue rollup feeds only the SW4RM queue-summary banner
|
// Job-queue rollup feeds only the 4G3NTS queue-summary banner
|
||||||
// (per-agent card badges are transient-only — see swarm.js). Its
|
// (per-agent card badges are transient-only — see agents.js). Its
|
||||||
// own `JobqRollup` mount self-fetches GET /api/jobq/rollup — not
|
// own `JobqRollup` mount self-fetches GET /api/jobq/rollup — not
|
||||||
// read off `s` (this page's snapshot carries no jobq field).
|
// read off `s` (this page's snapshot carries no jobq field).
|
||||||
// initJobqRollup mounts once and is a no-op on later calls; the
|
// initJobqRollup mounts once and is a no-op on later calls; the
|
||||||
|
|
@ -182,7 +182,7 @@ window.marked = marked;
|
||||||
|
|
||||||
// ─── live updates: dashboard event stream ──────────────────────────────
|
// ─── live updates: dashboard event stream ──────────────────────────────
|
||||||
// The dashboard subscribes to /dashboard/stream for live mutation
|
// The dashboard subscribes to /dashboard/stream for live mutation
|
||||||
// events so the SW4RM / SYST3M panes update without an
|
// events so the 4G3NTS / SYST3M panes update without an
|
||||||
// operator action triggering a refreshState.
|
// operator action triggering a refreshState.
|
||||||
//
|
//
|
||||||
// Bare `EventSource` (no terminal infrastructure needed — the
|
// Bare `EventSource` (no terminal infrastructure needed — the
|
||||||
|
|
@ -202,7 +202,7 @@ window.marked = marked;
|
||||||
container_state_changed: applyContainerStateChanged,
|
container_state_changed: applyContainerStateChanged,
|
||||||
container_removed: applyContainerRemoved,
|
container_removed: applyContainerRemoved,
|
||||||
// rebuild_queue_changed: a payload-less push trigger. Refreshes the
|
// rebuild_queue_changed: a payload-less push trigger. Refreshes the
|
||||||
// SW4RM queue-summary banner (see swarm.js) and the R3BU1LD QU3U3
|
// 4G3NTS queue-summary banner (see agents.js) and the R3BU1LD QU3U3
|
||||||
// tab's JobqGraph (see builds.js).
|
// tab's JobqGraph (see builds.js).
|
||||||
rebuild_queue_changed: () => {
|
rebuild_queue_changed: () => {
|
||||||
applyRebuildQueueChanged();
|
applyRebuildQueueChanged();
|
||||||
|
|
@ -260,9 +260,9 @@ window.marked = marked;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ─── tab routing ───────────────────────────────────────────────────────
|
// ─── tab routing ───────────────────────────────────────────────────────
|
||||||
// Hash-based: `#swarm` / `#rebuild` / `#meta` / `#buildlogs` /
|
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` /
|
||||||
// `#permissions` activate the matching pane on the dashboard. Empty
|
// `#permissions` activate the matching pane on the dashboard. Empty
|
||||||
// hash defaults to SW4RM. 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 H0M3 hub. 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
|
||||||
|
|
@ -275,7 +275,7 @@ window.marked = marked;
|
||||||
// to their own pages rather than hide them in a dropdown.
|
// to their own pages rather than hide them in a dropdown.
|
||||||
function activateTab(target) {
|
function activateTab(target) {
|
||||||
// Track active tab on the body so renderSelectionBar can gate
|
// Track active tab on the body so renderSelectionBar can gate
|
||||||
// visibility (bar only belongs on SW4RM where agent cards live).
|
// visibility (bar only belongs on 4G3NTS where agent cards live).
|
||||||
document.body.dataset.activeTab = target;
|
document.body.dataset.activeTab = target;
|
||||||
renderSelectionBar(Array.from(containersState.values()));
|
renderSelectionBar(Array.from(containersState.values()));
|
||||||
// Permissions tables: SSE covers worker-applied changes
|
// Permissions tables: SSE covers worker-applied changes
|
||||||
|
|
@ -292,9 +292,9 @@ window.marked = marked;
|
||||||
|
|
||||||
// Wire the shared tab strip now that activateTab + the lazy-load fns it
|
// Wire the shared tab strip now that activateTab + the lazy-load fns it
|
||||||
// calls are defined. The strip resolves the active tab from the hash
|
// calls are defined. The strip resolves the active tab from the hash
|
||||||
// (default SW4RM), toggles the active tab/pane + aria-selected, and
|
// (default 4G3NTS), toggles the active tab/pane + aria-selected, and
|
||||||
// fires activateTab for the per-tab side-effects on every change.
|
// fires activateTab for the per-tab side-effects on every change.
|
||||||
createTabStrip($("tabbar"), { defaultId: "swarm", onShow: activateTab });
|
createTabStrip($("tabbar"), { defaultId: "agents", onShow: activateTab });
|
||||||
// The rebuild queue mounts now, not on first show, so its tab's count pill
|
// The rebuild queue mounts now, not on first show, so its tab's count pill
|
||||||
// is live from the start.
|
// is live from the start.
|
||||||
initBuilds();
|
initBuilds();
|
||||||
|
|
@ -313,14 +313,14 @@ window.marked = marked;
|
||||||
/** Recompute every tab's count from the current state. Called on
|
/** Recompute every tab's count from the current state. Called on
|
||||||
* every renderXxx that's tab-relevant. */
|
* every renderXxx that's tab-relevant. */
|
||||||
function refreshTabCounts() {
|
function refreshTabCounts() {
|
||||||
// SW4RM — flag any container that's stale (needs_update). Empty
|
// 4G3NTS — flag any container that's stale (needs_update). Empty
|
||||||
// when everyone's current. Container-row pulse signals state
|
// when everyone's current. Container-row pulse signals state
|
||||||
// transitions; the pill catches "deploy-pending" specifically.
|
// transitions; the pill catches "deploy-pending" specifically.
|
||||||
let swarm = 0;
|
let stale = 0;
|
||||||
for (const c of containersState.values()) {
|
for (const c of containersState.values()) {
|
||||||
if (c.needs_update) swarm++;
|
if (c.needs_update) stale++;
|
||||||
}
|
}
|
||||||
setTabCount("swarm", swarm);
|
setTabCount("agents", stale);
|
||||||
// R3BU1LD QU3U3 — rebuilds in flight.
|
// R3BU1LD QU3U3 — rebuilds in flight.
|
||||||
setTabCount("rebuild", rebuildCount());
|
setTabCount("rebuild", rebuildCount());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
// widening from the old per-agent-menu-only coordination) so
|
// widening from the old per-agent-menu-only coordination) so
|
||||||
// `closeAll()` can close each via its own `.close()`. `closeAllMenus()`
|
// `closeAll()` can close each via its own `.close()`. `closeAllMenus()`
|
||||||
// force-closes everything before tearing down DOM one might anchor off
|
// force-closes everything before tearing down DOM one might anchor off
|
||||||
// of (e.g. swarm.js before replacing the container tree).
|
// of (e.g. agents.js before replacing the container tree).
|
||||||
//
|
//
|
||||||
// The document-level listeners below, registered once: shadow-DOM
|
// The document-level listeners below, registered once: shadow-DOM
|
||||||
// retargeting means `e.target` for a click inside a shadow tree gets
|
// retargeting means `e.target` for a click inside a shadow tree gets
|
||||||
|
|
@ -58,7 +58,7 @@ document.addEventListener(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
// Close on Escape. stopImmediatePropagation so a caller's own
|
// Close on Escape. stopImmediatePropagation so a caller's own
|
||||||
// selection-clear Escape handler (e.g. swarm.js's) doesn't also fire
|
// selection-clear Escape handler (e.g. agents.js's) doesn't also fire
|
||||||
// while a menu is open.
|
// while a menu is open.
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
"keydown",
|
"keydown",
|
||||||
|
|
@ -76,7 +76,7 @@ class HiveMenu extends HTMLElement {
|
||||||
// A custom element's connectedCallback fires again on a same-document
|
// A custom element's connectedCallback fires again on a same-document
|
||||||
// *move* (insertBefore/append repositioning an already-connected node
|
// *move* (insertBefore/append repositioning an already-connected node
|
||||||
// runs the removal + insertion steps for its whole subtree), not just
|
// runs the removal + insertion steps for its whole subtree), not just
|
||||||
// on a fresh mount — swarm.js's row-fingerprint cache reuses/reorders
|
// on a fresh mount — agents.js's row-fingerprint cache reuses/reorders
|
||||||
// existing <li> subtrees, which can move an already-initialised
|
// existing <li> subtrees, which can move an already-initialised
|
||||||
// <hive-menu> without ever detaching it from the document. Guard
|
// <hive-menu> without ever detaching it from the document. Guard
|
||||||
// against re-running setup (attachShadow throws if the host already
|
// against re-running setup (attachShadow throws if the host already
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// hive-warn.js — <hive-warn>, the shared inline warning-banner
|
// hive-warn.js — <hive-warn>, the shared inline warning-banner
|
||||||
// component. Consolidates what were independently-written instances of
|
// component. Consolidates what were independently-written instances of
|
||||||
// the same thing, among them `.tombstone-warn` (core.js, JS-built) and
|
// the same thing, among them `.tombstone-warn` (core.js, JS-built) and
|
||||||
// `.port-conflict` (swarm.js, JS-built) — some differed only by an
|
// `.port-conflict` (agents.js, JS-built) — some differed only by an
|
||||||
// undeliberate 10% vs 8% tint, the strongest evidence this was drift,
|
// undeliberate 10% vs 8% tint, the strongest evidence this was drift,
|
||||||
// not genuinely different needs.
|
// not genuinely different needs.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// JobqRollup.tsx — <JobqRollup>, a compact one-line banner over
|
// JobqRollup.tsx — <JobqRollup>, a compact one-line banner over
|
||||||
// GET .../api/jobq/rollup's pre-tallied `Vec<hive_jobq_wire::StateCount>`
|
// GET .../api/jobq/rollup's pre-tallied `Vec<hive_jobq_wire::StateCount>`
|
||||||
// (any endpoint serving that shape works — hive-c0re and swarm-controller
|
// (any endpoint serving that shape works — hive-c0re and swarm-controller
|
||||||
// both do). Extracted out of dashboard/src/swarm.js's hand-rolled
|
// both do). Extracted out of dashboard/src/agents.js's hand-rolled
|
||||||
// queue-summary banner, which this component now replaces there too —
|
// queue-summary banner, which this component now replaces there too —
|
||||||
// same "one shared component, not two parallel implementations" pattern
|
// same "one shared component, not two parallel implementations" pattern
|
||||||
// `JobqGraph` already established for the rebuild-queue tree view.
|
// `JobqGraph` already established for the rebuild-queue tree view.
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
//
|
//
|
||||||
// JSX (swarm-ui) — `<JobqRollup endpoint="..." queueHref="..." />`. Or
|
// JSX (swarm-ui) — `<JobqRollup endpoint="..." queueHref="..." />`. Or
|
||||||
// plain `render(h(JobqRollup, props), container)` (dashboard/src/
|
// plain `render(h(JobqRollup, props), container)` (dashboard/src/
|
||||||
// swarm.js, no JSX pragma needed) — call again with a bumped
|
// agents.js, no JSX pragma needed) — call again with a bumped
|
||||||
// `refreshToken` to force a refetch. No mount wrapper: `render` is
|
// `refreshToken` to force a refetch. No mount wrapper: `render` is
|
||||||
// already the re-render/diff entry point.
|
// already the re-render/diff entry point.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
// swarm-level job (e.g. CreateAgent) lands.
|
// swarm-level job (e.g. CreateAgent) lands.
|
||||||
//
|
//
|
||||||
// JobqRollup sits above the graph, same "N running / M queued" banner
|
// JobqRollup sits above the graph, same "N running / M queued" banner
|
||||||
// the dashboard's SW4RM tab shows — no `queueHref`, since a "view
|
// the dashboard's 4G3NTS tab shows — no `queueHref`, since a "view
|
||||||
// queue →" link back to this same page would be noise.
|
// queue →" link back to this same page would be noise.
|
||||||
//
|
//
|
||||||
// Auto-refresh reuses `HivesPage`'s exact pattern (`RefreshIntervalPicker`
|
// Auto-refresh reuses `HivesPage`'s exact pattern (`RefreshIntervalPicker`
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use super::AppState;
|
||||||
/// option) when present, otherwise **404** — there is no bundled
|
/// option) when present, otherwise **404** — there is no bundled
|
||||||
/// server-side default any more (that was `hive_sh4re::assets::
|
/// server-side default any more (that was `hive_sh4re::assets::
|
||||||
/// branding_svg`, now removed). Consumers fall back client-side: the
|
/// branding_svg`, now removed). Consumers fall back client-side: the
|
||||||
/// dashboard's `swarm.js` and this agent's own `app.js` both swap an
|
/// dashboard's `agents.js` and this agent's own `app.js` both swap an
|
||||||
/// `/icon` load failure to the frontend-bundled `/favicon.svg` rather
|
/// `/icon` load failure to the frontend-bundled `/favicon.svg` rather
|
||||||
/// than probing first, so a 404 here is the expected "unconfigured"
|
/// than probing first, so a 404 here is the expected "unconfigured"
|
||||||
/// signal, not an error case to work around.
|
/// signal, not an error case to work around.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue