Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx

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