diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 0bf00d7d..b6c4e42b 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: 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 | +| `/alerts` | the alerts the swarm's Grafana holds now (firing, pending, no data, error), 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 a0c23add..b13d2ef7 100644 --- a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx @@ -1,13 +1,10 @@ // — `/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`. +// now, from swarm-controller's `GET /api/alerts`. 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 { @@ -16,7 +13,16 @@ import { type RefreshIntervalMs, } from "../ui/refresh-interval/RefreshInterval.js"; import { Table, type TableColumn } from "../ui/table/Table.js"; -import { activeSinceMs, alertKey, listedAlerts, type Alert } from "./alerts.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; +} const STATE_TONE: Record = { Alerting: "negative", @@ -25,9 +31,18 @@ const STATE_TONE: Record = { 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"; +// 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[] = [ { @@ -75,11 +90,8 @@ 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 () => { @@ -98,25 +110,11 @@ export function AlertsPage() { title="alerts" icon="🚨" actions={ - <> - - Grafana's alert list - - {showAll || hidden > 0 ? ( - - ) : null} - - + } > {error ? ( @@ -126,13 +124,11 @@ export function AlertsPage() { /> ) : null} {!error && alerts === null ?

loading…

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

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

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

no alerts.

: null} + {alerts && alerts.length > 0 ? ( diff --git a/frontend/packages/swarm-ui/src/pages/alerts.ts b/frontend/packages/swarm-ui/src/pages/alerts.ts deleted file mode 100644 index 3161e2d0..00000000 --- a/frontend/packages/swarm-ui/src/pages/alerts.ts +++ /dev/null @@ -1,40 +0,0 @@ -// 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 deleted file mode 100644 index e68e8e4d..00000000 --- a/frontend/packages/swarm-ui/test/alerts.test.mjs +++ /dev/null @@ -1,89 +0,0 @@ -// 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)); -});