Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57407cb437 | ||
|
|
a376714116 |
4 changed files with 287 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,30 @@ 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.
|
||||
|
||||
A saved change isn't instant. The hive reads the caps about every 30 seconds,
|
||||
and a running agent takes a changed cap at its next spawn or rebuild, so the
|
||||
row shows the new value before the agent runs under it. 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;
|
||||
}
|
||||
212
frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx
Normal file
212
frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// <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 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}>
|
||||
<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