Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx
iris 37c91eb916 Roll back stored settings when a creation fails, and keep form ids unique
create_agent puts back the caps and inference settings it replaced if a
later store or the job insert fails, so a refused creation leaves nothing
behind for the name. The shared field components take an id prefix so the
create dialog and an open editor never share a DOM id. The queue check now
follows the hive roster check, the 422 is documented, and the copy says
empty rather than closed and that destroy keeps stored settings.
2026-10-15 13:47:14 +02:00

247 lines
7.4 KiB
TypeScript

// <AgentLimits> — one agent's resource caps on the detail panel: the CPU and
// memory caps the swarm sets for it, and an editor for them.
//
// Reads `GET /api/agents/{name}/limits`, which answers the stored caps as
// `{ cpu_quota?, memory_max? }` (`{}` when the swarm sets none). A cap
// replaces what the hive says for that field, and a field the swarm leaves
// out stays with the hive, which is why an empty box means "the hive decides".
// The editor PUTs the filled fields, a PUT replacing the stored value whole, so
// a field left out is cleared; the clear action DELETEs. The route checks each
// value against systemd's own syntax, and its refusal shows as it arrives, next
// to the fields.
//
// Its state belongs to one agent: callers key it by agent name, so switching
// agents remounts it instead of showing the previous agent's caps.
import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import "./AgentLimits.css";
// The caps as the route carries them; both fields are optional.
interface Caps {
cpu_quota?: string;
memory_max?: string;
}
const HIVE_DECIDES = "the hive decides";
function limitsUrl(agent: string): string {
return `/api/agents/${encodeURIComponent(agent)}/limits`;
}
// The caps as typed, as the body the route takes: only the filled fields, so
// an empty box leaves that field to the hive.
export function capsBody(cpu: string, memory: string): Caps {
const body: Caps = {};
if (cpu.trim() !== "") body.cpu_quota = cpu.trim();
if (memory.trim() !== "") body.memory_max = memory.trim();
return body;
}
// The two cap boxes with their hints, shared by the editor here and the
// create-agent form.
export function LimitsFields({
cpu,
memory,
onCpu,
onMemory,
idPrefix = "limits",
}: {
cpu: string;
memory: string;
onCpu: (v: string) => void;
onMemory: (v: string) => void;
// Ids must be unique on the page, and the agents page can hold the editor
// and the create dialog at once.
idPrefix?: string;
}) {
return (
<>
<TextField
id={`${idPrefix}-cpu-quota`}
label="cpu quota"
value={cpu}
placeholder="e.g. 400%"
onInput={onCpu}
/>
<p class="ui-agent-limits-hint">
A percentage of one core: 100% is one core, 400% is four.
</p>
<TextField
id={`${idPrefix}-memory-max`}
label="memory max"
value={memory}
placeholder="e.g. 8G"
onInput={onMemory}
/>
<p class="ui-agent-limits-hint">
A size such as 512M or 8G, a percentage of the machine's memory such as
50%, or infinity.
</p>
</>
);
}
export function AgentLimits({ agent }: { agent: string }) {
const [caps, setCaps] = useState<Caps | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
// Bumped after a save or a clear, so the caps are fetched again.
const [reload, setReload] = useState(0);
const [editing, setEditing] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(limitsUrl(agent));
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
}
const body = (await r.json()) as Caps;
if (cancelled) return;
setCaps(body);
setError(null);
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, [agent, reload]);
if (error) {
return (
<Badge
tone="negative"
value="resource caps unavailable"
title={error.detail ?? "reading the resource caps failed"}
/>
);
}
if (caps === null) return <span class="ui-agent-limits-muted">…</span>;
return (
<div class="ui-agent-limits">
<ul class="ui-agent-limits-list">
<li>
<Badge label="cpu quota" value={caps.cpu_quota ?? HIVE_DECIDES} />
</li>
<li>
<Badge label="memory max" value={caps.memory_max ?? HIVE_DECIDES} />
</li>
</ul>
<Badge
variant="quiet"
value="edit"
title={`set the swarm's CPU and memory caps for ${agent}`}
onClick={() => setEditing(true)}
/>
<Dialog
open={editing}
onClose={() => setEditing(false)}
label="edit resource caps"
plain
>
{/* Remounted per open, so a cancelled edit leaves nothing behind. */}
{editing ? (
<LimitsForm
agent={agent}
caps={caps}
onClose={() => setEditing(false)}
onSaved={() => {
setEditing(false);
setReload((n) => n + 1);
}}
/>
) : null}
</Dialog>
</div>
);
}
function LimitsForm({
agent,
caps,
onClose,
onSaved,
}: {
agent: string;
caps: Caps;
onClose: () => void;
onSaved: () => void;
}) {
const [cpu, setCpu] = useState(caps.cpu_quota ?? "");
const [memory, setMemory] = useState(caps.memory_max ?? "");
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<ProblemDetails | null>(null);
const empty = cpu.trim() === "" && memory.trim() === "";
const capped = caps.cpu_quota !== undefined || caps.memory_max !== undefined;
async function send(init: RequestInit) {
setBusy(true);
setProblem(null);
try {
const r = await fetch(limitsUrl(agent), init);
if (!r.ok) {
// The form stays as typed, so the operator can fix it and save again.
setProblem(await readApiError(r));
return;
}
onSaved();
} catch (e: unknown) {
setProblem({ detail: String(e) });
} finally {
setBusy(false);
}
}
function save(e: Event) {
e.preventDefault();
// Only the filled fields: a PUT replaces the stored value whole, so an
// empty box is cleared and the hive decides that field again.
void send({
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(capsBody(cpu, memory)),
});
}
return (
<Panel title={`resource caps — ${agent}`} icon="📏" onClose={onClose}>
<p>
Caps what {agent} may use, wherever it runs. A cap here replaces the
hive's own answer for that field; an empty box leaves the field to the
hive. The hive reads a change within about 30 seconds, and a running
agent takes it at its next spawn or rebuild.
</p>
<form class="ui-agent-limits-form" onSubmit={save}>
<LimitsFields
cpu={cpu}
memory={memory}
onCpu={setCpu}
onMemory={setMemory}
/>
{problem ? (
<ApiErrorPanel context="the caps were not saved" problem={problem} />
) : null}
<div class="ui-agent-limits-actions">
<Button variant="primary" type="submit" disabled={busy || empty}>
{busy ? "saving…" : "save"}
</Button>
<Button
disabled={busy || !capped}
onClick={() => void send({ method: "DELETE" })}
>
clear the swarm's caps
</Button>
</div>
</form>
</Panel>
);
}