Watch
0
0
Fork
You've already forked hyperhive
0

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:
iris 2026-11-03 23:47:44 +01:00 • committed by mara
commit 5e23734667
10 changed files with 437 additions and 302 deletions

View file

@ -26,7 +26,7 @@ smaller; short confirm dialogs stay short.
| route | what | | 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 | | `/` | 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>/terminal` | one agent's live terminal |
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' 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** | | `/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 filters by name, hive, status, message, wanted state
and config PR. and config PR.
An opened card has four sections, each folded except **overview**, and each An opened card has four sections, and each reads nothing until you open it.
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 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 of `GET /api/links`; the row stays hidden when the swarm lists none), and its
open config PR. open config PR.
- **settings**: inference, instructions, resource caps, permissions and allowed - **terminal**: the live terminal with its message box.
domains, each with its own editor (described below). - **subagents**: the agent's subagents and their terminal preview (next section).
- **accounts**: the agent's linked accounts, and the menu that links another. - **settings**: inference, instructions, resource caps, internet, allowed
- **terminal**: the live terminal with its message box, and the agent's domains and permissions, then the agent's linked accounts and the menu that
subagents under it (next section). 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 ### 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 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: 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 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 preview. The popover doesn't dim 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 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 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 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 ### Permissions
An opened agent card shows, under **settings**, what the swarm grants it under **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 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 knows (`GET /api/permissions`), ticked as the grant stands, or on the default
set when the swarm sets none. 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 `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 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 `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 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 box shows a refusal's reason under the boxes and puts them back as stored.
them.
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 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` 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 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 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 agent has: one badge per source with its count, **swarm-wide** (the entries for every
the ones for this agent alone (badge with the agent's name). An entry matters 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 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 **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. 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 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 (`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 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 The fourth, the API-key environment, is the exception: a save overwrites what the
store holds. 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, per account: kind, name and host. Opening an agent makes one request,
`GET /api/hives/{hive}/agents/{agent}/linked-accounts`, which returns every `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 account of that agent as names and hosts and never a credential. When the card

View file

@ -17,7 +17,7 @@ import type { AgentActions } from "../roster/AgentActions.js";
import { AgentInference } from "./AgentInference.js"; import { AgentInference } from "./AgentInference.js";
import { AgentInstructions } from "./AgentInstructions.js"; import { AgentInstructions } from "./AgentInstructions.js";
import { AgentLimits } from "./AgentLimits.js"; import { AgentLimits } from "./AgentLimits.js";
import { AgentPermissions } from "./AgentPermissions.js"; import { AgentInternet, AgentPermissions } from "./AgentPermissions.js";
import { AllowedDomains } from "./AllowedDomains.js"; import { AllowedDomains } from "./AllowedDomains.js";
import { AgentStateRow } from "./AgentStateRow.js"; import { AgentStateRow } from "./AgentStateRow.js";
import { AgentTermPreview } from "../terminal/AgentTermPreview.js"; import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
@ -184,14 +184,18 @@ function SettingsRows({ agent }: { agent: string }) {
<dd> <dd>
<AgentLimits key={agent} agent={agent} /> <AgentLimits key={agent} agent={agent} />
</dd> </dd>
<dt>permissions</dt> <dt>internet</dt>
<dd> <dd>
<AgentPermissions key={agent} agent={agent} /> <AgentInternet key={agent} agent={agent} />
</dd> </dd>
<dt>allowed domains</dt> <dt>allowed domains</dt>
<dd> <dd>
<AllowedDomains key={agent} agent={agent} /> <AllowedDomains key={agent} agent={agent} />
</dd> </dd>
<dt>permissions</dt>
<dd>
<AgentPermissions key={agent} agent={agent} />
</dd>
</> </>
); );
} }
@ -251,35 +255,27 @@ function SectionBody({
actions: AgentActions; actions: AgentActions;
}) { }) {
switch (section) { switch (section) {
case "overview": case "status":
return ( return (
<dl class="ui-agent-detail-fields"> <dl class="ui-agent-detail-fields">
<OverviewRows row={row} actions={actions} /> <OverviewRows row={row} actions={actions} />
</dl> </dl>
); );
case "settings": case "settings":
return (
<dl class="ui-agent-detail-fields">
<SettingsRows agent={row.name} />
</dl>
);
case "accounts":
return ( return (
<> <>
<dl class="ui-agent-detail-fields"> <dl class="ui-agent-detail-fields">
<SettingsRows agent={row.name} />
<AccountsRows row={row} actions={actions} /> <AccountsRows row={row} actions={actions} />
</dl> </dl>
<LinkAccountAction row={row} actions={actions} /> <LinkAccountAction row={row} actions={actions} />
</> </>
); );
case "terminal": case "terminal":
return ( // The agent's live terminal and its input box.
<> return <AgentTermPreview agentName={row.name} />;
{/* The agent's live terminal and its input box. */} case "subagents":
<AgentTermPreview agentName={row.name} /> return <SubagentTerms key={row.name} agentName={row.name} />;
<SubagentTerms key={row.name} agentName={row.name} />
</>
);
} }
} }
@ -291,38 +287,30 @@ export function AgentDetail({
}: { }: {
row: AgentRow; row: AgentRow;
actions: AgentActions; actions: AgentActions;
/** Add the live terminal and subagents: for the flat layout, below the rows. */ /** Add the live terminal and subagents sections. */
withTerminal?: boolean; 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; 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 ( return (
<div class="ui-agent-detail"> <div class="ui-agent-detail ui-agent-detail-sections">
<dl class="ui-agent-detail-fields"> {inCard ? null : (
<HeaderRows row={row} actions={actions} /> <dl class="ui-agent-detail-fields">
<OverviewRows row={row} actions={actions} /> <HeaderRows row={row} actions={actions} />
<SettingsRows agent={row.name} /> </dl>
<AccountsRows row={row} actions={actions} /> )}
</dl> {sectionsFor(withTerminal).map((section) => (
<LinkAccountAction row={row} actions={actions} /> <LazyDisclosure
{withTerminal ? ( key={section}
<SectionBody section="terminal" row={row} actions={actions} /> title={SECTION_TITLE[section]}
) : null} startOpen={startsOpen(section)}
>
<SectionBody section={section} row={row} actions={actions} />
</LazyDisclosure>
))}
</div> </div>
); );
} }

View file

@ -1,24 +1,27 @@
// <AgentPermissions> — one agent's permissions in an opened agent card: what the // <AgentPermissions> and <AgentInternet> — one agent's grant in an opened agent
// swarm grants it, and an editor for that. // 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 // Both read `GET /api/agents/{name}/permissions`, which answers the stored
// `{ permissions? }` (`{}` when the swarm sets none, so the agent runs on the // grant as `{ permissions?, unrestricted_internet?, internet_log? }` (`{}` when
// default set). The editor offers the catalogue from `GET /api/permissions` as // the swarm sets none, so the agent runs on the default set). The permissions
// checkboxes and PUTs the ticked ones, a PUT replacing the stored value whole. // control offers the catalogue from `GET /api/permissions` as checkboxes and
// A selection equal to the default set DELETEs instead, as the create form // PUTs the ticked ones, a PUT replacing the stored value whole. A selection
// sends nothing for it: the agent then follows the default if that changes. // equal to the default set DELETEs instead, as the create form sends nothing
// The route's refusal shows as it arrives, next to the boxes. // 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 // Their state belongs to one agent: callers key them by agent name, so
// agents remounts it instead of showing the previous agent's grant. // switching agents remounts them instead of showing the previous agent's grant.
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js"; import { Badge } from "@hive/shared/badge.js";
import { BadgePopover } from "../../ui/badge-popover/BadgePopover.js";
import { Button } from "../../ui/button/Button.js"; import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { MultiselectFilterOptions } from "../../ui/multiselect-filter/MultiselectFilter.js"; import { MultiselectFilterOptions } from "../../ui/multiselect-filter/MultiselectFilter.js";
import { Panel } from "../../ui/panel/Panel.js";
import { import {
requestPermissions, requestPermissions,
togglePermission, togglePermission,
@ -46,28 +49,26 @@ interface Catalogue {
default: string[]; 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][] = [ const INTERNET_MODES: [InternetMode, string, string][] = [
[ ["restricted", "restricted", "allow only the listed names"],
"unrestricted",
"allow unrestricted",
"let its traffic leave the host unfiltered",
],
["log", "log", "allow what restricted would deny, and log it as would_deny"], ["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 { function permissionsUrl(agent: string): string {
return `/api/agents/${encodeURIComponent(agent)}/permissions`; 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 [grant, setGrant] = useState<Grant | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null); const [error, setError] = useState<ProblemDetails | null>(null);
// Bumped after a save or a clear, so the grant is fetched again. // Bumped after a save or a clear, so the grant is fetched again.
const [reload, setReload] = useState(0); const [reload, setReload] = useState(0);
const [editing, setEditing] = useState(false); const [busy, setBusy] = useState(false);
const [flagBusy, setFlagBusy] = useState(false); // The refusal of the last save, kept apart from a failed read.
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -89,165 +90,94 @@ export function AgentPermissions({ agent }: { agent: string }) {
}; };
}, [agent, reload]); }, [agent, reload]);
// Sets the internet mode, sending the stored list with it. // Sends one change and reads the grant again; true when the route took it.
async function setInternet(g: Grant, mode: InternetMode) { async function send(init: RequestInit): Promise<boolean> {
if (flagBusy) return; setBusy(true);
setFlagBusy(true); setSaveError(null);
try { try {
const r = await fetch(permissionsUrl(agent), { const r = await fetch(permissionsUrl(agent), init);
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(internetBody(g.permissions, mode)),
});
if (!r.ok) { if (!r.ok) {
setError(await readApiError(r)); setSaveError(await readApiError(r));
return; return false;
} }
setReload((n) => n + 1); setReload((n) => n + 1);
return true;
} catch (e: unknown) { } catch (e: unknown) {
setError({ detail: String(e) }); setSaveError({ detail: String(e) });
return false;
} finally { } finally {
setFlagBusy(false); setBusy(false);
} }
} }
// A grant already loaded stays under a failed refetch, so a save that // A grant already loaded stays under a failed refetch, so a save that
// worked is not hidden by a read that did not. // worked is not hidden by a read that did not.
const errorPanel = error ? ( const readFailure = error ? (
<> <>
<ApiErrorPanel context="reading the permissions failed" problem={error} /> <ApiErrorPanel context="reading the permissions failed" problem={error} />
<Button onClick={() => setReload((n) => n + 1)}>retry</Button> <Button onClick={() => setReload((n) => n + 1)}>retry</Button>
</> </>
) : null; ) : null;
if (grant === null) { const loading =
return ( grant === null
errorPanel ?? ( ? (readFailure ?? (
<span class="ui-agent-limits-muted" role="status"> <span class="ui-agent-limits-muted" role="status">
loading the permissions… loading the permissions…
</span> </span>
) ))
); : null;
} return { grant, busy, saveError, readFailure, loading, send };
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>
);
} }
function PermissionsForm({ export function AgentPermissions({ agent }: { agent: string }) {
agent, const { grant, busy, saveError, readFailure, loading, send } =
grant, useGrant(agent);
onClose,
onSaved,
}: {
agent: string;
grant: Grant;
onClose: () => void;
onSaved: () => void;
}) {
const [catalogue, setCatalogue] = useState<Catalogue | null>(null); const [catalogue, setCatalogue] = useState<Catalogue | null>(null);
const [selected, setSelected] = useState<string[]>([]); const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>(
const [busy, setBusy] = useState(false); null,
const [problem, setProblem] = 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
const r = await fetch("/api/permissions"); const r = await fetch("/api/permissions");
if (!r.ok) { if (!r.ok) {
if (!cancelled) setProblem(await readApiError(r)); if (!cancelled) setCatalogueError(await readApiError(r));
return; return;
} }
const data = (await r.json()) as Catalogue; const data = (await r.json()) as Catalogue;
if (cancelled) return; if (!cancelled) setCatalogue(data);
setCatalogue(data);
setSelected(editorSelection(grant.permissions, data.all, data.default));
})().catch((e: unknown) => { })().catch((e: unknown) => {
if (!cancelled) setProblem({ detail: String(e) }); if (!cancelled) setCatalogueError({ detail: String(e) });
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [grant]); }, []);
async function send(init: RequestInit) { // The stored grant changing (a save went through) resets the boxes to it.
setBusy(true); const stored = grant?.permissions;
setProblem(null); useEffect(() => {
try { setSelected(null);
const r = await fetch(permissionsUrl(agent), init); }, [stored]);
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);
}
}
function save(e: Event) { if (grant === null) return loading;
e.preventDefault();
const ticked =
selected ??
(catalogue
? editorSelection(stored, catalogue.all, catalogue.default)
: []);
async function toggle(value: string) {
if (!catalogue) return; if (!catalogue) return;
const next = togglePermission(ticked, value);
setSelected(next);
// The default set sends no list, so the agent follows the default. // The default set sends no list, so the agent follows the default.
const list = requestPermissions(selected, catalogue.default); const list = requestPermissions(next, catalogue.default);
void send( const ok = await send(
list === undefined list === undefined
? { method: "DELETE" } ? { method: "DELETE" }
: { : {
@ -256,53 +186,110 @@ function PermissionsForm({
body: JSON.stringify({ permissions: list }), body: JSON.stringify({ permissions: list }),
}, },
); );
// A refused change puts the boxes back as stored.
if (!ok) setSelected(null);
} }
return ( return (
<Panel title={`permissions — ${agent}`} icon="🔑" onClose={onClose}> <div class="ui-agent-limits">
<p> {readFailure}
What the swarm lets {agent} do. A tick grants that permission; leaving <BadgePopover
the default set ticked keeps {agent} on the default if it changes. The label="permissions"
hive reads a change within about 30 seconds, and the agent takes it at value={grantSummary(grant.permissions)}
its next turn. Changing <code>manage_root_agent</code> restarts it after title={`see and change what the swarm lets ${agent} do`}
its current turn. popoverLabel={`${agent} permissions`}
</p> >
<form class="ui-agent-limits-form" onSubmit={save}> <p class="ui-badge-popover-help">
<div role="group" aria-label="permissions"> What the swarm lets {agent} do. A tick grants it; ticking the default
{catalogue ? ( 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 <MultiselectFilterOptions
options={catalogue.all} options={catalogue.all}
filter={{ values: selected, negate: false }} filter={{ values: ticked, negate: false }}
onToggle={(v) => setSelected((s) => togglePermission(s, v))} onToggle={(v) => void toggle(v)}
/> />
) : ( </div>
<span class="ui-agent-limits-muted" role="status"> ) : (
{problem ? "could not load the permissions" : "loading…"} <span class="ui-agent-limits-muted" role="status">
</span> {catalogueError ? "could not load the permissions" : "loading…"}
)} </span>
</div> )}
{problem ? ( {saveError ? (
<ApiErrorPanel <ApiErrorPanel
context="the permissions were not saved" context="the permissions were not saved"
problem={problem} problem={saveError}
/> />
) : null} ) : null}
<div class="ui-agent-limits-actions"> <Button
<Button disabled={busy || grant.permissions === undefined}
variant="primary" onClick={() => void send({ method: "DELETE" })}
type="submit" >
disabled={busy || catalogue === null} use the default
> </Button>
{busy ? "saving…" : "save"} </BadgePopover>
</Button> </div>
<Button );
disabled={busy || grant.permissions === undefined} }
onClick={() => void send({ method: "DELETE" })}
> export function AgentInternet({ agent }: { agent: string }) {
use the default const { grant, busy, saveError, readFailure, loading, send } =
</Button> useGrant(agent);
</div> if (grant === null) return loading;
</form>
</Panel> 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>
); );
} }

View file

@ -24,6 +24,7 @@ import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js"; import { Badge } from "@hive/shared/badge.js";
import { BadgePopover } from "../../ui/badge-popover/BadgePopover.js";
import { Button } from "../../ui/button/Button.js"; import { Button } from "../../ui/button/Button.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js"; import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
import { Dialog } from "../../ui/dialog/Dialog.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 { TextField } from "../../ui/text-field/TextField.js";
import { import {
ALLOWED_DOMAINS_URL, ALLOWED_DOMAINS_URL,
allowedSummary,
entriesFor, entriesFor,
entryBody, entryBody,
entryGroups,
type AllowedDomain, type AllowedDomain,
type EntryGroup,
} from "./allowed-domains.js"; } from "./allowed-domains.js";
import "./AgentLimits.css"; import "./AgentLimits.css";
@ -42,6 +44,24 @@ interface Listing {
entries: AllowedDomain[]; 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 }) { export function AllowedDomains({ agent }: { agent: string }) {
const [entries, setEntries] = useState<AllowedDomain[] | null>(null); const [entries, setEntries] = useState<AllowedDomain[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null); const [error, setError] = useState<ProblemDetails | null>(null);
@ -116,40 +136,38 @@ export function AllowedDomains({ agent }: { agent: string }) {
return ( return (
<div class="ui-agent-limits"> <div class="ui-agent-limits">
{errorPanel} {errorPanel}
<Badge label="allowed domains" value={allowedSummary(shown)} /> {shown.length === 0 ? (
{shown.length > 0 ? ( <Badge label="allowed domains" value="none added" />
<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>
) : null} ) : 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 <Badge
variant="quiet" variant="quiet"
value="add" value="add"

View file

@ -44,6 +44,32 @@ export function allowedSummary(shown: AllowedDomain[]): string {
.join(", "); .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 * 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; * and refuses what is not a name. `forSwarm` makes it hold for every agent;

View file

@ -1,28 +1,30 @@
// The sections an agent card opens into, without a DOM: which there are, in // 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 // 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. */ /** 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. */ /** What each section's header says. */
export const SECTION_TITLE: Record<DetailSection, string> = { export const SECTION_TITLE: Record<DetailSection, string> = {
overview: "overview", status: "status",
settings: "settings",
accounts: "accounts",
terminal: "terminal", terminal: "terminal",
subagents: "subagents",
settings: "settings",
}; };
/** /**
* The sections a card shows, top to bottom. The terminal is left out where * The sections a card shows, top to bottom. The terminal and subagents are
* the page is already beside one (the terminal page's details dialog). * left out where the page is already beside a terminal (the terminal page's
* details popover).
*/ */
export function sectionsFor(withTerminal: boolean): DetailSection[] { export function sectionsFor(withTerminal: boolean): DetailSection[] {
const sections: DetailSection[] = ["overview", "settings", "accounts"]; return withTerminal
return withTerminal ? [...sections, "terminal"] : sections; ? ["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 { export function startsOpen(section: DetailSection): boolean {
return section === "overview"; return section === "status" || section === "terminal";
} }

View file

@ -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;
}

View file

@ -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>
</>
);
}

View file

@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { import {
allowedSummary, allowedSummary,
entryGroups,
entriesFor, entriesFor,
entryBody, entryBody,
} from "../src/agent/detail/allowed-domains.ts"; } 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.equal(allowedSummary([derived]), "1 automatic");
assert.deepEqual(entriesFor([derived], "atlas"), [derived]); 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([]), []);
});

View file

@ -8,21 +8,24 @@ import {
startsOpen, startsOpen,
} from "../src/agent/detail/sections.ts"; } 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), [ assert.deepEqual(sectionsFor(true), [
"overview", "status",
"settings",
"accounts",
"terminal", "terminal",
"subagents",
"settings",
]); ]);
}); });
test("a page beside a terminal leaves the terminal out", () => { test("a page beside a terminal leaves the terminal and subagents out", () => {
assert.deepEqual(sectionsFor(false), ["overview", "settings", "accounts"]); assert.deepEqual(sectionsFor(false), ["status", "settings"]);
}); });
test("only the overview starts open", () => { test("the status and the terminal start open", () => {
assert.deepEqual(sectionsFor(true).filter(startsOpen), ["overview"]); assert.deepEqual(sectionsFor(true).filter(startsOpen), [
"status",
"terminal",
]);
}); });
test("every section has a header", () => { test("every section has a header", () => {