From 6c9bf2012f7331d1f6627989a89148450d9ddf0c Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 26 Jul 2026 15:17:45 +0200 Subject: [PATCH] dashboard: show and edit per-agent resource limits in core LOAD tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds CPU/memory cap columns and an inline edit form to the container-load table in /core.html, backed by a new POST /api/resource-limits/{name} dashboard endpoint. ## Backend (hive-c0re) lifecycle_ops.rs — new post_resource_limits handler: - Parses ResourceLimitsForm { cpu_quota, memory_max } (both optional; empty string = clear override, fall back to hive-wide default). - Validates each non-empty value via resource_limits::validate_cpu_quota / validate_memory_max — returns 422 UNPROCESSABLE_ENTITY with a human- readable message on invalid input so the dashboard can surface it inline. - Calls meta::commit_resource_limits (staged git write under META_LOCK, same as hivectl set-limits). - Re-applies the drop-in immediately via lifecycle::write_dropins so the new ceilings take effect on the next container start without waiting for a rebuild. - Triggers rescan_containers_and_emit so ContainerView.cpu_quota/memory_max update via SSE without waiting for the next periodic sweep. dashboard/mod.rs — registers the route: POST /api/resource-limits/{name} ## Frontend (core.js + system-sections.css) core.js: - containersState derived from /api/state snapshot alongside tombstonesState — supplies configured cpu_quota/memory_max to the LOAD table. - lastLoadRows stash lets SSE-triggered re-renders call renderContainerLoad without waiting for the next 5s poll. - renderContainerLoad: adds cpu cap / mem cap columns (muted; tooltip 'configured ceiling — takes effect on next start') sourced from ContainerView, plus a per-row S3T toggle button that expands an inline edit form with cpu_quota / memory_max text inputs and a S4V3 button. The edit form shows a restart hint, surfaces validation errors inline, and collapses on success. - container_state_changed SSE handler: updates containersState in place and re-renders the LOAD table so the cap columns flip immediately after a save. system-sections.css: - CSS for the new cap columns (.cload-cap-th, .cload-cap) and inline edit form (.cload-edit-row, .cload-edit-form, .cload-edit-label, etc.). - Remove dead .rqe-step rule (step sub-step label retired from the wire in 'job_queue: retire the now-off-wire step sub-step label'). --- frontend/packages/dashboard/src/core.js | 143 +++++++++++++++++- .../dashboard/src/system-sections.css | 61 +++++++- hive-c0re/src/dashboard/lifecycle_ops.rs | 62 ++++++++ hive-c0re/src/dashboard/mod.rs | 4 + 4 files changed, 263 insertions(+), 7 deletions(-) diff --git a/frontend/packages/dashboard/src/core.js b/frontend/packages/dashboard/src/core.js index 75059976..08afb418 100644 --- a/frontend/packages/dashboard/src/core.js +++ b/frontend/packages/dashboard/src/core.js @@ -16,8 +16,12 @@ import { createTabStrip } from '@hive/shared/tabs.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) ────────────────────────────────────────────── @@ -169,7 +173,12 @@ function cloadMeter(pct) { 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) { @@ -178,12 +187,25 @@ function renderContainerLoad(rows) { 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'; + + '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'; @@ -210,7 +232,113 @@ function renderContainerLoad(rows) { disk.title = 'state dir + container writable rootfs (shared nix store excluded); sampled every few minutes'; disk.textContent = (r.disk_bytes != null) ? cloadFmtBytes(Number(r.disk_bytes)) : '—'; 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 ? 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 ? 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'; + cpuInput.placeholder = cv ? cv.cpu_quota : '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'; + memInput.placeholder = cv ? cv.memory_max : '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); @@ -312,6 +440,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) { diff --git a/frontend/packages/dashboard/src/system-sections.css b/frontend/packages/dashboard/src/system-sections.css index 07aa6338..1f848ac5 100644 --- a/frontend/packages/dashboard/src/system-sections.css +++ b/frontend/packages/dashboard/src/system-sections.css @@ -213,12 +213,6 @@ .rqe-node-arrow { color: var(--muted); } .rqe-node-log { margin-left: 0.1em; text-decoration: none; } -.rqe-step { - flex-basis: 100%; - margin: 0.1em 0 0 1.8em; - color: var(--cyan); - font-size: 0.85em; -} .rqe-error { flex-basis: 100%; margin: 0.3em 0 0; @@ -355,6 +349,61 @@ background: var(--red); } +/* Configured-cap columns: muted header so they read apart from live metrics. + The S3T button sits in its own column; keep it compact. */ +.cload-cap-th { + color: var(--muted); +} +.cload-cap { + color: var(--muted); +} +.cload-set-btn { + padding: 0.1em 0.5em; + font-size: 0.8em; +} + +/* Inline edit row — spans all columns, contains a mini-form. */ +.cload-edit-row td { + border-top: none; + padding-top: 0; +} +.cload-edit-cell { + padding: 0.2em 0.4em 0.6em 1.2em; +} +.cload-edit-form { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5em; +} +.cload-edit-label { + display: flex; + align-items: center; + gap: 0.3em; + font-size: 0.85em; + color: var(--muted); +} +.cload-cpu-input, +.cload-mem-input { + width: 7em; + font-size: 0.9em; +} +.cload-save-btn { + padding: 0.15em 0.6em; + font-size: 0.85em; +} +.cload-edit-hint { + font-size: 0.8em; +} +.cload-edit-err { + color: var(--red); + font-size: 0.85em; +} + +/* Remove the now-dead `.rqe-step` rule (the `step` sub-step label was + retired from the wire in `feat(job_queue): retire the now-off-wire step + sub-step label` — the rule has had no JS referents since then). */ + /* ─── K3PT ST4T3 › stale permission entries sub-section ──────────────────── Agents with explicit capability / tool-group entries in the JSON but no live container (renamed or manually-deleted agents). Lazy-loaded on tab diff --git a/hive-c0re/src/dashboard/lifecycle_ops.rs b/hive-c0re/src/dashboard/lifecycle_ops.rs index 70d4acbf..94d3e3aa 100644 --- a/hive-c0re/src/dashboard/lifecycle_ops.rs +++ b/hive-c0re/src/dashboard/lifecycle_ops.rs @@ -187,6 +187,68 @@ pub(super) async fn post_resume( (StatusCode::OK, "ok").into_response() } +/// Form fields for `post_resource_limits`. Both fields are optional strings; +/// an empty value clears the per-agent override for that field, falling back +/// to the hive-wide default. +#[derive(Deserialize, Default)] +pub(super) struct ResourceLimitsForm { + #[serde(default)] + cpu_quota: String, + #[serde(default)] + memory_max: String, +} + +/// `POST /api/resource-limits/{name}` — write per-agent CPU/memory limit +/// overrides for `name`. +/// +/// An empty `cpu_quota` or `memory_max` field clears that field's override, +/// falling back to the hive-wide default. Both empty together removes the +/// agent's entry entirely. The new drop-in is written immediately — the +/// limits take effect on the next container start or restart. Triggers an +/// immediate rescan so `ContainerView.cpu_quota`/`memory_max` update on +/// the dashboard via SSE without waiting for the next periodic sweep. +pub(super) async fn post_resource_limits( + State(state): State, + AxumPath(name): AxumPath, + Form(form): Form, +) -> Response { + let logical = strip_container_prefix(&name); + if let Some(reject) = guard_agent_name(&state, &logical).await { + return reject; + } + let ident = match Ident::parse(&logical) { + Ok(i) => i, + Err(e) => return (StatusCode::BAD_REQUEST, format!("bad agent name: {e}")).into_response(), + }; + let cpu_quota = if form.cpu_quota.is_empty() { None } else { Some(form.cpu_quota.as_str()) }; + let memory_max = if form.memory_max.is_empty() { None } else { Some(form.memory_max.as_str()) }; + if let Some(v) = cpu_quota + && let Err(e) = crate::resource_limits::validate_cpu_quota(v) + { + return (StatusCode::UNPROCESSABLE_ENTITY, e).into_response(); + } + if let Some(v) = memory_max + && let Err(e) = crate::resource_limits::validate_memory_max(v) + { + return (StatusCode::UNPROCESSABLE_ENTITY, e).into_response(); + } + let limits = crate::resource_limits::AgentLimits { + cpu_quota: cpu_quota.map(str::to_owned), + memory_max: memory_max.map(str::to_owned), + }; + if let Err(e) = crate::meta::commit_resource_limits(ident.as_str(), &limits).await { + return error_response(&format!("set limits {logical}: {e:#}")); + } + let agent_dir = crate::paths::agent_runtime_dir(ident.as_str()); + let hive = state.coord.hive_env(); + let paths = crate::coordinator::Coordinator::agent_paths(ident.as_str(), agent_dir); + if let Err(e) = crate::lifecycle::write_dropins(ident.as_str(), &hive, &paths).await { + return error_response(&format!("write_dropins {logical}: {e:#}")); + } + state.coord.rescan_containers_and_emit().await; + (StatusCode::OK, "ok").into_response() +} + pub(super) async fn post_update_all(State(state): State) -> Response { let containers = lifecycle::list().await.unwrap_or_default(); for container in containers { diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index cdb35f55..1470d7fd 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -195,6 +195,10 @@ pub async fn serve( .route("/api/rebuild/{name}", post(lifecycle_ops::post_rebuild)) .route("/api/pause/{name}", post(lifecycle_ops::post_pause)) .route("/api/resume/{name}", post(lifecycle_ops::post_resume)) + .route( + "/api/resource-limits/{name}", + post(lifecycle_ops::post_resource_limits), + ) .route("/api/update-all", post(lifecycle_ops::post_update_all)) .route( "/api/infra-container/{name}/{action}",