mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(danger-zones): configurable infra-node hazard correlation + fire age-gate
- danger_zones config section (isolated dataclass + danger_zones.yaml + GUI panel on Notifications page); defaults disabled + dry_run - DangerZoneCorrelator: distance-based correlation of hazard events vs infra nodes (CLIENT_BASE/ROUTER/ROUTER_LATE), DM delivery, cooldown - fire age-gate (wfigs max_declare_age_seconds) suppresses stale/closed fires announced as "New" - tests: correlator + fire-gate boundary; wfigs fixture fix Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b6e15f656f
commit
95b1a23ef1
14 changed files with 1425 additions and 7 deletions
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from 'lucide-react'
|
||||
import ChannelPicker from '@/components/ChannelPicker'
|
||||
import NodePicker from '@/components/NodePicker'
|
||||
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
|
||||
|
||||
// Types
|
||||
interface NotificationRuleConfig {
|
||||
|
|
@ -2200,6 +2201,467 @@ export default function Notifications() {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger Zones — self-contained panel (own GET/PUT for the isolated
|
||||
`danger_zones` config section; never entangled with the notifications
|
||||
save above). Additive only. */}
|
||||
<DangerZonesPanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Danger Zones panel — 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 page's `notifications` config so it can
|
||||
// never be tangled with the existing save logic.
|
||||
// ============================================================================
|
||||
|
||||
const DZ_MONITOR_ROLES = ['CLIENT_BASE', 'ROUTER', 'ROUTER_LATE'] as const
|
||||
|
||||
const DZ_SEVERITY_OPTIONS = [
|
||||
{ value: 'routine', label: 'Routine' },
|
||||
{ value: 'priority', label: 'Priority' },
|
||||
{ value: 'immediate', label: 'Immediate' },
|
||||
]
|
||||
|
||||
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
|
||||
}[] = [
|
||||
{ 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 },
|
||||
{ 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_severity: string
|
||||
min_acres: number
|
||||
}
|
||||
|
||||
interface DangerZonesConfig {
|
||||
enabled: boolean
|
||||
dry_run: boolean
|
||||
monitor_roles: string[]
|
||||
position_max_age_hours: number
|
||||
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<string, string>
|
||||
}
|
||||
|
||||
function dzDefaultHazard(): DangerZoneHazardConfig {
|
||||
return { enabled: false, buffer_mi: 5.0, min_severity: 'priority', 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'],
|
||||
position_max_age_hours: 72,
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||
{label}
|
||||
{info && <InfoButton info={info} />}
|
||||
</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }
|
||||
cfg: DangerZoneHazardConfig
|
||||
onChange: (c: DangerZoneHazardConfig) => void
|
||||
}) {
|
||||
const { Icon } = meta
|
||||
return (
|
||||
<div className="border border-[#1e2a3a] p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-2 flex-1">
|
||||
<Icon size={15} className="text-slate-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<span className="text-sm text-slate-300">{meta.label}</span>
|
||||
<p className="text-xs text-slate-600">{meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ ...cfg, enabled: !cfg.enabled })}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors flex-shrink-0 ml-3 ${
|
||||
cfg.enabled ? 'bg-accent' : 'bg-[#1e2a3a]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
cfg.enabled ? 'translate-x-5' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{cfg.enabled && (
|
||||
<div className={`grid gap-3 pt-2 border-t border-[#1e2a3a] ${meta.showAcres ? 'grid-cols-3' : 'grid-cols-2'}`}>
|
||||
<NumberInput
|
||||
label="Buffer (mi)"
|
||||
value={cfg.buffer_mi ?? 0}
|
||||
onChange={(v) => onChange({ ...cfg, buffer_mi: v })}
|
||||
min={0}
|
||||
step={0.5}
|
||||
/>
|
||||
<DZSelect
|
||||
label="Min Severity"
|
||||
value={cfg.min_severity || 'priority'}
|
||||
onChange={(v) => onChange({ ...cfg, min_severity: v })}
|
||||
options={DZ_SEVERITY_OPTIONS}
|
||||
/>
|
||||
{meta.showAcres && (
|
||||
<NumberInput
|
||||
label="Min Acres"
|
||||
value={cfg.min_acres ?? 0}
|
||||
onChange={(v) => onChange({ ...cfg, min_acres: v })}
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DangerZonesPanel() {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [cfg, setCfg] = useState<DangerZonesConfig | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const raw = (await apiFetchConfig('danger_zones')) as Partial<DangerZonesConfig>
|
||||
// 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<DangerZonesConfig>) => 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 (
|
||||
<div className="bg-bg-card border border-border">
|
||||
{/* Collapsible header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle size={18} className="text-amber-400" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-200">Danger Zones</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
Alert when monitored infrastructure nodes are in/near a hazard
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{cfg && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
cfg.enabled
|
||||
? (cfg.dry_run ? 'bg-yellow-500/10 text-yellow-400' : 'bg-green-500/10 text-green-400')
|
||||
: 'bg-slate-800 text-slate-500'
|
||||
}`}>
|
||||
{cfg.enabled ? (cfg.dry_run ? 'Dry-run' : 'Live') : 'Disabled'}
|
||||
</span>
|
||||
)}
|
||||
{expanded ? <ChevronDown size={18} className="text-slate-500" /> : <ChevronRight size={18} className="text-slate-500" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="p-6 pt-0 space-y-6">
|
||||
{/* Safety copy */}
|
||||
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertCircle size={16} className="text-amber-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-amber-200/90 leading-relaxed">
|
||||
Ships disabled; when enabled, defaults to dry-run / log-only — no mesh traffic
|
||||
until you turn dry-run off. Requires <span className="font-medium">Enable Notifications</span> (above)
|
||||
and environmental feeds to be on, since hazard events only flow when those are active.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status messages */}
|
||||
{error && (
|
||||
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
|
||||
<Check size={14} className="inline mr-2" />{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading || !cfg ? (
|
||||
<div className="text-sm text-slate-500">Loading danger zones config...</div>
|
||||
) : (
|
||||
<>
|
||||
<Toggle
|
||||
label="Enable Danger Zones"
|
||||
checked={cfg.enabled}
|
||||
onChange={(v) => upd({ enabled: v })}
|
||||
helper="Master switch for the infrastructure danger-zone correlator"
|
||||
/>
|
||||
<Toggle
|
||||
label="Dry-run (log only)"
|
||||
checked={cfg.dry_run}
|
||||
onChange={(v) => 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 */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||
Monitored Roles
|
||||
<InfoButton info="Which Meshtastic node roles to correlate against hazards. Only nodes with a fresh GPS position (see Position Max Age) are scanned." />
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DZ_MONITOR_ROLES.map(role => {
|
||||
const on = (cfg.monitor_roles || []).includes(role)
|
||||
return (
|
||||
<button
|
||||
key={role}
|
||||
type="button"
|
||||
onClick={() => toggleRole(role)}
|
||||
className={`px-3 py-1.5 rounded text-sm transition-colors ${
|
||||
on ? 'bg-accent text-white' : 'bg-[#1e2a3a] text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{role}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global numeric settings */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<NumberInput
|
||||
label="Position Max Age (hrs)"
|
||||
value={cfg.position_max_age_hours}
|
||||
onChange={(v) => upd({ position_max_age_hours: v })}
|
||||
min={1}
|
||||
helper="Skip nodes whose last position is older than this"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Default Buffer (mi)"
|
||||
value={cfg.default_buffer_mi}
|
||||
onChange={(v) => upd({ default_buffer_mi: v })}
|
||||
min={0}
|
||||
step={0.5}
|
||||
helper="Buffer used when a family has none set"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Cooldown (min)"
|
||||
value={cfg.cooldown_minutes}
|
||||
onChange={(v) => upd({ cooldown_minutes: v })}
|
||||
min={0}
|
||||
helper="Min time between repeat alerts per node+family"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Per-family hazard config */}
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||
Hazard Families
|
||||
<InfoButton info="Enable each hazard family to monitor, with its own buffer distance and severity threshold. Snow is a sub-gate of Weather; Flood a sub-gate of Seismic." />
|
||||
</label>
|
||||
{DZ_FAMILIES.map(meta => (
|
||||
<DZFamilyRow
|
||||
key={meta.key}
|
||||
meta={meta}
|
||||
cfg={cfg[meta.key as keyof DangerZonesConfig] as DangerZoneHazardConfig}
|
||||
onChange={(c) => upd({ [meta.key]: c } as Partial<DangerZonesConfig>)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Delivery */}
|
||||
<div className="space-y-4 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-300">
|
||||
<Send size={14} />
|
||||
DELIVERY
|
||||
</div>
|
||||
<DZSelect
|
||||
label="Delivery Method"
|
||||
value={cfg.delivery_type || 'mesh_dm'}
|
||||
onChange={(v) => 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' && (
|
||||
<NodePicker
|
||||
label="Recipient Nodes"
|
||||
value={cfg.node_ids || []}
|
||||
onChange={(v) => upd({ node_ids: v })}
|
||||
helper="Nodes that receive direct messages"
|
||||
valueType="node_id_hex"
|
||||
/>
|
||||
)}
|
||||
|
||||
{cfg.delivery_type === 'mesh_broadcast' && (
|
||||
<ChannelPicker
|
||||
label="Broadcast Channel"
|
||||
value={cfg.broadcast_channel ?? 0}
|
||||
onChange={(v) => upd({ broadcast_channel: v })}
|
||||
helper="Select the mesh radio channel"
|
||||
mode="single"
|
||||
/>
|
||||
)}
|
||||
|
||||
{cfg.delivery_type === 'webhook' && (
|
||||
<TextInput
|
||||
label="Webhook URL"
|
||||
value={cfg.webhook_url || ''}
|
||||
onChange={(v) => upd({ webhook_url: v })}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
helper="POST alert as JSON"
|
||||
/>
|
||||
)}
|
||||
|
||||
{cfg.delivery_type === 'email' && (
|
||||
<p className="text-xs text-slate-600">
|
||||
Email delivery uses the SMTP settings configured for notification rules.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white transition-colors"
|
||||
>
|
||||
<Save size={16} />
|
||||
{saving ? 'Saving...' : 'Save Danger Zones'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue