259 lines
8.6 KiB
TypeScript
259 lines
8.6 KiB
TypeScript
// <NotificationsPage> — the notification router's flows. Backed by
|
|
// swarm-controller's `GET/PUT /api/notify/flows`; the controller is the only
|
|
// writer of the stored document and the router reads it every few seconds.
|
|
//
|
|
// A flow draws as an indented tree, the shape the API validates it to. A
|
|
// built-in flow has a switch and can be duplicated; a custom flow is edited
|
|
// on its own page (<FlowEditorPage>), and a new one starts here.
|
|
import { useState } from "preact/hooks";
|
|
import { Link, useLocation } from "wouter-preact";
|
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
|
import { Button } from "../../ui/button/Button.js";
|
|
import { Card } from "../../ui/card/Card.js";
|
|
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
|
import { Panel } from "../../ui/panel/Panel.js";
|
|
import { TextField } from "../../ui/text-field/TextField.js";
|
|
import {
|
|
RefreshIntervalPicker,
|
|
type RefreshIntervalMs,
|
|
} from "../../ui/refresh-interval/RefreshInterval.js";
|
|
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
|
import { Deliveries } from "./Deliveries.js";
|
|
import {
|
|
duplicateFlow,
|
|
newFlow,
|
|
saveOutcome,
|
|
saveRequest,
|
|
} from "./FlowDraft.js";
|
|
import {
|
|
describeNode,
|
|
treeRows,
|
|
withBuiltin,
|
|
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;
|
|
|
|
// `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 }) => (
|
|
<li
|
|
class="notify-node"
|
|
key={node.id}
|
|
style={{ paddingLeft: `${depth * 1.5}em` }}
|
|
>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
export function NotificationsPage() {
|
|
const [intervalMs, setIntervalMs] =
|
|
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
|
const {
|
|
data: view,
|
|
error,
|
|
refresh,
|
|
} = 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("");
|
|
const [, navigate] = useLocation();
|
|
|
|
// Switch one built-in on or off: the whole document goes back with the
|
|
// revision this page read (0 for none), so a change made elsewhere since is refused
|
|
// rather than overwritten.
|
|
async function setBuiltin(id: string, enabled: boolean) {
|
|
if (!view) return;
|
|
setPending(true);
|
|
setActionError(null);
|
|
try {
|
|
const r = await fetch("/api/notify/flows", {
|
|
method: "PUT",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
revision: view.revision,
|
|
disabled_builtins: withBuiltin(view.disabled_builtins, id, enabled),
|
|
flows: view.flows,
|
|
}),
|
|
});
|
|
if (!r.ok) setActionError(await readApiError(r));
|
|
} catch (err) {
|
|
setActionError({ detail: String(err) });
|
|
} finally {
|
|
setPending(false);
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
// Add `flow` to the custom flows and open it in the editor. Its id is
|
|
// made free of every flow's, the built-ins' too: the controller refuses a
|
|
// custom flow that shares a built-in's.
|
|
async function addFlow(make: (taken: string[]) => Flow) {
|
|
if (!view) return;
|
|
const taken = [...view.builtins, ...view.flows].map((f) => f.id);
|
|
const flow = make(taken);
|
|
setPending(true);
|
|
setActionError(null);
|
|
try {
|
|
const r = await fetch(
|
|
"/api/notify/flows",
|
|
saveRequest(view, [...view.flows, flow], view.disabled_builtins),
|
|
);
|
|
if (saveOutcome(r.status) === "saved") {
|
|
setNewName("");
|
|
navigate(`/notifications/${encodeURIComponent(flow.id)}`);
|
|
return;
|
|
}
|
|
setActionError(await readApiError(r));
|
|
} catch (err) {
|
|
setActionError({ detail: String(err) });
|
|
} finally {
|
|
setPending(false);
|
|
refresh();
|
|
}
|
|
}
|
|
|
|
const duplicate = (flow: Flow) =>
|
|
addFlow((taken) => duplicateFlow(flow, `${flow.name} (copy)`, taken));
|
|
|
|
return (
|
|
<Panel
|
|
title="notifications"
|
|
icon="🔔"
|
|
actions={
|
|
<RefreshIntervalPicker
|
|
id="notifications-refresh"
|
|
value={intervalMs}
|
|
onChange={setIntervalMs}
|
|
/>
|
|
}
|
|
>
|
|
{error ? (
|
|
<ApiErrorPanel context="failed to load the flows" problem={error} />
|
|
) : null}
|
|
{actionError ? (
|
|
<ApiErrorPanel
|
|
context="the flows were not changed"
|
|
problem={actionError}
|
|
/>
|
|
) : null}
|
|
{!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);
|
|
return (
|
|
<Card key={flow.id} class={on ? "" : "notify-off"}>
|
|
<div class="notify-flow-head">
|
|
<span class="notify-flow-name">{flow.name}</span>
|
|
<CheckboxField
|
|
id={`builtin-${flow.id}`}
|
|
label={on ? "on" : "off"}
|
|
checked={on}
|
|
disabled={pending}
|
|
onChange={(checked) => setBuiltin(flow.id, checked)}
|
|
/>
|
|
</div>
|
|
<FlowTree flow={flow} hits={hitsOf(shown, flow.id)} />
|
|
<Button disabled={pending} onClick={() => void duplicate(flow)}>
|
|
duplicate to edit
|
|
</Button>
|
|
</Card>
|
|
);
|
|
})}
|
|
<h3>custom flows</h3>
|
|
{view.flows.length === 0 ? <p>no custom flows</p> : null}
|
|
<div class="notify-new">
|
|
<TextField
|
|
id="notify-new-name"
|
|
label="new flow"
|
|
placeholder="name"
|
|
value={newName}
|
|
onInput={setNewName}
|
|
/>
|
|
<Button
|
|
disabled={pending || newName.trim() === ""}
|
|
onClick={() =>
|
|
void addFlow((taken) => newFlow(newName.trim(), taken))
|
|
}
|
|
>
|
|
create
|
|
</Button>
|
|
</div>
|
|
{view.flows.map((flow) => (
|
|
<Card key={flow.id} class={flow.enabled ? "" : "notify-off"}>
|
|
<div class="notify-flow-head">
|
|
<span class="notify-flow-name">{flow.name}</span>
|
|
<span>{flow.enabled ? "on" : "off"}</span>
|
|
<Link href={`/notifications/${encodeURIComponent(flow.id)}`}>
|
|
edit
|
|
</Link>
|
|
<Button disabled={pending} onClick={() => void duplicate(flow)}>
|
|
duplicate
|
|
</Button>
|
|
</div>
|
|
<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>
|
|
);
|
|
}
|