notify router: deliveries log, dry-run hit counts, api and page
This commit is contained in:
parent
6dd9d0086e
commit
ed1fd053e7
24 changed files with 1848 additions and 40 deletions
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
65
frontend/packages/swarm-ui/test/router-live.test.mjs
Normal file
65
frontend/packages/swarm-ui/test/router-live.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue