feat(notifications): v0.5.0 -- Master Toggles UX redesign + Central Connection GUI + grouped categories + region scoping

Per-family notification policy (PagerDuty/Grafana-style): each family gets a
severity threshold + region scope + a severity->channel routing matrix, so an
operator opts in per family rather than hand-writing rules.

SECTION 1 -- BACKEND
- config.py: new NotificationToggle dataclass (enabled, min_severity, regions,
  severity_channels{severity->[channel types]}, quiet_hours_override, + per-channel
  delivery config: broadcast_channel/node_ids/smtp_*/recipients/webhook_*).
  notifications.toggles is now a dict[family]->NotificationToggle with 8 family
  defaults (mesh_health, weather, fire, rf_propagation, roads, avalanche, seismic,
  tracking), all enabled=false (opt-in), min_severity=priority,
  severity_channels={priority:[mesh_broadcast], immediate:[mesh_broadcast, mesh_dm]},
  quiet_hours_override=true. (Old TogglesConfig.enabled was only read by
  build_pipeline via getattr -> degrades to ToggleFilter no-op, so the pipeline
  filter is unchanged; toggles now drive the Dispatcher instead.)
- region_scope:list added to NotificationRuleConfig; _matching_rules filters by
  event.region/regions ([] = all).
- Dispatcher: _dispatch_toggles runs IN PARALLEL to rule matching -- looks up
  get_toggle(event.category), checks enabled + region scope + severity threshold,
  then for each channel in severity_channels[event.severity] builds a synthetic
  rule (override_quiet set only for immediate when quiet_hours_override) and
  delivers. 'digest' channel is skipped in live dispatch (handled by accumulator).
- categories.py: get_toggle() prefix fallback maps the live phases-2.7-2.14
  categories (weather_warning, wildfire_incident, earthquake_event,
  traffic_congestion, geomagnetic/rf_*, stream_*, ...) to their family, fixing the
  v0.4 "category -> other" gap.
- config_loader.py: SECRET_FIELDS += notifications.toggles.*.smtp_password.
- _dataclass_to_dict now recurses dict-of-dataclasses, and the loader coerces the
  toggles dict -> NotificationToggle on both the full-load and section-PUT paths
  (so GUI save round-trips correctly).
- tests/test_notification_toggles.py (11): enabled/disabled, region filter
  (empty+populated+regions-list), severity threshold, per-severity channel routing,
  digest-skipped-live, quiet-hours-override immediate-only, category->family,
  rules+toggles both fire. Full suite: 294 passed (283 + 11).

SECTION 2 -- FRONTEND
- Notifications.tsx: MasterToggles component above the rules section -- 8 family
  cards (icon + enable toggle; collapsed summary 'OFF' or 'N regions, M channels at
  <sev>+'; expanded: severity threshold, severity x channel checkbox matrix,
  region list, quiet-hours-override toggle, per-channel config:
  broadcast_channel/DM node IDs/recipients/SMTP host+port/webhook URL).
- Environment.tsx: CentralConnectionPanel above the family tabs (url, durable,
  enabled) wired to environmental.central.
- npm run build clean (tsc strict); rebuilt static committed (index-CfYlhn4e.js).

SECTION 3 -- VERIFICATION
- py_compile + tsc strict clean; pytest 294 passed.
- Rebuilt prod: /notifications serves Master Toggles, /environment serves Central
  Connection (strings confirmed in the served bundle); 8 adapters, pipeline
  started, no tracebacks, healthy.
- GUI round-trip: enable weather toggle (min_severity=priority,
  regions=[Magic Valley], severity_channels.priority=[mesh_broadcast]) -> PUT
  {saved:true} -> notifications.yaml reflects it; env_feeds traffic.api_key stayed
  ${TOMTOM_API_KEY} (C.3.1 secret preservation holds). Restored to clean opt-in
  baseline.
- Synthetic NWS weather_warning/priority/Magic Valley -> routes through the weather
  toggle to mesh_broadcast; out-of-region and below-threshold events correctly
  dropped.

