diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 9f54c649..860d38f6 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -203,6 +203,11 @@ agents doesn't, and the form warns when an edit would. A start in the past fires the latest missed time once, right away, in every agent chosen, and the form says so. +The form offers the agents of the roster that `/agents` lists. The page reloads +that roster with the schedules, so an agent created after you opened the page +shows up without a reload. If the roster fails to load, the form says so instead +of claiming there are no agents. + Only an operator can change a schedule here or with `swarmctl schedule`; an agent has no way to add one. The page needs the controller's `/api/schedules` routes. diff --git a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx index 70e02cfb..74bc1f5a 100644 --- a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx @@ -23,6 +23,7 @@ import { } from "../ui/refresh-interval/RefreshInterval.js"; import { fetchJson, usePolled } from "../ui/polled/usePolled.js"; import { Table, type TableColumn } from "../ui/table/Table.js"; +import { domainCell } from "./hive-domain.js"; type Freshness = "fresh" | "stale" | "never_reported" | "unknown"; @@ -59,16 +60,19 @@ const COLUMNS: TableColumn[] = [ { key: "domain", header: "domain", - render: (h) => - h.domain && h.dashboard ? ( - - {h.domain} + render: (h) => { + const { text, href } = domainCell(h); + if (!text) return "—"; + return href ? ( + + {text} ) : ( - "—" - ), - sortBy: (h) => h.domain ?? "", - filterValue: (h) => h.domain ?? "", + text + ); + }, + sortBy: (h) => domainCell(h).text, + filterValue: (h) => domainCell(h).text, }, { key: "status", @@ -137,6 +141,7 @@ export function HivesPage() { columns={COLUMNS} rows={hives} rowKey={(h) => h.name} + emptyMessage="no hives in the roster yet" storageKey="swarm-ui:hives:table-filters" /> ) : null} diff --git a/frontend/packages/swarm-ui/src/pages/hive-domain.ts b/frontend/packages/swarm-ui/src/pages/hive-domain.ts new file mode 100644 index 00000000..0285616a --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/hive-domain.ts @@ -0,0 +1,22 @@ +// What the hives table shows in its domain column. The cell, the sort and the +// text filter all read this one value, so a row sorts and filters under the +// text it displays. + +export interface HiveDomainFields { + domain: string | null; + dashboard: string | null; +} + +export interface DomainCell { + /** The domain, or "" when the hive has none. */ + text: string; + /** The dashboard URL, or null when the hive has no dashboard to open. */ + href: string | null; +} + +export function domainCell(h: HiveDomainFields): DomainCell { + return { + text: h.domain ?? "", + href: h.domain && h.dashboard ? `https://${h.dashboard}/` : null, + }; +} diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx index 27eeee9c..f5d4123c 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx @@ -51,6 +51,7 @@ function nextMinuteUnix(): number { export function ScheduleForm({ schedule, agentOptions, + agentsError, onSaved, onCancel, }: { @@ -58,6 +59,8 @@ export function ScheduleForm({ schedule?: Schedule; /** Every agent a schedule can target. */ agentOptions: string[]; + /** Why the agent list could not load; unset when it did. */ + agentsError?: string; onSaved: () => void; onCancel: () => void; }) { @@ -187,7 +190,11 @@ export function ScheduleForm({
agents {agentOptions.length === 0 ? ( - no agents to pick from + + {agentsError + ? `could not load the agent list: ${agentsError}` + : "no agents to pick from"} + ) : ( [] = [ }, ]; -// The agent names a schedule can target, from the same roster the agents -// page lists. A failed load leaves the picker empty rather than blocking -// the page: the cards themselves do not need it. -async function loadAgentNames(): Promise { - const r = await fetch("/api/agents/status"); - if (!r.ok) return []; - const rows = (await r.json()) as AgentRow[]; - return rows.map((a) => a.name).sort(); -} - export function SchedulesPage() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); @@ -76,7 +67,14 @@ export function SchedulesPage() { } = usePolled(intervalMs, (signal) => fetchJson("/api/schedules", signal), ); - const [agentNames, setAgentNames] = useState([]); + // The agent names a schedule can target, from the roster the agents page + // lists, polled on the same cadence so an agent created after this page + // opened shows up. A failed load does not block the page, the cards do not + // need it; the form says the list could not load. + const { data: roster, error: rosterError } = usePolled(intervalMs, (signal) => + fetchJson("/api/agents/status", signal), + ); + const agentNames = useMemo(() => agentNamesFrom(roster), [roster]); // The schedule whose card is currently its edit form, and whether the // "new schedule" form is open above the list. const [editingId, setEditingId] = useState(null); @@ -85,10 +83,6 @@ export function SchedulesPage() { const [removeTarget, setRemoveTarget] = useState(null); const [actionError, setActionError] = useState(null); - useEffect(() => { - loadAgentNames().then(setAgentNames, () => setAgentNames([])); - }, []); - function markPending(id: string, on: boolean) { setPendingIds((cur) => { const next = new Set(cur); @@ -139,13 +133,10 @@ export function SchedulesPage() { ); } - // A schedule may still target an agent that has since left the roster; - // its own agents stay pickable so an edit can keep or drop them. - const optionsFor = useMemo( - () => (s?: Schedule) => - [...new Set([...agentNames, ...(s?.agents ?? [])])].sort(), - [agentNames], - ); + const optionsFor = (s?: Schedule) => agentOptionsFor(agentNames, s); + const agentsError = rosterError + ? (rosterError.detail ?? rosterError.title ?? "the request failed") + : undefined; function saved() { setCreating(false); @@ -186,6 +177,7 @@ export function SchedulesPage() { {creating ? ( setCreating(false)} /> @@ -204,6 +196,7 @@ export function SchedulesPage() { setEditingId(null)} /> diff --git a/frontend/packages/swarm-ui/src/pages/schedules/agent-options.ts b/frontend/packages/swarm-ui/src/pages/schedules/agent-options.ts new file mode 100644 index 00000000..498497ea --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/schedules/agent-options.ts @@ -0,0 +1,18 @@ +// The agent names a schedule form offers. + +/** Sorted names from the agent roster; none while it has not loaded. */ +export function agentNamesFrom( + rows: readonly { name: string }[] | null, +): string[] { + return (rows ?? []).map((a) => a.name).sort(); +} + +/** The roster names plus the schedule's own agents, sorted without repeats. A + * schedule can still target an agent that has since left the roster, so an + * edit keeps those pickable and can drop them. */ +export function agentOptionsFor( + names: readonly string[], + schedule?: { agents: readonly string[] }, +): string[] { + return [...new Set([...names, ...(schedule?.agents ?? [])])].sort(); +} diff --git a/frontend/packages/swarm-ui/test/agent-options.test.mjs b/frontend/packages/swarm-ui/test/agent-options.test.mjs new file mode 100644 index 00000000..803dc5ac --- /dev/null +++ b/frontend/packages/swarm-ui/test/agent-options.test.mjs @@ -0,0 +1,27 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + agentNamesFrom, + agentOptionsFor, +} from "../src/pages/schedules/agent-options.ts"; + +test("agentNamesFrom: sorted names, empty before the roster loads", () => { + assert.deepEqual(agentNamesFrom([{ name: "b" }, { name: "a" }]), ["a", "b"]); + assert.deepEqual(agentNamesFrom(null), []); +}); + +test("agentOptionsFor: roster names alone for a new schedule", () => { + assert.deepEqual(agentOptionsFor(["b", "a"]), ["a", "b"]); +}); + +test("agentOptionsFor: keeps an agent that left the roster, once", () => { + assert.deepEqual(agentOptionsFor(["a", "b"], { agents: ["b", "gone"] }), [ + "a", + "b", + "gone", + ]); +}); + +test("agentOptionsFor: an empty roster still offers the schedule's agents", () => { + assert.deepEqual(agentOptionsFor([], { agents: ["x"] }), ["x"]); +}); diff --git a/frontend/packages/swarm-ui/test/hive-domain.test.mjs b/frontend/packages/swarm-ui/test/hive-domain.test.mjs new file mode 100644 index 00000000..64b3a746 --- /dev/null +++ b/frontend/packages/swarm-ui/test/hive-domain.test.mjs @@ -0,0 +1,31 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { domainCell } from "../src/pages/hive-domain.ts"; + +test("domainCell: a domain with a dashboard links to it", () => { + assert.deepEqual( + domainCell({ domain: "a.example", dashboard: "d.a.example" }), + { + text: "a.example", + href: "https://d.a.example/", + }, + ); +}); + +test("domainCell: a domain without a dashboard still shows as text", () => { + assert.deepEqual(domainCell({ domain: "a.example", dashboard: null }), { + text: "a.example", + href: null, + }); +}); + +test("domainCell: no domain is empty text and no link", () => { + assert.deepEqual(domainCell({ domain: null, dashboard: "d.a.example" }), { + text: "", + href: null, + }); + assert.deepEqual(domainCell({ domain: null, dashboard: null }), { + text: "", + href: null, + }); +});