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 |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | 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

View file

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

View file

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

View file

@ -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"

View file

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

View file

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

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 {
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([]), []);
});

View file

@ -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", () => {