swarm-ui: restructure the agent card into status, terminal, subagents and settings, with badge controls for internet, permissions and allowed domains
This commit is contained in:
parent
197caa1e52
commit
5e23734667
10 changed files with 437 additions and 302 deletions
|
|
@ -26,7 +26,7 @@ smaller; short confirm dialogs stay short.
|
|||
| route | what |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `/` | the start page: the swarm's name over a digital-rain backdrop with the slug (`hyperhive / we are the wired`, its shaded gradients pulsing in a wave unless reduced motion is on) at the foot, plus a paperclip offering to set up an agent while no `ruth` exists, or her hive declares her destroyed (it opens the create form with `ruth` filled in); the swarm name in the header links here |
|
||||
| `/agents` | every agent as a card that opens in place (status, config PR, wanted state, inference settings, resource caps, permissions, accounts, terminal), under a folded **hives** card with the hive directory and each hive's last reported status; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts |
|
||||
| `/agents` | every agent as a card that opens in place (status, terminal, subagents, and settings with permissions, internet, allowed domains and accounts), under a folded **hives** card with the hive directory and each hive's last reported status; create agents (with their permissions, optionally from an [archetype](archetypes.md)), 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 |
|
||||
| `/reports` | two folded cards, each read only once opened: the **alerts** the swarm's Grafana holds now, the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token, and a cross-repo **issue report** |
|
||||
|
|
@ -70,18 +70,19 @@ filter card, which also holds the **+ agent** button and the refresh picker,
|
|||
filters by name, hive, status, message, wanted state
|
||||
and config PR.
|
||||
|
||||
An opened card has four sections, each folded except **overview**, and each
|
||||
reads nothing until you open it:
|
||||
An opened card has four sections, and each reads nothing until you open it.
|
||||
**status** and **terminal** start open, the other two folded:
|
||||
|
||||
- **overview**: the agent's turn state, context and cost, its hive, links to its
|
||||
- **status**: the agent's turn state, context and cost, its hive, links to its
|
||||
profile and its config repo on the swarm's forge (built from the forge entry
|
||||
of `GET /api/links`; the row stays hidden when the swarm lists none), and its
|
||||
open config PR.
|
||||
- **settings**: inference, instructions, resource caps, permissions and allowed
|
||||
domains, each with its own editor (described below).
|
||||
- **accounts**: the agent's linked accounts, and the menu that links another.
|
||||
- **terminal**: the live terminal with its message box, and the agent's
|
||||
subagents under it (next section).
|
||||
- **terminal**: the live terminal with its message box.
|
||||
- **subagents**: the agent's subagents and their terminal preview (next section).
|
||||
- **settings**: inference, instructions, resource caps, internet, allowed
|
||||
domains and permissions, then the agent's linked accounts and the menu that
|
||||
links another. Internet, allowed domains and permissions show a badge; select
|
||||
it to open the control that changes it (described below).
|
||||
|
||||
### Agent and subagent terminals
|
||||
|
||||
|
|
@ -148,8 +149,8 @@ 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, state, wanted state, inference settings, resource caps, permissions, allowed domains and linked accounts as the
|
||||
opened card on `/agents`, without the terminal preview. The popover doesn't dim the
|
||||
the same status, state, wanted state and folded **status** and **settings** sections (inference, resource caps, internet, allowed domains, permissions and linked accounts) as the
|
||||
opened card on `/agents`, without the terminal and subagents. The popover doesn't dim the
|
||||
page. Selecting outside it, or Escape, closes it. From the keyboard, opening a
|
||||
popover moves focus into it, and Escape puts focus back on the badge. The filter
|
||||
popovers on table headers work the same way. You can stop or pause the
|
||||
|
|
@ -645,19 +646,22 @@ sets the hive's own limits.
|
|||
### Permissions
|
||||
|
||||
An opened agent card shows, under **settings**, what the swarm grants it under **permissions**:
|
||||
the default set, none, or a count with one badge per permission. A swarm
|
||||
a badge reading the default set, none, or a count. A swarm
|
||||
grant is the agent's whole set, so it follows the agent to whichever hive runs
|
||||
it. **edit** opens a dialog with one checkbox per permission the controller
|
||||
it. Selecting the badge opens a box under it with one checkbox per permission the controller
|
||||
knows (`GET /api/permissions`), ticked as the grant stands, or on the default
|
||||
set when the swarm sets none.
|
||||
|
||||
Saving does a `PUT /api/agents/{agent}/permissions` of the ticked boxes. A
|
||||
Each tick saves at once, as a `PUT /api/agents/{agent}/permissions` of the ticked boxes. A
|
||||
`PUT` replaces the stored list whole, so an unticked box revokes that
|
||||
permission, and ticking nothing grants nothing. Ticking exactly the default set sends the
|
||||
`DELETE` instead, as the create form sends no list for it, so the agent follows
|
||||
the default if it changes. **use the default** sends the `DELETE` too. The
|
||||
dialog shows a refusal's reason under the boxes and leaves them as you ticked
|
||||
them.
|
||||
box shows a refusal's reason under the boxes and puts them back as stored.
|
||||
|
||||
The agent's **internet** access is its own row beside it: a badge reading
|
||||
restricted, log or unrestricted. Selecting it lists the three modes; picking one does a
|
||||
`PUT` of the mode with the stored permissions beside it.
|
||||
|
||||
A saved change isn't instant. The hive reads the grant about every 30 seconds
|
||||
and the agent takes it at its next turn. A change to `manage_root_agent`
|
||||
|
|
@ -670,14 +674,15 @@ same grant from a shell.
|
|||
|
||||
An opened agent card lists, under **settings**, the destinations it may reach through its
|
||||
hive's egress proxy under **allowed domains**, beyond the base list every
|
||||
agent has: the entries for every agent in the swarm (badge **swarm-wide**) and
|
||||
the ones for this agent alone (badge with the agent's name). An entry matters
|
||||
agent has: one badge per source with its count, **swarm-wide** (the entries for every
|
||||
agent), **this agent** (the ones for it alone) and **automatic**. Selecting a badge
|
||||
opens the names behind it. An entry matters
|
||||
to a restricted agent. One with the unrestricted internet flag reaches any
|
||||
name the proxy's private-range rules leave. **remove** drops one entry;
|
||||
name the proxy's private-range rules leave. **remove**, beside a name, drops one entry;
|
||||
**add** opens a dialog with a domain box and a tick for every agent in the
|
||||
swarm. The tick is off by default, so an entry is for this agent alone.
|
||||
|
||||
An entry with the badge **automatic** has no **remove**: the controller keeps
|
||||
An entry under **automatic** has no **remove**: the controller keeps
|
||||
it from the host in the agent's stored API-key environment
|
||||
(`ANTHROPIC_BASE_URL`, or any `*_BASE_URL` variable), so a restricted agent on
|
||||
another provider reaches it. Saving or removing the environment changes it, and
|
||||
|
|
@ -773,7 +778,7 @@ name that already holds an account; delete that account first to replace it.
|
|||
The fourth, the API-key environment, is the exception: a save overwrites what the
|
||||
store holds.
|
||||
|
||||
An opened agent card lists its linked accounts in its **accounts** section, one row
|
||||
An opened agent card lists its linked accounts at the end of its **settings** section, one row
|
||||
per account: kind, name and host. Opening an agent makes one request,
|
||||
`GET /api/hives/{hive}/agents/{agent}/linked-accounts`, which returns every
|
||||
account of that agent as names and hosts and never a credential. When the card
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import type { AgentActions } from "../roster/AgentActions.js";
|
|||
import { AgentInference } from "./AgentInference.js";
|
||||
import { AgentInstructions } from "./AgentInstructions.js";
|
||||
import { AgentLimits } from "./AgentLimits.js";
|
||||
import { AgentPermissions } from "./AgentPermissions.js";
|
||||
import { AgentInternet, AgentPermissions } from "./AgentPermissions.js";
|
||||
import { AllowedDomains } from "./AllowedDomains.js";
|
||||
import { AgentStateRow } from "./AgentStateRow.js";
|
||||
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
|
||||
|
|
@ -184,14 +184,18 @@ function SettingsRows({ agent }: { agent: string }) {
|
|||
<dd>
|
||||
<AgentLimits key={agent} agent={agent} />
|
||||
</dd>
|
||||
<dt>permissions</dt>
|
||||
<dt>internet</dt>
|
||||
<dd>
|
||||
<AgentPermissions key={agent} agent={agent} />
|
||||
<AgentInternet key={agent} agent={agent} />
|
||||
</dd>
|
||||
<dt>allowed domains</dt>
|
||||
<dd>
|
||||
<AllowedDomains key={agent} agent={agent} />
|
||||
</dd>
|
||||
<dt>permissions</dt>
|
||||
<dd>
|
||||
<AgentPermissions key={agent} agent={agent} />
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -251,35 +255,27 @@ function SectionBody({
|
|||
actions: AgentActions;
|
||||
}) {
|
||||
switch (section) {
|
||||
case "overview":
|
||||
case "status":
|
||||
return (
|
||||
<dl class="ui-agent-detail-fields">
|
||||
<OverviewRows row={row} actions={actions} />
|
||||
</dl>
|
||||
);
|
||||
case "settings":
|
||||
return (
|
||||
<dl class="ui-agent-detail-fields">
|
||||
<SettingsRows agent={row.name} />
|
||||
</dl>
|
||||
);
|
||||
case "accounts":
|
||||
return (
|
||||
<>
|
||||
<dl class="ui-agent-detail-fields">
|
||||
<SettingsRows agent={row.name} />
|
||||
<AccountsRows row={row} actions={actions} />
|
||||
</dl>
|
||||
<LinkAccountAction row={row} actions={actions} />
|
||||
</>
|
||||
);
|
||||
case "terminal":
|
||||
return (
|
||||
<>
|
||||
{/* The agent's live terminal and its input box. */}
|
||||
<AgentTermPreview agentName={row.name} />
|
||||
<SubagentTerms key={row.name} agentName={row.name} />
|
||||
</>
|
||||
);
|
||||
// The agent's live terminal and its input box.
|
||||
return <AgentTermPreview agentName={row.name} />;
|
||||
case "subagents":
|
||||
return <SubagentTerms key={row.name} agentName={row.name} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -291,38 +287,30 @@ export function AgentDetail({
|
|||
}: {
|
||||
row: AgentRow;
|
||||
actions: AgentActions;
|
||||
/** Add the live terminal and subagents: for the flat layout, below the rows. */
|
||||
/** Add the live terminal and subagents sections. */
|
||||
withTerminal?: boolean;
|
||||
/** The folded sections of an opened card, without the rows its header shows. */
|
||||
/**
|
||||
* An opened card, whose header already shows the state rows. Without it
|
||||
* (the terminal page's popover) those rows go above the same sections.
|
||||
*/
|
||||
inCard?: boolean;
|
||||
}) {
|
||||
if (inCard) {
|
||||
return (
|
||||
<div class="ui-agent-detail ui-agent-detail-sections">
|
||||
{sectionsFor(withTerminal).map((section) => (
|
||||
<LazyDisclosure
|
||||
key={section}
|
||||
title={SECTION_TITLE[section]}
|
||||
startOpen={startsOpen(section)}
|
||||
>
|
||||
<SectionBody section={section} row={row} actions={actions} />
|
||||
</LazyDisclosure>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div class="ui-agent-detail">
|
||||
<dl class="ui-agent-detail-fields">
|
||||
<HeaderRows row={row} actions={actions} />
|
||||
<OverviewRows row={row} actions={actions} />
|
||||
<SettingsRows agent={row.name} />
|
||||
<AccountsRows row={row} actions={actions} />
|
||||
</dl>
|
||||
<LinkAccountAction row={row} actions={actions} />
|
||||
{withTerminal ? (
|
||||
<SectionBody section="terminal" row={row} actions={actions} />
|
||||
) : null}
|
||||
<div class="ui-agent-detail ui-agent-detail-sections">
|
||||
{inCard ? null : (
|
||||
<dl class="ui-agent-detail-fields">
|
||||
<HeaderRows row={row} actions={actions} />
|
||||
</dl>
|
||||
)}
|
||||
{sectionsFor(withTerminal).map((section) => (
|
||||
<LazyDisclosure
|
||||
key={section}
|
||||
title={SECTION_TITLE[section]}
|
||||
startOpen={startsOpen(section)}
|
||||
>
|
||||
<SectionBody section={section} row={row} actions={actions} />
|
||||
</LazyDisclosure>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,27 @@
|
|||
// <AgentPermissions> — one agent's permissions in an opened agent card: what the
|
||||
// swarm grants it, and an editor for that.
|
||||
// <AgentPermissions> and <AgentInternet> — one agent's grant in an opened agent
|
||||
// card: the permissions it holds, and how far its traffic may leave the host.
|
||||
// Each is a badge that opens a control under it, which saves as soon as it
|
||||
// changes.
|
||||
//
|
||||
// Reads `GET /api/agents/{name}/permissions`, which answers the stored list as
|
||||
// `{ permissions? }` (`{}` when the swarm sets none, so the agent runs on the
|
||||
// default set). The editor offers the catalogue from `GET /api/permissions` as
|
||||
// checkboxes and PUTs the ticked ones, a PUT replacing the stored value whole.
|
||||
// A selection equal to the default set DELETEs instead, as the create form
|
||||
// sends nothing for it: the agent then follows the default if that changes.
|
||||
// The route's refusal shows as it arrives, next to the boxes.
|
||||
// Both read `GET /api/agents/{name}/permissions`, which answers the stored
|
||||
// grant as `{ permissions?, unrestricted_internet?, internet_log? }` (`{}` when
|
||||
// the swarm sets none, so the agent runs on the default set). The permissions
|
||||
// control offers the catalogue from `GET /api/permissions` as checkboxes and
|
||||
// PUTs the ticked ones, a PUT replacing the stored value whole. A selection
|
||||
// equal to the default set DELETEs instead, as the create form sends nothing
|
||||
// for it: the agent then follows the default if that changes. The internet
|
||||
// control PUTs the mode with the stored list beside it. The route's refusal
|
||||
// shows as it arrives, in the control.
|
||||
//
|
||||
// Its state belongs to one agent: callers key it by agent name, so switching
|
||||
// agents remounts it instead of showing the previous agent's grant.
|
||||
// Their state belongs to one agent: callers key them by agent name, so
|
||||
// switching agents remounts them instead of showing the previous agent's grant.
|
||||
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 { BadgePopover } from "../../ui/badge-popover/BadgePopover.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||
import { MultiselectFilterOptions } from "../../ui/multiselect-filter/MultiselectFilter.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import {
|
||||
requestPermissions,
|
||||
togglePermission,
|
||||
|
|
@ -46,28 +49,26 @@ interface Catalogue {
|
|||
default: string[];
|
||||
}
|
||||
|
||||
// The buttons for the modes an agent is not in: mode, label, tooltip.
|
||||
// The modes of the internet control: mode, label, what it means.
|
||||
const INTERNET_MODES: [InternetMode, string, string][] = [
|
||||
[
|
||||
"unrestricted",
|
||||
"allow unrestricted",
|
||||
"let its traffic leave the host unfiltered",
|
||||
],
|
||||
["restricted", "restricted", "allow only the listed names"],
|
||||
["log", "log", "allow what restricted would deny, and log it as would_deny"],
|
||||
["restricted", "restrict", "allow only the listed names"],
|
||||
["unrestricted", "unrestricted", "let its traffic leave the host unfiltered"],
|
||||
];
|
||||
|
||||
function permissionsUrl(agent: string): string {
|
||||
return `/api/agents/${encodeURIComponent(agent)}/permissions`;
|
||||
}
|
||||
|
||||
export function AgentPermissions({ agent }: { agent: string }) {
|
||||
// The stored grant of one agent, and what changes it.
|
||||
function useGrant(agent: string) {
|
||||
const [grant, setGrant] = useState<Grant | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
// Bumped after a save or a clear, so the grant is fetched again.
|
||||
const [reload, setReload] = useState(0);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [flagBusy, setFlagBusy] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// The refusal of the last save, kept apart from a failed read.
|
||||
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
|
@ -89,165 +90,94 @@ export function AgentPermissions({ agent }: { agent: string }) {
|
|||
};
|
||||
}, [agent, reload]);
|
||||
|
||||
// Sets the internet mode, sending the stored list with it.
|
||||
async function setInternet(g: Grant, mode: InternetMode) {
|
||||
if (flagBusy) return;
|
||||
setFlagBusy(true);
|
||||
// Sends one change and reads the grant again; true when the route took it.
|
||||
async function send(init: RequestInit): Promise<boolean> {
|
||||
setBusy(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const r = await fetch(permissionsUrl(agent), {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(internetBody(g.permissions, mode)),
|
||||
});
|
||||
const r = await fetch(permissionsUrl(agent), init);
|
||||
if (!r.ok) {
|
||||
setError(await readApiError(r));
|
||||
return;
|
||||
setSaveError(await readApiError(r));
|
||||
return false;
|
||||
}
|
||||
setReload((n) => n + 1);
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
setError({ detail: String(e) });
|
||||
setSaveError({ detail: String(e) });
|
||||
return false;
|
||||
} finally {
|
||||
setFlagBusy(false);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// A grant already loaded stays under a failed refetch, so a save that
|
||||
// worked is not hidden by a read that did not.
|
||||
const errorPanel = error ? (
|
||||
const readFailure = error ? (
|
||||
<>
|
||||
<ApiErrorPanel context="reading the permissions failed" problem={error} />
|
||||
<Button onClick={() => setReload((n) => n + 1)}>retry</Button>
|
||||
</>
|
||||
) : null;
|
||||
if (grant === null) {
|
||||
return (
|
||||
errorPanel ?? (
|
||||
<span class="ui-agent-limits-muted" role="status">
|
||||
loading the permissions…
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="ui-agent-limits">
|
||||
{errorPanel}
|
||||
<Badge label="permissions" value={grantSummary(grant.permissions)} />
|
||||
{grant.permissions && grant.permissions.length > 0 ? (
|
||||
<ul class="ui-agent-limits-list">
|
||||
{grant.permissions.map((p) => (
|
||||
<li key={p}>
|
||||
<Badge value={p} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<Badge
|
||||
label="internet"
|
||||
value={internetSummary(grant.unrestricted_internet, grant.internet_log)}
|
||||
/>
|
||||
{INTERNET_MODES.filter(
|
||||
([mode]) =>
|
||||
mode !==
|
||||
internetMode(grant.unrestricted_internet, grant.internet_log),
|
||||
).map(([mode, label, title]) => (
|
||||
<Badge
|
||||
key={mode}
|
||||
variant="quiet"
|
||||
value={label}
|
||||
title={`${title} for ${agent}`}
|
||||
onClick={() => void setInternet(grant, mode)}
|
||||
/>
|
||||
))}
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value="edit"
|
||||
title={`set the permissions the swarm grants ${agent}`}
|
||||
onClick={() => setEditing(true)}
|
||||
/>
|
||||
<Dialog
|
||||
open={editing}
|
||||
onClose={() => setEditing(false)}
|
||||
label="edit permissions"
|
||||
plain
|
||||
>
|
||||
{/* Remounted per open, so a cancelled edit leaves nothing behind. */}
|
||||
{editing ? (
|
||||
<PermissionsForm
|
||||
agent={agent}
|
||||
grant={grant}
|
||||
onClose={() => setEditing(false)}
|
||||
onSaved={() => {
|
||||
setEditing(false);
|
||||
setReload((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
const loading =
|
||||
grant === null
|
||||
? (readFailure ?? (
|
||||
<span class="ui-agent-limits-muted" role="status">
|
||||
loading the permissions…
|
||||
</span>
|
||||
))
|
||||
: null;
|
||||
return { grant, busy, saveError, readFailure, loading, send };
|
||||
}
|
||||
|
||||
function PermissionsForm({
|
||||
agent,
|
||||
grant,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
agent: string;
|
||||
grant: Grant;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
export function AgentPermissions({ agent }: { agent: string }) {
|
||||
const { grant, busy, saveError, readFailure, loading, send } =
|
||||
useGrant(agent);
|
||||
const [catalogue, setCatalogue] = useState<Catalogue | null>(null);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [problem, setProblem] = useState<ProblemDetails | null>(null);
|
||||
const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>(
|
||||
null,
|
||||
);
|
||||
// The boxes as ticked; they follow the stored grant once it is read again.
|
||||
const [selected, setSelected] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const r = await fetch("/api/permissions");
|
||||
if (!r.ok) {
|
||||
if (!cancelled) setProblem(await readApiError(r));
|
||||
if (!cancelled) setCatalogueError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
const data = (await r.json()) as Catalogue;
|
||||
if (cancelled) return;
|
||||
setCatalogue(data);
|
||||
setSelected(editorSelection(grant.permissions, data.all, data.default));
|
||||
if (!cancelled) setCatalogue(data);
|
||||
})().catch((e: unknown) => {
|
||||
if (!cancelled) setProblem({ detail: String(e) });
|
||||
if (!cancelled) setCatalogueError({ detail: String(e) });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [grant]);
|
||||
}, []);
|
||||
|
||||
async function send(init: RequestInit) {
|
||||
setBusy(true);
|
||||
setProblem(null);
|
||||
try {
|
||||
const r = await fetch(permissionsUrl(agent), init);
|
||||
if (!r.ok) {
|
||||
// The boxes stay as ticked, 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);
|
||||
}
|
||||
}
|
||||
// The stored grant changing (a save went through) resets the boxes to it.
|
||||
const stored = grant?.permissions;
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
}, [stored]);
|
||||
|
||||
function save(e: Event) {
|
||||
e.preventDefault();
|
||||
if (grant === null) return loading;
|
||||
|
||||
const ticked =
|
||||
selected ??
|
||||
(catalogue
|
||||
? editorSelection(stored, catalogue.all, catalogue.default)
|
||||
: []);
|
||||
|
||||
async function toggle(value: string) {
|
||||
if (!catalogue) return;
|
||||
const next = togglePermission(ticked, value);
|
||||
setSelected(next);
|
||||
// The default set sends no list, so the agent follows the default.
|
||||
const list = requestPermissions(selected, catalogue.default);
|
||||
void send(
|
||||
const list = requestPermissions(next, catalogue.default);
|
||||
const ok = await send(
|
||||
list === undefined
|
||||
? { method: "DELETE" }
|
||||
: {
|
||||
|
|
@ -256,53 +186,110 @@ function PermissionsForm({
|
|||
body: JSON.stringify({ permissions: list }),
|
||||
},
|
||||
);
|
||||
// A refused change puts the boxes back as stored.
|
||||
if (!ok) setSelected(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title={`permissions — ${agent}`} icon="🔑" onClose={onClose}>
|
||||
<p>
|
||||
What the swarm lets {agent} do. A tick grants that permission; leaving
|
||||
the default set ticked keeps {agent} on the default if it changes. The
|
||||
hive reads a change within about 30 seconds, and the agent takes it at
|
||||
its next turn. Changing <code>manage_root_agent</code> restarts it after
|
||||
its current turn.
|
||||
</p>
|
||||
<form class="ui-agent-limits-form" onSubmit={save}>
|
||||
<div role="group" aria-label="permissions">
|
||||
{catalogue ? (
|
||||
<div class="ui-agent-limits">
|
||||
{readFailure}
|
||||
<BadgePopover
|
||||
label="permissions"
|
||||
value={grantSummary(grant.permissions)}
|
||||
title={`see and change what the swarm lets ${agent} do`}
|
||||
popoverLabel={`${agent} permissions`}
|
||||
>
|
||||
<p class="ui-badge-popover-help">
|
||||
What the swarm lets {agent} do. A tick grants it; ticking the default
|
||||
set keeps {agent} on the default if that changes. The hive reads a
|
||||
change within about 30 seconds and the agent takes it at its next
|
||||
turn. Changing <code>manage_root_agent</code> restarts it after its
|
||||
current turn.
|
||||
</p>
|
||||
{catalogue ? (
|
||||
<div role="group" aria-label="permissions">
|
||||
<MultiselectFilterOptions
|
||||
options={catalogue.all}
|
||||
filter={{ values: selected, negate: false }}
|
||||
onToggle={(v) => setSelected((s) => togglePermission(s, v))}
|
||||
filter={{ values: ticked, negate: false }}
|
||||
onToggle={(v) => void toggle(v)}
|
||||
/>
|
||||
) : (
|
||||
<span class="ui-agent-limits-muted" role="status">
|
||||
{problem ? "could not load the permissions" : "loading…"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{problem ? (
|
||||
</div>
|
||||
) : (
|
||||
<span class="ui-agent-limits-muted" role="status">
|
||||
{catalogueError ? "could not load the permissions" : "loading…"}
|
||||
</span>
|
||||
)}
|
||||
{saveError ? (
|
||||
<ApiErrorPanel
|
||||
context="the permissions were not saved"
|
||||
problem={problem}
|
||||
problem={saveError}
|
||||
/>
|
||||
) : null}
|
||||
<div class="ui-agent-limits-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={busy || catalogue === null}
|
||||
>
|
||||
{busy ? "saving…" : "save"}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || grant.permissions === undefined}
|
||||
onClick={() => void send({ method: "DELETE" })}
|
||||
>
|
||||
use the default
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
<Button
|
||||
disabled={busy || grant.permissions === undefined}
|
||||
onClick={() => void send({ method: "DELETE" })}
|
||||
>
|
||||
use the default
|
||||
</Button>
|
||||
</BadgePopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentInternet({ agent }: { agent: string }) {
|
||||
const { grant, busy, saveError, readFailure, loading, send } =
|
||||
useGrant(agent);
|
||||
if (grant === null) return loading;
|
||||
|
||||
const mode = internetMode(grant.unrestricted_internet, grant.internet_log);
|
||||
return (
|
||||
<div class="ui-agent-limits">
|
||||
{readFailure}
|
||||
<BadgePopover
|
||||
label="internet"
|
||||
value={internetSummary(grant.unrestricted_internet, grant.internet_log)}
|
||||
tone={mode === "restricted" ? "neutral" : "warning"}
|
||||
title={`how far ${agent}'s traffic may leave the host`}
|
||||
popoverLabel={`${agent} internet access`}
|
||||
>
|
||||
<p class="ui-badge-popover-help">
|
||||
How far {agent}'s traffic may leave the host. Restricted allows only
|
||||
the names listed under allowed domains; the hive reads a change within
|
||||
about 30 seconds.
|
||||
</p>
|
||||
<ul class="ui-badge-popover-list">
|
||||
{INTERNET_MODES.map(([m, label, meaning]) => (
|
||||
<li key={m}>
|
||||
<Badge
|
||||
variant={m === mode ? "default" : "quiet"}
|
||||
tone={m === mode ? "accent" : "neutral"}
|
||||
value={label}
|
||||
title={`${meaning} for ${agent}`}
|
||||
disabled={busy}
|
||||
onClick={
|
||||
m === mode
|
||||
? undefined
|
||||
: () =>
|
||||
void send({
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
internetBody(grant.permissions, m),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>{" "}
|
||||
<span class="ui-agent-limits-muted">{meaning}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{saveError ? (
|
||||
<ApiErrorPanel
|
||||
context="the internet mode was not saved"
|
||||
problem={saveError}
|
||||
/>
|
||||
) : null}
|
||||
</BadgePopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ 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 { BadgePopover } from "../../ui/badge-popover/BadgePopover.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||
|
|
@ -31,10 +32,11 @@ import { Panel } from "../../ui/panel/Panel.js";
|
|||
import { TextField } from "../../ui/text-field/TextField.js";
|
||||
import {
|
||||
ALLOWED_DOMAINS_URL,
|
||||
allowedSummary,
|
||||
entriesFor,
|
||||
entryBody,
|
||||
entryGroups,
|
||||
type AllowedDomain,
|
||||
type EntryGroup,
|
||||
} from "./allowed-domains.js";
|
||||
import "./AgentLimits.css";
|
||||
|
||||
|
|
@ -42,6 +44,24 @@ interface Listing {
|
|||
entries: AllowedDomain[];
|
||||
}
|
||||
|
||||
// What each source's badge says, and what its list holds.
|
||||
const GROUP_LABEL: Record<EntryGroup["source"], string> = {
|
||||
"swarm-wide": "swarm-wide",
|
||||
own: "this agent",
|
||||
automatic: "automatic",
|
||||
};
|
||||
|
||||
function groupHelp(source: EntryGroup["source"], agent: string): string {
|
||||
switch (source) {
|
||||
case "swarm-wide":
|
||||
return "Names every agent in the swarm may reach through its hive's proxy.";
|
||||
case "own":
|
||||
return `Names only ${agent} may reach through its hive's proxy. Remove one to stop allowing it.`;
|
||||
case "automatic":
|
||||
return `Kept from ${agent}'s backend environment or a linked account; change or unlink that to change them.`;
|
||||
}
|
||||
}
|
||||
|
||||
export function AllowedDomains({ agent }: { agent: string }) {
|
||||
const [entries, setEntries] = useState<AllowedDomain[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
|
|
@ -116,40 +136,38 @@ export function AllowedDomains({ agent }: { agent: string }) {
|
|||
return (
|
||||
<div class="ui-agent-limits">
|
||||
{errorPanel}
|
||||
<Badge label="allowed domains" value={allowedSummary(shown)} />
|
||||
{shown.length > 0 ? (
|
||||
<ul class="ui-agent-limits-list">
|
||||
{shown.map((e) => (
|
||||
<li key={`${e.agent ?? "*"}/${e.domain}/${e.derived === true}`}>
|
||||
<Badge
|
||||
label={
|
||||
e.derived === true
|
||||
? "automatic"
|
||||
: e.agent === undefined
|
||||
? "swarm-wide"
|
||||
: agent
|
||||
}
|
||||
value={e.domain}
|
||||
title={
|
||||
e.derived === true
|
||||
? "kept from this agent's backend environment or a linked account; change or unlink that to change it"
|
||||
: undefined
|
||||
}
|
||||
/>{" "}
|
||||
{e.derived === true ? null : (
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value="remove"
|
||||
title={`stop allowing ${e.domain} for ${
|
||||
e.agent === undefined ? "every agent" : agent
|
||||
}`}
|
||||
onClick={() => void remove(e)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{shown.length === 0 ? (
|
||||
<Badge label="allowed domains" value="none added" />
|
||||
) : null}
|
||||
{entryGroups(shown).map((g) => (
|
||||
<BadgePopover
|
||||
key={g.source}
|
||||
label={GROUP_LABEL[g.source]}
|
||||
value={g.entries.length}
|
||||
title={groupHelp(g.source, agent)}
|
||||
popoverLabel={`${agent} allowed domains, ${GROUP_LABEL[g.source]}`}
|
||||
>
|
||||
<p class="ui-badge-popover-help">{groupHelp(g.source, agent)}</p>
|
||||
<ul class="ui-badge-popover-list">
|
||||
{g.entries.map((e) => (
|
||||
<li key={`${e.agent ?? "*"}/${e.domain}`}>
|
||||
<Badge value={e.domain} />{" "}
|
||||
{e.derived === true ? null : (
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value="remove"
|
||||
title={`stop allowing ${e.domain} for ${
|
||||
e.agent === undefined ? "every agent" : agent
|
||||
}`}
|
||||
disabled={busy}
|
||||
onClick={() => void remove(e)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</BadgePopover>
|
||||
))}
|
||||
<Badge
|
||||
variant="quiet"
|
||||
value="add"
|
||||
|
|
|
|||
|
|
@ -44,6 +44,32 @@ export function allowedSummary(shown: AllowedDomain[]): string {
|
|||
.join(", ");
|
||||
}
|
||||
|
||||
/** The entries of one source, for the badge that counts them. */
|
||||
export interface EntryGroup {
|
||||
source: "swarm-wide" | "own" | "automatic";
|
||||
entries: AllowedDomain[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The shown entries by where they come from, in the order the badges go: the
|
||||
* ones for every agent, the ones for this agent alone, then the automatic
|
||||
* ones. A source with no entry has no group.
|
||||
*/
|
||||
export function entryGroups(shown: AllowedDomain[]): EntryGroup[] {
|
||||
const groups: EntryGroup[] = [
|
||||
{
|
||||
source: "swarm-wide",
|
||||
entries: shown.filter((e) => e.agent === undefined && !e.derived),
|
||||
},
|
||||
{
|
||||
source: "own",
|
||||
entries: shown.filter((e) => e.agent !== undefined && !e.derived),
|
||||
},
|
||||
{ source: "automatic", entries: shown.filter((e) => e.derived === true) },
|
||||
];
|
||||
return groups.filter((g) => g.entries.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The body that adds or removes `domain`, as typed: the route lower-cases it
|
||||
* and refuses what is not a name. `forSwarm` makes it hold for every agent;
|
||||
|
|
|
|||
|
|
@ -1,28 +1,30 @@
|
|||
// The sections an agent card opens into, without a DOM: which there are, in
|
||||
// what order, and which start open. The card's header already shows the
|
||||
// agent's status, message and wanted state, so the sections hold the rest.
|
||||
// agent's state, message and wanted state, so the sections hold the rest.
|
||||
|
||||
/** One folded block of an opened agent card. */
|
||||
export type DetailSection = "overview" | "settings" | "accounts" | "terminal";
|
||||
export type DetailSection = "status" | "terminal" | "subagents" | "settings";
|
||||
|
||||
/** What each section's header says. */
|
||||
export const SECTION_TITLE: Record<DetailSection, string> = {
|
||||
overview: "overview",
|
||||
settings: "settings",
|
||||
accounts: "accounts",
|
||||
status: "status",
|
||||
terminal: "terminal",
|
||||
subagents: "subagents",
|
||||
settings: "settings",
|
||||
};
|
||||
|
||||
/**
|
||||
* The sections a card shows, top to bottom. The terminal is left out where
|
||||
* the page is already beside one (the terminal page's details dialog).
|
||||
* The sections a card shows, top to bottom. The terminal and subagents are
|
||||
* left out where the page is already beside a terminal (the terminal page's
|
||||
* details popover).
|
||||
*/
|
||||
export function sectionsFor(withTerminal: boolean): DetailSection[] {
|
||||
const sections: DetailSection[] = ["overview", "settings", "accounts"];
|
||||
return withTerminal ? [...sections, "terminal"] : sections;
|
||||
return withTerminal
|
||||
? ["status", "terminal", "subagents", "settings"]
|
||||
: ["status", "settings"];
|
||||
}
|
||||
|
||||
/** Only the overview is open when a card opens; the rest build on demand. */
|
||||
/** The status and the terminal are open when a card opens; the rest build on demand. */
|
||||
export function startsOpen(section: DetailSection): boolean {
|
||||
return section === "overview";
|
||||
return section === "status" || section === "terminal";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
/* <BadgePopover> — the box a badge opens under itself. */
|
||||
.ui-badge-popover {
|
||||
display: inline-block;
|
||||
}
|
||||
.ui-badge-popover-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.4em;
|
||||
padding: 0.5em 0.75em;
|
||||
max-width: min(32em, 90vw);
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ui-badge-popover-help {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.ui-badge-popover-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
// <BadgePopover> — a badge that shows a value and, when clicked, opens a
|
||||
// popover under it holding the control that changes the value: a list of
|
||||
// checkboxes, a choice of modes, the entries behind a count. The control sits
|
||||
// next to the thing it changes instead of in a dialog away from it.
|
||||
//
|
||||
// A click on the badge toggles the popover; a click outside it or Escape
|
||||
// closes it (`Popover` does that).
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||
import { Popover } from "../popover/Popover.js";
|
||||
import "./BadgePopover.css";
|
||||
|
||||
export function BadgePopover({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
title,
|
||||
popoverLabel,
|
||||
children,
|
||||
}: {
|
||||
/** Dim prefix of the badge, e.g. "permissions". */
|
||||
label?: ComponentChildren;
|
||||
/** What the badge says, e.g. "7 granted". */
|
||||
value: ComponentChildren;
|
||||
tone?: BadgeTone;
|
||||
/** Tooltip on the badge: what clicking it does. */
|
||||
title?: string;
|
||||
/** `aria-label` of the floating box. */
|
||||
popoverLabel: string;
|
||||
children: ComponentChildren;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLSpanElement>(null);
|
||||
return (
|
||||
<>
|
||||
<span class="ui-badge-popover" ref={anchorRef}>
|
||||
<Badge
|
||||
label={label}
|
||||
value={value}
|
||||
tone={tone}
|
||||
title={title}
|
||||
expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
/>
|
||||
</span>
|
||||
<Popover
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
anchorRef={anchorRef}
|
||||
label={popoverLabel}
|
||||
>
|
||||
<div class="ui-badge-popover-body">{children}</div>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import assert from "node:assert/strict";
|
|||
import { test } from "node:test";
|
||||
import {
|
||||
allowedSummary,
|
||||
entryGroups,
|
||||
entriesFor,
|
||||
entryBody,
|
||||
} from "../src/agent/detail/allowed-domains.ts";
|
||||
|
|
@ -62,3 +63,25 @@ test("a derived entry is counted as automatic, not as the operator's own", () =>
|
|||
assert.equal(allowedSummary([derived]), "1 automatic");
|
||||
assert.deepEqual(entriesFor([derived], "atlas"), [derived]);
|
||||
});
|
||||
|
||||
test("entries are grouped by source, empty sources left out", () => {
|
||||
const derived = { domain: "auto.io", agent: "atlas", derived: true };
|
||||
const groups = entryGroups([
|
||||
{ domain: "a.io" },
|
||||
{ domain: "b.io", agent: "atlas" },
|
||||
derived,
|
||||
]);
|
||||
assert.deepEqual(
|
||||
groups.map((g) => [g.source, g.entries.length]),
|
||||
[
|
||||
["swarm-wide", 1],
|
||||
["own", 1],
|
||||
["automatic", 1],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
entryGroups([{ domain: "a.io" }]).map((g) => g.source),
|
||||
["swarm-wide"],
|
||||
);
|
||||
assert.deepEqual(entryGroups([]), []);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,21 +8,24 @@ import {
|
|||
startsOpen,
|
||||
} from "../src/agent/detail/sections.ts";
|
||||
|
||||
test("a card holds overview, settings and accounts, then the terminal", () => {
|
||||
test("a card holds status, terminal, subagents, then settings", () => {
|
||||
assert.deepEqual(sectionsFor(true), [
|
||||
"overview",
|
||||
"settings",
|
||||
"accounts",
|
||||
"status",
|
||||
"terminal",
|
||||
"subagents",
|
||||
"settings",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a page beside a terminal leaves the terminal out", () => {
|
||||
assert.deepEqual(sectionsFor(false), ["overview", "settings", "accounts"]);
|
||||
test("a page beside a terminal leaves the terminal and subagents out", () => {
|
||||
assert.deepEqual(sectionsFor(false), ["status", "settings"]);
|
||||
});
|
||||
|
||||
test("only the overview starts open", () => {
|
||||
assert.deepEqual(sectionsFor(true).filter(startsOpen), ["overview"]);
|
||||
test("the status and the terminal start open", () => {
|
||||
assert.deepEqual(sectionsFor(true).filter(startsOpen), [
|
||||
"status",
|
||||
"terminal",
|
||||
]);
|
||||
});
|
||||
|
||||
test("every section has a header", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue