diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 1dbc2cb8..a6e8d25b 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -395,26 +395,26 @@ Contents: (`{ "ruth": ["manage_root_agent"] }`). Written by `capabilities::set_caps`; injected as `HIVE_CAPABILITIES` env var. Absent agents have no extra capabilities. -- `swarm-resource-limits.json` — the caps the swarm sets for this - hive's agents +- `resource-limits.json` — per-agent container resource overrides (`{ "sock": { "cpu_quota": "400%", "memory_max": "8G" } }`). - Written only by `workers::swarm_limits`, which copies each agent's key of - the swarm's `agent-limits` bucket here every 30 seconds. Read where + Written by `resource_limits::set_limits`; read where `lifecycle::write_dropins` generates the systemd drop-in, **not** injected into the container — these are host-side caps on the container, so the capped party never sets them. It sees only the resulting memory cap, - as a byte count or `infinity` in its runtime dir (below). Fallback is per - *field*: an absent file, absent agent, or absent field falls back to the + as a byte count or `infinity` in its runtime dir (below). Fallback is per *field*: + an absent file, absent agent, or absent field falls back to the hive-wide `services.hyperhive.c0re.agentCpuQuota` / `agentMemoryMax`, - so an agent can have only its memory capped and still track the hive - default for CPU. Every value passes the validators first, and one bad - value makes the worker change nothing here. The `CPUWeight=` / `IOWeight=` - shares in the same drop-in have **no** per-agent setting — they're - hive-wide only and come straight off `HiveEnv`, so this file has no field - for them. -- `resource-limits.json` — retired. Per-agent overrides set on the hive used - to live here; nothing reads or writes it now. An old copy in the meta repo - is harmless and you can delete it. + so an agent can override only its memory and still track the hive + default for CPU. The `CPUWeight=` / `IOWeight=` shares in the same + drop-in have **no** per-agent override — they're hive-wide only and + come straight off `HiveEnv`, so this file has no field for them. +- `swarm-resource-limits.json` — the caps the swarm sets for this + hive's agents, same shape as `resource-limits.json`. Written only by + `workers::swarm_limits`, which copies each agent's key of the swarm's + `agent-limits` bucket here every 30 seconds. A field set here replaces + that field's answer from `resource-limits.json` and the hive-wide + default. Every value passes the same validators as the operator's + overrides first, and one bad value makes the worker change nothing here. The root agent has the meta dir RO-mounted at `/meta/`. diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index 4634269f..e7fb554e 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -455,12 +455,12 @@ requiring a host rebuild. The answer per agent and per field is, first match wins: 1. the swarm's cap for the agent (see below); -2. `agentCpuQuota` / `agentMemoryMax`. +2. the operator's per-agent override in `meta/resource-limits.json`; +3. `agentCpuQuota` / `agentMemoryMax`. A swarm cap replaces the hive's answer, so a hive can't raise an agent above it. Like the rest of the drop-in, a changed swarm cap reaches a -running agent at its next spawn or rebuild. A hive has no way to set a cap -for one agent: you set caps per agent in the swarm UI. +running agent at its next spawn or rebuild. The container can't read its own cap, so `write_dropins` also writes the effective `MemoryMax=` into the agent's runtime dir @@ -468,7 +468,7 @@ effective `MemoryMax=` into the agent's runtime dir `infinity` for no cap. A percentage becomes bytes the way systemd resolves it, as that share of the host's physical memory rounded down to a page. A value systemd ignores (zero, over 100%) is `infinity`. When hive-c0re keeps -the last drop-in because it can't read the swarm caps file, +the last drop-in because it can't read the overrides or the swarm caps file, the file carries that drop-in's `MemoryMax=`. At boot the container derives two values from a byte count: @@ -520,7 +520,7 @@ given. Every 30 seconds hive-c0re (`workers::swarm_limits`) reads the key of each agent it has and stores the result in `meta/swarm-resource-limits.json`. Each -value passes the validators first. One bad +value passes the same validators as the operator's overrides first. One bad value or a failed read leaves the file unchanged, and repeated failures raise a dashboard banner. @@ -548,8 +548,8 @@ wants it. Two things to know about the weights: - They're **hive-wide** — unlike the caps there is no per-agent - setting, so every agent carries the same value and the weight does _not_ - rank agents against each other. + override in `meta/resource-limits.json`, so every agent carries the + same value and the weight does _not_ rank agents against each other. What `80` buys is that agents yield to everything **not** on this drop-in path: host services (nginx and dnsmasq among them) and the infra containers (`hive-forge`, `hive-gateway`, `hive-matrix`), which diff --git a/docs/scheduler/observability.md b/docs/scheduler/observability.md index 41c4922e..8749d221 100644 --- a/docs/scheduler/observability.md +++ b/docs/scheduler/observability.md @@ -291,8 +291,8 @@ cleanly and the affected level button goes on returning zero rows: When OTEL is enabled, **hive-c0re itself** also exports each agent -container's resource load — the same cgroup gauges — -to this hive's own collector, exactly like an agent does and with +container's resource load — the same cgroup gauges shown on the dashboard +LOAD tab — to this hive's own collector, exactly like an agent does and with no separate toggle. These come from the host, not the in-container Claude SDK, so they cover containers even when their agent is idle. @@ -312,7 +312,7 @@ exists, so off-the-shelf OTEL/Grafana container dashboards work. Resource | `hyperhive.container.memory.limit` | `By` | gauge | `memory.max` (custom — semconv has no `.limit` metric; omitted when unlimited) | | `hyperhive.container.memory.peak` | `By` | gauge | `memory.peak` (custom — no semconv metric; omitted if unavailable) | | `hyperhive.container.storage.usage` | `By` | gauge | `state/` + `work/` + writable rootfs, measured by `hive-priv` every 30 min (custom — semconv only has `disk.io`; omitted while unknown: not measured yet, or the last measurement failed) | -| `hyperhive.container.cpu.percent` | `%` | gauge | host-normalised percent (custom — no `rate()` needed) | +| `hyperhive.container.cpu.percent` | `%` | gauge | host-normalised percent (custom — the value the dashboard LOAD tab shows, no `rate()` needed) | The `hyperhive.`-prefixed metrics have no semconv equivalent (memory limit + peak, on-disk footprint, and an instantaneous cpu percent kept diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 480bf2d9..99bb2b1e 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -22,6 +22,7 @@ This document contains the help content for the `hivectl` command-line program. * [`hivectl agent kill`↴](#hivectl-agent-kill) * [`hivectl agent destroy`↴](#hivectl-agent-destroy) * [`hivectl agent rebuild`↴](#hivectl-agent-rebuild) +* [`hivectl agent set-limits`↴](#hivectl-agent-set-limits) * [`hivectl agent choom`↴](#hivectl-agent-choom) * [`hivectl agent watch`↴](#hivectl-agent-watch) * [`hivectl agent quota`↴](#hivectl-agent-quota) @@ -220,6 +221,7 @@ Everything here targets a single named agent (`hivectl agent foo restart`, `hive * `kill` — Hard-stop this managed container * `destroy` — Tear down this sub-agent container, keeping its state by default. No undo * `rebuild` — Apply pending config to this managed container +* `set-limits` — Declare this agent's CPU/memory limits, overriding the hive-wide defaults * `choom` — Open an interactive Claude session inside this agent's container * `watch` — Follow this agent's live turn/tool-call event stream from the CLI * `quota` — This agent's disk accounting + optional quota via btrfs qgroups @@ -313,6 +315,22 @@ Apply pending config to this managed container +## `hivectl agent set-limits` + +Declare this agent's CPU/memory limits, overriding the hive-wide defaults. + +Replaces the agent's whole override entry rather than merging into it: any limit you don't pass returns to the hive-wide default. To change one and keep the other, pass both. Disk is a separate resource with its own group — see `quota`. + +**Usage:** `hivectl agent set-limits [OPTIONS]` + +###### **Options:** + +* `--cpu-quota ` — systemd `CPUQuota=` value, for example `400%` (100% = one full core) +* `--memory-max ` — systemd `MemoryMax=` value, for example `8G`, `50%`, or `infinity` +* `--reset` — Drop all overrides — the agent returns to the hive-wide defaults. Required to clear limits, so that a `set-limits` with a forgotten value can't silently reset the agent + + + ## `hivectl agent choom` Open an interactive Claude session inside this agent's container. diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 7629c072..293ed79e 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -136,6 +136,34 @@ resume drains the backlog rather than dropping it. Points worth knowing: - Visible as ` paused` in `list-agents`'s STATUS column, as a `paused` field on the JSON rows, and as a badge on the dashboard card. +### Per-agent resource limits + +```bash +hivectl agent sock set-limits --cpu-quota 400% --memory-max 8G +hivectl agent sock set-limits --memory-max 8G # CPU falls back to the hive default +hivectl agent sock set-limits --reset # drop all overrides +``` + +Overrides the hive-wide `services.hyperhive.c0re.agentCpuQuota` / +`agentMemoryMax` for one agent, persisted to +`meta/resource-limits.json` (see +[`persistence.md`](../agent-lifecycle/persistence.md)). Values are systemd's +`CPUQuota=` / `MemoryMax=` syntax: a percentage (`400%` = four full +cores) for CPU; a size (`8G`), a percentage of physical RAM, or +`infinity` for memory. `set-limits` validates both before persisting them — +they go into a systemd drop-in verbatim, and a typo there makes the +unit fail to start. + +**Declarative, not incremental**: each invocation replaces the agent's +whole entry. `hivectl agent sock set-limits --memory-max 8G` leaves `sock` +with _only_ a memory override, reverting any previously set CPU quota to +the hive default. To avoid a forgotten flag silently wiping an override, +hivectl rejects `set-limits` with no flags at all — clearing requires the +explicit `--reset`. + +The command rewrites the container's drop-in and reloads systemd, so +new containers and restarts pick the values up immediately. + ## Choom Drop into an interactive Claude session inside an agent container. diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index f65593e5..b6c86cec 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -74,17 +74,13 @@ agent is stale. Passive / rare-interaction state, reached from the **Core** tile on the H0M3 hub (served at `/core.html`), with the same minimal chrome as `/builds.html`: a `← home` -back-link and a single pane, **K3PT ST4T3**. The page is its own esbuild -bundle (`core.js`) that cold-loads `/api/state` and subscribes to -`/api/dashboard/stream` for `tombstones_changed`, `capabilities_changed`, -and `tool_groups_changed` (the latter two re-render the stale-perms -sub-section when permission data changes). (Rebuild queue and meta inputs -live on the BU1LDS page — see below.) - -Per-container CPU, memory and disk load isn't shown in the hive UI. The -same figures go out as OTEL metrics and appear in Grafana (see -[observability](../scheduler/observability.md)). An agent's CPU and memory -caps live per agent in the swarm UI. +back-link + a `createTabStrip` sub-tab nav (**K3PT ST4T3** default, +then **C0NT41N3R L04D**). The page is its own esbuild bundle (`core.js`) +that cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for +`tombstones_changed`, `capabilities_changed`, and `tool_groups_changed` +(the latter two re-render the stale-perms sub-section when permission +data changes). (Rebuild queue and meta inputs live on the BU1LDS +page — see below.) **K3PT ST4T3** — two sub-sections on one pane: @@ -106,11 +102,32 @@ snapshot-storage rework where the problem changes shape. *Stale permission entries*: agents with explicit capability or tool-group JSON entries but no live container — typically renamed or deleted agents whose entries persisted in `capabilities.json` / -`tool-groups.json`. Loaded when the page opens; +`tool-groups.json`. Lazy-loaded on first K3PT ST4T3 tab activation; autorefreshes on `capabilities_changed` + `tool_groups_changed` SSE events. Each ghost agent gets a `✕ clear perms` button (`DELETE /api/permissions/{name}`). +**C0NT41N3R L04D** — live CPU + memory per agent container, read +straight from cgroup v2 on the host (`cpu.stat`, `memory.current`, +`memory.peak`, `memory.max` under +`/sys/fs/cgroup/machine.slice/machine-h\x2d.scope/`). CPU is a +host-normalised percentage (0..100 across all cores) sampled over a +short (~200 ms) two-read interval; memory shows current + peak with a +bar against the `memory.max` quota. Backed by +`GET /api/container-resources` (`container_stats.rs`), which reads the +files read-only (world-readable; no `hive-priv`) and skips agents whose +scope dir is absent (= not running). Pull-only: `core.js` polls every +5 s **only while the C0NT41N3R L04D sub-tab is active** (CPU needs a fresh +sample each refresh), and stops on sub-tab change. Disk size (`disk_bytes`) +rides the same row, but a separate background sampler feeds it every +30 min: `hive-priv` runs one bounded `du -x` as root over the agent's +`state/` + `work/` + container writable rootfs (shared nix store excluded), +so the 5 s poll stays cheap cgroup-only reads. The row carries the last +measured value; the column shows "unknown" (`null`) before the first +measurement and whenever the last one failed, never 0. Network isn't tracked +per container — see `docs/networking/network.md` for the current +per-agent netns model. + ## BU1LDS page (`/builds.html`) The build lifecycle hub — rebuild queue, live build log, meta inputs, @@ -748,6 +765,17 @@ below — some endpoints aren't in it yet. zero-turn dbs); returns swarm totals, a busiest-first per-agent rollup, swarm model mix, and a labelled `est_cost_usd`. Window defaults to `24h`. +- `GET /api/container-resources` — live per-agent-container CPU + + memory from cgroup v2 (C0R3 › C0NT41N3R L04D panel). Returns one + row per running agent (`name`, `cpu_pct`, `mem_current_bytes`, + `mem_peak_bytes`, `mem_max_bytes`, `disk_bytes`); samples CPU over + ~200 ms so the call briefly awaits. Skips non-running agents (no scope + dir). No network field — agents share the host netns. `disk_bytes` is + the last value from a **separate slow sampler** (not this hot path): + every 30 min `hive-priv` runs a bounded `du -sxb` as root over the agent's + `state/` + `work/` + container writable rootfs, `-x` excluding the shared + read-only nix store (`PrivRequest::MeasureAgentDisk`). `null` means + unknown: not measured yet, or the last measurement failed. - `POST /api/cancel-reminder/{id}` — hard-delete a pending reminder. - `POST /api/retry-reminder/{id}` — re-arm a reminder whose delivery failed (clears the failure state so the scheduler retries). diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css index 5d5d57ec..22b9f871 100644 --- a/frontend/packages/dashboard/src/common.css +++ b/frontend/packages/dashboard/src/common.css @@ -619,8 +619,8 @@ hive-side-panel .md td { /* ─── stats table ───────────────────────────────────────────────── Shared right-aligned numeric table. Used by /stats.html (busiest - agents) — moved here from dashboard.css when ST4TS became its own - page. */ + agents) and the dashboard SYST3M › C0NT41N3R L04D table — moved here + from dashboard.css when ST4TS became its own page. */ .hive-stats-table { width: 100%; border-collapse: collapse; diff --git a/frontend/packages/dashboard/src/core.css b/frontend/packages/dashboard/src/core.css index be613a1d..cb44415e 100644 --- a/frontend/packages/dashboard/src/core.css +++ b/frontend/packages/dashboard/src/core.css @@ -1,7 +1,6 @@ /* ─── /core.html — C0R3 page ─────────────────────────────────────── - Kept state (tombstones) and stale permission entries. Section rules - imported from system-sections.css (shared with dashboard.css + - builds.css). */ + Kept state (tombstones) and container load. Section rules imported + from system-sections.css (shared with dashboard.css + builds.css). */ @import "./system-sections.css"; body.core-shell { @@ -9,10 +8,22 @@ body.core-shell { padding: 0; } +/* Fill the header row next to the ← home back-link, same as the + builds sub-tab strip. */ +.core-tabbar { + flex: 1; +} + .core-main { padding: 1.2em 1.5em 2em; } +/* createTabStrip toggles the native `hidden` attribute on inactive + panes; ensure it wins over any inherited display. */ +.core-pane[hidden] { + display: none; +} + /* ─── 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 diff --git a/frontend/packages/dashboard/src/core.html b/frontend/packages/dashboard/src/core.html index 8728f020..37210ac8 100644 --- a/frontend/packages/dashboard/src/core.html +++ b/frontend/packages/dashboard/src/core.html @@ -13,31 +13,70 @@
- -

