diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 472043fb..54e01fd6 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -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//terminal` | one agent's live terminal | -| `/agents//subagents//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//terminal` | one agent's live terminal | +| `/agents//subagents//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 diff --git a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx index d094d24f..5eaa2f73 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx @@ -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 = { "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(null); - const [error, setError] = useState(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} /> - + ) : 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(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 ( - +

- 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 github.com; a leading dot, as in{" "} - .githubusercontent.com, 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 github.com; a + leading dot, as in .githubusercontent.com, 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.

void save(e)}> - + {agent === undefined ? null : ( + + )} {problem ? ( ) : null} diff --git a/frontend/packages/swarm-ui/src/agent/detail/useAllowedDomains.ts b/frontend/packages/swarm-ui/src/agent/detail/useAllowedDomains.ts new file mode 100644 index 00000000..ca53c71e --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/useAllowedDomains.ts @@ -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(null); + const [error, setError] = useState(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), + }; +} diff --git a/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx b/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx new file mode 100644 index 00000000..285bccd0 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx @@ -0,0 +1,114 @@ +// β€” 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 ? ( + <> + + + + ) : null; + if (entries === null) { + return ( + errorPanel ?? ( + + loading the allowed domains… + + ) + ); + } + + const swarmWide = entries.filter( + (e) => e.agent === undefined && e.derived !== true, + ); + return ( + <> + {errorPanel} +

+ 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. +

+ {swarmWide.length === 0 ? ( +

none added

+ ) : ( +
    + {swarmWide.map((e) => ( +
  • + {" "} + void remove(e)} + /> +
  • + ))} +
+ )} + setAdding(true)} + /> + setAdding(false)} + label="add an allowed domain" + plain + > + {/* Remounted per open, so a cancelled add leaves nothing behind. */} + {adding ? ( + setAdding(false)} + onAdded={() => { + setAdding(false); + refetch(); + }} + /> + ) : null} + + + ); +} + +export function EgressCard() { + return ( + + + + ); +} diff --git a/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css b/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css index 4e5c46af..5ac1a089 100644 --- a/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css +++ b/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css @@ -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; +} diff --git a/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.tsx b/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.tsx index 246e1bd9..f9fbdabb 100644 --- a/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.tsx @@ -1,12 +1,13 @@ // β€” 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() {
+
+ +