From 322793dab3dcb86fd8ca8f9e6b2919bc4485ac2c Mon Sep 17 00:00:00 2001 From: malice Date: Tue, 7 Jul 2026 02:06:18 -0600 Subject: [PATCH] feat(gui): unified Delivery Destinations editor (define once, reference everywhere) (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Destinations manager to the Routing page — define each delivery target (mesh channel / email / webhook / digest) once — and a destination picker on each family and rule. The duplicated per-family inline email/webhook editors move under an Advanced/legacy disclosure. De-fragments delivery config: no more configuring the same email in two places. Co-authored-by: Matt Johnson Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/pages/Notifications.tsx | 437 +++++++++++++++++- 1 file changed, 429 insertions(+), 8 deletions(-) diff --git a/work/dashboard-frontend/src/pages/Notifications.tsx b/work/dashboard-frontend/src/pages/Notifications.tsx index 11e26ee..8d2a54c 100644 --- a/work/dashboard-frontend/src/pages/Notifications.tsx +++ b/work/dashboard-frontend/src/pages/Notifications.tsx @@ -13,6 +13,29 @@ import { ManagedSecret } from '@/components/ManagedSecret' import { KeyValueInput } from '../components/KeyValueInput' // Types + +// Integration C2: a named, reusable delivery target. Mirrors the backend +// config.py::NotificationDestination field set. Defined ONCE under +// NotificationsConfig.destinations and referenced by name from toggles/rules +// via their `destinations` list. Only the fields relevant to `type` are used. +export interface NotificationDestination { + name: string + type: 'mesh_broadcast' | 'meshcore_broadcast' | 'mesh_dm' | 'meshcore_dm' | 'email' | 'webhook' | 'digest' + broadcast_channel?: number | null + meshcore_channel?: string | null + node_ids?: string[] + meshcore_dm_contacts?: string[] + smtp_host?: string + smtp_port?: number + smtp_user?: string + smtp_password?: string + smtp_tls?: boolean + from_address?: string + recipients?: string[] + webhook_url?: string + webhook_headers?: Record +} + interface NotificationRuleConfig { name: string enabled: boolean @@ -39,6 +62,9 @@ interface NotificationRuleConfig { webhook_headers: Record cooldown_minutes: number region_scope: string[] + // Integration C2: reference reusable destinations by name. When non-empty the + // rule delivers via the resolved destination(s); empty => inline fields below. + destinations?: string[] } export interface NotificationToggle { @@ -62,6 +88,9 @@ export interface NotificationToggle { recipients: string[] webhook_url: string webhook_headers: Record + // Integration C2: reference reusable destinations by name. When non-empty this + // family delivers via the resolved destination(s); empty => inline fields. + destinations?: string[] } export interface DigestConfig { @@ -78,6 +107,8 @@ export interface NotificationsConfig { digest?: DigestConfig rules: NotificationRuleConfig[] toggles?: Record + // Integration C2: named, reusable delivery targets (name -> destination). + destinations?: Record } interface AlertCategory { @@ -733,6 +764,7 @@ function NotificationRuleCard({ ruleIndex, categories, regions, + destinationNames, onChange, onDelete, onDuplicate, @@ -742,6 +774,7 @@ function NotificationRuleCard({ ruleIndex: number categories: AlertCategory[] regions: RegionInfo[] + destinationNames: string[] onChange: (r: NotificationRuleConfig) => void onDelete: () => void onDuplicate: () => void @@ -1255,6 +1288,31 @@ function NotificationRuleCard({ SEND VIA + {/* Primary path: reference reusable destinations */} +
+ + onChange({ ...rule, destinations: v })} + /> + {(rule.destinations?.length || 0) > 0 && ( +

+ Delivers via {rule.destinations!.length} destination{rule.destinations!.length !== 1 ? 's' : ''}. The inline delivery method below is ignored while destinations are selected. +

+ )} +
+ + {/* Legacy inline delivery — kept for back-compat */} +
+ + + Advanced / legacy inline delivery + +
+
{/* Behavior section */} @@ -1718,6 +1778,8 @@ function mergeMeshtasticAndOtherFields( base.recipients = mine.recipients base.webhook_url = mine.webhook_url base.webhook_headers = mine.webhook_headers + // Integration C2: destination references are edited on this page. + base.destinations = mine.destinations || [] // NOTE: do NOT overlay gating fields (enabled, min_severity, freshness_seconds, // cooldown_seconds, regions) — those are managed by Data Feeds > Family Settings. @@ -1883,6 +1945,325 @@ function OtherChannelsGrid({ } +// --------------------------------------------------------------------------- +// Integration C2 — unified Delivery Destinations +// --------------------------------------------------------------------------- + +// The destination types, in the order the picker/select presents them. Mirrors +// NotificationDestination.type on the backend. +const DESTINATION_TYPE_OPTIONS: { value: NotificationDestination['type']; label: string; Icon: typeof Activity }[] = [ + { value: 'mesh_broadcast', label: 'Mesh broadcast', Icon: Radio }, + { value: 'meshcore_broadcast', label: 'MeshCore broadcast', Icon: Radio }, + { value: 'mesh_dm', label: 'Mesh DM', Icon: MessageSquare }, + { value: 'meshcore_dm', label: 'MeshCore DM', Icon: MessageSquare }, + { value: 'email', label: 'Email', Icon: Mail }, + { value: 'webhook', label: 'Webhook', Icon: Globe }, + { value: 'digest', label: 'Digest', Icon: Clock }, +] + +// A single editable destination card. Shows ONLY the fields relevant to the +// chosen type. `dest` is fully controlled; edits flow up via onChange (field +// patch), onRename (the id/key changes), and onDelete. +function DestinationCard({ + dest, + onChange, + onRename, + onDelete, +}: { + dest: NotificationDestination + onChange: (patch: Partial) => void + onRename: (name: string) => void + onDelete: () => void +}) { + const meta = DESTINATION_TYPE_OPTIONS.find((o) => o.value === dest.type) ?? DESTINATION_TYPE_OPTIONS[0] + const Icon = meta.Icon + return ( +
+
+
+ +
+ + +
+
+ +
+ + {/* Type-specific fields */} +
+
+ {meta.label} settings +
+ + {dest.type === 'mesh_broadcast' && ( + onChange({ broadcast_channel: v })} + min={0} + helper="Meshtastic channel index (0 = LongFast primary)" + /> + )} + + {dest.type === 'meshcore_broadcast' && ( + onChange({ meshcore_channel: v })} + placeholder="e.g. aida" + helper="MeshCore channel NAME on the companion" + /> + )} + + {dest.type === 'mesh_dm' && ( + onChange({ node_ids: v })} + placeholder="!hex_id" + helper="Meshtastic DM recipients (hex node IDs)" + /> + )} + + {dest.type === 'meshcore_dm' && ( + onChange({ meshcore_dm_contacts: v })} + placeholder="Contact name" + helper="MeshCore companion contact names" + /> + )} + + {dest.type === 'email' && ( +
+ onChange({ recipients: v })} + placeholder="ops@example.com" + helper="Email addresses to receive alerts" + /> +
+ onChange({ smtp_host: v })} placeholder="smtp.example.com" /> + onChange({ smtp_port: v })} min={1} max={65535} /> +
+
+ onChange({ smtp_user: v })} /> + onChange({ smtp_password: v })} helper="SMTP password (App Password for Gmail)" /> +
+ onChange({ smtp_tls: v })} /> + onChange({ from_address: v })} placeholder="alerts@example.com" /> +
+ )} + + {dest.type === 'webhook' && ( +
+ onChange({ webhook_url: v })} + placeholder="https://discord.com/api/webhooks/..." + helper="POST alert as JSON" + info="Works with Discord webhooks, ntfy.sh, Slack, Home Assistant, Pushover, or any HTTP POST endpoint." + /> + onChange({ webhook_headers: v })} + helper="Custom HTTP headers (e.g. Authorization)" + keyPlaceholder="Header" + valuePlaceholder="Value" + /> +
+ )} + + {dest.type === 'digest' && ( +

+ Delivers into the daily digest (schedule/included families configured above). + No per-destination fields. +

+ )} +
+
+ ) +} + +// The single place delivery targets are defined. Controlled: receives the +// destinations record and emits an updated record. Cards are keyed by array +// index (not the name) so editing the Name field never remounts the input. +function DestinationsManager({ + destinations, + onChange, +}: { + destinations: Record + onChange: (d: Record) => void +}) { + const entries = Object.entries(destinations) + + const updateEntry = (i: number, patch: Partial) => { + onChange(Object.fromEntries(entries.map(([k, v], j) => (j === i ? [k, { ...v, ...patch }] : [k, v])))) + } + const renameEntry = (i: number, newName: string) => { + onChange(Object.fromEntries(entries.map(([k, v], j) => (j === i ? [newName, { ...v, name: newName }] : [k, v])))) + } + const deleteEntry = (i: number) => { + onChange(Object.fromEntries(entries.filter((_, j) => j !== i))) + } + const addDestination = () => { + const existing = new Set(entries.map(([k]) => k)) + let n = entries.length + 1 + let name = `destination-${n}` + while (existing.has(name)) { n++; name = `destination-${n}` } + onChange({ ...destinations, [name]: { name, type: 'mesh_broadcast', broadcast_channel: 0 } }) + } + + return ( +
+ {entries.length === 0 && ( +
+ No destinations defined yet. Add one to configure a delivery target (email, webhook, + mesh channel, digest) once, then reference it from any family or rule below. +
+ )} + {entries.map(([key, dest], i) => ( + updateEntry(i, patch)} + onRename={(name) => renameEntry(i, name)} + onDelete={() => deleteEntry(i)} + /> + ))} + +
+ ) +} + +// A multi-select of destination names, rendered as toggle chips. Edits the +// caller's `selected` string[] (a toggle.destinations or rule.destinations). +function DestinationPicker({ + available, + selected, + onChange, +}: { + available: string[] + selected: string[] + onChange: (v: string[]) => void +}) { + if (available.length === 0) { + return ( +

