Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: a swarm tab after agents holding the hives card and the beta notification router; /notifications redirects to it

This commit is contained in:
iris 2026-11-04 20:56:24 +01:00 • committed by mara
commit 0302582d1c
12 changed files with 94 additions and 49 deletions

View file

@ -83,7 +83,7 @@ todo, and `head-classifier`: source `pull_request` and `pull_request_sync` →
match on `action` (`opened`, `reopened`, `synchronized`) and `draft` = `false`, or on
`undrafted` → `request_reviewer` for `review-classifier` ([below](#requesting-a-review)). The code defines them
(`swarm_wire::notify_flows::builtins`), so a build that changes one reaches
every swarm. Each switches off on `/notifications`. A custom flow has the same type and
every swarm. Each switches off on the notification router card on `/swarm`. A custom flow has the same type and
the same evaluator, so it can do anything a built-in does.
**The stored document.** swarm-controller keeps the rest in one document, the
@ -96,7 +96,7 @@ check above and reads only what exists: a source event from `issues`,
three `pull_request_review_*` events, and condition and recipient fields from the event's field list. The same goes for the `{field}`
placeholders in a todo's key and text. A `PUT` must carry the `revision` it read, `0` for no
document, and gets 409 when the stored revision is another. Swarm UI's
[`/notifications`](ui.md#notification-flows) page shows the document and
[notification router card on `/swarm`](ui.md#notification-flows) shows the document and
switches built-ins.
**The schema.** `GET /api/notify/flows` also returns a `schema` object, built
@ -263,7 +263,7 @@ serves them next to `GET /api/notify/flows`:
The dry-run request carries a whole document. The `GET` sends the saved one;
the `POST` takes a `PUT` body (it ignores a `revision`), answers 400
with the `PUT` error for a document that fails the check, and forwards a valid one.
[`/notifications`](ui.md#notification-flows) shows both: the recent deliveries,
[notification router card on `/swarm`](ui.md#notification-flows) shows both: the recent deliveries,
and the counts beside each node.
**Deploying in any order.** A router that predates these subjects doesn't

View file

@ -26,13 +26,13 @@ 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), under a folded **hives** card with the hive directory and each hive's last reported status; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts |
| `/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 |
| `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows |
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
@ -58,7 +58,7 @@ works too.
### The agents page
`/agents` is a column of separate cards: the hives card, a filter card, then one
`/agents` is a column of separate cards: a filter card, then one
card per agent, none of them inside a bigger card. A card's
header shows its icon, name, freshness, the status message, a **terminal**
button that opens the agent's terminal tab, the **wanted** menu (up, offline,
@ -295,7 +295,7 @@ routes.
### Notification flows
`/notifications` lists the [notification router's flows](notify-router.md#flows)
The **notification router** card on `/swarm` (beta, folded; `/notifications` redirects to it) lists the [notification router's flows](notify-router.md#flows)
in two groups. Each **built-in** flow has an on/off switch; switching one off
stops the router running it from its next event, and switching it on again
restores it. Each **custom** flow shows its name and whether it's on. Every

View file

@ -9,7 +9,7 @@ import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
import { MessagesPage } from "./pages/messages/MessagesPage.js";
import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js";
import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js";
import { NotificationsPage } from "./pages/notifications/NotificationsPage.js";
import { SwarmPage } from "./pages/swarm/SwarmPage.js";
import { ComponentsPage } from "./pages/ComponentsPage.js";
import { JobsPage } from "./pages/JobsPage.js";
import { ReportsPage } from "./pages/reports/ReportsPage.js";
@ -50,7 +50,7 @@ export function App() {
/>
<Route path="/jobs" component={JobsPage} />
<Route path="/messages" component={MessagesPage} />
<Route path="/notifications" component={NotificationsPage} />
<Route path="/swarm" component={SwarmPage} />
<Route path={NEW_FLOW_PATH}>
<FlowEditorPage params={{ flowId: "" }} isNew />
</Route>

View file

@ -1,10 +0,0 @@
/* The folded hives card over the agents. */
.ui-agents-hives {
margin-block-end: 0.5em;
}
/* The roster's refresh picker, at the top right of the open card. */
.ui-hives-refresh {
display: flex;
justify-content: flex-end;
margin-block-end: 0.5em;
}

View file

@ -4,7 +4,7 @@
// status panel has anything to render against without a roster to embed
// in — so all three ended up as one page.
//
// One fetch, on a refresh-interval cadence like HivesCard: `GET
// One fetch, on a refresh-interval cadence like the hives card: `GET
// /api/agents/status` — one row per roster agent (identity store is the
// roster, so a never-reported agent still gets a row), each already
// carrying its freshness/snapshot/config PR/wanted state. Used to be
@ -36,7 +36,6 @@ import { FRESHNESS, type AgentRow } from "../../agent/AgentTypes.js";
import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
import { HivesCard } from "./HivesCard.js";
import {
RefreshIntervalPicker,
type RefreshIntervalMs,
@ -44,9 +43,8 @@ import {
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { type FilterColumn } from "../../ui/table/Table.js";
import { CreateAgentForm } from "../CreateAgentForm.js";
import "./AgentsPage.css";
// Same 30s default + same reasoning as HivesCard: no inputs on this page
// Same 30s default + same reasoning as the hives card: no inputs on this page
// for a refresh to clobber, so the out-of-the-box behaviour should just
// solve staleness rather than require an opt-in every visit.
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
@ -139,9 +137,6 @@ export function AgentsPage() {
return (
<>
<div class="ui-agents-hives">
<HivesCard />
</div>
{/* Until the roster loads (or when it fails) there are no filters, but the
buttons stay, so an agent can still be created. */}
{rows === null ? (

View file

@ -1,6 +1,7 @@
// <NotificationsPage> — the notification router's flows. Backed by
// swarm-controller's `GET/PUT /api/notify/flows`; the controller is the only
// writer of the stored document and the router reads it every few seconds.
// <NotificationRouter> — the notification router's flows, the body of its card on
// the swarm page. Backed by swarm-controller's `GET/PUT /api/notify/flows`; the
// controller is the only writer of the stored document and the router reads it
// every few seconds.
//
// A flow draws as its graph, read-only. A built-in flow has a switch and can
// be duplicated; a custom flow is edited on its own page (<FlowEditorPage>).
@ -13,7 +14,6 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Button } from "../../ui/button/Button.js";
import { Card } from "../../ui/card/Card.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
import { Panel } from "../../ui/panel/Panel.js";
import {
RefreshIntervalPicker,
type RefreshIntervalMs,
@ -35,7 +35,7 @@ import "./NotificationsPage.css";
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
export function NotificationsPage() {
export function NotificationRouter() {
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const {
@ -96,22 +96,17 @@ export function NotificationsPage() {
);
return (
<Panel
title="notifications"
icon="🔔"
actions={
<div class="notify-panel-actions">
<RefreshIntervalPicker
id="notifications-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
<Button variant="primary" onClick={() => draftFrom()}>
create flow
</Button>
</div>
}
>
<>
<div class="notify-panel-actions">
<RefreshIntervalPicker
id="notifications-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
<Button variant="primary" onClick={() => draftFrom()}>
create flow
</Button>
</div>
{error ? (
<ApiErrorPanel context="failed to load the flows" problem={error} />
) : null}
@ -192,6 +187,6 @@ export function NotificationsPage() {
)}
</div>
) : null}
</Panel>
</>
);
}

View file

@ -0,0 +1,162 @@
// <HivesCard> — the hive roster, a card on the agents page that starts folded.
// Fetches
// swarm-controller's `GET /api/hives/status`, the swarm-wide status
// aggregate: one row per roster hive, freshness derived at read time
// from the status bucket. Supersedes the earlier `GET /api/hives` +
// static "configured" chip placeholder now that the real rollup
// exists — same component, richer data, no rebuild, exactly the plan
// that placeholder's comment laid out. Its own component/file rather
// than living in `App.tsx`, matching `JobsPage`'s shape: `App.tsx` is
// routing, a page owns its own fetch + render.
//
// Polls on a `RefreshIntervalPicker` cadence rather than fetching once
// at mount — no inputs on this page, so a re-fetch clobbering an
// in-progress edit (the hook's stated caller obligation) isn't a live
// concern here.
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import {
RefreshIntervalPicker,
type RefreshIntervalMs,
} 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";
interface HiveStatus {
name: string;
domain: string | null;
dashboard: string | null;
freshness: Freshness;
last_seen_unix: number | null;
// `age_seconds` (a once-computed-at-fetch age) is part of the wire
// shape but deliberately unused here — rendering it directly is the
// exact bug `RelativeTime` exists to fix, so this page recomputes the
// age client-side from `last_seen_unix` instead.
}
// One tone + label per freshness value. `unknown` (reporting but absent
// from the roster) reads `negative` — it's the one case this endpoint
// cannot vouch for at all, per `status.rs`'s doc comment.
const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
fresh: { tone: "positive", label: "fresh" },
stale: { tone: "warning", label: "stale" },
never_reported: { tone: "neutral", label: "never reported" },
unknown: { tone: "negative", label: "unknown" },
};
const COLUMNS: TableColumn<HiveStatus>[] = [
{
key: "name",
header: "name",
render: (h) => h.name,
sortBy: (h) => h.name,
filterValue: (h) => h.name,
},
{
key: "domain",
header: "domain",
render: (h) => {
const { text, href } = domainCell(h);
if (!text) return "—";
return href ? (
<a href={href} target="_blank" rel="noreferrer">
{text}
</a>
) : (
text
);
},
sortBy: (h) => domainCell(h).text,
filterValue: (h) => domainCell(h).text,
},
{
key: "status",
header: "status",
render: (h) => {
const { tone, label } = FRESHNESS[h.freshness];
return (
<Badge
tone={tone}
value={
h.last_seen_unix !== null ? (
<>
{label} (<RelativeTime epochMs={h.last_seen_unix * 1000} />)
</>
) : (
label
)
}
/>
);
},
sortBy: (h) => FRESHNESS[h.freshness].label,
// Multiselect (not the single-value `"select"` mode) so the operator
// can filter for e.g. "stale or offline" in one pass rather than one
// freshness state at a time.
filterValues: (h) => [FRESHNESS[h.freshness].label],
filterMode: "multiselect",
},
];
// 30s default: this card has no inputs to interrupt, and the whole
// point of a refresh-interval control is "no manual reload needed" — an
// operator who wants it off still can, but the out-of-the-box behaviour
// should actually solve the staleness problem rather than require an
// opt-in every visit.
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
// The roster is read on the first open and followed after, so an agents page
// where the card is never opened does not poll it.
function HiveRoster() {
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const { data: hives, error } = usePolled(intervalMs, (signal) =>
fetchJson<HiveStatus[]>("/api/hives/status", signal),
);
return (
<>
<div class="ui-hives-refresh">
<RefreshIntervalPicker
id="hives-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
</div>
{error ? (
<ApiErrorPanel
context="failed to load the hive roster"
problem={error}
/>
) : null}
{!error && hives === null ? <p>loading…</p> : null}
{hives ? (
<Table
columns={COLUMNS}
rows={hives}
rowKey={(h) => h.name}
emptyMessage="no hives in the roster yet"
storageKey="swarm-ui:hives:table-filters"
/>
) : null}
</>
);
}
export function HivesCard() {
return (
<LazyDisclosure
title="hives"
icon="🐝"
hint="the swarm's hives and when each last reported"
>
<HiveRoster />
</LazyDisclosure>
);
}

View file

@ -0,0 +1,19 @@
/* The cards on the swarm page: hives, then the notification router. */
.ui-swarm-card {
margin-block-end: 0.5em;
}
/* The roster's refresh picker, at the top right of the open card. */
.ui-hives-refresh {
display: flex;
justify-content: flex-end;
margin-block-end: 0.5em;
}
/* The warning at the top of the beta notification router card. */
.ui-swarm-beta {
margin: 0 0 0.75em;
padding: 0.5em 0.75em;
border: 1px solid var(--yellow);
border-left-width: 4px;
border-radius: 0.4em;
background: color-mix(in srgb, var(--yellow) 12%, transparent);
}

View file

@ -0,0 +1,40 @@
// <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 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 { HivesCard } from "./HivesCard.js";
import "./SwarmPage.css";
export function SwarmPage() {
return (
<>
<div class="ui-swarm-card">
<HivesCard />
</div>
<div class="ui-swarm-card">
<LazyDisclosure
title="notification router"
icon="🔔"
hint={
<>
<Badge tone="warning" value="beta" /> forge events routed to
agents, as flows
</>
}
>
<p class="ui-swarm-beta" role="note">
<strong>beta:</strong> the notification router is still changing.
Flows, their wording and what they match may change, and a flow you
wrote may need rewriting after an update.
</p>
<NotificationRouter />
</LazyDisclosure>
</div>
</>
);
}

View file

@ -57,10 +57,10 @@ const SOLID_KEY = "swarm-ui:solid-backgrounds";
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: "/agents", label: "agents", accent: "var(--green)" },
{ href: "/swarm", label: "swarm", accent: "var(--yellow)" },
{ href: "/reports", label: "reports", accent: "var(--red)" },
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
{ href: "/messages", label: "messages", accent: "var(--purple)" },
{ href: "/notifications", label: "notifications", accent: "var(--yellow)" },
{ href: "/components", label: "components", accent: "var(--blue)" },
];

View file

@ -38,6 +38,7 @@ export const MOVED_ROUTES: Readonly<Record<string, string>> = {
"/builds": "/jobs",
"/review": "/jobs",
"/schedules": "/messages",
"/notifications": "/swarm",
};
/** Where an old address goes, keeping its query; `null` for any other path. */

View file

@ -69,3 +69,8 @@ test("no old address leads to another old address", () => {
assert.equal(to in MOVED_ROUTES, false, to);
}
});
test("the old notifications list goes to the swarm tab, the editor pages stay", () => {
assert.equal(movedHref("/notifications", ""), "/swarm");
assert.equal(movedHref("/notifications/some-flow", ""), null);
});