dashboard: show and edit per-agent resource limits in core LOAD tab
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').
This commit is contained in:
parent
6973610d39
commit
6c9bf2012f
4 changed files with 263 additions and 7 deletions
|
|
@ -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 = '<thead><tr><th>agent</th><th>cpu</th><th>memory</th>'
|
||||
+ '<th>peak</th><th>limit</th><th>disk</th></tr></thead>';
|
||||
+ '<th>peak</th><th>limit</th><th>disk</th>'
|
||||
+ '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">cpu cap</th>'
|
||||
+ '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">mem cap</th>'
|
||||
+ '<th></th></tr></thead>';
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
Form(form): Form<ResourceLimitsForm>,
|
||||
) -> 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<AppState>) -> Response {
|
||||
let containers = lifecycle::list().await.unwrap_or_default();
|
||||
for container in containers {
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
Loading…
Reference in a new issue