feat(dashboard): bring ipaws adapter + emergency family to full GUI parity

The IPAWS civil-alert adapter shipped backend-complete but frontend-partial:
it rendered only in the generic adapter-config page and the Advanced (raw)
Data Feeds tab, and the `emergency` family was absent from the curated Data
Feeds panel, the family-settings toggles, and the MeshCore routing matrix.

Adapter (ipaws), benchmarked against firms/nws:
- Environment.tsx: add `ipaws` to AdapterKey union, EnvConfig interface,
  META (native-only, keyless), a new `emergency` FAMILIES group, PANEL_META_KEY
  (LLM toggle), and a hand-written renderSettings panel exposing base_url,
  user_agent, tick_seconds, state_fips, same_codes, exclude_weather,
  status_actual_only — with coverage-scope handling like the other adapters.
- Environment.tsx: IPAWS_DEFAULT backfill so pre-ipaws GET payloads don't crash.
- Dashboard.tsx: SOURCE_ICONS entry (Siren/IPAWS) so ipaws events aren't a slug.
- ActivityLog.tsx: TABLE_LABELS + CATEGORIES + text-hint so ipaws_alerts rows
  show labeled "Emergency" and honor the category filter.
- dispatcher.py: _SOURCE_TO_TABLE fallback ipaws -> ipaws_alerts so region-routed
  emergency sends land labeled in the audit feed (not NULL).

Family (emergency), benchmarked against fire:
- Notifications.tsx: add `emergency` to TOGGLE_FAMILY_META (Siren icon). This
  cascades to Family Settings, the Meshtastic delivery matrix, and the MeshCore
  routing matrix (the last was hardcoded to the static list and previously
  omitted emergency entirely). Backend VALID_TOGGLES/gating/categories were
  already complete — no backend family change needed.

Tests: update the _SOURCE_TO_TABLE exact-match guard and add an ipaws audit-row
parity test. Full suite 2407 passed / 6 pre-existing unrelated failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-07-17 17:06:00 +00:00
commit 47d7ef7e78
8 changed files with 207 additions and 82 deletions

View file

