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.
This commit is contained in:
parent
b39e095aa8
commit
f0a6d6c042
4 changed files with 64 additions and 22 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 of the swarm's Grafana that are firing now, with a link to Grafana's full alert list for the pending, no-data and error ones; 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,12 +1,13 @@
|
||||||
// <AlertsPage> — `/alerts`' content: the alerts of the swarm's Grafana that
|
// <AlertsPage> — `/alerts`' content: the alerts the swarm's Grafana holds
|
||||||
// are firing now, from swarm-controller's `GET /api/alerts` (which also lists
|
// now, from swarm-controller's `GET /api/alerts`. Only the firing ones show at
|
||||||
// pending, no-data and errored instances; those stay in Grafana). Same shape
|
// first; a button in the header adds the pending, no-data and errored ones.
|
||||||
// as `HivesPage`: one fetch on a `RefreshIntervalPicker` cadence, rendered as
|
// Same shape as `HivesPage`: one fetch on a `RefreshIntervalPicker` cadence,
|
||||||
// a `Table`.
|
// 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 } 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 {
|
||||||
|
|
@ -15,16 +16,19 @@ 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, firingAlerts, type Alert } from "./alerts.js";
|
import { activeSinceMs, alertKey, listedAlerts, type Alert } from "./alerts.js";
|
||||||
|
|
||||||
// Grafana's alert-groups view, where the instances this page leaves out are.
|
const STATE_TONE: Record<string, BadgeTone> = {
|
||||||
|
Alerting: "negative",
|
||||||
|
Error: "negative",
|
||||||
|
NoData: "warning",
|
||||||
|
Pending: "warning",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Grafana's own alert list, for what this page does not show.
|
||||||
const GRAFANA_ALERTS_URL =
|
const GRAFANA_ALERTS_URL =
|
||||||
"https://grafana.constellation.darkest.space/alerting/groups";
|
"https://grafana.constellation.darkest.space/alerting/groups";
|
||||||
|
|
||||||
// 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";
|
|
||||||
|
|
||||||
const COLUMNS: TableColumn<Alert>[] = [
|
const COLUMNS: TableColumn<Alert>[] = [
|
||||||
{
|
{
|
||||||
key: "name",
|
key: "name",
|
||||||
|
|
@ -40,7 +44,7 @@ const COLUMNS: TableColumn<Alert>[] = [
|
||||||
const since = activeSinceMs(a);
|
const since = activeSinceMs(a);
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
tone="negative"
|
tone={STATE_TONE[a.state] ?? "neutral"}
|
||||||
value={
|
value={
|
||||||
since !== null ? (
|
since !== null ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -53,7 +57,9 @@ const COLUMNS: TableColumn<Alert>[] = [
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
sortBy: (a) => activeSinceMs(a) ?? 0,
|
sortBy: (a) => a.state,
|
||||||
|
filterValues: (a) => [a.state],
|
||||||
|
filterMode: "multiselect",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "summary",
|
key: "summary",
|
||||||
|
|
@ -69,9 +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 firing = alerts ? firingAlerts(alerts) : 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 () => {
|
||||||
|
|
@ -96,8 +104,13 @@ export function AlertsPage() {
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
all alerts in Grafana
|
Grafana's alert list
|
||||||
</a>
|
</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}
|
||||||
|
|
@ -113,13 +126,15 @@ export function AlertsPage() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!error && alerts === null ? <p>loading…</p> : null}
|
{!error && alerts === null ? <p>loading…</p> : null}
|
||||||
{firing && firing.length === 0 ? <p>nothing is alerting.</p> : null}
|
{listed && listed.length === 0 ? (
|
||||||
{firing && firing.length > 0 ? (
|
<p>{showAll ? "no alerts." : "nothing is alerting."}</p>
|
||||||
|
) : null}
|
||||||
|
{listed && listed.length > 0 ? (
|
||||||
<Table
|
<Table
|
||||||
columns={COLUMNS}
|
columns={COLUMNS}
|
||||||
rows={firing}
|
rows={listed}
|
||||||
rowKey={alertKey}
|
rowKey={alertKey}
|
||||||
storageKey={FILTERS_STORAGE_KEY}
|
storageKey="swarm-ui:alerts:table-filters"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@ export function firingAlerts(alerts: Alert[]): Alert[] {
|
||||||
return alerts.filter((a) => a.state === FIRING);
|
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,
|
// Grafana reports an instance that never became active with the zero time,
|
||||||
// which `Date.parse` reads as year 1.
|
// which `Date.parse` reads as year 1.
|
||||||
export function activeSinceMs(a: Alert): number | null {
|
export function activeSinceMs(a: Alert): number | null {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@
|
||||||
// is imported as it is.
|
// is imported as it is.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { activeSinceMs, alertKey, firingAlerts } from "../src/pages/alerts.ts";
|
import {
|
||||||
|
activeSinceMs,
|
||||||
|
alertKey,
|
||||||
|
firingAlerts,
|
||||||
|
listedAlerts,
|
||||||
|
} from "../src/pages/alerts.ts";
|
||||||
|
|
||||||
const alert = (name, state, extra = {}) => ({
|
const alert = (name, state, extra = {}) => ({
|
||||||
name,
|
name,
|
||||||
|
|
@ -28,6 +33,22 @@ test("only instances in the Alerting state are listed", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
test("nothing firing leaves the list empty, and the input is not changed", () => {
|
||||||
const all = [alert("a", "Pending"), alert("b", "Error")];
|
const all = [alert("a", "Pending"), alert("b", "Error")];
|
||||||
assert.deepEqual(firingAlerts(all), []);
|
assert.deepEqual(firingAlerts(all), []);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue