Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx
iris 6e7b5f84e9 swarm-ui: give the new-flow draft a route no flow id can take, and drop the dead hit-count rule
/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.
2026-10-25 02:05:54 +02:00

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