swarm-ui: an agent's detail shows its resource caps and edits them
A resource caps row in the agent details reads GET /api/agents/{name}/limits
and shows the cpu quota and memory max, each reading as the hive deciding
when the swarm sets none. An edit dialog PUTs the filled fields, which
replaces the stored value whole, and a clear action DELETEs it. A refusal,
such as a percentage with a leading zero, shows as the route's reason under
the fields and leaves them as typed.
This commit is contained in:
parent
0f53e9be6c
commit
a376714116
4 changed files with 282 additions and 13 deletions
|
|
@ -7,17 +7,17 @@ domain, covers host-level detail for one hive.
|
|||
|
||||
## What it shows
|
||||
|
||||
| route | what |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/` | the hive directory, each hive with its last reported status |
|
||||
| `/agents` | every agent: status, config PR, wanted state, inference settings; create agents, link forge, matrix and GitHub accounts |
|
||||
| `/agents/<name>/terminal` | one agent's live terminal |
|
||||
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
|
||||
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
||||
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
|
||||
| `/issues` | a cross-repo issue report |
|
||||
| `/review-runs` | the review classifier's recent runs: which PRs, what it answered, which failed |
|
||||
| `/review-tryout` | try a review classification against a real PR |
|
||||
| route | what |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/` | the hive directory, each hive with its last reported status |
|
||||
| `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps; create agents, link forge, matrix and GitHub accounts |
|
||||
| `/agents/<name>/terminal` | one agent's live terminal |
|
||||
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
|
||||
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
||||
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
|
||||
| `/issues` | a cross-repo issue report |
|
||||
| `/review-runs` | the review classifier's recent runs: which PRs, what it answered, which failed |
|
||||
| `/review-tryout` | try a review classification against a real PR |
|
||||
|
||||
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
|
||||
An agent created here or with `swarmctl agent create` starts `paused`; set it
|
||||
|
|
@ -48,7 +48,7 @@ with no icon keeps the hyperhive mark.
|
|||
|
||||
The tab also has an agent badge, with the agent's icon and name, ahead of the
|
||||
connection badge. It opens the agent's details in a popover under the badge:
|
||||
the same status, wanted state, inference settings and linked accounts as the
|
||||
the same status, wanted state, inference settings, resource caps and linked accounts as the
|
||||
panel on `/agents`, without the terminal preview. The popover doesn't dim the
|
||||
page. Selecting outside it, or Escape, closes it. You can stop or pause the
|
||||
agent, or link an account, without leaving the terminal. A subagent's tab opens
|
||||
|
|
@ -216,6 +216,26 @@ The panel reads `GET /api/agents/{agent}/inference`; before the agent's first
|
|||
report reaches the controller, it shows the swarm's stored values only.
|
||||
[inference](inference.md) describes the settings themselves.
|
||||
|
||||
### Resource caps
|
||||
|
||||
The agent's detail panel shows its CPU quota and memory max under **resource
|
||||
caps**. A cap is the swarm's, so it follows the agent to whichever hive runs
|
||||
it, and it replaces what the hive says for that field. A field the swarm
|
||||
leaves out shows as the hive deciding it. **edit** opens a dialog with the two
|
||||
fields, filled with what the swarm stores. A cpu quota is a percentage of one
|
||||
core (`400%` is four cores). A memory max is a size such as `512M` or `8G`, a
|
||||
percentage of the machine's memory, or `infinity`.
|
||||
|
||||
Saving does a `PUT /api/agents/{agent}/limits` of the filled fields. A `PUT`
|
||||
replaces the stored value whole, so an empty box clears that cap and the hive
|
||||
decides that field again. **clear the swarm's caps** sends the `DELETE`. The
|
||||
controller checks each value against systemd's own syntax, because systemd
|
||||
ignores a value it can't parse and the agent would run uncapped, and it
|
||||
refuses, for example, a percentage with a leading zero. The dialog shows the
|
||||
refusal's reason under the fields and leaves them as you typed them. The panel
|
||||
reads `GET /api/agents/{agent}/limits`. [hivectl](../tools/hivectl.md#per-agent-resource-limits)
|
||||
sets the hive's own limits.
|
||||
|
||||
### Linking external accounts
|
||||
|
||||
Each agent on `/agents` opens three dialogs that write a credential for it
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// <AgentDetail> — one agent's details: its status, wanted state, hive, config
|
||||
// PR, inference settings and linked accounts, plus the actions on them. The
|
||||
// PR, inference settings, resource caps and linked accounts, plus the actions on them. The
|
||||
// body of the detail panel on `/agents` (where `withTerminal` adds the live
|
||||
// terminal and subagents below it) and of the detail dialog on a terminal
|
||||
// page (which is already beside the terminal, so omits it).
|
||||
|
|
@ -11,6 +11,7 @@ import { Badge } from "@hive/shared/badge.js";
|
|||
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
|
||||
import type { AgentActions } from "../roster/AgentActions.js";
|
||||
import { AgentInference } from "./AgentInference.js";
|
||||
import { AgentLimits } from "./AgentLimits.js";
|
||||
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
|
||||
import { FRESHNESS, type AgentRow } from "../AgentTypes.js";
|
||||
import { LinkAccountMenu } from "./LinkAccountMenu.js";
|
||||
|
|
@ -102,6 +103,10 @@ export function AgentDetail({
|
|||
<dd>
|
||||
<AgentInference key={row.name} agent={row.name} />
|
||||
</dd>
|
||||
<dt>resource caps</dt>
|
||||
<dd>
|
||||
<AgentLimits key={row.name} agent={row.name} />
|
||||
</dd>
|
||||
<dt>accounts</dt>
|
||||
<dd>
|
||||
{row.hive ? (
|
||||
|
|
|
|||
33
frontend/packages/swarm-ui/src/agent/detail/AgentLimits.css
Normal file
33
frontend/packages/swarm-ui/src/agent/detail/AgentLimits.css
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/* <AgentLimits> — one cap per line, then the edit action. */
|
||||
.ui-agent-limits {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.3em;
|
||||
}
|
||||
.ui-agent-limits-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.3em;
|
||||
}
|
||||
.ui-agent-limits-muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
.ui-agent-limits-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
.ui-agent-limits-hint {
|
||||
margin: 0 0 0.5em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ui-agent-limits-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75em;
|
||||
}
|
||||
211
frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx
Normal file
211
frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// <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`;
|
||||
}
|
||||
|
||||
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.
|
||||
const body: Caps = {};
|
||||
if (cpu.trim() !== "") body.cpu_quota = cpu.trim();
|
||||
if (memory.trim() !== "") body.memory_max = memory.trim();
|
||||
void send({
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
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 applies a change within seconds.
|
||||
</p>
|
||||
<form class="ui-agent-limits-form" onSubmit={save}>
|
||||
<TextField
|
||||
id="limits-cpu-quota"
|
||||
label="cpu quota"
|
||||
value={cpu}
|
||||
placeholder="e.g. 400%"
|
||||
onInput={setCpu}
|
||||
/>
|
||||
<p class="ui-agent-limits-hint">
|
||||
A percentage of one core: 100% is one core, 400% is four.
|
||||
</p>
|
||||
<TextField
|
||||
id="limits-memory-max"
|
||||
label="memory max"
|
||||
value={memory}
|
||||
placeholder="e.g. 8G"
|
||||
onInput={setMemory}
|
||||
/>
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue