Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: an egress card on the swarm page for the swarm-wide allowed domains, sharing the agent card's add form and list hook

This commit is contained in:
iris 2026-11-04 21:14:43 +01:00
commit ad76e7e937
6 changed files with 254 additions and 84 deletions

View file

@ -23,16 +23,16 @@ shut. A dropdown inside a dialog opens above the dialog and the dialog doesn't c
A dialog grows to fit its content, up to 64em or nine tenths of the window, whichever is
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, terminal, subagents, and settings with permissions, internet, allowed domains and accounts); create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts |
| `/swarm` | what belongs to the whole swarm: a folded **hives** card with the hive directory and each hive's last reported status, and a folded, **beta** **notification router** card (with a warning banner) holding the router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows. `/notifications` redirects here |
| `/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** |
| `/jobs` | the controller's job graph, where agent creation and credential mints show progress, and under it the swarm builder's recent builds (agent config PRs, and agents built from `internal/meta` before a deploy), with what nix printed when one failed, and the review classifier's recent runs, a try-out on a real PR, and its settings |
| `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient; a folded **broadcast** card above the feed sends one message to more than one agent at once, and a folded **schedules** card holds the scheduled prompts: add, edit, pause, resume and remove |
| 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, terminal, subagents, and settings with permissions, internet, allowed domains and accounts); create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts |
| `/swarm` | what belongs to the whole swarm: a folded **hives** card with the hive directory and each hive's last reported status, a folded **egress** card with the swarm-wide allowed domains, and a folded, **beta** **notification router** card (with a warning banner) holding the router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows. `/notifications` redirects here |
| `/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** |
| `/jobs` | the controller's job graph, where agent creation and credential mints show progress, and under it the swarm builder's recent builds (agent config PRs, and agents built from `internal/meta` before a deploy), with what nix printed when one failed, and the review classifier's recent runs, a try-out on a real PR, and its settings |
| `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient; a folded **broadcast** card above the feed sends one message to more than one agent at once, and a folded **schedules** card holds the scheduled prompts: add, edit, pause, resume and remove |
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
Pages that refresh on a timer share one polling rule: the page sends no new
@ -682,6 +682,10 @@ name the proxy's private-range rules leave. **remove**, beside a name, drops one
**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.
The **egress** card on `/swarm` lists only the swarm-wide entries, one per line, with
**remove** beside each and an **add** that opens the same dialog without the tick (the
entry is always for every agent). It uses the same requests as the agent card.
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

View file

@ -20,7 +20,7 @@
// refuses to remove it.
//
// Its state belongs to one agent: callers key it by agent name.
import { useEffect, useState } from "preact/hooks";
import { 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";
@ -35,16 +35,12 @@ import {
entriesFor,
entryBody,
entryGroups,
type AllowedDomain,
type EntryGroup,
} from "./allowed-domains.js";
import { useAllowedDomains } from "./useAllowedDomains.js";
import "../../ui/badge-row/BadgeRow.css";
import "./AgentLimits.css";
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",
@ -64,53 +60,8 @@ function groupHelp(source: EntryGroup["source"], agent: string): string {
}
export function AllowedDomains({ agent }: { agent: string }) {
const [entries, setEntries] = useState<AllowedDomain[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
// Bumped after an add or a removal, so the list is fetched again.
const [reload, setReload] = useState(0);
const { entries, error, busy, remove, refetch } = useAllowedDomains();
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(ALLOWED_DOMAINS_URL);
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
}
const body = (await r.json()) as Listing;
if (cancelled) return;
setEntries(body.entries);
setError(null);
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, [reload]);
async function remove(entry: AllowedDomain) {
if (busy) return;
setBusy(true);
try {
const r = await fetch(ALLOWED_DOMAINS_URL, {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify(entry),
});
if (!r.ok) {
setError(await readApiError(r));
return;
}
setReload((n) => n + 1);
} catch (e: unknown) {
setError({ detail: String(e) });
} finally {
setBusy(false);
}
}
// A list already loaded stays under a failed refetch, so a change that
// worked is not hidden by a read that did not.
@ -120,7 +71,7 @@ export function AllowedDomains({ agent }: { agent: string }) {
context="the allowed domains were not read or changed"
problem={error}
/>
<Button onClick={() => setReload((n) => n + 1)}>retry</Button>
<Button onClick={() => refetch()}>retry</Button>
</>
) : null;
if (entries === null) {
@ -190,7 +141,7 @@ export function AllowedDomains({ agent }: { agent: string }) {
onClose={() => setAdding(false)}
onAdded={() => {
setAdding(false);
setReload((n) => n + 1);
refetch();
}}
/>
) : null}
@ -199,17 +150,19 @@ export function AllowedDomains({ agent }: { agent: string }) {
);
}
function AddForm({
// The add form of one allowed domain: for `agent`, or for every agent. With no
// `agent` (the swarm page) it can only add a swarm-wide entry.
export function AddForm({
agent,
onClose,
onAdded,
}: {
agent: string;
agent?: string;
onClose: () => void;
onAdded: () => void;
}) {
const [domain, setDomain] = useState("");
const [forSwarm, setForSwarm] = useState(false);
const [forSwarm, setForSwarm] = useState(agent === undefined);
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<ProblemDetails | null>(null);
@ -221,7 +174,7 @@ function AddForm({
const r = await fetch(ALLOWED_DOMAINS_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(entryBody(domain, forSwarm, agent)),
body: JSON.stringify(entryBody(domain, forSwarm, agent ?? "")),
});
if (!r.ok) {
// The box stays as typed, so the operator can fix it and add again.
@ -237,15 +190,25 @@ function AddForm({
}
return (
<Panel title={`allowed domain — ${agent}`} icon="🌐" onClose={onClose}>
<Panel
title={
agent === undefined
? "allowed domain — swarm-wide"
: `allowed domain — ${agent}`
}
icon="🌐"
onClose={onClose}
>
<p>
Lets {agent} reach one more name through its hive's proxy, or every
agent when the box below is ticked. A name needs at least two labels,
such as <code>github.com</code>; a leading dot, as in{" "}
<code>.githubusercontent.com</code>, also allows every subdomain. IP
addresses and wildcards are refused. A name that resolves to a private
address stays blocked. The entry adds to the names each hive's own
configuration allows. The hive reads a change within about 30 seconds.
{agent === undefined
? "Lets every agent reach one more name through its hive's proxy."
: `Lets ${agent} reach one more name through its hive's proxy, or every agent when the box below is ticked.`}{" "}
A name needs at least two labels, such as <code>github.com</code>; a
leading dot, as in <code>.githubusercontent.com</code>, also allows
every subdomain. IP addresses and wildcards are refused. A name that
resolves to a private address stays blocked. The entry adds to the names
each hive's own configuration allows. The hive reads a change within
about 30 seconds.
</p>
<form class="ui-agent-limits-form" onSubmit={(e) => void save(e)}>
<TextField
@ -255,12 +218,14 @@ function AddForm({
placeholder="e.g. github.com"
onInput={setDomain}
/>
<CheckboxField
id="allowed-domain-swarm"
label="for every agent in the swarm"
checked={forSwarm}
onChange={setForSwarm}
/>
{agent === undefined ? null : (
<CheckboxField
id="allowed-domain-swarm"
label="for every agent in the swarm"
checked={forSwarm}
onChange={setForSwarm}
/>
)}
{problem ? (
<ApiErrorPanel context="the domain was not added" problem={problem} />
) : null}

View file

@ -0,0 +1,71 @@
// The swarm's allowed-domain entries and the one change that is not a form:
// removing an entry. Shared by an agent card's allowed domains and the swarm
// page's egress card, which differ only in which entries they show.
//
// Reads `GET /api/egress/allowed-domains` (every entry) and removes one with a
// `DELETE` carrying the entry, each answering the whole list. `refetch` reads
// the list again, for a caller that added an entry through its own form.
import { useEffect, useState } from "preact/hooks";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { ALLOWED_DOMAINS_URL, type AllowedDomain } from "./allowed-domains.js";
interface Listing {
entries: AllowedDomain[];
}
export function useAllowedDomains() {
const [entries, setEntries] = useState<AllowedDomain[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
// Bumped after an add or a removal, so the list is fetched again.
const [reload, setReload] = useState(0);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(ALLOWED_DOMAINS_URL);
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
}
const body = (await r.json()) as Listing;
if (cancelled) return;
setEntries(body.entries);
setError(null);
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, [reload]);
async function remove(entry: AllowedDomain) {
if (busy) return;
setBusy(true);
try {
const r = await fetch(ALLOWED_DOMAINS_URL, {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify(entry),
});
if (!r.ok) {
setError(await readApiError(r));
return;
}
setReload((n) => n + 1);
} catch (e: unknown) {
setError({ detail: String(e) });
} finally {
setBusy(false);
}
}
return {
entries,
error,
busy,
remove,
refetch: () => setReload((n) => n + 1),
};
}

View file

@ -0,0 +1,114 @@
// <EgressCard> — the names every agent in the swarm may reach through its
// hive's egress proxy: the swarm-wide allowed domains, with an add box and a
// remove beside each. A card that starts folded and reads nothing until it is
// opened.
//
// The entries are the ones of `GET /api/egress/allowed-domains` that hold for
// every agent (an agent's own entries stay on its card). They add to the names
// each hive's own configuration allows. An entry the controller keeps from an
// environment or account (`derived`) is not listed here. Adding and removing
// are the agent card's own: `AddForm` without an agent, `useAllowedDomains`.
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge } from "@hive/shared/badge.js";
import { AddForm } from "../../agent/detail/AllowedDomains.js";
import { useAllowedDomains } from "../../agent/detail/useAllowedDomains.js";
import { Button } from "../../ui/button/Button.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import "../../agent/detail/AgentLimits.css";
import "./SwarmPage.css";
function EgressBody() {
const { entries, error, busy, remove, refetch } = useAllowedDomains();
const [adding, setAdding] = useState(false);
// A list already loaded stays under a failed refetch, so a change that
// worked is not hidden by a read that did not.
const errorPanel = error ? (
<>
<ApiErrorPanel
context="the allowed domains were not read or changed"
problem={error}
/>
<Button onClick={() => refetch()}>retry</Button>
</>
) : null;
if (entries === null) {
return (
errorPanel ?? (
<span class="ui-agent-limits-muted" role="status">
loading the allowed domains…
</span>
)
);
}
const swarmWide = entries.filter(
(e) => e.agent === undefined && e.derived !== true,
);
return (
<>
{errorPanel}
<p class="ui-swarm-egress-help">
Names every agent in the swarm may reach through its hive's proxy, on
top of what each hive's own configuration allows. An agent that has
unrestricted internet reaches any name anyway. The hive reads a change
within about 30 seconds.
</p>
{swarmWide.length === 0 ? (
<p class="ui-agent-limits-muted">none added</p>
) : (
<ul class="ui-swarm-egress-list">
{swarmWide.map((e) => (
<li key={e.domain}>
<Badge value={e.domain} />{" "}
<Badge
variant="quiet"
value="remove"
title={`stop allowing ${e.domain} for every agent`}
disabled={busy}
onClick={() => void remove(e)}
/>
</li>
))}
</ul>
)}
<Badge
variant="quiet"
value="add"
title="allow a domain for every agent"
onClick={() => setAdding(true)}
/>
<Dialog
open={adding}
onClose={() => setAdding(false)}
label="add an allowed domain"
plain
>
{/* Remounted per open, so a cancelled add leaves nothing behind. */}
{adding ? (
<AddForm
onClose={() => setAdding(false)}
onAdded={() => {
setAdding(false);
refetch();
}}
/>
) : null}
</Dialog>
</>
);
}
export function EgressCard() {
return (
<LazyDisclosure
title="egress"
icon="🌐"
hint="the domains every agent may reach through its hive's proxy"
>
<EgressBody />
</LazyDisclosure>
);
}

View file

@ -17,3 +17,15 @@
border-radius: 0.4em;
background: color-mix(in srgb, var(--yellow) 12%, transparent);
}
/* The egress card: the swarm-wide allowed domains, one per line. */
.ui-swarm-egress-help {
margin: 0 0 0.75em;
color: var(--muted);
}
.ui-swarm-egress-list {
list-style: none;
margin: 0 0 0.5em;
padding: 0;
display: grid;
gap: 0.3em;
}

View file

@ -1,12 +1,13 @@
// <SwarmPage> — what belongs to the swarm as a whole rather than to one agent:
// the hive roster and the notification router. Each is a card that starts
// folded and reads nothing until it is opened.
// the hive roster, the swarm-wide egress allow list and the notification router.
// Each is a card that starts folded and reads nothing until it is opened.
//
// The notification router is marked beta: its card says so beside the title and
// carries a warning banner inside.
import { Badge } from "@hive/shared/badge.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import { NotificationRouter } from "../notifications/NotificationsPage.js";
import { EgressCard } from "./EgressCard.js";
import { HivesCard } from "./HivesCard.js";
import "./SwarmPage.css";
@ -16,6 +17,9 @@ export function SwarmPage() {
<div class="ui-swarm-card">
<HivesCard />
</div>
<div class="ui-swarm-card">
<EgressCard />
</div>
<div class="ui-swarm-card">
<LazyDisclosure
title="notification router"