diff --git a/work/dashboard-frontend/src/App.tsx b/work/dashboard-frontend/src/App.tsx index 500d4e4..7ac1299 100644 --- a/work/dashboard-frontend/src/App.tsx +++ b/work/dashboard-frontend/src/App.tsx @@ -4,7 +4,7 @@ import Dashboard from './pages/Dashboard' import Mesh from './pages/Mesh' import Environment from './pages/Environment' import Config from './pages/Config' -import Alerts from './pages/Alerts' +import ActivityLog from './pages/ActivityLog' import Notifications from './pages/Notifications' import Reference from './pages/Reference' import AdapterConfig from './pages/AdapterConfig' @@ -12,10 +12,15 @@ import GaugeSites from './pages/GaugeSites' import TownAnchors from './pages/TownAnchors' import MeshCoreRouting from './pages/MeshCoreRouting' import MeshCoreConnection from './pages/MeshCoreConnection' -import MeshCoreContacts from './pages/MeshCoreContacts' import MeshCoreCompanion from './pages/MeshCoreCompanion' import MeshtasticConnection from './pages/MeshtasticConnection' import MeshtasticSources from './pages/MeshtasticSources' +import Places from './pages/Places' +import MeshtasticNodes from './pages/MeshtasticNodes' +import MeshCoreContactsCompanion from './pages/MeshCoreContactsCompanion' +import ScheduledBroadcasts from './pages/ScheduledBroadcasts' +import MeshtasticDangerZones from './pages/MeshtasticDangerZones' +import MeshCoreDangerZones from './pages/MeshCoreDangerZones' import { ToastProvider } from './components/ToastProvider' import { DirtyProvider } from './context/DirtyContext' @@ -25,22 +30,38 @@ function App() { + {/* Core routes */} } /> - } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> + + {/* New aggregated pages */} + } /> + + {/* De-navved routes still work */} } /> } /> - } /> - } /> - } /> - } /> + } /> + + {/* Meshtastic routes */} } /> } /> + } /> + } /> + } /> + + {/* MeshCore routes */} + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/work/dashboard-frontend/src/components/DangerZonesPanel.tsx b/work/dashboard-frontend/src/components/DangerZonesPanel.tsx new file mode 100644 index 0000000..74c4363 --- /dev/null +++ b/work/dashboard-frontend/src/components/DangerZonesPanel.tsx @@ -0,0 +1,449 @@ +// DangerZonesPanel — fully isolated, additive feature. +// Loads/saves the standalone `danger_zones` config section via the generic +// /api/config helpers. Has its OWN state, fetch (on mount), and save. It is +// intentionally decoupled from the notifications config so it can never be +// tangled with the existing save logic. +// Extracted from Notifications.tsx — do NOT re-entangle. + +import { useState, useEffect, useCallback } from 'react' +import { + ChevronDown, ChevronRight, AlertTriangle, AlertCircle, Save, Check, Send, + Activity, Cloud, Flame, Snowflake, Mountain, +} from 'lucide-react' +import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api' +import { Toggle, NumberInput, TextInput, InfoButton } from '@/pages/Notifications' +import NodePicker from '@/components/NodePicker' +import ChannelPicker from '@/components/ChannelPicker' + +const DZ_MONITOR_ROLES = ['CLIENT_BASE', 'ROUTER', 'ROUTER_LATE'] as const + +const DZ_DELIVERY_OPTIONS = [ + { value: 'mesh_dm', label: 'Mesh DM (unicast to nodes)' }, + { value: 'mesh_broadcast', label: 'Mesh Broadcast (channel)' }, + { value: 'email', label: 'Email' }, + { value: 'webhook', label: 'Webhook' }, + { value: 'none', label: '(None / log only)' }, +] + +// Per-family rows. snow is a sub-gate of weather; flood a sub-gate of seismic. +const DZ_FAMILIES: { + key: string + label: string + description: string + Icon: typeof Activity + showAcres?: boolean + tabled?: boolean +}[] = [ + { key: 'fire', label: 'Fire', description: 'Active wildfires (radius from fire perimeter).', Icon: Flame, showAcres: true }, + { key: 'weather', label: 'Weather', description: 'Severe weather warnings near a node.', Icon: Cloud }, + { key: 'snow', label: 'Snow (sub-gate of Weather)', description: 'Snow-category weather events.', Icon: Snowflake, tabled: true }, + { key: 'flood', label: 'Flood (sub-gate of Seismic)', description: 'Stream/flood gauge events.', Icon: Activity }, + { key: 'avalanche', label: 'Avalanche', description: 'Avalanche advisories near a node.', Icon: Mountain }, + { key: 'seismic', label: 'Seismic', description: 'Earthquakes and seismic events near a node.', Icon: Mountain }, +] + +interface DangerZoneHazardConfig { + enabled: boolean + buffer_mi: number + min_acres: number +} + +interface DangerZonesConfig { + enabled: boolean + dry_run: boolean + monitor_roles: string[] + default_buffer_mi: number + cooldown_minutes: number + fire: DangerZoneHazardConfig + weather: DangerZoneHazardConfig + snow: DangerZoneHazardConfig + flood: DangerZoneHazardConfig + avalanche: DangerZoneHazardConfig + seismic: DangerZoneHazardConfig + delivery_type: string + node_ids: string[] + broadcast_channel: number | null + webhook_url: string + webhook_headers: Record +} + +function dzDefaultHazard(): DangerZoneHazardConfig { + return { enabled: false, buffer_mi: 5.0, min_acres: 0 } +} + +// New-object default. NOTE: delivery_type defaults to mesh_dm (do NOT copy the +// page's mesh_broadcast new-rule default). +function dzDefault(): DangerZonesConfig { + return { + enabled: false, + dry_run: true, + monitor_roles: ['ROUTER', 'ROUTER_LATE', 'CLIENT_BASE'], + default_buffer_mi: 5.0, + cooldown_minutes: 360, + fire: dzDefaultHazard(), + weather: dzDefaultHazard(), + snow: dzDefaultHazard(), + flood: dzDefaultHazard(), + avalanche: dzDefaultHazard(), + seismic: dzDefaultHazard(), + delivery_type: 'mesh_dm', + node_ids: [], + broadcast_channel: null, + webhook_url: '', + webhook_headers: {}, + } +} + +// Minimal local select — SelectInput lives in Config.tsx (which we must not +// touch/entangle), so this panel keeps its own tiny equivalent. +function DZSelect({ label, value, onChange, options, info = '' }: { + label: string + value: string + onChange: (v: string) => void + options: { value: string; label: string }[] + info?: string +}) { + return ( +
+ + +
+ ) +} + +// Per-family row — mirrors the AlertRuleToggle (toggle + thresholds) pattern. +// AlertRuleToggle itself lives in Config.tsx and is NOT exported, so this is a +// local equivalent purpose-built for the per-family hazard config. +function DZFamilyRow({ meta, cfg, onChange }: { + meta: { key: string; label: string; description: string; Icon: typeof Activity; showAcres?: boolean; tabled?: boolean } + cfg: DangerZoneHazardConfig + onChange: (c: DangerZoneHazardConfig) => void +}) { + const { Icon } = meta + return ( +
+
+
+ +
+ {meta.label} +

