diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 54e01fd6..33c567d6 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, 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 | +| 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 and denied 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,9 +682,15 @@ 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 +The **egress** card on `/swarm` has two lists of 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. +entry is always for every agent). **allowed** is the list above. **denied** is the swarm's +deny list: a denied name is refused in every egress mode, even when an allowed entry, a hive's +own `allowedDomains` or the unrestricted internet flag would let it through. The card says +which hives have not applied the current deny list yet; until they do, their proxies may still let +those names through. The two lists use the same requests as the agent card, against +`/api/egress/allowed-domains` and `/api/egress/denied-domains`. The deny list has no +per-agent editor on the agent card yet, and no automatic entries. An entry under **automatic** has no **remove**: the controller keeps it from the host in the agent's stored API-key environment diff --git a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx index 5eaa2f73..ac3902f2 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx @@ -32,6 +32,7 @@ import { Panel } from "../../ui/panel/Panel.js"; import { TextField } from "../../ui/text-field/TextField.js"; import { ALLOWED_DOMAINS_URL, + DENIED_DOMAINS_URL, entriesFor, entryBody, entryGroups, @@ -150,17 +151,20 @@ export function AllowedDomains({ agent }: { agent: string }) { ); } -// 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. +// The add form of one allowed or denied 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, + kind = "allow", onClose, onAdded, }: { agent?: string; + kind?: "allow" | "deny"; onClose: () => void; onAdded: () => void; }) { + const deny = kind === "deny"; const [domain, setDomain] = useState(""); const [forSwarm, setForSwarm] = useState(agent === undefined); const [busy, setBusy] = useState(false); @@ -171,7 +175,7 @@ export function AddForm({ setBusy(true); setProblem(null); try { - const r = await fetch(ALLOWED_DOMAINS_URL, { + const r = await fetch(deny ? DENIED_DOMAINS_URL : ALLOWED_DOMAINS_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(entryBody(domain, forSwarm, agent ?? "")), @@ -191,28 +195,30 @@ export function AddForm({ return (

- {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.`}{" "} + {deny + ? agent === undefined + ? "Stops every agent reaching one name through its hive's proxy." + : `Stops ${agent} reaching one name through its hive's proxy, or every agent when the box below is ticked.` + : 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. + leading dot, as in .githubusercontent.com, also{" "} + {deny ? "refuses" : "allows"} every subdomain. IP addresses and + wildcards are refused.{" "} + {deny + ? "A denied name is refused in every egress mode, even when a swarm entry or a hive's own configuration allows it." + : "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 : ( e.agent === undefined && e.derived !== true); } + +/** + * What the deny list says about hives that have not taken its current + * revision: a hive in this list does not refuse the names yet. `null` when + * every hive has. + */ +export function behindNote(behind: string[]): string | null { + if (behind.length === 0) return null; + const names = behind.join(", "); + return behind.length === 1 + ? `${names} has not applied this list yet, so its proxy may still let these names through.` + : `${names} have not applied this list yet, so their proxies may still let these names through.`; +} diff --git a/frontend/packages/swarm-ui/src/agent/detail/useAllowedDomains.ts b/frontend/packages/swarm-ui/src/agent/detail/useAllowedDomains.ts index ca53c71e..f886cf1f 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/useAllowedDomains.ts +++ b/frontend/packages/swarm-ui/src/agent/detail/useAllowedDomains.ts @@ -1,20 +1,28 @@ -// 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. +// The swarm's allowed-domain and denied-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. +// Reads `GET ` (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. The deny list also reports +// `behind`, the hives that have not taken its current revision yet. 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"; +import { + ALLOWED_DOMAINS_URL, + DENIED_DOMAINS_URL, + type AllowedDomain, +} from "./allowed-domains.js"; interface Listing { entries: AllowedDomain[]; + behind?: string[]; } -export function useAllowedDomains() { +function useDomainList(url: string) { const [entries, setEntries] = useState(null); + const [behind, setBehind] = useState([]); const [error, setError] = useState(null); // Bumped after an add or a removal, so the list is fetched again. const [reload, setReload] = useState(0); @@ -23,7 +31,7 @@ export function useAllowedDomains() { useEffect(() => { let cancelled = false; (async () => { - const r = await fetch(ALLOWED_DOMAINS_URL); + const r = await fetch(url); if (!r.ok) { if (!cancelled) setError(await readApiError(r)); return; @@ -31,6 +39,7 @@ export function useAllowedDomains() { const body = (await r.json()) as Listing; if (cancelled) return; setEntries(body.entries); + setBehind(body.behind ?? []); setError(null); })().catch((e: unknown) => { if (!cancelled) setError({ detail: String(e) }); @@ -38,13 +47,13 @@ export function useAllowedDomains() { return () => { cancelled = true; }; - }, [reload]); + }, [url, reload]); async function remove(entry: AllowedDomain) { if (busy) return; setBusy(true); try { - const r = await fetch(ALLOWED_DOMAINS_URL, { + const r = await fetch(url, { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify(entry), @@ -63,9 +72,13 @@ export function useAllowedDomains() { return { entries, + behind, error, busy, remove, refetch: () => setReload((n) => n + 1), }; } + +export const useAllowedDomains = () => useDomainList(ALLOWED_DOMAINS_URL); +export const useDeniedDomains = () => useDomainList(DENIED_DOMAINS_URL); diff --git a/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx b/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx index 5a37482e..36c87058 100644 --- a/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx +++ b/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx @@ -1,26 +1,47 @@ -// — 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 names every agent in the swarm may or may not reach +// through its hive's egress proxy: the swarm-wide allowed domains and the +// swarm-wide denied ones, each with an add box and a remove beside each entry. +// 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`. +// The entries are the ones of `GET /api/egress/allowed-domains` and +// `GET /api/egress/denied-domains` that hold for every agent (an agent's own +// entries stay on its card). Allowed names add to what each hive's own +// configuration allows; a denied name is refused over everything, in every +// mode. An allowed 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` and `useDeniedDomains`. import { useState } from "preact/hooks"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { AddForm } from "../../agent/detail/AllowedDomains.js"; -import { swarmWideEntries } from "../../agent/detail/allowed-domains.js"; -import { useAllowedDomains } from "../../agent/detail/useAllowedDomains.js"; +import { + behindNote, + swarmWideEntries, +} from "../../agent/detail/allowed-domains.js"; +import { + useAllowedDomains, + useDeniedDomains, +} 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(); +type DomainList = ReturnType; + +// One list of the card: a heading, what it means, the rows and an add button. +function DomainSection({ + kind, + list, + heading, + help, +}: { + kind: "allow" | "deny"; + list: DomainList; + heading: string; + help: string; +}) { + const { entries, behind, error, busy, remove, refetch } = list; const [adding, setAdding] = useState(false); // A list already loaded stays under a failed refetch, so a change that @@ -28,64 +49,85 @@ function EgressBody() { const errorPanel = error ? ( <> ) : null; - if (entries === null) { - return ( - errorPanel ?? ( - - loading the allowed domains… - - ) - ); - } - const swarmWide = swarmWideEntries(entries); + const swarmWide = entries === null ? [] : swarmWideEntries(entries); + const note = kind === "deny" ? behindNote(behind) : null; + return ( +
+

