diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index b6c4e42b..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 the swarm's Grafana holds now (firing, pending, no data, error), 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 b13d2ef7..a0c23add 100644 --- a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx @@ -1,10 +1,13 @@ // — `/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`. +// 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, 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 { @@ -13,16 +16,7 @@ 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`. Anything else renders neutral. - state: string; - active_at: string | null; - summary: string | null; - labels: Record; -} +import { activeSinceMs, alertKey, listedAlerts, type Alert } from "./alerts.js"; const STATE_TONE: Record = { Alerting: "negative", @@ -31,18 +25,9 @@ const STATE_TONE: Record = { Pending: "warning", }; -// 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()); -} +// Grafana's own alert list, for what this page does not show. +const GRAFANA_ALERTS_URL = + "https://grafana.constellation.darkest.space/alerting/groups"; const COLUMNS: TableColumn[] = [ { @@ -90,8 +75,11 @@ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; export function AlertsPage() { const [alerts, setAlerts] = useState(null); const [error, setError] = useState(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 () => { @@ -110,11 +98,25 @@ export function AlertsPage() { title="alerts" icon="🚨" actions={ - + <> + + Grafana's alert list + + {showAll || hidden > 0 ? ( + + ) : null} + + } > {error ? ( @@ -124,11 +126,13 @@ export function AlertsPage() { /> ) : null} {!error && alerts === null ?

loading…

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

no alerts.

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

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

+ ) : null} + {listed && listed.length > 0 ? ( 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..3161e2d0 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/alerts.ts @@ -0,0 +1,40 @@ +// 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); +} + +// 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 { + 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..e68e8e4d --- /dev/null +++ b/frontend/packages/swarm-ui/test/alerts.test.mjs @@ -0,0 +1,89 @@ +// 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, + listedAlerts, +} 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("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), []); + 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)); +});