From 842b37dc334ca2048988265214e7c9f0bf0b5509 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 18 Oct 2026 13:07:23 +0200 Subject: [PATCH 1/4] swarm-ui: show only firing alerts on the alerts page and link to Grafana's list The page listed every non-normal instance. It now keeps the ones in the Alerting state and links to Grafana's alert-groups view for the pending, no-data and error ones. --- docs/swarm/ui.md | 2 +- .../swarm-ui/src/pages/AlertsPage.tsx | 55 +++++++++++-------- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index b6c4e42b..566a5f6b 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -14,7 +14,7 @@ domain, covers host-level detail for one hive. | `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts | | `/agents//terminal` | one agent's live terminal | | `/agents//subagents//terminal` | one of that agent's subagents' live terminal | -| `/alerts` | the alerts the swarm's Grafana holds now (firing, pending, no data, error), read through the controller with a Viewer service-account token | +| `/alerts` | the alerts of the swarm's Grafana that are firing now, with a link to Grafana's full alert list for the pending, no-data and error ones; read through the controller with a Viewer service-account token | | `/jobs` | the controller's job graph — where agent creation and credential mints show progress | | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/broadcast` | send one message to several agents at once, picked from a grid of their icons | diff --git a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx index b13d2ef7..1cb90f9b 100644 --- a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx @@ -1,10 +1,12 @@ -// — `/alerts`' content: the alerts the swarm's Grafana holds -// now, from swarm-controller's `GET /api/alerts`. Same shape as `HivesPage`: -// one fetch on a `RefreshIntervalPicker` cadence, rendered as a `Table`. +// — `/alerts`' content: the alerts of the swarm's Grafana that +// are firing now, from swarm-controller's `GET /api/alerts` (which also lists +// pending, no-data and errored instances; those stay in Grafana). Same shape +// as `HivesPage`: one fetch on a `RefreshIntervalPicker` cadence, rendered as +// a `Table`. 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, type BadgeTone } from "@hive/shared/badge.js"; +import { Badge } from "@hive/shared/badge.js"; import { Panel } from "../ui/panel/Panel.js"; import { RelativeTime } from "../ui/relative-time/RelativeTime.js"; import { @@ -17,19 +19,18 @@ import { Table, type TableColumn } from "../ui/table/Table.js"; interface Alert { name: string; // Grafana's state for the instance: `Alerting`, `Pending`, `NoData` or - // `Error`. Anything else renders neutral. + // `Error`. state: string; active_at: string | null; summary: string | null; labels: Record; } -const STATE_TONE: Record = { - Alerting: "negative", - Error: "negative", - NoData: "warning", - Pending: "warning", -}; +// Grafana's alert-groups view, where the instances this page leaves out are. +const GRAFANA_ALERTS_URL = + "https://grafana.constellation.darkest.space/alerting/groups"; + +const FIRING = "Alerting"; // Grafana reports an instance that never became active with the zero time, // which `Date.parse` reads as year 1. @@ -59,7 +60,7 @@ const COLUMNS: TableColumn[] = [ const since = activeSinceMs(a); return ( @@ -72,9 +73,7 @@ const COLUMNS: TableColumn[] = [ /> ); }, - sortBy: (a) => a.state, - filterValues: (a) => [a.state], - filterMode: "multiselect", + sortBy: (a) => activeSinceMs(a) ?? 0, }, { key: "summary", @@ -90,6 +89,7 @@ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; export function AlertsPage() { const [alerts, setAlerts] = useState(null); const [error, setError] = useState(null); + const firing = alerts?.filter((a) => a.state === FIRING) ?? null; const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); @@ -110,11 +110,20 @@ export function AlertsPage() { title="alerts" icon="🚨" actions={ - + <> + + all alerts in Grafana + + + } > {error ? ( @@ -124,11 +133,11 @@ export function AlertsPage() { /> ) : null} {!error && alerts === null ?

loading…

