Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-15 12:26:06 +02:00 • committed by mara
commit 7d09776742
23 changed files with 888 additions and 37 deletions

View file

@ -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

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

View file

@ -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)" },