From 310abe8c066021493ed5671deccae143fb314b1c Mon Sep 17 00:00:00 2001 From: Matt Johnson Date: Fri, 3 Jul 2026 03:11:11 +0000 Subject: [PATCH] feat(dashboard): split Notifications into Meshtastic and MeshCore sections Delineate per-mesh routing: each family configures Meshtastic delivery (mesh_broadcast/mesh_dm, channel index, node IDs) and MeshCore delivery (meshcore_broadcast/meshcore_dm, channel name, contacts) in separate sections; shared settings (enable/severity/regions/email/webhook) once. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/pages/Notifications.tsx | 242 ++++++++++++++---- work/dashboard-frontend/vite.config.ts | 4 +- 2 files changed, 198 insertions(+), 48 deletions(-) diff --git a/work/dashboard-frontend/src/pages/Notifications.tsx b/work/dashboard-frontend/src/pages/Notifications.tsx index e5fe133..51107d2 100644 --- a/work/dashboard-frontend/src/pages/Notifications.tsx +++ b/work/dashboard-frontend/src/pages/Notifications.tsx @@ -45,9 +45,12 @@ interface NotificationToggle { min_severity: string regions: string[] severity_channels: Record + freshness_seconds?: number + cooldown_seconds?: number broadcast_channel: number | null meshcore_channel?: string | null node_ids: string[] + meshcore_dm_contacts: string[] smtp_host: string smtp_port: number smtp_user: string @@ -1541,9 +1544,70 @@ function GroupedCategoryPicker({ ) } -const TOGGLE_CHANNELS = ['digest', 'mesh_broadcast', 'mesh_dm', 'email', 'webhook'] +// Per-mesh channel groups for the split severity matrix +const MT_CHANNELS = ['mesh_broadcast', 'mesh_dm'] as const +const MC_CHANNELS = ['meshcore_broadcast', 'meshcore_dm'] as const +const OTHER_CHANNELS = ['digest', 'email', 'webhook'] as const const TOGGLE_SEVERITIES = ['routine', 'priority', 'immediate'] +// Reusable severity × channel matrix. +// Toggling a single channel only touches that exact string in the severity row — +// all other channels (including those owned by the other mesh section) are preserved. +// This makes every checkbox merge-safe: Meshtastic toggles never clobber MeshCore +// entries and vice-versa. +function SeverityChannelMatrix({ + channels, + severityChannels, + onChange, +}: { + channels: readonly string[] + severityChannels: Record + onChange: (updated: Record) => void +}) { + const colLabel = (c: string) => + c.replace('meshcore_', 'mc_').replace('mesh_', '').replace(/_/g, ' ') + return ( + + + + + {channels.map((c) => ( + + ))} + + + + {TOGGLE_SEVERITIES.map((sev) => ( + + + {channels.map((ch) => { + const on = (severityChannels[sev] || []).includes(ch) + return ( + + ) + })} + + ))} + +
severity{colLabel(c)}
{sev} + { + // Shallow-copy the dict so we don't mutate state, then + // only modify the specific channel being toggled. + const cur: Record = { ...severityChannels } + const arr = new Set(cur[sev] || []) + if (e.target.checked) arr.add(ch) + else arr.delete(ch) + cur[sev] = Array.from(arr) + onChange(cur) + }} + /> +
+ ) +} + function MasterToggles({ toggles, onChange }: { toggles: Record onChange: (t: Record) => void @@ -1581,53 +1645,139 @@ function MasterToggles({ toggles, onChange }: { )} {isOpen && ( -
- upd(key, { min_severity: v })} /> -
Severity → channels
- - - {TOGGLE_CHANNELS.map((c) => )} - - - {TOGGLE_SEVERITIES.map((sev) => ( - - - {TOGGLE_CHANNELS.map((ch) => { - const on = (t.severity_channels?.[sev] || []).includes(ch) - return ( - - ) - })} - - ))} - -
{c.replace('_', ' ')}
{sev} - { - const cur: Record = { ...(t.severity_channels || {}) } - const arr = new Set(cur[sev] || []) - if (e.target.checked) arr.add(ch); else arr.delete(ch) - cur[sev] = Array.from(arr) - upd(key, { severity_channels: cur }) - }} /> -
- upd(key, { regions: v })} placeholder="Add region..." />
Channel config
- upd(key, { broadcast_channel: v })} /> -
- - upd(key, { meshcore_channel: e.target.value === '' ? null : e.target.value })} - placeholder="" - className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent" +
+ + {/* ── General ──────────────────────────────────────────────── */} +
+
General
+ upd(key, { min_severity: v })} /> + upd(key, { regions: v })} + placeholder="Add region..." /> -

