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
2 changed files with 254 additions and 84 deletions

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