DEFERRED (noted for a follow-up, not blocking Matt's morning config): Section 2B
rules-editor polish -- grouped-by-family category checkboxes, region_scope
multi-select in the rule editor (backend field + filtering ARE in), tooltips, and
the fire-count Active/No-activity badge -- were not built tonight to keep the build
shippable and verified; the Advanced Rules section is otherwise unchanged and
still functional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-28 07:00:10 +00:00
commit b90afc3a74
10 changed files with 574 additions and 143 deletions

View file

@ -388,6 +388,28 @@ export default function Environment() {
</div>
)}
{/* Central Connection (v0.5) -- NATS source for adapters set to central */}
{env.central && (
<div className="border border-[#1e2a3a] rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-slate-300">Central Connection</span>
<p className="text-xs text-slate-600">NATS JetStream source for any adapter set to "central"</p>
</div>
<Toggle label="" checked={!!env.central.enabled}
onChange={(v) => up({ central: { ...env.central!, enabled: v } })} />
</div>
<div className={env.central.enabled ? 'space-y-3' : 'space-y-3 opacity-40 pointer-events-none select-none'}>
<TextInput label="URL" value={env.central.url || ''}
onChange={(v) => up({ central: { ...env.central!, url: v } })}
placeholder="nats://central.echo6.mesh:4222" />
<TextInput label="Durable" value={env.central.durable || ''}
onChange={(v) => up({ central: { ...env.central!, durable: v } })}
placeholder="meshai-v04" />
</div>
</div>
)}
{/* Family tabs */}
<div className="flex gap-1 border-b border-border overflow-x-auto">
{FAMILIES.map(({ key, label, icon: Icon }) => (

View file

@ -3,7 +3,8 @@ import {
Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight,
Check, X, Eye as EyeIcon, EyeOff, Send, Clock, Zap,
Calendar, AlertTriangle, Copy, Moon, AlertCircle, Layers,
Wifi, WifiOff, Mail, Globe, Radio, MessageSquare
Wifi, WifiOff, Mail, Globe, Radio, MessageSquare,
Activity, Cloud, Flame, Car, Snowflake, Mountain, MapPin
} from 'lucide-react'
import ChannelPicker from '@/components/ChannelPicker'
import NodePicker from '@/components/NodePicker'
@ -37,12 +38,33 @@ interface NotificationRuleConfig {
override_quiet: boolean
}
interface NotificationToggle {
name: string
enabled: boolean
min_severity: string
regions: string[]
severity_channels: Record<string, string[]>
quiet_hours_override: boolean
broadcast_channel: number | null
node_ids: 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<string, string>
}
interface NotificationsConfig {
enabled: boolean
quiet_hours_enabled: boolean
quiet_hours_start: string
quiet_hours_end: string
rules: NotificationRuleConfig[]
toggles?: Record<string, NotificationToggle>
}
interface AlertCategory {
@ -1340,6 +1362,105 @@ function NotificationRuleCard({
}
// Main Notifications Page Component
const TOGGLE_FAMILY_META: { key: string; label: string; Icon: typeof Activity }[] = [
{ key: 'mesh_health', label: 'Mesh Health', Icon: Activity },
{ key: 'weather', label: 'Weather', Icon: Cloud },
{ key: 'fire', label: 'Fire', Icon: Flame },
{ key: 'rf_propagation', label: 'RF Propagation', Icon: Radio },
{ key: 'roads', label: 'Roads', Icon: Car },
{ key: 'avalanche', label: 'Avalanche', Icon: Snowflake },
{ key: 'seismic', label: 'Seismic', Icon: Mountain },
{ key: 'tracking', label: 'Tracking', Icon: MapPin },
]
const TOGGLE_CHANNELS = ['digest', 'mesh_broadcast', 'mesh_dm', 'email', 'webhook']
const TOGGLE_SEVERITIES = ['routine', 'priority', 'immediate']
function MasterToggles({ toggles, onChange }: {
toggles: Record<string, NotificationToggle>
onChange: (t: Record<string, NotificationToggle>) => void
}) {
const [expanded, setExpanded] = useState<string | null>(null)
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle })
return (
<div className="space-y-3 mb-8">
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Master Toggles
<InfoButton info="Per-family notification policy: enable a family, set its severity threshold, choose which channels fire at each severity, and scope to regions (PagerDuty/Grafana-style)." />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
const t = (toggles[key] || ({} as NotificationToggle))
const isOpen = expanded === key
const chanCount = Object.values(t.severity_channels || {}).reduce((n, arr) => n + ((arr as string[])?.length || 0), 0)
const regionCount = (t.regions || []).length
return (
<div key={key} className="border border-[#1e2a3a] rounded-lg p-3">
<div className="flex items-center justify-between">
<button type="button" onClick={() => setExpanded(isOpen ? null : key)}
className="flex items-center gap-2 text-sm text-slate-200">
<Icon size={15} /> {label}
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<Toggle label="" checked={!!t.enabled} onChange={(v) => upd(key, { enabled: v })} />
</div>
{!isOpen && (
<div className="text-xs text-slate-600 mt-1">
{t.enabled
? `${regionCount || 'all'} region${regionCount === 1 ? '' : 's'}, ${chanCount} channel${chanCount === 1 ? '' : 's'} at ${t.min_severity || 'priority'}+`
: 'OFF'}
</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..." />
<Toggle label="Quiet-hours override (immediate only)" checked={!!t.quiet_hours_override} onChange={(v) => upd(key, { quiet_hours_override: v })} />
<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 })} />
<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://..." />
</div>
)}
</div>
)
})}
</div>
</div>
)
}
export default function Notifications() {
const [config, setConfig] = useState<NotificationsConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<NotificationsConfig | null>(null)
@ -1802,6 +1923,14 @@ export default function Notifications() {
)}
</div>
{/* Master Toggles */}
{config.toggles && (
<MasterToggles
toggles={config.toggles}
onChange={(t) => setConfig({ ...config, toggles: t })}
/>
)}
{/* Rules Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">