@ -52,6 +52,7 @@ const TABLE_LABELS: Record<string, string> = {
swpc_events: 'Space Wx',
gauge_readings: 'Hydro',
event_log: 'Avalanche',
ipaws_alerts: 'Emergency',
}
// Text-prefix / emoji heuristics for legacy NULL-source rows.
@ -66,6 +67,7 @@ const TABLE_LABELS: Record<string, string> = {
// incident_handler.py (legacy) → "⚠️ Road Incident …" | "🚫 Road Closed …"
// NOTE: more-specific prefixes must appear before shorter ones that share a leading char.
const TEXT_HINTS: Array<[string, string]> = [
['🚨', 'Emergency'], // ipaws.py immediate civil alerts (evac/AMBER/HazMat)
['🔥', 'Fire'],
['🚧', 'Traffic'],
['⚠️ Road Incident', 'Traffic'], // legacy road-incident rows (⚠️ = U+26A0+FE0F)
@ -115,6 +117,7 @@ const CATEGORIES = [
{ value: 'satpass_events', label: 'Satellite' },
{ value: 'band_conditions_broadcasts', label: 'Band' },
{ value: 'traffic_events', label: 'Traffic' },
{ value: 'ipaws_alerts', label: 'Emergency' },
]
const PAGE = 100

View file

@ -32,6 +32,7 @@ import {
Construction,
Satellite,
Sun,
Siren,
} from 'lucide-react'
@ -357,6 +358,7 @@ const SOURCE_ICONS: Record<string, { icon: typeof Cloud; color: string; label: s
usgs: { icon: Droplets, color: 'text-sky-400', label: 'USGS' },
traffic: { icon: Car, color: 'text-[#777]', label: 'Traffic' },
roads: { icon: Construction, color: 'text-accent-dim', label: '511' },
ipaws: { icon: Siren, color: 'text-red-500', label: 'IPAWS' },
}
// Severity badge colors (3-level system + legacy support)

View file

@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from 'react'
import {
Cloud, Flame, Radio, Car, Mountain, Satellite, Activity, Server,
Save, RotateCcw, RefreshCw, AlertCircle, AlertTriangle, Info, Bell,
Sliders, ChevronRight,
Sliders, ChevronRight, Siren,
} from 'lucide-react'
import {
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
@ -33,6 +33,10 @@ interface EnvConfig {
roads511: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[]; feed_source?: FeedSource }
wzdx: { enabled: boolean; tick_seconds: number; api_key: string; base_url: string; endpoints: string[]; bbox: number[]; states: string[]; registry_url: string; registry_ttl?: number; feed_source?: FeedSource }
firms: { enabled: boolean; tick_seconds: number; map_key: string; source: string; bbox: number[]; day_range: number; confidence_min: string; proximity_km: number; feed_source?: FeedSource }
// FEMA IPAWS-OPEN civil-alert feed (native only). Two-stage Atom+CAP fetch;
// NON-weather emergencies (evacuation, AMBER, HazMat, 911 outage, …) → the
// `emergency` notification family.
ipaws: { enabled: boolean; tick_seconds: number; base_url: string; user_agent: string; state_fips: string[]; same_codes: string[]; exclude_weather: boolean; drop_senders: string[]; status_actual_only: boolean; feed_source?: FeedSource }
// Native satpass (SGP4) YAML layer — drives env/satpass.py + env/tle_fetch.py.
// Distinct from the Central adapter_config/satpass layer (see SatpassConfig
// interface + satpassConfig state below). `observers` seeds observer_locations.
@ -65,6 +69,22 @@ const SATPASS_NATIVE_DEFAULT: EnvConfig['satpass'] = {
feed_source: 'central',
}
// Sane defaults for the ipaws block so a GET payload predating the IPAWS
// adapter (no `environmental.ipaws`) doesn't crash the editor. Mirrors
// config.py::IPAWSConfig defaults (Idaho + neighbouring-state FIPS).
const IPAWS_DEFAULT: EnvConfig['ipaws'] = {
enabled: false,
tick_seconds: 60,
base_url: 'https://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest',
user_agent: '',
state_fips: ['16', '53', '41', '32', '49', '56', '30'],
same_codes: [],
exclude_weather: true,
drop_senders: ['noaa.gov', 'nws', 'weather.gov'],
status_actual_only: true,
feed_source: 'native',
}
// WFIGS adapter config shape
interface WfigsConfig {
allowed_incident_types: string[]
@ -262,7 +282,7 @@ function AdapterPanel({ title, subtitle, enabled, onEnabled, feedSource, onFeedS
}
// ---------------------------------------------------------------- families
type AdapterKey = 'nws' | 'fires' | 'firms' | 'swpc' | 'ducting' | 'traffic' | 'roads511' | 'wzdx' | 'usgs_quake' | 'usgs' | 'avalanche' | 'satpass'
type AdapterKey = 'nws' | 'fires' | 'firms' | 'swpc' | 'ducting' | 'traffic' | 'roads511' | 'wzdx' | 'usgs_quake' | 'usgs' | 'avalanche' | 'satpass' | 'ipaws'
interface AdapterMeta { label: string; subtitle: string; health: string; hasCentral: boolean; nativeOnly: boolean; hasKey: boolean }
@ -279,6 +299,7 @@ const META: Record<AdapterKey, AdapterMeta> = {
usgs: { label: 'USGS Stream Gauges', subtitle: 'River and stream water levels', health: 'usgs', hasCentral: true, nativeOnly: false, hasKey: true },
avalanche: { label: 'Avalanche Advisories', subtitle: 'Backcountry avalanche danger ratings', health: 'avalanche', hasCentral: true, nativeOnly: false, hasKey: true },
satpass: { label: 'Satellite Passes', subtitle: 'Observer pass alerts via Central', health: 'satpass', hasCentral: true, nativeOnly: false, hasKey: true },
ipaws: { label: 'FEMA IPAWS civil alerts', subtitle: 'Evacuations, AMBER, HazMat, 911 outages (non-weather)', health: 'ipaws', hasCentral: false, nativeOnly: true, hasKey: false },
}
// Keyed adapters → their secret env var (matches secrets_store.SECRET_LABELS).
@ -299,6 +320,7 @@ const FAMILIES: { key: string; label: string; icon: typeof Cloud; adapters: Adap
{ key: 'rf', label: 'RF Propagation', icon: Radio, adapters: ['swpc', 'ducting'] },
{ key: 'roads', label: 'Roads', icon: Car, adapters: ['traffic', 'roads511', 'wzdx'] },
{ key: 'geohazards', label: 'Geohazards', icon: Mountain, adapters: ['usgs_quake', 'usgs', 'avalanche'] },
{ key: 'emergency', label: 'Emergency', icon: Siren, adapters: ['ipaws'] },
{ key: 'tracking', label: 'Tracking', icon: Satellite, adapters: ['satpass'] },
{ key: 'mesh', label: 'Mesh Health', icon: Activity, adapters: [] },
{ key: 'family_settings', label: 'Family Settings', icon: Bell, adapters: [] },
@ -411,6 +433,7 @@ export default function Environment() {
// round-trip PUT restores them rather than dropping them).
data.satpass = { ...SATPASS_NATIVE_DEFAULT, ...(data.satpass ?? {}) }
data.wzdx = { states: ['ID'], registry_url: '', ...(data.wzdx ?? {}) }
data.ipaws = { ...IPAWS_DEFAULT, ...(data.ipaws ?? {}) }
setEnv(data)
setOriginal(JSON.stringify(data))
@ -855,6 +878,7 @@ const save = async () => {
usgs_quake: 'usgs_quake',
avalanche: 'avalanche',
satpass: 'satpass',
ipaws: 'ipaws',
}
// ── Notification family gating helpers ────────────────────────────────────
@ -1450,6 +1474,49 @@ const save = async () => {
</div>
)}
</>)
case 'ipaws': return (<>
<div className="text-[11px] text-[#666]">
Keyless FEMA IPAWS-OPEN EAS feed. Broadcasts NON-weather civil emergencies
(evacuation orders, AMBER, HazMat, 911 outages, law-enforcement/shelter-in-place).
Weather CAP is dropped so it never double-broadcasts the NWS adapter.
</div>
<TextInput label="Base URL" value={env.ipaws.base_url} onChange={(v) => up({ ipaws: { ...env.ipaws, base_url: v } })}
placeholder="https://apps.fema.gov/IPAWSOPEN_EAS_SERVICE/rest"
helper="IPAWS-OPEN EAS REST root — Atom index at /feed, per-alert CAP at /eas/<id>. Point at the Conduit proxy in prod." />
<TextInput label="User Agent" value={env.ipaws.user_agent} onChange={(v) => up({ ipaws: { ...env.ipaws, user_agent: v } })}
placeholder="meshai-ipaws/1.0 (you@email.com)" helper="Sent on every FEMA request. Blank uses the built-in default." />
<NumberInput label="Tick Seconds" value={env.ipaws.tick_seconds} onChange={(v) => up({ ipaws: { ...env.ipaws, tick_seconds: v } })} min={30} />
{scopedByCoverage('ipaws') ? (
<div className="text-xs text-[#666] bg-bg-hover px-3 py-2 border border-border/50">
Region scope (state FIPS / SAME codes) is set by the{' '}
<a href="/coverage" className="text-accent hover:underline">Coverage map</a>.
</div>
) : (<>
<ListInput label="State FIPS" value={env.ipaws.state_fips} onChange={(v) => up({ ipaws: { ...env.ipaws, state_fips: v } })}
helper="Coarse pre-fetch gate — 2-digit state FIPS to keep, e.g. 16 (ID), 41 (OR), 53 (WA)" />
<ListInput label="SAME Codes" value={env.ipaws.same_codes} onChange={(v) => up({ ipaws: { ...env.ipaws, same_codes: v } })}
helper="Optional fine gate — 6-digit SAME county codes, e.g. 016001. Empty = all counties in the FIPS states." />
</>)}
<div className="border-t border-border pt-4 mt-4">
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">Broadcast Filters</div>
<div className="space-y-2">
<label className="flex items-center justify-between">
<span className="text-sm font-sans text-[#e0e0e0]">Exclude weather-sourced alerts</span>
<input type="checkbox" checked={env.ipaws.exclude_weather}
onChange={(e) => up({ ipaws: { ...env.ipaws, exclude_weather: e.target.checked } })}
className="w-4 h-4 accent-[#f59e0b]" />
</label>
<p className="text-xs text-[#666]">Drop NWS/NOAA-originated CAP so weather stays on the NWS adapter (no double-broadcast).</p>
<label className="flex items-center justify-between pt-2">
<span className="text-sm font-sans text-[#e0e0e0]">Actual status only</span>
<input type="checkbox" checked={env.ipaws.status_actual_only}
onChange={(e) => up({ ipaws: { ...env.ipaws, status_actual_only: e.target.checked } })}
className="w-4 h-4 accent-[#f59e0b]" />
</label>
<p className="text-xs text-[#666]">Skip Test / Exercise / System messages broadcast only status=Actual alerts.</p>
</div>
</div>
</>)
case 'satpass': {
// Armed state keys off the NATIVE enable (environmental.satpass.enabled),
// which is what actually gates the native SGP4 broadcaster — consistent

View file

@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import {
Save, RotateCcw, RefreshCw, Check,
Eye as EyeIcon, EyeOff, Plus, X, Radio,
Activity, Cloud, Flame, Car, Snowflake, Mountain, MapPin, Satellite, Layers,
Activity, Cloud, Flame, Car, Snowflake, Mountain, MapPin, Satellite, Layers, Siren,
} from 'lucide-react'
import { useDirty } from '@/context/DirtyContext'
@ -395,6 +395,7 @@ export const TOGGLE_FAMILY_META: FamilyMeta[] = [
{ key: 'mesh_health', label: 'Mesh Health', Icon: Activity },
{ key: 'weather', label: 'Weather', Icon: Cloud },
{ key: 'fire', label: 'Fire', Icon: Flame },
{ key: 'emergency', label: 'Emergency', Icon: Siren },
{ key: 'rf_propagation', label: 'RF Propagation', Icon: Radio },
{ key: 'roads', label: 'Roads', Icon: Car },
{ key: 'avalanche', label: 'Avalanche', Icon: Snowflake },

View file

@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-dwX3LKqm.js"></script>
<script type="module" crossorigin src="/assets/index-FgUiz9s3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CTVGSJxQ.css">
</head>
<body>

View file

@ -1354,6 +1354,10 @@ class Dispatcher:
"wzdx": "traffic_events",
"traffic": "traffic_events",
"511": "traffic_events",
# IPAWS civil alerts (env/ipaws.py, source="ipaws"). Own dedup table
# ipaws_alerts — so region-routed emergency sends land in the audit
# feed labeled "Emergency" instead of NULL/unlabeled.
"ipaws": "ipaws_alerts",
}
def _post_broadcast_commit(self, event, payload, rule, ch_type: str,

View file

@ -554,8 +554,50 @@ def test_source_to_table_fallback_stamps_audit_row(db_path):
assert row["source_event_pk"] is None, "pk should be NULL for fallback path"
def test_source_to_table_fallback_stamps_ipaws_audit_row(db_path):
"""Parity guard for the IPAWS civil-alert adapter: a native ipaws event
with no _broadcast_audit must stamp source_event_table='ipaws_alerts' so
emergency sends show labeled (not NULL/unlabeled) in the Activity Log.
"""
from unittest.mock import MagicMock
from meshai.notifications.events import make_event
from meshai.notifications.pipeline.dispatcher import Dispatcher
from meshai.persistence import get_db
cfg = _build_config(cold_start_grace=0)
factory, _ = _mk_channel_factory()
d = Dispatcher(cfg, factory)
ev = make_event(
source="ipaws",
category="emergency_evacuation",
severity="immediate",
region="US-ID",
title="🚨 Evacuation Immediate",
lat=43.6, lon=-116.2,
)
rule = MagicMock()
rule.broadcast_channel = 1
rule.delivery_types = ["mesh_broadcast"]
payload = MagicMock()
payload.message = "🚨 Evacuation Immediate — test"
d._post_broadcast_commit(ev, payload, rule, "mesh_broadcast", success=True)
conn = get_db()
row = conn.execute(
"SELECT source_event_table FROM mesh_broadcasts_out ORDER BY id DESC LIMIT 1"
).fetchone()
assert row is not None, "No audit row was written"
assert row["source_event_table"] == "ipaws_alerts", (
f"Expected 'ipaws_alerts', got {row['source_event_table']!r}"
)
def test_source_to_table_fallback_all_native_sources(db_path):
"""Verify _SOURCE_TO_TABLE covers all five native adapter sources."""
"""Verify _SOURCE_TO_TABLE covers all native adapter sources."""
from meshai.notifications.pipeline.dispatcher import Dispatcher
expected = {
"nws": "nws_alerts",
@ -563,6 +605,7 @@ def test_source_to_table_fallback_all_native_sources(db_path):
"wzdx": "traffic_events",
"traffic": "traffic_events",
"511": "traffic_events",
"ipaws": "ipaws_alerts",
}
cfg = _build_config()
factory, _ = _mk_channel_factory()