// — 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 (), 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 }) { return (
    {treeRows(flow).map(({ node, depth }) => (
  • {node.kind} {describeNode(node)} {hits ? ( {hits.get(node.id) ?? 0} ) : null}
  • ))}
); } export function NotificationsPage() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); const { data: view, error, refresh, } = usePolled(intervalMs, (signal) => fetchJson("/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(DRY_RUN_URL, signal), ); const { data: deliveries, error: deliveriesError } = usePolled( intervalMs, (signal) => fetchJson(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(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 ( } > {error ? ( ) : null} {actionError ? ( ) : null} {!error && view === null ?

loading…

: null} {view ? (
{dryError ? (

{unavailableText("hit counts", dryError)}

) : dry ? (

counts: events that reached each node, of the newest {dry.events}

) : null}

built-in flows

{view.builtins.map((flow) => { const on = !view.disabled_builtins.includes(flow.id); return (
{flow.name} setBuiltin(flow.id, checked)} />
); })}

custom flows

{view.flows.length === 0 ?

no custom flows

: null}
{view.flows.map((flow) => (
{flow.name} {flow.enabled ? "on" : "off"} edit
))}

recent deliveries

{deliveriesError ? (

{unavailableText("the deliveries log", deliveriesError)}

) : deliveries ? ( ) : (

loading…

)}
) : null}
); }