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.
247 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|