: null} - {alerts && alerts.length === 0 ?

no alerts.

: null} - {alerts && alerts.length > 0 ? ( + {firing && firing.length === 0 ?

nothing is alerting.

: null} + {firing && firing.length > 0 ? ( From 6da252aa2088d9743ad8b5dc2669371e5d52da9a Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 18 Oct 2026 13:10:53 +0200 Subject: [PATCH 2/4] swarm-ui: version the alerts table's saved-filters key The state column stopped being a filter, so a saved "not Pending" selection under the old key would hide every row. A new key makes the page ignore it. --- frontend/packages/swarm-ui/src/pages/AlertsPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx index 1cb90f9b..f13ea5bd 100644 --- a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx @@ -32,6 +32,10 @@ const GRAFANA_ALERTS_URL = const FIRING = "Alerting"; +// Versioned: the state column used to be a filter, and a saved "not Pending" +// selection under the old key would now hide every row. +const FILTERS_STORAGE_KEY = "swarm-ui:alerts:table-filters:v2"; + // Grafana reports an instance that never became active with the zero time, // which `Date.parse` reads as year 1. function activeSinceMs(a: Alert): number | null { @@ -139,7 +143,7 @@ export function AlertsPage() { columns={COLUMNS} rows={firing} rowKey={alertKey} - storageKey="swarm-ui:alerts:table-filters" + storageKey={FILTERS_STORAGE_KEY} /> ) : null} From b39e095aa8aef7b6e969900b1f1324ea66934bca Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 18 Oct 2026 13:14:22 +0200 Subject: [PATCH 3/4] swarm-ui: test which alert instances the alerts page lists The firing-only filter and the helpers beside it move out of the page component into a plain module, with tests for the state match, the never-active time and the row key. --- .../swarm-ui/src/pages/AlertsPage.tsx | 28 +------- .../packages/swarm-ui/src/pages/alerts.ts | 34 ++++++++++ .../packages/swarm-ui/test/alerts.test.mjs | 68 +++++++++++++++++++ 3 files changed, 104 insertions(+), 26 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/pages/alerts.ts create mode 100644 frontend/packages/swarm-ui/test/alerts.test.mjs diff --git a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx index f13ea5bd..9778b0ea 100644 --- a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx @@ -15,40 +15,16 @@ import { type RefreshIntervalMs, } from "../ui/refresh-interval/RefreshInterval.js"; import { Table, type TableColumn } from "../ui/table/Table.js"; - -interface Alert { - name: string; - // Grafana's state for the instance: `Alerting`, `Pending`, `NoData` or - // `Error`. - state: string; - active_at: string | null; - summary: string | null; - labels: Record; -} +import { activeSinceMs, alertKey, firingAlerts, type Alert } from "./alerts.js"; // Grafana's alert-groups view, where the instances this page leaves out are. const GRAFANA_ALERTS_URL = "https://grafana.constellation.darkest.space/alerting/groups"; -const FIRING = "Alerting"; - // Versioned: the state column used to be a filter, and a saved "not Pending" // selection under the old key would now hide every row. const FILTERS_STORAGE_KEY = "swarm-ui:alerts:table-filters:v2"; -// Grafana reports an instance that never became active with the zero time, -// which `Date.parse` reads as year 1. -function activeSinceMs(a: Alert): number | null { - if (a.active_at === null) return null; - const ms = Date.parse(a.active_at); - return Number.isNaN(ms) || ms <= 0 ? null : ms; -} - -// A row needs a key that is unique among instances of the same rule. -function alertKey(a: Alert): string { - return JSON.stringify(Object.entries(a.labels).sort()); -} - const COLUMNS: TableColumn[] = [ { key: "name", @@ -93,7 +69,7 @@ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; export function AlertsPage() { const [alerts, setAlerts] = useState(null); const [error, setError] = useState(null); - const firing = alerts?.filter((a) => a.state === FIRING) ?? null; + const firing = alerts ? firingAlerts(alerts) : null; const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); diff --git a/frontend/packages/swarm-ui/src/pages/alerts.ts b/frontend/packages/swarm-ui/src/pages/alerts.ts new file mode 100644 index 00000000..5bbce7a2 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/alerts.ts @@ -0,0 +1,34 @@ +// What the alerts page lists, apart from how it draws it: the shape of one +// instance from swarm-controller's `GET /api/alerts` and which of them count +// as firing. + +export interface Alert { + name: string; + // Grafana's state for the instance: `Alerting`, `Pending`, `NoData` or + // `Error`. + state: string; + active_at: string | null; + summary: string | null; + labels: Record; +} + +const FIRING = "Alerting"; + +// The instances the page shows: the ones Grafana says are firing now. Pending, +// no-data and errored ones are left to Grafana's own list. +export function firingAlerts(alerts: Alert[]): Alert[] { + return alerts.filter((a) => a.state === FIRING); +} + +// Grafana reports an instance that never became active with the zero time, +// which `Date.parse` reads as year 1. +export function activeSinceMs(a: Alert): number | null { + if (a.active_at === null) return null; + const ms = Date.parse(a.active_at); + return Number.isNaN(ms) || ms <= 0 ? null : ms; +} + +// A row needs a key that is unique among instances of the same rule. +export function alertKey(a: Alert): string { + return JSON.stringify(Object.entries(a.labels).sort()); +} diff --git a/frontend/packages/swarm-ui/test/alerts.test.mjs b/frontend/packages/swarm-ui/test/alerts.test.mjs new file mode 100644 index 00000000..24050725 --- /dev/null +++ b/frontend/packages/swarm-ui/test/alerts.test.mjs @@ -0,0 +1,68 @@ +// Which alert instances the alerts page lists. Run by `npm test`, which Node +// runs with its built-in test runner and type stripping, so the `.ts` module +// is imported as it is. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { activeSinceMs, alertKey, firingAlerts } from "../src/pages/alerts.ts"; + +const alert = (name, state, extra = {}) => ({ + name, + state, + active_at: null, + summary: null, + labels: { instance: name }, + ...extra, +}); + +test("only instances in the Alerting state are listed", () => { + const all = [ + alert("a", "Alerting"), + alert("b", "Pending"), + alert("c", "NoData"), + alert("d", "Error"), + alert("e", "Alerting"), + ]; + assert.deepEqual( + firingAlerts(all).map((a) => a.name), + ["a", "e"], + ); +}); + +test("nothing firing leaves the list empty, and the input is not changed", () => { + const all = [alert("a", "Pending"), alert("b", "Error")]; + assert.deepEqual(firingAlerts(all), []); + assert.equal(all.length, 2); + assert.deepEqual(firingAlerts([]), []); +}); + +test("the state match is exact, so an unknown or lower-case state is left out", () => { + assert.deepEqual(firingAlerts([alert("a", "alerting"), alert("b", "")]), []); +}); + +test("an instance that never became active has no active-since time", () => { + assert.equal(activeSinceMs(alert("a", "Alerting")), null); + assert.equal( + activeSinceMs( + alert("a", "Alerting", { active_at: "0001-01-01T00:00:00Z" }), + ), + null, + ); + assert.equal( + activeSinceMs(alert("a", "Alerting", { active_at: "not a date" })), + null, + ); + assert.equal( + activeSinceMs( + alert("a", "Alerting", { active_at: "2026-10-18T08:00:00Z" }), + ), + Date.parse("2026-10-18T08:00:00Z"), + ); +}); + +test("instances of one rule get different keys, whatever the label order", () => { + const a = alert("r", "Alerting", { labels: { x: "1", y: "2" } }); + const b = alert("r", "Alerting", { labels: { y: "2", x: "1" } }); + const c = alert("r", "Alerting", { labels: { x: "1", y: "3" } }); + assert.equal(alertKey(a), alertKey(b)); + assert.notEqual(alertKey(a), alertKey(c)); +}); From f0a6d6c04273b4474f1b4f6052fcb1e3e106a125 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 18 Oct 2026 13:17:35 +0200 Subject: [PATCH 4/4] swarm-ui: add a button to the alerts page that shows the pending, no-data and error instances The page opens on the firing ones. The button lists every instance again, with the state filter and the tones back, so the table's filtering is visible. The saved-filters key goes back to its original name, since the state column is a filter again. --- docs/swarm/ui.md | 2 +- .../swarm-ui/src/pages/AlertsPage.tsx | 55 ++++++++++++------- .../packages/swarm-ui/src/pages/alerts.ts | 6 ++ .../packages/swarm-ui/test/alerts.test.mjs | 23 +++++++- 4 files changed, 64 insertions(+), 22 deletions(-) diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 566a5f6b..0bf00d7d 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -14,7 +14,7 @@ domain, covers host-level detail for one hive. | `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts | | `/agents//terminal` | one agent's live terminal | | `/agents//subagents//terminal` | one of that agent's subagents' live terminal | -| `/alerts` | the alerts of the swarm's Grafana that are firing now, with a link to Grafana's full alert list for the pending, no-data and error ones; read through the controller with a Viewer service-account token | +| `/alerts` | 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 | | `/jobs` | the controller's job graph — where agent creation and credential mints show progress | | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/broadcast` | send one message to several agents at once, picked from a grid of their icons | diff --git a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx index 9778b0ea..a0c23add 100644 --- a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx @@ -1,12 +1,13 @@ -// — `/alerts`' content: the alerts of the swarm's Grafana that -// are firing now, from swarm-controller's `GET /api/alerts` (which also lists -// pending, no-data and errored instances; those stay in Grafana). Same shape -// as `HivesPage`: one fetch on a `RefreshIntervalPicker` cadence, rendered as -// a `Table`. +// — `/alerts`' content: the alerts the swarm's Grafana holds +// now, from swarm-controller's `GET /api/alerts`. Only the firing ones show at +// first; a button in the header adds the pending, no-data and errored ones. +// Same shape as `HivesPage`: one fetch on a `RefreshIntervalPicker` cadence, +// rendered as a `Table`. 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"; +import { Badge, type BadgeTone } from "@hive/shared/badge.js"; +import { Button } from "../ui/button/Button.js"; import { Panel } from "../ui/panel/Panel.js"; import { RelativeTime } from "../ui/relative-time/RelativeTime.js"; import { @@ -15,16 +16,19 @@ import { type RefreshIntervalMs, } from "../ui/refresh-interval/RefreshInterval.js"; import { Table, type TableColumn } from "../ui/table/Table.js"; -import { activeSinceMs, alertKey, firingAlerts, type Alert } from "./alerts.js"; +import { activeSinceMs, alertKey, listedAlerts, type Alert } from "./alerts.js"; -// Grafana's alert-groups view, where the instances this page leaves out are. +const STATE_TONE: Record = { + Alerting: "negative", + Error: "negative", + NoData: "warning", + Pending: "warning", +}; + +// Grafana's own alert list, for what this page does not show. const GRAFANA_ALERTS_URL = "https://grafana.constellation.darkest.space/alerting/groups"; -// Versioned: the state column used to be a filter, and a saved "not Pending" -// selection under the old key would now hide every row. -const FILTERS_STORAGE_KEY = "swarm-ui:alerts:table-filters:v2"; - const COLUMNS: TableColumn[] = [ { key: "name", @@ -40,7 +44,7 @@ const COLUMNS: TableColumn[] = [ const since = activeSinceMs(a); return ( @@ -53,7 +57,9 @@ const COLUMNS: TableColumn[] = [ /> ); }, - sortBy: (a) => activeSinceMs(a) ?? 0, + sortBy: (a) => a.state, + filterValues: (a) => [a.state], + filterMode: "multiselect", }, { key: "summary", @@ -69,9 +75,11 @@ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; export function AlertsPage() { const [alerts, setAlerts] = useState(null); const [error, setError] = useState(null); - const firing = alerts ? firingAlerts(alerts) : null; + const [showAll, setShowAll] = useState(false); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); + const listed = alerts ? listedAlerts(alerts, showAll) : null; + const hidden = alerts && listed ? alerts.length - listed.length : 0; useRefreshInterval(intervalMs, () => { (async () => { @@ -96,8 +104,13 @@ export function AlertsPage() { target="_blank" rel="noopener noreferrer" > - all alerts in Grafana + Grafana's alert list + {showAll || hidden > 0 ? ( + + ) : null} ) : null} {!error && alerts === null ?

loading…

: null} - {firing && firing.length === 0 ?

nothing is alerting.

: null} - {firing && firing.length > 0 ? ( + {listed && listed.length === 0 ? ( +

{showAll ? "no alerts." : "nothing is alerting."}

+ ) : null} + {listed && listed.length > 0 ? (
) : null} diff --git a/frontend/packages/swarm-ui/src/pages/alerts.ts b/frontend/packages/swarm-ui/src/pages/alerts.ts index 5bbce7a2..3161e2d0 100644 --- a/frontend/packages/swarm-ui/src/pages/alerts.ts +++ b/frontend/packages/swarm-ui/src/pages/alerts.ts @@ -20,6 +20,12 @@ export function firingAlerts(alerts: Alert[]): Alert[] { return alerts.filter((a) => a.state === FIRING); } +// What the page lists: the firing ones, or every instance once the operator +// asks for the rest. +export function listedAlerts(alerts: Alert[], showAll: boolean): Alert[] { + return showAll ? alerts : firingAlerts(alerts); +} + // Grafana reports an instance that never became active with the zero time, // which `Date.parse` reads as year 1. export function activeSinceMs(a: Alert): number | null { diff --git a/frontend/packages/swarm-ui/test/alerts.test.mjs b/frontend/packages/swarm-ui/test/alerts.test.mjs index 24050725..e68e8e4d 100644 --- a/frontend/packages/swarm-ui/test/alerts.test.mjs +++ b/frontend/packages/swarm-ui/test/alerts.test.mjs @@ -3,7 +3,12 @@ // is imported as it is. import assert from "node:assert/strict"; import { test } from "node:test"; -import { activeSinceMs, alertKey, firingAlerts } from "../src/pages/alerts.ts"; +import { + activeSinceMs, + alertKey, + firingAlerts, + listedAlerts, +} from "../src/pages/alerts.ts"; const alert = (name, state, extra = {}) => ({ name, @@ -28,6 +33,22 @@ test("only instances in the Alerting state are listed", () => { ); }); +test("asking for the rest lists every instance, in order", () => { + const all = [ + alert("a", "Pending"), + alert("b", "Alerting"), + alert("c", "Error"), + ]; + assert.deepEqual( + listedAlerts(all, true).map((a) => a.name), + ["a", "b", "c"], + ); + assert.deepEqual( + listedAlerts(all, false).map((a) => a.name), + ["b"], + ); +}); + test("nothing firing leaves the list empty, and the input is not changed", () => { const all = [alert("a", "Pending"), alert("b", "Error")]; assert.deepEqual(firingAlerts(all), []);