Watch
0
0
Fork
You've already forked hyperhive
0

notify router: deliveries log, dry-run hit counts, api and page

This commit is contained in:
atlas 2026-10-24 22:13:07 +02:00
commit ed1fd053e7
24 changed files with 1848 additions and 40 deletions

View file

@ -0,0 +1,61 @@
// <Deliveries> — the notification router's recent deliveries, newest first.
// The router keeps the newest 500 rows; this shows what
// `GET /api/notify/deliveries` returns.
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { Table, type TableColumn } from "../../ui/table/Table.js";
import { issueRef, outcomeText, type Delivery } from "./RouterLive.js";
// A delivery with its place in the answer, which is what tells two identical rows
// apart.
type Row = Delivery & { place: number };
const COLUMNS: TableColumn<Row>[] = [
{
key: "at",
header: "when",
render: (d) => <RelativeTime epochMs={d.at * 1000} />,
sortBy: (d) => d.at,
},
{
key: "flow",
header: "flow",
render: (d) => `${d.flow}/${d.node}`,
sortBy: (d) => d.flow,
filterValue: (d) => d.flow,
},
{
key: "event",
header: "event",
render: (d) => `${d.event} ${issueRef(d)}`,
filterValue: (d) => `${d.event} ${issueRef(d)}`,
},
{
key: "target",
header: "to",
render: (d) =>
d.kind === "request_reviewer" ? `review by ${d.target}` : d.target,
filterValue: (d) => d.target,
},
{
key: "outcome",
header: "outcome",
render: (d) => (
<span class={d.ok ? "" : "notify-failed"}>{outcomeText(d)}</span>
),
cellClass: "ui-table-prose",
sortBy: (d) => (d.ok ? 0 : 1),
},
];
export function Deliveries({ rows }: { rows: Delivery[] }) {
if (rows.length === 0) return <p>no deliveries yet</p>;
const placed = rows.map((d, place) => ({ ...d, place }));
return (
<Table
columns={COLUMNS}
rows={placed}
rowKey={(d) => String(d.place)}
storageKey="swarm-ui:notifications:deliveries-filters"
/>
);
}

View file

@ -249,3 +249,26 @@
.notify-name {
flex: 0 1 24em;
}
/* The dry run's counts beside each node, and the router's "not available". */
.notify-node-hits {
margin-left: auto;
flex: none;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.notify-dry-run {
margin: 0;
color: var(--muted);
font-size: 0.9em;
}
.notify-unavailable {
margin: 0;
color: var(--muted);
}
.notify-failed {
color: var(--red);
}

View file

@ -19,6 +19,7 @@ import {
type RefreshIntervalMs,
} from "../../ui/refresh-interval/RefreshInterval.js";
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { Deliveries } from "./Deliveries.js";
import {
duplicateFlow,
newFlow,
@ -32,11 +33,21 @@ import {
type Flow,
type FlowsView,
} from "./FlowTypes.js";
import {
DELIVERIES_URL,
DRY_RUN_URL,
hitsOf,
unavailableText,
type Deliveries as DeliveriesView,
type DryRun,
} from "./RouterLive.js";
import "./NotificationsPage.css";
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
function FlowTree({ flow }: { flow: Flow }) {
// `hits`, when the dry run covers this flow, puts the number of the newest events
// that reached each node beside it.
function FlowTree({ flow, hits }: { flow: Flow; hits?: Map<string, number> }) {
return (
<ul class="notify-flow-tree">
{treeRows(flow).map(({ node, depth }) => (
@ -47,6 +58,11 @@ function FlowTree({ flow }: { flow: Flow }) {
>
<span class="notify-node-kind">{node.kind}</span>
<span class="notify-node-text">{describeNode(node)}</span>
{hits ? (
<span class="notify-node-hits" title="events of the last dry run">
{hits.get(node.id) ?? 0}
</span>
) : null}
</li>
))}
</ul>
@ -63,6 +79,18 @@ export function NotificationsPage() {
} = usePolled(intervalMs, (signal) =>
fetchJson<FlowsView>("/api/notify/flows", signal),
);
// The router's own answers. Either is 503 while the running router predates
// it, which the page says in place of the section.
const { data: dry, error: dryError } = usePolled(intervalMs, (signal) =>
fetchJson<DryRun>(DRY_RUN_URL, signal),
);
const { data: deliveries, error: deliveriesError } = usePolled(
intervalMs,
(signal) => fetchJson<DeliveriesView>(DELIVERIES_URL, signal),
);
// A failed poll keeps the last answer; the counts are shown only while the
// latest poll worked.
const shown = dryError ? null : dry;
const [pending, setPending] = useState(false);
const [actionError, setActionError] = useState<ProblemDetails | null>(null);
const [newName, setNewName] = useState("");
@ -149,6 +177,15 @@ export function NotificationsPage() {
{!error && view === null ? <p>loading…</p> : null}
{view ? (
<div class="notify-flows">
{dryError ? (
<p class="notify-unavailable">
{unavailableText("hit counts", dryError)}
</p>
) : dry ? (
<p class="notify-dry-run">
counts: events that reached each node, of the newest {dry.events}
</p>
) : null}
<h3>built-in flows</h3>
{view.builtins.map((flow) => {
const on = !view.disabled_builtins.includes(flow.id);
@ -164,7 +201,7 @@ export function NotificationsPage() {
onChange={(checked) => setBuiltin(flow.id, checked)}
/>
</div>
<FlowTree flow={flow} />
<FlowTree flow={flow} hits={hitsOf(shown, flow.id)} />
<Button disabled={pending} onClick={() => void duplicate(flow)}>
duplicate to edit
</Button>
@ -202,9 +239,19 @@ export function NotificationsPage() {
duplicate
</Button>
</div>
<FlowTree flow={flow} />
<FlowTree flow={flow} hits={hitsOf(shown, flow.id)} />
</Card>
))}
<h3>recent deliveries</h3>
{deliveriesError ? (
<p class="notify-unavailable">
{unavailableText("the deliveries log", deliveriesError)}
</p>
) : deliveries ? (
<Deliveries rows={deliveries.rows} />
) : (
<p>loading…</p>
)}
</div>
) : null}
</Panel>

View file

@ -0,0 +1,62 @@
// Wire types and helpers for what the notification router reports about
// itself: its deliveries log and a dry run of the saved flows. Backed by
// swarm-controller's `GET /api/notify/deliveries` and
// `GET /api/notify/dry-run`, which answer 503 while the running router
// predates them.
import type { ProblemDetails } from "@hive/shared/api-error.js";
export const DELIVERIES_URL = "/api/notify/deliveries";
export const DRY_RUN_URL = "/api/notify/dry-run";
export interface Delivery {
// Unix seconds.
at: number;
flow: string;
node: string;
event: string;
repo: string;
number: number;
kind: "todo" | "request_reviewer";
target: string;
ok: boolean;
detail: string;
}
export interface Deliveries {
rows: Delivery[];
}
export interface DryRun {
// How many of the newest forge events were run.
events: number;
flows: { flow: string; nodes: { node: string; hits: number }[] }[];
}
// Hits per node id, for one flow; `undefined` when the dry run has no entry
// for it (the flow is switched off, or there is no dry run).
export function hitsOf(
dry: DryRun | null,
flowId: string,
): Map<string, number> | undefined {
const flow = dry?.flows.find((f) => f.flow === flowId);
return flow && new Map(flow.nodes.map((n) => [n.node, n.hits]));
}
// `repo#n`, the way the forge names an issue or a PR.
export function issueRef(d: Pick<Delivery, "repo" | "number">): string {
return `${d.repo}#${d.number}`;
}
// The outcome column: "ok" with what was done, or the error.
export function outcomeText(d: Pick<Delivery, "ok" | "detail">): string {
if (!d.ok) return `error: ${d.detail}`;
return d.detail === "" ? "ok" : `ok: ${d.detail}`;
}
// The line shown in place of a section the router cannot serve.
export function unavailableText(what: string, problem: ProblemDetails): string {
const why = problem.detail ?? problem.title ?? "no reason given";
return problem.status === 503
? `${what} not available: ${why}`
: `${what} failed to load: ${why}`;
}

View file

@ -0,0 +1,65 @@
// The notifications page's helpers for the router's deliveries and dry run.
// Run by `npm test`.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
hitsOf,
issueRef,
outcomeText,
unavailableText,
} from "../src/pages/notifications/RouterLive.ts";
const dry = {
events: 50,
flows: [
{
flow: "assigned",
nodes: [
{ node: "source", hits: 49 },
{ node: "sink", hits: 0 },
],
},
],
};
test("hitsOf maps a flow's node ids to their counts, zero included", () => {
const hits = hitsOf(dry, "assigned");
assert.equal(hits.get("source"), 49);
assert.equal(hits.get("sink"), 0);
});
test("hitsOf has nothing for a flow the dry run left out, or no dry run", () => {
assert.equal(hitsOf(dry, "closed"), undefined);
assert.equal(hitsOf(null, "assigned"), undefined);
});
test("a delivery reads as its issue and its outcome", () => {
assert.equal(issueRef({ repo: "o/r", number: 9 }), "o/r#9");
assert.equal(
outcomeText({ ok: true, detail: "upsert forge:o/r#9:assignment" }),
"ok: upsert forge:o/r#9:assignment",
);
assert.equal(outcomeText({ ok: true, detail: "" }), "ok");
assert.equal(
outcomeText({ ok: false, detail: "the forge said no" }),
"error: the forge said no",
);
});
test("a 503 reads as not available, any other failure as failed to load", () => {
assert.equal(
unavailableText("the deliveries log", {
status: 503,
detail: "the notification router does not answer this",
}),
"the deliveries log not available: the notification router does not answer this",
);
assert.equal(
unavailableText("hit counts", { status: 502, detail: "bad" }),
"hit counts failed to load: bad",
);
assert.equal(
unavailableText("hit counts", { status: 503 }),
"hit counts not available: no reason given",
);
});