MeshCore channel name on your companion (e.g. AIDA); blank = not broadcast on MeshCore.

+
+ upd(key, { freshness_seconds: v })} + min={0} + helper="Drop events older than this" + info="Events older than this window (seconds) are discarded at dispatcher entrance. 600 = 10 min." + /> + upd(key, { cooldown_seconds: v })} + min={0} + helper="0 = no throttle" + info="Per (family, category, region) throttle window. Prevents repeat sends within this window." + /> +
- upd(key, { node_ids: v })} placeholder="!nodeid" /> - upd(key, { recipients: v })} placeholder="ops@example.com" /> - upd(key, { smtp_host: v })} placeholder="smtp.example.com" /> - upd(key, { smtp_port: v })} /> - upd(key, { webhook_url: v })} placeholder="https://..." /> + + {/* ── Meshtastic ───────────────────────────────────────────── */} +
+
+ + Meshtastic +
+ upd(key, { severity_channels: sc })} + /> + upd(key, { broadcast_channel: v })} + min={0} + helper="Meshtastic channel index (0 = LongFast primary)" + info="The Meshtastic channel index used for mesh_broadcast delivery. 0 = primary channel." + /> + upd(key, { node_ids: v })} + placeholder="!hex_id" + helper="Meshtastic DM recipients (hex node IDs)" + info="Hex node IDs for mesh_dm delivery (e.g. !a1b2c3d4). Used when mesh_dm is enabled for a severity." + /> +
+ + {/* ── MeshCore ─────────────────────────────────────────────── */} +
+
+ + MeshCore +
+ upd(key, { severity_channels: sc })} + /> +
+ + upd(key, { meshcore_channel: e.target.value === '' ? null : e.target.value })} + placeholder="AIDA" + className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent" + /> +

Channel name on your MeshCore companion (e.g. AIDA). Blank = not broadcast on MeshCore.

+
+ upd(key, { meshcore_dm_contacts: v })} + placeholder="contact name or pubkey" + helper="MeshCore DM recipients (names or pubkeys)" + info="Contact names or pubkeys on the MeshCore companion. Used when meshcore_dm is enabled for a severity." + /> +
+ + {/* ── Other channels ───────────────────────────────────────── */} +
+
+ + Other channels +
+ upd(key, { severity_channels: sc })} + /> + upd(key, { recipients: v })} + placeholder="ops@example.com" + /> +
+ + + SMTP settings + +
+ upd(key, { smtp_host: v })} placeholder="smtp.example.com" /> + upd(key, { smtp_port: v })} /> + upd(key, { smtp_user: v })} /> + upd(key, { smtp_password: v })} type="password" /> + upd(key, { smtp_tls: v })} /> + upd(key, { from_address: v })} placeholder="alerts@example.com" /> +
+
+ upd(key, { webhook_url: v })} + placeholder="https://..." + helper="POST alert as JSON" + /> +
+
)}
diff --git a/work/dashboard-frontend/vite.config.ts b/work/dashboard-frontend/vite.config.ts index 275917b..cfe50ef 100644 --- a/work/dashboard-frontend/vite.config.ts +++ b/work/dashboard-frontend/vite.config.ts @@ -16,11 +16,11 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://localhost:8080', + target: 'http://localhost:8082', changeOrigin: true, }, '/ws': { - target: 'ws://localhost:8080', + target: 'ws://localhost:8082', ws: true, }, },