{meta.description}

+ {meta.tabled && ( + + Tabled — needs snowfall + elevation pipeline + + )} +
+
+ +
+ {cfg.enabled && !meta.tabled && ( +
+ onChange({ ...cfg, buffer_mi: v })} + min={0} + step={0.5} + /> + {meta.showAcres && ( + onChange({ ...cfg, min_acres: v })} + min={0} + step={1} + /> + )} +
+ )} +
+ ) +} + +export default function DangerZonesPanel() { + const [expanded, setExpanded] = useState(false) + const [cfg, setCfg] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const raw = (await apiFetchConfig('danger_zones')) as Partial + // Merge over defaults so missing/new fields are always present. + const d = dzDefault() + setCfg({ + ...d, + ...raw, + fire: { ...d.fire, ...(raw.fire || {}) }, + weather: { ...d.weather, ...(raw.weather || {}) }, + snow: { ...d.snow, ...(raw.snow || {}) }, + flood: { ...d.flood, ...(raw.flood || {}) }, + avalanche: { ...d.avalanche, ...(raw.avalanche || {}) }, + seismic: { ...d.seismic, ...(raw.seismic || {}) }, + monitor_roles: raw.monitor_roles ?? d.monitor_roles, + node_ids: raw.node_ids ?? d.node_ids, + webhook_headers: raw.webhook_headers ?? d.webhook_headers, + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load danger zones config') + setCfg(dzDefault()) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { load() }, [load]) + + const save = async () => { + if (!cfg) return + setSaving(true) + setError(null) + setSuccess(null) + try { + await apiUpdateConfig('danger_zones', cfg) + setSuccess('Danger Zones config saved') + setTimeout(() => setSuccess(null), 3000) + } catch (err) { + setError(err instanceof Error ? err.message : 'Save failed') + } finally { + setSaving(false) + } + } + + const upd = (patch: Partial) => setCfg(c => (c ? { ...c, ...patch } : c)) + + const toggleRole = (role: string) => { + if (!cfg) return + const cur = cfg.monitor_roles || [] + upd({ monitor_roles: cur.includes(role) ? cur.filter(r => r !== role) : [...cur, role] }) + } + + return ( +
+ {/* Collapsible header */} + + + {expanded && ( +
+ {/* Safety copy */} +
+ +
+ Ships disabled; when enabled, defaults to dry-run / log-only — no mesh traffic + until you turn dry-run off. Requires Enable Notifications (above) + and environmental feeds to be on, since hazard events only flow when those are active. +
+
+ + {/* Status messages */} + {error && ( +
{error}
+ )} + {success && ( +
+ {success} +
+ )} + + {loading || !cfg ? ( +
Loading danger zones config...
+ ) : ( + <> + upd({ enabled: v })} + helper="Master switch for the infrastructure danger-zone correlator" + /> + upd({ dry_run: v })} + helper="When on, matches are logged but nothing is sent to the mesh. Turn off only after verifying dry-run output." + /> + + {/* Monitored roles */} +
+ +
+ {DZ_MONITOR_ROLES.map(role => { + const on = (cfg.monitor_roles || []).includes(role) + return ( + + ) + })} +
+
+ + {/* Global numeric settings */} +
+ upd({ default_buffer_mi: v })} + min={0} + step={0.5} + helper="Buffer used when a family has none set" + /> + upd({ cooldown_minutes: v })} + min={0} + helper="Min time between repeat alerts per node+family" + /> +
+ + {/* Per-family hazard config */} +
+ + {DZ_FAMILIES.map(meta => ( + upd({ [meta.key]: c } as Partial)} + /> + ))} +
+ + {/* Delivery */} +
+
+ + DELIVERY +
+ upd({ delivery_type: v })} + options={DZ_DELIVERY_OPTIONS} + info="Where danger-zone alerts get delivered. Mesh DM unicasts to specific nodes; broadcast sends to a channel. Has no effect while dry-run is on." + /> + + {cfg.delivery_type === 'mesh_dm' && ( + upd({ node_ids: v })} + helper="Nodes that receive direct messages" + valueType="node_id_hex" + /> + )} + + {cfg.delivery_type === 'mesh_broadcast' && ( + upd({ broadcast_channel: v })} + helper="Select the mesh radio channel" + mode="single" + /> + )} + + {cfg.delivery_type === 'webhook' && ( + upd({ webhook_url: v })} + placeholder="https://discord.com/api/webhooks/..." + helper="POST alert as JSON" + /> + )} + + {cfg.delivery_type === 'email' && ( +

+ Email delivery uses the SMTP settings configured for notification rules. +

+ )} +
+ + {/* Save */} +
+ +
+ + )} +
+ )} +
+ ) +} diff --git a/work/dashboard-frontend/src/components/Layout.tsx b/work/dashboard-frontend/src/components/Layout.tsx index 46bc2d5..59207fc 100644 --- a/work/dashboard-frontend/src/components/Layout.tsx +++ b/work/dashboard-frontend/src/components/Layout.tsx @@ -5,7 +5,6 @@ import { LayoutDashboard, Radio, Cloud, - Bell, BellRing, BookOpen, Sliders, @@ -17,6 +16,9 @@ import { Users, Bot, Settings, + Calendar, + Activity, + AlertTriangle, type LucideIcon, } from 'lucide-react' import { fetchStatus, type SystemStatus } from '@/lib/api' @@ -39,28 +41,26 @@ interface NavGroup { items: NavItem[] } -// Top-level, ungrouped items (no header). -const topNavItems: NavItem[] = [ - { path: '/', label: 'Dashboard', icon: LayoutDashboard }, - { path: '/environment', label: 'Environment', icon: Cloud }, - { path: '/alerts', label: 'Alerts', icon: Bell }, - { path: '/reference', label: 'Reference', icon: BookOpen }, - { path: '/adapter-config', label: 'Adapter Config', icon: Sliders }, - { path: '/config', label: 'Config', icon: Settings }, - { path: '/gauge-sites', label: 'Gauge Sites', icon: Droplets }, - { path: '/town-anchors', label: 'Town Anchors', icon: MapPin }, -] - -// Grouped sections with labeled headers. Meshtastic "Connection" and "Sources" -// are focused standalone pages (/meshtastic/connection, /meshtastic/sources). +// All nav items are grouped — no ungrouped topNavItems. const navGroups: NavGroup[] = [ + { + header: 'General', + items: [ + { path: '/', label: 'Dashboard', icon: LayoutDashboard }, + { path: '/config', label: 'Settings', icon: Settings }, + { path: '/environment', label: 'Data Feeds', icon: Cloud }, + { path: '/activity', label: 'Activity Log', icon: Activity }, + { path: '/places', label: 'Places', icon: MapPin }, + ], + }, { header: 'Meshtastic', items: [ { path: '/meshtastic/connection', label: 'Connection', icon: Wifi }, { path: '/notifications', label: 'Routing', icon: BellRing }, - { path: '/mesh', label: 'Mesh', icon: Radio }, - { path: '/meshtastic/sources', label: 'Sources', icon: Layers }, + { path: '/meshtastic/scheduled', label: 'Scheduled Broadcasts', icon: Calendar }, + { path: '/meshtastic/nodes', label: 'Nodes & Health', icon: Activity }, + { path: '/meshtastic/danger-zones', label: 'Danger Zones', icon: AlertTriangle }, ], }, { @@ -68,18 +68,47 @@ const navGroups: NavGroup[] = [ items: [ { path: '/meshcore/connection', label: 'Connection', icon: Network }, { path: '/meshcore/routing', label: 'Routing', icon: BellRing }, - { path: '/meshcore/contacts', label: 'Contacts', icon: Users }, - { path: '/meshcore/companion', label: 'Companion', icon: Bot }, + { path: '/meshcore/scheduled', label: 'Scheduled Broadcasts', icon: Calendar }, + { path: '/meshcore/contacts', label: 'Contacts & Companion', icon: Users }, + { path: '/meshcore/danger-zones', label: 'Danger Zones', icon: AlertTriangle }, + ], + }, + { + header: 'Documentation', + items: [ + { path: '/reference', label: 'Reference', icon: BookOpen }, ], }, ] -// Flattened view of every nav item (top + all groups) for title lookup. -const allNavItems: NavItem[] = [ - ...topNavItems, - ...navGroups.flatMap((g) => g.items), +// Flattened view of every nav item for title lookup. +// Also include de-navved routes so they still resolve a title. +const extraTitleItems: NavItem[] = [ + { path: '/adapter-config', label: 'Adapter Config', icon: Sliders }, + { path: '/gauge-sites', label: 'Gauge Sites', icon: Droplets }, + { path: '/town-anchors', label: 'Town Anchors', icon: MapPin }, + { path: '/mesh', label: 'Mesh', icon: Radio }, + { path: '/meshtastic/sources', label: 'Sources', icon: Layers }, + { path: '/meshcore/companion', label: 'Companion', icon: Bot }, ] +const allNavItems: NavItem[] = [ + ...navGroups.flatMap((g) => g.items), + ...extraTitleItems, +] + +// Titles for new pages not reachable via explicit nav items. +const pathTitles: Record = { + '/places': 'Places', + '/meshtastic/scheduled': 'Scheduled Broadcasts', + '/meshcore/scheduled': 'Scheduled Broadcasts', + '/meshtastic/nodes': 'Nodes & Health', + '/meshtastic/danger-zones': 'Danger Zones', + '/meshcore/danger-zones': 'Danger Zones', + '/meshcore/contacts': 'Contacts & Companion', + '/meshcore/companion': 'Contacts & Companion', +} + function formatUptime(seconds: number): string { const days = Math.floor(seconds / 86400) const hours = Math.floor((seconds % 86400) / 3600) @@ -125,11 +154,13 @@ function renderNavItem( } function getPageTitle(fullPath: string): string { - // Exact match first (honors any ?section= query on deep-linked items). + // Check explicit path-title map first (covers new pages). + const base = fullPath.split('?')[0] + if (pathTitles[base]) return pathTitles[base] + // Exact match (honors ?section= deep-links). const exact = allNavItems.find((i) => i.path === fullPath) if (exact) return exact.label // Fallback: match by pathname only, ignoring query strings. - const base = fullPath.split('?')[0] const byPath = allNavItems.find((i) => i.path.split('?')[0] === base) return byPath?.label || 'Dashboard' } @@ -203,7 +234,6 @@ export default function Layout({ children }: LayoutProps) { {/* Navigation */}