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) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-07-03 03:11:11 +00:00
commit 310abe8c06
2 changed files with 198 additions and 48 deletions

View file

@ -45,9 +45,12 @@ interface NotificationToggle {
min_severity: string
regions: string[]
severity_channels: Record<string, string[]>
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({
</div>
)
}
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<string, string[]>
onChange: (updated: Record<string, string[]>) => void
}) {
const colLabel = (c: string) =>
c.replace('meshcore_', 'mc_').replace('mesh_', '').replace(/_/g, ' ')
return (
<table className="text-xs w-full">
<thead>
<tr>
<th className="text-left text-slate-600 font-normal w-20">severity</th>
{channels.map((c) => (
<th key={c} className="text-slate-500 font-normal px-1 whitespace-nowrap">{colLabel(c)}</th>
))}
</tr>
</thead>
<tbody>
{TOGGLE_SEVERITIES.map((sev) => (
<tr key={sev}>
<td className="text-slate-400 pr-2 whitespace-nowrap">{sev}</td>
{channels.map((ch) => {
const on = (severityChannels[sev] || []).includes(ch)
return (
<td key={ch} className="text-center">
<input
type="checkbox"
checked={on}
onChange={(e) => {
// Shallow-copy the dict so we don't mutate state, then
// only modify the specific channel being toggled.
const cur: Record<string, string[]> = { ...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)
}}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
)
}
function MasterToggles({ toggles, onChange }: {
toggles: Record<string, NotificationToggle>
onChange: (t: Record<string, NotificationToggle>) => void
@ -1581,53 +1645,139 @@ function MasterToggles({ toggles, onChange }: {
</div>
)}
{isOpen && (
<div className={`mt-3 space-y-3 ${t.enabled ? '' : 'opacity-40 pointer-events-none select-none'}`}>
<SeveritySelector value={t.min_severity || 'priority'} onChange={(v) => upd(key, { min_severity: v })} />
<div className="text-xs text-slate-500">Severity &rarr; channels</div>
<table className="text-xs w-full">
<thead>
<tr><th></th>{TOGGLE_CHANNELS.map((c) => <th key={c} className="text-slate-500 font-normal px-1">{c.replace('_', ' ')}</th>)}</tr>
</thead>
<tbody>
{TOGGLE_SEVERITIES.map((sev) => (
<tr key={sev}>
<td className="text-slate-400 pr-2">{sev}</td>
{TOGGLE_CHANNELS.map((ch) => {
const on = (t.severity_channels?.[sev] || []).includes(ch)
return (
<td key={ch} className="text-center">
<input type="checkbox" checked={on} onChange={(e) => {
const cur: Record<string, string[]> = { ...(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 })
}} />
</td>
)
})}
</tr>
))}
</tbody>
</table>
<ListInput label="Regions (empty = all)" value={t.regions || []} onChange={(v) => upd(key, { regions: v })} placeholder="Add region..." /> <div className="text-xs text-slate-500 pt-1">Channel config</div>
<NumberInput label="Broadcast channel" value={t.broadcast_channel ?? 0} onChange={(v) => upd(key, { broadcast_channel: v })} />
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">MeshCore channel</label>
<input
type="text"
value={t.meshcore_channel != null ? t.meshcore_channel : ''}
onChange={(e) => 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"
<div className={`mt-3 space-y-4 ${t.enabled ? '' : 'opacity-40 pointer-events-none select-none'}`}>
{/* ── General ──────────────────────────────────────────────── */}
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="text-xs text-slate-500 uppercase tracking-wide">General</div>
<SeveritySelector value={t.min_severity || 'priority'} onChange={(v) => upd(key, { min_severity: v })} />
<ListInput
label="Regions (empty = all)"
value={t.regions || []}
onChange={(v) => upd(key, { regions: v })}
placeholder="Add region..."
/>
<p className="text-xs text-slate-600">MeshCore channel name on your companion (e.g. AIDA); blank = not broadcast on MeshCore.</p>
<div className="grid grid-cols-2 gap-3">
<NumberInput
label="Freshness (sec)"
value={t.freshness_seconds ?? 600}
onChange={(v) => 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."
/>
<NumberInput
label="Cooldown (sec)"
value={t.cooldown_seconds ?? 0}
onChange={(v) => upd(key, { cooldown_seconds: v })}
min={0}
helper="0 = no throttle"
info="Per (family, category, region) throttle window. Prevents repeat sends within this window."
/>
</div>
</div>
<ListInput label="DM node IDs" value={t.node_ids || []} onChange={(v) => upd(key, { node_ids: v })} placeholder="!nodeid" />
<ListInput label="Email recipients" value={t.recipients || []} onChange={(v) => upd(key, { recipients: v })} placeholder="ops@example.com" />
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
<NumberInput label="SMTP port" value={t.smtp_port ?? 587} onChange={(v) => upd(key, { smtp_port: v })} />
<TextInput label="Webhook URL" value={t.webhook_url || ''} onChange={(v) => upd(key, { webhook_url: v })} placeholder="https://..." />
{/* ── Meshtastic ───────────────────────────────────────────── */}
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
<Radio size={13} />
Meshtastic
</div>
<SeverityChannelMatrix
channels={MT_CHANNELS}
severityChannels={t.severity_channels || {}}
onChange={(sc) => upd(key, { severity_channels: sc })}
/>
<NumberInput
label="Broadcast channel"
value={t.broadcast_channel ?? 0}
onChange={(v) => 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."
/>
<ListInput
label="DM node IDs"
value={t.node_ids || []}
onChange={(v) => 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."
/>
</div>
{/* ── MeshCore ─────────────────────────────────────────────── */}
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
<MessageSquare size={13} />
MeshCore
</div>
<SeverityChannelMatrix
channels={MC_CHANNELS}
severityChannels={t.severity_channels || {}}
onChange={(sc) => upd(key, { severity_channels: sc })}
/>
<div className="space-y-1">
<label className="text-xs text-slate-500 uppercase tracking-wide">MeshCore channel name</label>
<input
type="text"
value={t.meshcore_channel != null ? t.meshcore_channel : ''}
onChange={(e) => 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"
/>
<p className="text-xs text-slate-600">Channel name on your MeshCore companion (e.g. AIDA). Blank = not broadcast on MeshCore.</p>
</div>
<ListInput
label="MeshCore DM contacts"
value={t.meshcore_dm_contacts || []}
onChange={(v) => 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."
/>
</div>
{/* ── Other channels ───────────────────────────────────────── */}
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
<Mail size={13} />
Other channels
</div>
<SeverityChannelMatrix
channels={OTHER_CHANNELS}
severityChannels={t.severity_channels || {}}
onChange={(sc) => upd(key, { severity_channels: sc })}
/>
<ListInput
label="Email recipients"
value={t.recipients || []}
onChange={(v) => upd(key, { recipients: v })}
placeholder="ops@example.com"
/>
<details className="group">
<summary className="flex items-center gap-2 cursor-pointer text-xs text-slate-400 hover:text-slate-200">
<ChevronRight size={12} className="group-open:rotate-90 transition-transform" />
SMTP settings
</summary>
<div className="mt-2 space-y-2 pl-4 border-l border-[#1e2a3a]">
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
<NumberInput label="SMTP port" value={t.smtp_port ?? 587} onChange={(v) => upd(key, { smtp_port: v })} />
<TextInput label="Username" value={t.smtp_user || ''} onChange={(v) => upd(key, { smtp_user: v })} />
<TextInput label="Password" value={t.smtp_password || ''} onChange={(v) => upd(key, { smtp_password: v })} type="password" />
<Toggle label="Use TLS" checked={t.smtp_tls ?? true} onChange={(v) => upd(key, { smtp_tls: v })} />
<TextInput label="From address" value={t.from_address || ''} onChange={(v) => upd(key, { from_address: v })} placeholder="alerts@example.com" />
</div>
</details>
<TextInput
label="Webhook URL"
value={t.webhook_url || ''}
onChange={(v) => upd(key, { webhook_url: v })}
placeholder="https://..."
helper="POST alert as JSON"
/>
</div>
</div>
)}
</div>

View file

@ -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,
},
},