- kept state from previously tombstoned agents — recreating an agent with - the same name reuses it. -

-
-

loading…

-
-
+ +
+

+ kept state from previously tombstoned agents — recreating an agent + with the same name reuses it. +

+
+

loading…

+
+
+
+ + +
+

+ live cpu + memory per agent container, from cgroup v2 on the host. cpu + is % of total host capacity (all cores), sampled over ~200ms each + refresh; polled every 5s while this tab is open. network is omitted on + purpose — agents share the host netns, so there is no per-container + counter (see docs/networking/network.md). +

+
+

loading…

+
+
diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js index dec4a397..b4be8b03 100644 --- a/frontend/packages/dashboard/src/core.js +++ b/frontend/packages/dashboard/src/core.js @@ -1,20 +1,27 @@ // C0R3 page entry (/core.html). The host/coordinator surface, its own -// standalone page so the dashboard tab strip stays lean: kept state -// (tombstones) and the stale permission entries. +// standalone page so the dashboard tab strip stays lean. Two sub-tabs +// via (default = K3PT ST4T3): kept state (tombstones) +// and container load. // -// 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. +// Rebuild queue + meta inputs live at /builds.html (the build lifecycle +// hub), 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 { asyncBtn, bindAsyncForms } from "@hive/shared/forms.js"; +import "@hive/shared/hive-tab-strip.js"; // ─── derived state (own copies; this bundle has its own runtime) ────────── let tombstonesState = []; +// ContainerView list from the last /api/state snapshot — used by renderContainerLoad +// to splice in the configured cpu_quota/memory_max limits alongside live cgroup data. +let containersState = []; function syncFromSnapshot(s) { tombstonesState = (s.tombstones || []).slice(); + containersState = (s.containers || []).slice(); } // ─── kept state (tombstones) ────────────────────────────────────────────── @@ -180,6 +187,273 @@ async function fetchAndRenderStalePerms() { stalePermsLoaded = true; } +// ─── container load (live cgroup poll while the LOAD sub-tab is open) ────── +let containerLoadTimer = null; + +function cloadFmtBytes(n) { + if (!Number.isFinite(n) || n <= 0) return "0"; + const u = ["B", "KiB", "MiB", "GiB", "TiB"]; + let i = 0; + let v = n; + while (v >= 1024 && i < u.length - 1) { + v /= 1024; + i += 1; + } + return v.toFixed(v < 10 && i > 0 ? 1 : 0) + " " + u[i]; +} +function cloadMeter(pct) { + const p = Math.max(0, Math.min(100, pct)); + const cls = p >= 90 ? "hot" : p >= 70 ? "warn" : ""; + const m = document.createElement("span"); + m.className = "cload-meter"; + m.title = p.toFixed(0) + "%"; + const fill = document.createElement("span"); + fill.className = "fill" + (cls ? " " + cls : ""); + fill.style.width = p + "%"; + m.append(fill); + return m; +} + +// Stash the last load rows so re-renders triggered by SSE state updates can +// call renderContainerLoad(lastLoadRows) without waiting for the next poll. +let lastLoadRows = []; + +function renderContainerLoad(rows) { + lastLoadRows = rows; + const root = $("container-load-section"); + if (!root) return; + if (!Array.isArray(rows) || rows.length === 0) { + root.replaceChildren(); + const p = document.createElement("p"); + p.className = "meta"; + p.textContent = "no running agent containers"; + root.append(p); + return; + } + + // Build a name→ContainerView lookup so we can splice in the configured + // cpu_quota / memory_max limits (set in meta/resource-limits.json and + // resolved by the server) next to the live cgroup readings. + const cvByName = new Map(containersState.map((c) => [c.name, c])); + + const table = document.createElement("table"); + table.className = "hive-stats-table"; + // "cpu cap" / "mem cap" show the configured ceilings from ContainerView + // (effective drop-in values, take effect on next start/restart). + // "limit" remains the live cgroup memory ceiling from /api/container-resources. + table.innerHTML = + "agentcpumemory" + + "peaklimitdisk" + + 'cpu cap' + + 'mem cap' + + ""; + const tb = document.createElement("tbody"); + for (const r of rows) { + const cv = cvByName.get(r.name); + const tr = document.createElement("tr"); + const name = document.createElement("td"); + name.textContent = r.name; + tr.append(name); + const cpu = document.createElement("td"); + cpu.className = "num"; + cpu.append( + (Number(r.cpu_pct) || 0).toFixed(1) + "%", + cloadMeter(Number(r.cpu_pct) || 0), + ); + tr.append(cpu); + const mem = document.createElement("td"); + mem.className = "num"; + const memCur = Number(r.mem_current_bytes) || 0; + if (r.mem_max_bytes) { + mem.append( + cloadFmtBytes(memCur), + cloadMeter((100 * memCur) / r.mem_max_bytes), + ); + } else { + mem.textContent = cloadFmtBytes(memCur); + } + tr.append(mem); + const peak = document.createElement("td"); + peak.className = "num"; + peak.textContent = r.mem_peak_bytes + ? cloadFmtBytes(Number(r.mem_peak_bytes)) + : "—"; + tr.append(peak); + const lim = document.createElement("td"); + lim.className = "num"; + lim.textContent = r.mem_max_bytes + ? cloadFmtBytes(Number(r.mem_max_bytes)) + : "∞"; + tr.append(lim); + // Disk: on-disk footprint (state/ + work/ + container writable rootfs, shared + // nix store excluded). Measured out-of-band every 30 min server-side; + // null means unknown (not measured yet, or the measurement failed). + const disk = document.createElement("td"); + disk.className = "num"; + disk.title = + "state/ + work/ + container writable rootfs (shared nix store excluded); measured every 30 min"; + disk.textContent = + r.disk_bytes != null ? cloadFmtBytes(Number(r.disk_bytes)) : "unknown"; + tr.append(disk); + + // Configured CPU / memory caps from ContainerView (resolved effective + // values: per-agent override when set, hive-wide default otherwise). + const cpuCap = document.createElement("td"); + cpuCap.className = "num cload-cap"; + cpuCap.title = "configured ceiling — takes effect on next start"; + cpuCap.textContent = cv?.cpu_quota || "—"; + tr.append(cpuCap); + const memCap = document.createElement("td"); + memCap.className = "num cload-cap"; + memCap.title = "configured ceiling — takes effect on next start"; + memCap.textContent = cv?.memory_max || "—"; + tr.append(memCap); + + // S3T button toggles the inline edit row for this agent. + const actTd = document.createElement("td"); + const setBtn = document.createElement("button"); + setBtn.type = "button"; + setBtn.className = "btn btn-sm cload-set-btn"; + setBtn.textContent = "S3T"; + setBtn.title = "set CPU / memory cap for " + r.name; + tr.append(actTd); + actTd.append(setBtn); + tb.append(tr); + + // Inline edit row (hidden by default, toggled by the S3T button). + const editRow = document.createElement("tr"); + editRow.className = "cload-edit-row"; + editRow.hidden = true; + const editTd = document.createElement("td"); + editTd.colSpan = 9; + editTd.className = "cload-edit-cell"; + + const editForm = document.createElement("form"); + editForm.className = "cload-edit-form"; + editForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const cpuInput = editForm.querySelector(".cload-cpu-input"); + const memInput = editForm.querySelector(".cload-mem-input"); + const errSpan = editForm.querySelector(".cload-edit-err"); + const submitBtn = editForm.querySelector('[type="submit"]'); + errSpan.hidden = true; + submitBtn.disabled = true; + try { + const body = new URLSearchParams({ + cpu_quota: cpuInput.value.trim(), + memory_max: memInput.value.trim(), + }); + const resp = await fetch( + "/api/resource-limits/" + encodeURIComponent(r.name), + { method: "POST", body }, + ); + if (!resp.ok) { + errSpan.textContent = await resp + .text() + .catch(() => "error " + resp.status); + errSpan.hidden = false; + return; + } + // Success: collapse the edit row. The SSE rescan will push updated + // ContainerView data (cpu_quota/memory_max) to containersState, + // triggering a re-render of the cap columns via refreshContainerLoad. + editRow.hidden = true; + setBtn.textContent = "S3T"; + } catch (err) { + errSpan.textContent = String(err); + errSpan.hidden = false; + } finally { + submitBtn.disabled = false; + } + }); + + const cpuLabel = document.createElement("label"); + cpuLabel.className = "cload-edit-label"; + cpuLabel.textContent = "cpu quota"; + const cpuInput = document.createElement("input"); + cpuInput.type = "text"; + cpuInput.className = "cload-cpu-input"; + // Pre-fill with the current *effective* value (not just a placeholder) + // so submitting after editing only the other field round-trips this one + // unchanged. The form always posts both fields, and the server treats + // an empty field as "clear this override" — a placeholder-only hint + // reads as filled to the eye but posts as empty, silently resetting + // whichever field the user didn't touch. + cpuInput.value = cv ? cv.cpu_quota : ""; + cpuInput.placeholder = "e.g. 200%"; + cpuInput.title = + 'systemd CPUQuota= value (e.g. "400%"). empty = use hive default'; + cpuLabel.append(cpuInput); + + const memLabel = document.createElement("label"); + memLabel.className = "cload-edit-label"; + memLabel.textContent = "mem max"; + const memInput = document.createElement("input"); + memInput.type = "text"; + memInput.className = "cload-mem-input"; + // Same reasoning as cpuInput above. + memInput.value = cv ? cv.memory_max : ""; + memInput.placeholder = "e.g. 8G"; + memInput.title = + 'systemd MemoryMax= value (e.g. "8G", "50%", "infinity"). empty = use hive default'; + memLabel.append(memInput); + + const submitBtn = document.createElement("button"); + submitBtn.type = "submit"; + submitBtn.className = "btn btn-restart cload-save-btn"; + submitBtn.textContent = "S4V3"; + + const hintSpan = document.createElement("span"); + hintSpan.className = "meta cload-edit-hint"; + hintSpan.textContent = "↺ restart to apply to a running container"; + + const errSpan = document.createElement("span"); + errSpan.className = "cload-edit-err"; + errSpan.hidden = true; + + editForm.append(cpuLabel, memLabel, submitBtn, hintSpan, errSpan); + editTd.append(editForm); + editRow.append(editTd); + tb.append(editRow); + + setBtn.addEventListener("click", () => { + const open = !editRow.hidden; + editRow.hidden = open; + setBtn.textContent = open ? "S3T" : "✕"; + }); + } + table.append(tb); + root.replaceChildren(table); +} + +async function refreshContainerLoad() { + try { + const resp = await fetch("/api/container-resources"); + if (!resp.ok) throw new Error("http " + resp.status); + renderContainerLoad(await resp.json()); + } catch (e) { + const root = $("container-load-section"); + if (root) { + root.replaceChildren(); + const p = document.createElement("p"); + p.className = "meta"; + p.textContent = "container load fetch failed: " + e; + root.append(p); + } + } +} +function startContainerLoadPolling() { + refreshContainerLoad(); + if (containerLoadTimer) return; + containerLoadTimer = setInterval(refreshContainerLoad, 5000); +} +function stopContainerLoadPolling() { + if (containerLoadTimer) { + clearInterval(containerLoadTimer); + containerLoadTimer = null; + } +} + // ─── render-all (cold load + any full re-render) ────────────────────────── function renderAll() { renderTombstones({ tombstones: tombstonesState }); @@ -191,6 +465,19 @@ const SSE_HANDLERS = { tombstonesState = (ev.tombstones || []).slice(); renderTombstones({ tombstones: tombstonesState }); }, + // When a container's state changes (e.g. after resource-limits update + // triggers rescan_containers_and_emit), update containersState in place + // so the cap columns in the LOAD table reflect the new configured values. + container_state_changed(ev) { + if (!ev.container) return; + const idx = containersState.findIndex((c) => c.name === ev.container.name); + if (idx >= 0) { + containersState[idx] = ev.container; + } else { + containersState.push(ev.container); + } + if (lastLoadRows.length) renderContainerLoad(lastLoadRows); + }, // Refresh the stale-perms sub-section when perm data changes (a ghost // was cleared, or perms were saved for an agent whose name collides). capabilities_changed(_ev) { @@ -224,16 +511,33 @@ async function init() { // to the bare `ok` response page. bindAsyncForms(() => refreshState()); + // Hash-routed sub-tab strip; default K3PT ST4T3. Container-load + // polling runs only while the LOAD sub-tab is open (cpu is a short + // two-sample read each refresh on the server). + document.getElementById("core-tabbar").configure({ + tabs: [ + { id: "kept", label: "K3PT ST4T3" }, + { id: "load", label: "C0NT41N3R L04D" }, + ], + defaultId: "kept", + onShow: (id) => { + if (id === "load") startContainerLoadPolling(); + else stopContainerLoadPolling(); + // Lazy-load stale-perms on first K3PT ST4T3 activation; always + // re-fetch on subsequent visits in case perms changed. + if (id === "kept") fetchAndRenderStalePerms(); + }, + }); + await refreshState(); - fetchAndRenderStalePerms(); // kinds= matches SSE_HANDLERS below verbatim — narrows this from all - // 13 wire kinds down to the 3 this page acts on. This page was one + // 13 wire kinds down to the 4 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," + + "/api/dashboard/stream?kinds=tombstones_changed,container_state_changed," + "capabilities_changed,tool_groups_changed", ); if (es) { diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 897dc467..a02618b6 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -2,7 +2,7 @@ it defines the CSS custom properties, typography, and shared component rules that this file depends on. */ -/* SYST3M panel rules (rebuild queue / meta inputs / kept state) +/* SYST3M panel rules (rebuild queue / meta inputs / container load) live in system-sections.css, shared with /core.html. A few are still referenced from the dashboard (`.rebuild-queue`), so the dashboard pulls them in here. */ @@ -771,4 +771,6 @@ body.dashboard-shell.has-selection { /* ST4TS moved to its own page (`/stats.html`): the window selector / summary chips / bars live in stats.css, and the - shared `.hive-stats-table` moved to common.css. */ + shared `.hive-stats-table` moved to common.css (the SYST3M › + C0NT41N3R L04D table in system-sections.css / on /core.html still + uses it). */ diff --git a/frontend/packages/dashboard/src/dashboard.html b/frontend/packages/dashboard/src/dashboard.html index 2af668cc..3445d881 100644 --- a/frontend/packages/dashboard/src/dashboard.html +++ b/frontend/packages/dashboard/src/dashboard.html @@ -103,7 +103,7 @@