{heading}

+

{help}

+ {errorPanel} + {entries === null ? ( + errorPanel === null ? ( + + loading the {kind === "deny" ? "denied" : "allowed"} domains… + + ) : null + ) : ( + <> + {note ?

{note}

: null} + {swarmWide.length === 0 ? ( +

none added

+ ) : ( +
    + {swarmWide.map((e) => ( +
  • + {e.domain} + +
  • + ))} +
+ )} + + setAdding(false)} + label={`add a ${kind === "deny" ? "denied" : "allowed"} domain`} + plain + > + {/* Remounted per open, so a cancelled add leaves nothing behind. */} + {adding ? ( + setAdding(false)} + onAdded={() => { + setAdding(false); + refetch(); + }} + /> + ) : null} + + + )} +
+ ); +} + +function EgressBody() { + const allowed = useAllowedDomains(); + const denied = useDeniedDomains(); 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) => ( -
  • - {e.domain} - -
  • - ))} -
- )} - - 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} - + + ); } @@ -95,7 +137,7 @@ export function EgressCard() { diff --git a/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css b/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css index 743c670d..29efb5fc 100644 --- a/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css +++ b/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css @@ -37,3 +37,19 @@ padding: 0.25em 0; border-bottom: 1px solid var(--border); } +.ui-swarm-egress-section + .ui-swarm-egress-section { + margin-top: 1.25em; + padding-top: 1em; + border-top: 1px dashed var(--border); +} +.ui-swarm-egress-heading { + margin: 0 0 0.35em; + font-size: 1em; +} +.ui-swarm-egress-behind { + margin: 0 0 0.75em; + padding: 0.4em 0.7em; + border: 1px solid var(--yellow); + border-left-width: 4px; + border-radius: 0.4em; +} diff --git a/frontend/packages/swarm-ui/test/allowed-domains.test.mjs b/frontend/packages/swarm-ui/test/allowed-domains.test.mjs index 25db44a6..9e4c07e8 100644 --- a/frontend/packages/swarm-ui/test/allowed-domains.test.mjs +++ b/frontend/packages/swarm-ui/test/allowed-domains.test.mjs @@ -6,6 +6,7 @@ import { allowedSummary, entryGroups, swarmWideEntries, + behindNote, entriesFor, entryBody, } from "../src/agent/detail/allowed-domains.ts"; @@ -104,3 +105,12 @@ test("the egress card lists only the entries that hold for every agent", () => { test("an add from the swarm page, with no agent, is a swarm-wide entry", () => { assert.deepEqual(entryBody("github.com", true, ""), { domain: "github.com" }); }); + +test("the deny list names the hives that have not applied it, and says nothing when none is behind", () => { + assert.equal(behindNote([]), null); + assert.match(behindNote(["pr1ma"]), /^pr1ma has not applied this list yet/); + assert.match( + behindNote(["pr1ma", "mu3de"]), + /^pr1ma, mu3de have not applied this list yet/, + ); +});