useSubmit holds the idle, sending, done and error state and the request flow that six forms each wrote out; FormResult shows the success line and the problem panel; FormStack is the column the link, claude-login, inference and limits forms each styled by hand. The create-agent, link account, backend-env and agent-input forms use the submit flow; the link, claude-login, inference and limits forms use FormStack.
259 lines
8 KiB
TypeScript
259 lines
8 KiB
TypeScript
// <AgentLimits> — one agent's resource caps in an opened agent card: 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 { ActionRow } from "../../ui/action-row/ActionRow.js";
|
|
import { Button } from "../../ui/button/Button.js";
|
|
import { FormStack } from "../../ui/form-stack/FormStack.js";
|
|
import { Dialog } from "../../ui/dialog/Dialog.js";
|
|
import { Hint } from "../../ui/hint/Hint.js";
|
|
import { Panel } from "../../ui/panel/Panel.js";
|
|
import { TextField } from "../../ui/text-field/TextField.js";
|
|
import { BadgeSkeleton } from "../../ui/skeleton/BadgeSkeleton.js";
|
|
import "../../ui/badge-row/BadgeRow.css";
|
|
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}
|
|
/>
|
|
<Hint class="ui-agent-limits-hint">
|
|
A percentage of one core: 100% is one core, 400% is four.
|
|
</Hint>
|
|
<TextField
|
|
id={`${idPrefix}-memory-max`}
|
|
label="memory max"
|
|
value={memory}
|
|
placeholder="e.g. 8G"
|
|
onInput={onMemory}
|
|
/>
|
|
<Hint class="ui-agent-limits-hint">
|
|
A size such as 512M or 8G, a percentage of the machine's memory such as
|
|
50%, or infinity.
|
|
</Hint>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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]);
|
|
|
|
// Shown as text, not a hover title. Caps already loaded stay under a
|
|
// failed refetch, so a save that worked is not hidden by a read that did not.
|
|
const errorPanel = error ? (
|
|
<>
|
|
<ApiErrorPanel
|
|
context="reading the resource caps failed"
|
|
problem={error}
|
|
/>
|
|
<Button onClick={() => setReload((n) => n + 1)}>retry</Button>
|
|
</>
|
|
) : null;
|
|
if (caps === null) {
|
|
return errorPanel ?? <BadgeSkeleton what="the resource caps" width="9em" />;
|
|
}
|
|
|
|
return (
|
|
<div class="ui-agent-limits">
|
|
{errorPanel}
|
|
<div class="ui-badge-row">
|
|
<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)}
|
|
/>
|
|
</div>
|
|
<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>
|
|
<FormStack onSubmit={save}>
|
|
<LimitsFields
|
|
cpu={cpu}
|
|
memory={memory}
|
|
onCpu={setCpu}
|
|
onMemory={setMemory}
|
|
/>
|
|
{problem ? (
|
|
<ApiErrorPanel context="the caps were not saved" problem={problem} />
|
|
) : null}
|
|
<ActionRow>
|
|
<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>
|
|
</ActionRow>
|
|
</FormStack>
|
|
</Panel>
|
|
);
|
|
}
|