Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
4 changed files with 33 additions and 166 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: 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 | | `/alerts` | the alerts the swarm's Grafana holds now (firing, pending, no data, error), 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,13 +1,10 @@
// <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`. Only the firing ones show at // now, from swarm-controller's `GET /api/alerts`. Same shape as `HivesPage`:
// first; a button in the header adds the pending, no-data and errored ones. // one fetch on a `RefreshIntervalPicker` cadence, rendered as a `Table`.
// 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 {
@ -16,7 +13,16 @@ 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",
@ -25,9 +31,18 @@ const STATE_TONE: Record<string, BadgeTone> = {
Pending: "warning", Pending: "warning",
}; };
// Grafana's own alert list, for what this page does not show. // Grafana reports an instance that never became active with the zero time,
const GRAFANA_ALERTS_URL = // which `Date.parse` reads as year 1.
"https://grafana.constellation.darkest.space/alerting/groups"; 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>[] = [ const COLUMNS: TableColumn<Alert>[] = [
{ {
@ -75,11 +90,8 @@ 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 () => {
@ -98,25 +110,11 @@ export function AlertsPage() {
title="alerts" title="alerts"
icon="🚨" icon="🚨"
actions={ actions={
<> <RefreshIntervalPicker
<a id="alerts-refresh"
href={GRAFANA_ALERTS_URL} value={intervalMs}
target="_blank" onChange={setIntervalMs}
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 ? (
@ -126,13 +124,11 @@ export function AlertsPage() {
/> />
) : null} ) : null}
{!error && alerts === null ? <p>loading…</p> : null} {!error && alerts === null ? <p>loading…</p> : null}
{listed && listed.length === 0 ? ( {alerts && alerts.length === 0 ? <p>no alerts.</p> : null}
<p>{showAll ? "no alerts." : "nothing is alerting."}</p> {alerts && alerts.length > 0 ? (
) : null}
{listed && listed.length > 0 ? (
<Table <Table
columns={COLUMNS} columns={COLUMNS}
rows={listed} rows={alerts}
rowKey={alertKey} rowKey={alertKey}
storageKey="swarm-ui:alerts:table-filters" storageKey="swarm-ui:alerts:table-filters"
/> />

View file

@ -1,40 +0,0 @@
// 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

@ -1,89 +0,0 @@
// 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));
});