+ No destinations defined yet — add one under Delivery Destinations above. +

+ ) + } + const toggle = (name: string) => { + if (selected.includes(name)) onChange(selected.filter((s) => s !== name)) + else onChange([...selected, name]) + } + return ( +
+ {available.map((name) => { + const on = selected.includes(name) + return ( + + ) + })} +
+ ) +} + +// Per-family destination picker grid. The PRIMARY delivery surface: pick which +// reusable destinations each family delivers through. The inline severity matrix +// / SMTP / webhook editors live below under the Advanced/legacy disclosure. +function FamilyDestinationsGrid({ + toggles, + destinationNames, + onChange, +}: { + toggles: Record + destinationNames: string[] + onChange: (t: Record) => void +}) { + const families = useFamilies() + const upd = (fam: string, patch: Partial) => + onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), ...patch } as NotificationToggle }) + + return ( +
+
+ Per-family Destinations + +
+
+ {families.map(({ key, label, Icon }) => { + const t = toggles[key] || ({} as NotificationToggle) + const selected = t.destinations || [] + return ( +
+
+ {label} +
+ upd(key, { destinations: v })} + /> + {selected.length > 0 && ( +

+ Delivers via {selected.length} destination{selected.length !== 1 ? 's' : ''}. The inline + channel matrix & fields below are the legacy fallback. +

+ )} +
+ ) + })} +
+
+ ) +} + export default function Notifications() { const { setDirty } = useDirty() const families = useFamilies() @@ -1960,7 +2341,8 @@ export default function Notifications() { enabled: config.enabled, // global master switch lives on this page band_conditions_tz: config.band_conditions_tz, // top-level, edited on this page digest: config.digest, // daily digest schedule/include, edited on this page - rules: config.rules, // rules are only edited on this page + rules: config.rules, // rules are only edited on this page (incl. destination refs) + destinations: config.destinations || {}, // C2: the reusable-destinations registry, edited here toggles: { ...(fresh.toggles || {}) }, } const myToggles = config.toggles || {} @@ -2032,6 +2414,7 @@ export default function Notifications() { webhook_headers: {}, cooldown_minutes: 10, region_scope: [], + destinations: [], }) const addRule = () => { @@ -2379,17 +2762,53 @@ export default function Notifications() { ) })()} - {/* Meshtastic delivery grids — always visible regardless of master switch */} + {/* Delivery Destinations — define each target ONCE, reference everywhere */} +
+
+ + + {Object.keys(config.destinations || {}).length} destination{Object.keys(config.destinations || {}).length !== 1 ? 's' : ''} + +
+ setConfig({ ...config, destinations: d })} + /> +
+ + {/* Per-family delivery — primary picker + legacy inline editors */} {config.toggles && ( <> - setConfig({ ...config, toggles: t })} - /> - setConfig({ ...config, toggles: t })} /> + + {/* Legacy inline delivery — kept for back-compat, no longer the primary surface */} +
+ + + Advanced / legacy inline delivery + +
+

+ Per-family inline channel matrix & SMTP/webhook fields. Used only when a family has + no destinations selected above. Prefer defining a destination and picking it. +

+ setConfig({ ...config, toggles: t })} + /> + setConfig({ ...config, toggles: t })} + /> +
+
)} @@ -2413,7 +2832,9 @@ export default function Notifications() { rule={rule} ruleIndex={i} categories={categories} - regions={regions} onChange={(r) => { + regions={regions} + destinationNames={Object.keys(config.destinations || {})} + onChange={(r) => { const newRules = [...(config.rules || [])] newRules[i] = r setConfig({ ...config, rules: newRules })