Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a6d6c042 | ||
|
|
b39e095aa8 | ||
|
|
6da252aa20 | ||
|
|
842b37dc33 |
4 changed files with 166 additions and 33 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
<RefreshIntervalPicker
|
<>
|
||||||
id="alerts-refresh"
|
<a
|
||||||
value={intervalMs}
|
href={GRAFANA_ALERTS_URL}
|
||||||
onChange={setIntervalMs}
|
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
|
||||||
|
id="alerts-refresh"
|
||||||
|
value={intervalMs}
|
||||||
|
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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
40
frontend/packages/swarm-ui/src/pages/alerts.ts
Normal file
40
frontend/packages/swarm-ui/src/pages/alerts.ts
Normal 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());
|
||||||
|
}
|
||||||
89
frontend/packages/swarm-ui/test/alerts.test.mjs
Normal file
89
frontend/packages/swarm-ui/test/alerts.test.mjs
Normal 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));
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue