swarm UI: an alerts page, read by the controller with a Viewer Grafana token from bao
swarm-bao-grafana-controller-token, on the host that runs Grafana, logs in to Grafana's API over its socket as the built-in admin (the password swarm-bao-grafana-admin minted, #5138) and keeps a `swarm-controller` service account that is an enabled Viewer. It mints a token for that account and writes it to bao at swarm/controller/swarm-controller/grafana/token under Grafana's leaf. A ten-minute timer runs it again. The unit keeps a stored token Grafana accepts. If Grafana refuses the stored token with 401, or bao holds none, the unit deletes every token of the account and mints a new one. Grafana's grant gains create/read/update on that leaf, and the controller's gains read. swarm-controller serves GET /api/alerts from Grafana's /api/prometheus/grafana/api/v1/alerts. It reads the token from bao, keeps it in memory, and re-reads bao once on a 401. The HTTP client has 5s connect and 15s request timeouts. Any failure is a 503 that names its cause. swarm-ui gets an /alerts page listing them, built the same way as the hives page. This is option (c) on #5129, the operator's choice.
This commit is contained in:
parent
8cb6273782
commit
7d09776742
23 changed files with 888 additions and 37 deletions
|
|
@ -4,6 +4,7 @@ import { Route, Switch } from "wouter-preact";
|
|||
import { Shell } from "./shell/Shell.js";
|
||||
import { AgentsPage } from "./pages/agents/AgentsPage.js";
|
||||
import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
|
||||
import { AlertsPage } from "./pages/AlertsPage.js";
|
||||
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||
import { JobsPage } from "./pages/JobsPage.js";
|
||||
import { HivesPage } from "./pages/HivesPage.js";
|
||||
|
|
@ -27,6 +28,7 @@ export function App() {
|
|||
<Switch>
|
||||
<Route path="/" component={StartPage} />
|
||||
<Route path="/hives" component={HivesPage} />
|
||||
<Route path="/alerts" component={AlertsPage} />
|
||||
<Route path="/agents" component={AgentsPage} />
|
||||
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
|
||||
<Route
|
||||
|
|
|
|||
138
frontend/packages/swarm-ui/src/pages/AlertsPage.tsx
Normal file
138
frontend/packages/swarm-ui/src/pages/AlertsPage.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// <AlertsPage> — `/alerts`' content: the alerts the swarm's Grafana holds
|
||||
// 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 { Panel } from "../ui/panel/Panel.js";
|
||||
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
useRefreshInterval,
|
||||
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`. Anything else renders neutral.
|
||||
state: string;
|
||||
active_at: string | null;
|
||||
summary: string | null;
|
||||
labels: Record<string, string>;
|
||||
}
|
||||
|
||||
const STATE_TONE: Record<string, BadgeTone> = {
|
||||
Alerting: "negative",
|
||||
Error: "negative",
|
||||
NoData: "warning",
|
||||
Pending: "warning",
|
||||
};
|
||||
|
||||
// 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<Alert>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "alert",
|
||||
render: (a) => a.name,
|
||||
sortBy: (a) => a.name,
|
||||
filterValue: (a) => a.name,
|
||||
},
|
||||
{
|
||||
key: "state",
|
||||
header: "state",
|
||||
render: (a) => {
|
||||
const since = activeSinceMs(a);
|
||||
return (
|
||||
<Badge
|
||||
tone={STATE_TONE[a.state] ?? "neutral"}
|
||||
value={
|
||||
since !== null ? (
|
||||
<>
|
||||
{a.state} (<RelativeTime epochMs={since} />)
|
||||
</>
|
||||
) : (
|
||||
a.state
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
sortBy: (a) => a.state,
|
||||
filterValues: (a) => [a.state],
|
||||
filterMode: "multiselect",
|
||||
},
|
||||
{
|
||||
key: "summary",
|
||||
header: "summary",
|
||||
render: (a) => a.summary ?? "—",
|
||||
sortBy: (a) => a.summary ?? "",
|
||||
filterValue: (a) => a.summary ?? "",
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||
|
||||
export function AlertsPage() {
|
||||
const [alerts, setAlerts] = useState<Alert[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
|
||||
useRefreshInterval(intervalMs, () => {
|
||||
(async () => {
|
||||
const r = await fetch("/api/alerts");
|
||||
if (!r.ok) {
|
||||
setError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
setAlerts((await r.json()) as Alert[]);
|
||||
setError(null);
|
||||
})().catch((e: unknown) => setError({ detail: String(e) }));
|
||||
});
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="alerts"
|
||||
icon="🚨"
|
||||
actions={
|
||||
<RefreshIntervalPicker
|
||||
id="alerts-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<ApiErrorPanel
|
||||
context="failed to load Grafana's alerts"
|
||||
problem={error}
|
||||
/>
|
||||
) : null}
|
||||
{!error && alerts === null ? <p>loading…</p> : null}
|
||||
{alerts && alerts.length === 0 ? <p>no alerts.</p> : null}
|
||||
{alerts && alerts.length > 0 ? (
|
||||
<Table
|
||||
columns={COLUMNS}
|
||||
rows={alerts}
|
||||
rowKey={alertKey}
|
||||
storageKey="swarm-ui:alerts:table-filters"
|
||||
/>
|
||||
) : null}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ const MOTION_KEY = "swarm-ui:motion-override";
|
|||
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
||||
{ href: "/hives", label: "hives", accent: "var(--purple)" },
|
||||
{ href: "/agents", label: "agents", accent: "var(--green)" },
|
||||
{ href: "/alerts", label: "alerts", accent: "var(--red)" },
|
||||
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
||||
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" },
|
||||
{ href: "/issues", label: "issues", accent: "var(--yellow)" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue