Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
iris
f0a6d6c042 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.
2026-10-18 13:29:52 +02:00
iris
b39e095aa8 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.
2026-10-18 13:29:52 +02:00
iris
6da252aa20 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.
2026-10-18 13:29:52 +02:00
iris
842b37dc33 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.
2026-10-18 13:29:52 +02:00
4 changed files with 166 additions and 33 deletions

View file

@ -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` | 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/<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 |
| `/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 | | `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/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 | | `/broadcast` | send one message to several agents at once, picked from a grid of their icons |

View file

@ -1,10 +1,13 @@
// <AlertsPage> — `/alerts`' content: the alerts the swarm's Grafana holds // <AlertsPage> — `/alerts`' content: the alerts the swarm's Grafana holds
// now, from swarm-controller's `GET /api/alerts`. Same shape as `HivesPage`: // now, from swarm-controller's `GET /api/alerts`. Only the firing ones show at
// one fetch on a `RefreshIntervalPicker` cadence, rendered as a `Table`. // 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 { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge, type BadgeTone } 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 { Panel } from "../ui/panel/Panel.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js"; import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import { import {
@ -13,16 +16,7 @@ import {
type RefreshIntervalMs, type RefreshIntervalMs,
} from "../ui/refresh-interval/RefreshInterval.js"; } from "../ui/refresh-interval/RefreshInterval.js";
import { Table, type TableColumn } from "../ui/table/Table.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<string, string>;
}
const STATE_TONE: Record<string, BadgeTone> = { const STATE_TONE: Record<string, BadgeTone> = {
Alerting: "negative", Alerting: "negative",
@ -31,18 +25,9 @@ const STATE_TONE: Record<string, BadgeTone> = {
Pending: "warning", Pending: "warning",
}; };
// Grafana reports an instance that never became active with the zero time, // Grafana's own alert list, for what this page does not show.
// which `Date.parse` reads as year 1. const GRAFANA_ALERTS_URL =
function activeSinceMs(a: Alert): number | null { "https://grafana.constellation.darkest.space/alerting/groups";
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<Alert>[] = [ const COLUMNS: TableColumn<Alert>[] = [
{ {
@ -90,8 +75,11 @@ const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
export function AlertsPage() { export function AlertsPage() {
const [alerts, setAlerts] = useState<Alert[] | null>(null); const [alerts, setAlerts] = useState<Alert[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null); const [error, setError] = useState<ProblemDetails | null>(null);
const [showAll, setShowAll] = useState(false);
const [intervalMs, setIntervalMs] = const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const listed = alerts ? listedAlerts(alerts, showAll) : null;
const hidden = alerts && listed ? alerts.length - listed.length : 0;
useRefreshInterval(intervalMs, () => { useRefreshInterval(intervalMs, () => {
(async () => { (async () => {
@ -110,11 +98,25 @@ export function AlertsPage() {
title="alerts" title="alerts"
icon="🚨" icon="🚨"
actions={ actions={
<>
<a
href={GRAFANA_ALERTS_URL}
target="_blank"
rel="noopener noreferrer"
>
Grafana's alert list
</a>
{showAll || hidden > 0 ? (
<Button onClick={() => setShowAll(!showAll)}>
{showAll ? "only firing" : `show ${hidden} more`}
</Button>
) : null}
<RefreshIntervalPicker <RefreshIntervalPicker
id="alerts-refresh" id="alerts-refresh"
value={intervalMs} value={intervalMs}
onChange={setIntervalMs} onChange={setIntervalMs}
/> />
</>
} }
> >
{error ? ( {error ? (
@ -124,11 +126,13 @@ export function AlertsPage() {
/> />
) : null} ) : null}
{!error && alerts === null ? <p>loading…</p> : null} {!error && alerts === null ? <p>loading…</p> : null}
{alerts && alerts.length === 0 ? <p>no alerts.</p> : null} {listed && listed.length === 0 ? (
{alerts && alerts.length > 0 ? ( <p>{showAll ? "no alerts." : "nothing is alerting."}</p>
) : null}
{listed && listed.length > 0 ? (
<Table <Table
columns={COLUMNS} columns={COLUMNS}
rows={alerts} rows={listed}
rowKey={alertKey} rowKey={alertKey}
storageKey="swarm-ui:alerts:table-filters" storageKey="swarm-ui:alerts:table-filters"
/> />

View file

@ -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<string, string>;
}
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());
}

View file

@ -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));
});