/notifications/new could be a saved flow's own page, so the draft now opens at /notifications/draft/new, which has a segment more than any flow's route. The placeholder id no longer needs reserving. The .notify-node-hits rule styled the removed tree rows; nothing renders it.
197 lines
6.9 KiB
TypeScript
197 lines
6.9 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 its graph, read-only. A built-in flow has a switch and can
|
|
// be duplicated; a custom flow is edited on its own page (<FlowEditorPage>).
|
|
// A new flow and a duplicate both open that page on a draft: nothing is created
|
|
// until it is saved there.
|
|
import { useState } from "preact/hooks";
|
|
import { 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 {
|
|
RefreshIntervalPicker,
|
|
type RefreshIntervalMs,
|
|
} from "../../ui/refresh-interval/RefreshInterval.js";
|
|
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
|
import { Deliveries } from "./Deliveries.js";
|
|
import { NEW_FLOW_PATH } from "./FlowDraft.js";
|
|
import { FlowGraph } from "./FlowGraph.js";
|
|
import { withBuiltin, 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;
|
|
|
|
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 [, 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();
|
|
}
|
|
}
|
|
|
|
// Open the editor on a draft: empty, or a copy of the flow `from`.
|
|
const draftFrom = (from?: string) =>
|
|
navigate(
|
|
from === undefined
|
|
? NEW_FLOW_PATH
|
|
: `${NEW_FLOW_PATH}?from=${encodeURIComponent(from)}`,
|
|
);
|
|
|
|
return (
|
|
<Panel
|
|
title="notifications"
|
|
icon="🔔"
|
|
actions={
|
|
<div class="notify-panel-actions">
|
|
<RefreshIntervalPicker
|
|
id="notifications-refresh"
|
|
value={intervalMs}
|
|
onChange={setIntervalMs}
|
|
/>
|
|
<Button variant="primary" onClick={() => draftFrom()}>
|
|
create flow
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
{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 class="notify-flow-actions">
|
|
<Button onClick={() => draftFrom(flow.id)}>
|
|
duplicate to edit
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<FlowGraph flow={flow} hits={hitsOf(shown, flow.id)} />
|
|
</Card>
|
|
);
|
|
})}
|
|
<h3>custom flows</h3>
|
|
{view.flows.length === 0 ? <p>no custom flows</p> : null}
|
|
{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 class="notify-flow-state">
|
|
{flow.enabled ? "on" : "off"}
|
|
</span>
|
|
<div class="notify-flow-actions">
|
|
<Button
|
|
onClick={() =>
|
|
navigate(`/notifications/${encodeURIComponent(flow.id)}`)
|
|
}
|
|
>
|
|
edit
|
|
</Button>
|
|
<Button onClick={() => draftFrom(flow.id)}>duplicate</Button>
|
|
</div>
|
|
</div>
|
|
<FlowGraph 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>
|
|
);
|
|
}
|