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:
parent
3cc4fdb54c
commit
0302582d1c
12 changed files with 94 additions and 49 deletions
|
|
@ -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
|
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
|
`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
|
(`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 same evaluator, so it can do anything a built-in does.
|
||||||
|
|
||||||
**The stored document.** swarm-controller keeps the rest in one document, the
|
**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}`
|
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
|
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
|
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.
|
switches built-ins.
|
||||||
|
|
||||||
**The schema.** `GET /api/notify/flows` also returns a `schema` object, built
|
**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 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
|
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.
|
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.
|
and the counts beside each node.
|
||||||
|
|
||||||
**Deploying in any order.** A router that predates these subjects doesn't
|
**Deploying in any order.** A router that predates these subjects doesn't
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,13 @@ smaller; short confirm dialogs stay short.
|
||||||
| route | what |
|
| 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 |
|
| `/` | 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>/terminal` | one agent's live terminal |
|
||||||
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' 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** |
|
| `/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 |
|
| `/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 |
|
| `/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).
|
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
|
Pages that refresh on a timer share one polling rule: the page sends no new
|
||||||
|
|
@ -58,7 +58,7 @@ works too.
|
||||||
|
|
||||||
### The agents page
|
### 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
|
card per agent, none of them inside a bigger card. A card's
|
||||||
header shows its icon, name, freshness, the status message, a **terminal**
|
header shows its icon, name, freshness, the status message, a **terminal**
|
||||||
button that opens the agent's terminal tab, the **wanted** menu (up, offline,
|
button that opens the agent's terminal tab, the **wanted** menu (up, offline,
|
||||||
|
|
@ -295,7 +295,7 @@ routes.
|
||||||
|
|
||||||
### Notification flows
|
### 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
|
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
|
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
|
restores it. Each **custom** flow shows its name and whether it's on. Every
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
|
||||||
import { MessagesPage } from "./pages/messages/MessagesPage.js";
|
import { MessagesPage } from "./pages/messages/MessagesPage.js";
|
||||||
import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js";
|
import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js";
|
||||||
import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.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 { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||||
import { JobsPage } from "./pages/JobsPage.js";
|
import { JobsPage } from "./pages/JobsPage.js";
|
||||||
import { ReportsPage } from "./pages/reports/ReportsPage.js";
|
import { ReportsPage } from "./pages/reports/ReportsPage.js";
|
||||||
|
|
@ -50,7 +50,7 @@ export function App() {
|
||||||
/>
|
/>
|
||||||
<Route path="/jobs" component={JobsPage} />
|
<Route path="/jobs" component={JobsPage} />
|
||||||
<Route path="/messages" component={MessagesPage} />
|
<Route path="/messages" component={MessagesPage} />
|
||||||
<Route path="/notifications" component={NotificationsPage} />
|
<Route path="/swarm" component={SwarmPage} />
|
||||||
<Route path={NEW_FLOW_PATH}>
|
<Route path={NEW_FLOW_PATH}>
|
||||||
<FlowEditorPage params={{ flowId: "" }} isNew />
|
<FlowEditorPage params={{ flowId: "" }} isNew />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// status panel has anything to render against without a roster to embed
|
// status panel has anything to render against without a roster to embed
|
||||||
// in — so all three ended up as one page.
|
// 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
|
// /api/agents/status` — one row per roster agent (identity store is the
|
||||||
// roster, so a never-reported agent still gets a row), each already
|
// roster, so a never-reported agent still gets a row), each already
|
||||||
// carrying its freshness/snapshot/config PR/wanted state. Used to be
|
// 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 { Button } from "../../ui/button/Button.js";
|
||||||
import { Dialog } from "../../ui/dialog/Dialog.js";
|
import { Dialog } from "../../ui/dialog/Dialog.js";
|
||||||
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
|
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
|
||||||
import { HivesCard } from "./HivesCard.js";
|
|
||||||
import {
|
import {
|
||||||
RefreshIntervalPicker,
|
RefreshIntervalPicker,
|
||||||
type RefreshIntervalMs,
|
type RefreshIntervalMs,
|
||||||
|
|
@ -44,9 +43,8 @@ import {
|
||||||
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||||
import { type FilterColumn } from "../../ui/table/Table.js";
|
import { type FilterColumn } from "../../ui/table/Table.js";
|
||||||
import { CreateAgentForm } from "../CreateAgentForm.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
|
// for a refresh to clobber, so the out-of-the-box behaviour should just
|
||||||
// solve staleness rather than require an opt-in every visit.
|
// solve staleness rather than require an opt-in every visit.
|
||||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||||
|
|
@ -139,9 +137,6 @@ export function AgentsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="ui-agents-hives">
|
|
||||||
<HivesCard />
|
|
||||||
</div>
|
|
||||||
{/* Until the roster loads (or when it fails) there are no filters, but the
|
{/* Until the roster loads (or when it fails) there are no filters, but the
|
||||||
buttons stay, so an agent can still be created. */}
|
buttons stay, so an agent can still be created. */}
|
||||||
{rows === null ? (
|
{rows === null ? (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// <NotificationsPage> — the notification router's flows. Backed by
|
// <NotificationRouter> — the notification router's flows, the body of its card on
|
||||||
// swarm-controller's `GET/PUT /api/notify/flows`; the controller is the only
|
// the swarm page. Backed by swarm-controller's `GET/PUT /api/notify/flows`; the
|
||||||
// writer of the stored document and the router reads it every few seconds.
|
// 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
|
// 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>).
|
// 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 { Button } from "../../ui/button/Button.js";
|
||||||
import { Card } from "../../ui/card/Card.js";
|
import { Card } from "../../ui/card/Card.js";
|
||||||
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||||
import { Panel } from "../../ui/panel/Panel.js";
|
|
||||||
import {
|
import {
|
||||||
RefreshIntervalPicker,
|
RefreshIntervalPicker,
|
||||||
type RefreshIntervalMs,
|
type RefreshIntervalMs,
|
||||||
|
|
@ -35,7 +35,7 @@ import "./NotificationsPage.css";
|
||||||
|
|
||||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||||
|
|
||||||
export function NotificationsPage() {
|
export function NotificationRouter() {
|
||||||
const [intervalMs, setIntervalMs] =
|
const [intervalMs, setIntervalMs] =
|
||||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||||
const {
|
const {
|
||||||
|
|
@ -96,22 +96,17 @@ export function NotificationsPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel
|
<>
|
||||||
title="notifications"
|
<div class="notify-panel-actions">
|
||||||
icon="🔔"
|
<RefreshIntervalPicker
|
||||||
actions={
|
id="notifications-refresh"
|
||||||
<div class="notify-panel-actions">
|
value={intervalMs}
|
||||||
<RefreshIntervalPicker
|
onChange={setIntervalMs}
|
||||||
id="notifications-refresh"
|
/>
|
||||||
value={intervalMs}
|
<Button variant="primary" onClick={() => draftFrom()}>
|
||||||
onChange={setIntervalMs}
|
create flow
|
||||||
/>
|
</Button>
|
||||||
<Button variant="primary" onClick={() => draftFrom()}>
|
</div>
|
||||||
create flow
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<ApiErrorPanel context="failed to load the flows" problem={error} />
|
<ApiErrorPanel context="failed to load the flows" problem={error} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -192,6 +187,6 @@ export function NotificationsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Panel>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
162
frontend/packages/swarm-ui/src/pages/swarm/HivesCard.tsx
Normal file
162
frontend/packages/swarm-ui/src/pages/swarm/HivesCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css
Normal file
19
frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css
Normal 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);
|
||||||
|
}
|
||||||
40
frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.tsx
Normal file
40
frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -57,10 +57,10 @@ const SOLID_KEY = "swarm-ui:solid-backgrounds";
|
||||||
|
|
||||||
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
||||||
{ href: "/agents", label: "agents", accent: "var(--green)" },
|
{ href: "/agents", label: "agents", accent: "var(--green)" },
|
||||||
|
{ href: "/swarm", label: "swarm", accent: "var(--yellow)" },
|
||||||
{ href: "/reports", label: "reports", accent: "var(--red)" },
|
{ href: "/reports", label: "reports", accent: "var(--red)" },
|
||||||
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
||||||
{ href: "/messages", label: "messages", accent: "var(--purple)" },
|
{ href: "/messages", label: "messages", accent: "var(--purple)" },
|
||||||
{ href: "/notifications", label: "notifications", accent: "var(--yellow)" },
|
|
||||||
{ href: "/components", label: "components", accent: "var(--blue)" },
|
{ href: "/components", label: "components", accent: "var(--blue)" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export const MOVED_ROUTES: Readonly<Record<string, string>> = {
|
||||||
"/builds": "/jobs",
|
"/builds": "/jobs",
|
||||||
"/review": "/jobs",
|
"/review": "/jobs",
|
||||||
"/schedules": "/messages",
|
"/schedules": "/messages",
|
||||||
|
"/notifications": "/swarm",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Where an old address goes, keeping its query; `null` for any other path. */
|
/** Where an old address goes, keeping its query; `null` for any other path. */
|
||||||
|
|
|
||||||
|
|
@ -69,3 +69,8 @@ test("no old address leads to another old address", () => {
|
||||||
assert.equal(to in MOVED_ROUTES, false, to);
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue