From b39e095aa8aef7b6e969900b1f1324ea66934bca Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 18 Oct 2026 13:14:22 +0200 Subject: [PATCH] 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)); +});