swarm-ui: show the notification router's flows and switch built-ins off
This commit is contained in:
parent
e2e124755d
commit
0512862b3d
6 changed files with 368 additions and 0 deletions
|
|
@ -0,0 +1,95 @@
|
|||
// Wire types of swarm-controller's `GET/PUT /api/notify/flows` and the small
|
||||
// pure helpers the notifications page draws a flow with.
|
||||
|
||||
export type Recipients = { field: string } | { agent: string };
|
||||
|
||||
export interface Condition {
|
||||
field: string;
|
||||
equals: string;
|
||||
}
|
||||
|
||||
// A node as the API sends it: `kind` sits beside `id`.
|
||||
export type FlowNode =
|
||||
| { id: string; kind: "source"; events: string[] }
|
||||
| { id: string; kind: "match"; when: Condition[] }
|
||||
| {
|
||||
id: string;
|
||||
kind: "todo";
|
||||
to: Recipients;
|
||||
op: "upsert" | "clear";
|
||||
key: string;
|
||||
prefix: boolean;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export interface Flow {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
nodes: FlowNode[];
|
||||
edges: { from: string; to: string }[];
|
||||
}
|
||||
|
||||
export interface FlowsView {
|
||||
revision: number | null;
|
||||
disabled_builtins: string[];
|
||||
builtins: Flow[];
|
||||
flows: Flow[];
|
||||
}
|
||||
|
||||
// One node of a flow with its depth below the source, in the order a reader
|
||||
// walks the tree: a parent, then each child's subtree in edge order.
|
||||
export interface TreeRow {
|
||||
node: FlowNode;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function treeRows(flow: Flow): TreeRow[] {
|
||||
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
|
||||
const root = flow.nodes.find((n) => n.kind === "source");
|
||||
if (!root) return [];
|
||||
const rows: TreeRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
const walk = (id: string, depth: number) => {
|
||||
const node = byId.get(id);
|
||||
// The controller refuses a flow that is not a tree; a repeat here would
|
||||
// mean a cycle, which `seen` stops from looping.
|
||||
if (!node || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
rows.push({ node, depth });
|
||||
for (const e of flow.edges) if (e.from === id) walk(e.to, depth + 1);
|
||||
};
|
||||
walk(root.id, 0);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function describeRecipients(to: Recipients): string {
|
||||
return "field" in to ? `{${to.field}}` : to.agent;
|
||||
}
|
||||
|
||||
// One line saying what a node is and does.
|
||||
export function describeNode(node: FlowNode): string {
|
||||
switch (node.kind) {
|
||||
case "source":
|
||||
return `forge ${node.events.join(", ")}`;
|
||||
case "match":
|
||||
return node.when.length === 0
|
||||
? "match anything"
|
||||
: node.when.map((c) => `${c.field} = ${c.equals}`).join(" and ");
|
||||
case "todo":
|
||||
return node.op === "clear"
|
||||
? `clear todos ${node.key}${node.prefix ? "*" : ""} of ${describeRecipients(node.to)}`
|
||||
: `todo to ${describeRecipients(node.to)}, key ${node.key}: ${node.text}`;
|
||||
}
|
||||
}
|
||||
|
||||
// `disabled` with built-in `id` switched on or off, without repeats and in a
|
||||
// stable order.
|
||||
export function withBuiltin(
|
||||
disabled: string[],
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
): string[] {
|
||||
const rest = disabled.filter((d) => d !== id);
|
||||
return (enabled ? rest : [...rest, id]).sort();
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/* NotificationsPage — the flows drawn as indented trees. */
|
||||
|
||||
.notify-flows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
.notify-flow-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.notify-flow-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notify-flow-tree {
|
||||
margin: 0.4em 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.notify-node {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
padding: 0.1em 0;
|
||||
}
|
||||
|
||||
.notify-node-kind {
|
||||
flex: none;
|
||||
min-width: 3.5em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.notify-node-text {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.notify-off {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
// <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.
|
||||
//
|
||||
// Read-only except for one switch per built-in flow. A flow draws as an
|
||||
// indented tree, the shape the API validates it to. Custom flows are not
|
||||
// editable here: they arrive through the API.
|
||||
import { useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.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 {
|
||||
describeNode,
|
||||
treeRows,
|
||||
withBuiltin,
|
||||
type Flow,
|
||||
type FlowsView,
|
||||
} from "./FlowTypes.js";
|
||||
import "./NotificationsPage.css";
|
||||
|
||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||
|
||||
function FlowTree({ flow }: { flow: Flow }) {
|
||||
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>
|
||||
</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),
|
||||
);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [actionError, setActionError] = useState<ProblemDetails | null>(null);
|
||||
|
||||
// Switch one built-in on or off: the whole document goes back with the
|
||||
// revision this page read, 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();
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<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} />
|
||||
</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>{flow.enabled ? "on" : "off"}</span>
|
||||
</div>
|
||||
<FlowTree flow={flow} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue