From 21d6520ffd0bfc4eb735879d6a71046d4d62d540 Mon Sep 17 00:00:00 2001 From: zvx-echo6 Date: Wed, 13 May 2026 20:33:48 -0600 Subject: [PATCH] fix(dashboard): weather feed shows location + hazard, prioritizes local - Event feed shows event_type + area_desc instead of timestamp headline - First sentence of description shown as hazard summary - Local events (matching NWS zones) pinned to top with highlight - Nearby events grouped below, slightly dimmed - Dedup by event_id --- dashboard-frontend/src/pages/Dashboard.tsx | 62 +++- meshai/dashboard/api/env_routes.py | 402 +++++++++++---------- 2 files changed, 267 insertions(+), 197 deletions(-) diff --git a/dashboard-frontend/src/pages/Dashboard.tsx b/dashboard-frontend/src/pages/Dashboard.tsx index 8732e0e..71b71e0 100644 --- a/dashboard-frontend/src/pages/Dashboard.tsx +++ b/dashboard-frontend/src/pages/Dashboard.tsx @@ -465,7 +465,7 @@ const SEVERITY_COLORS: Record = { emergency: 'bg-red-700/20 text-red-200 border-red-700/30', } -function EventFeedItem({ event }: { event: EnvEvent }) { +function EventFeedItem({ event, isLocal }: { event: EnvEvent; isLocal?: boolean }) { const sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source } const Icon = sourceConfig.icon const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info @@ -483,18 +483,43 @@ function EventFeedItem({ event }: { event: EnvEvent }) { return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) } + // Build display title: prefer event_type + area_desc, fall back to headline + const eventType = (event as Record).event_type as string | undefined + const areaDesc = (event as Record).area_desc as string | undefined + const description = (event as Record).description as string | undefined + + let title = event.headline + if (eventType && areaDesc) { + // Shorten area description (remove "County" repetition) + const shortArea = areaDesc.replace(/ County/g, '').split(';')[0] + title = `${eventType} — ${shortArea}` + } else if (eventType) { + title = eventType + } + + // Get first sentence of description as subtitle + const subtitle = description ? description.split('. ')[0] : null + return ( -
+
{event.severity || 'info'} + {isLocal && ( + + LOCAL + + )} {sourceConfig.label} {formatTime(event.fetched_at)}
-
{event.headline}
+
{title}
+ {subtitle && ( +
{subtitle}
+ )}
) @@ -502,8 +527,31 @@ function EventFeedItem({ event }: { event: EnvEvent }) { // Live Event Feed Card function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) { + // Severity order for sorting + const severityOrder: Record = { immediate: 0, priority: 1, routine: 2 } + const sortedEvents = useMemo(() => { - return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0)) + // Dedup by event_id + const seen = new Set() + const deduped = events.filter(e => { + if (!e.event_id) return true + if (seen.has(e.event_id)) return false + seen.add(e.event_id) + return true + }) + + // Sort: local first, then by severity, then by time + return deduped.sort((a, b) => { + const aLocal = (a as Record).is_local ? 1 : 0 + const bLocal = (b as Record).is_local ? 1 : 0 + if (aLocal !== bLocal) return bLocal - aLocal // local first + + const aSev = severityOrder[a.severity?.toLowerCase() || 'routine'] ?? 2 + const bSev = severityOrder[b.severity?.toLowerCase() || 'routine'] ?? 2 + if (aSev !== bSev) return aSev - bSev // higher severity first + + return (b.fetched_at || 0) - (a.fetched_at || 0) // newest first + }) }, [events]) // Calculate feed health summary @@ -528,7 +576,11 @@ function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: E {sortedEvents.length > 0 ? (
{sortedEvents.map((event, i) => ( - + ).is_local as boolean | undefined} + /> ))}
) : ( diff --git a/meshai/dashboard/api/env_routes.py b/meshai/dashboard/api/env_routes.py index 532b3c6..631eeff 100644 --- a/meshai/dashboard/api/env_routes.py +++ b/meshai/dashboard/api/env_routes.py @@ -1,192 +1,210 @@ -"""Environmental data API routes.""" - -from fastapi import APIRouter, Request - -router = APIRouter(tags=["environment"]) - - -@router.get("/env/status") -async def get_env_status(request: Request): - """Get environmental feeds status.""" - env_store = getattr(request.app.state, "env_store", None) - - if not env_store: - return {"enabled": False, "feeds": []} - - return { - "enabled": True, - "feeds": env_store.get_source_health(), - } - - -@router.get("/env/active") -async def get_active_env(request: Request): - """Get active environmental events.""" - env_store = getattr(request.app.state, "env_store", None) - - if not env_store: - return [] - - return env_store.get_active() - - -@router.get("/env/swpc") -async def get_swpc_data(request: Request): - """Get SWPC space weather data.""" - env_store = getattr(request.app.state, "env_store", None) - - if not env_store: - return {"enabled": False} - - status = env_store.get_swpc_status() - if not status: - return {"enabled": False} - - return { - "enabled": True, - **status, - } - - -@router.get("/env/propagation") -async def get_rf_propagation(request: Request): - """Get combined HF + UHF propagation data for dashboard.""" - env_store = getattr(request.app.state, "env_store", None) - - if not env_store: - return {"hf": {}, "uhf_ducting": {}} - - return env_store.get_rf_propagation() - - -@router.get("/env/ducting") -async def get_ducting_data(request: Request): - """Get tropospheric ducting assessment.""" - env_store = getattr(request.app.state, "env_store", None) - - if not env_store: - return {"enabled": False} - - status = env_store.get_ducting_status() - if not status: - return {"enabled": False} - - return { - "enabled": True, - **status, - } - - -@router.get("/env/fires") -async def get_fires_data(request: Request): - """Get active wildfire perimeters.""" - env_store = getattr(request.app.state, "env_store", None) - - if not env_store: - return [] - - return env_store.get_active(source="nifc") - - -@router.get("/env/avalanche") -async def get_avalanche_data(request: Request): - """Get avalanche advisories.""" - env_store = getattr(request.app.state, "env_store", None) - - if not env_store: - return {"off_season": True, "advisories": []} - - adapters = getattr(env_store, "_adapters", {}) - avy_adapter = adapters.get("avalanche") - - if avy_adapter and avy_adapter.is_off_season(): - return {"off_season": True, "advisories": []} - - return { - "off_season": False, - "advisories": env_store.get_active(source="avalanche"), - } - - -@router.get("/env/streams") -async def get_streams_data(request: Request): - """Get USGS stream gauge readings.""" - env_store = getattr(request.app.state, "env_store", None) - - if not env_store: - return [] - - return env_store.get_active(source="usgs") - - -@router.get("/env/usgs/lookup/{site_id}") -async def lookup_usgs_site(request: Request, site_id: str): - """Lookup USGS site metadata and NWS flood stages. - - Returns site name, location, and flood stage thresholds from NWS NWPS. - Used by the config UI to auto-populate fields when adding a new gauge. - """ - env_store = getattr(request.app.state, "env_store", None) - - if not env_store: - return {"error": "Environmental feeds not enabled"} - - adapters = getattr(env_store, "_adapters", {}) - usgs_adapter = adapters.get("usgs") - - if not usgs_adapter: - # Create a temporary adapter for lookup - from meshai.env.usgs import USGSStreamsAdapter - from meshai.config import USGSConfig - usgs_adapter = USGSStreamsAdapter(USGSConfig()) - - try: - result = usgs_adapter.lookup_site(site_id) - return result - except Exception as e: - return {"error": str(e), "site_id": site_id} - - -@router.get("/env/traffic") -async def get_traffic_data(request: Request): - """Get TomTom traffic flow data.""" - env_store = getattr(request.app.state, "env_store", None) - - if not env_store: - return [] - - return env_store.get_active(source="traffic") - - -@router.get("/env/roads") -async def get_roads_data(request: Request): - """Get 511 road conditions.""" - env_store = getattr(request.app.state, "env_store", None) - - if not env_store: - return [] - - return env_store.get_active(source="511") - - -@router.get("/env/hotspots") -async def get_hotspots_data(request: Request): - """Get NASA FIRMS satellite fire hotspots.""" - env_store = getattr(request.app.state, "env_store", None) - - if not env_store: - return {"hotspots": [], "new_ignitions": 0} - - firms_adapter = getattr(env_store, "_firms", None) - - if not firms_adapter: - return {"hotspots": [], "new_ignitions": 0, "enabled": False} - - hotspots = env_store.get_active(source="firms") - new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")] - - return { - "enabled": True, - "hotspots": hotspots, - "new_ignitions": len(new_ignitions), - } +"""Environmental data API routes.""" + +from fastapi import APIRouter, Request + +router = APIRouter(tags=["environment"]) + + +@router.get("/env/status") +async def get_env_status(request: Request): + """Get environmental feeds status.""" + env_store = getattr(request.app.state, "env_store", None) + + if not env_store: + return {"enabled": False, "feeds": []} + + return { + "enabled": True, + "feeds": env_store.get_source_health(), + } + + +@router.get("/env/active") +async def get_active_env(request: Request): + """Get active environmental events with local zone marking.""" + env_store = getattr(request.app.state, "env_store", None) + + if not env_store: + return [] + + events = env_store.get_active() + mesh_zones = set(getattr(env_store, '_mesh_zones', [])) + + # Dedup by event_id and add is_local field + seen_ids = set() + result = [] + for event in events: + event_id = event.get("event_id") + if event_id and event_id in seen_ids: + continue + if event_id: + seen_ids.add(event_id) + + # Mark as local if event zones overlap with configured mesh zones + event_zones = set(event.get("areas", [])) + event["is_local"] = bool(event_zones & mesh_zones) + result.append(event) + + return result + + +@router.get("/env/swpc") +async def get_swpc_data(request: Request): + """Get SWPC space weather data.""" + env_store = getattr(request.app.state, "env_store", None) + + if not env_store: + return {"enabled": False} + + status = env_store.get_swpc_status() + if not status: + return {"enabled": False} + + return { + "enabled": True, + **status, + } + + +@router.get("/env/propagation") +async def get_rf_propagation(request: Request): + """Get combined HF + UHF propagation data for dashboard.""" + env_store = getattr(request.app.state, "env_store", None) + + if not env_store: + return {"hf": {}, "uhf_ducting": {}} + + return env_store.get_rf_propagation() + + +@router.get("/env/ducting") +async def get_ducting_data(request: Request): + """Get tropospheric ducting assessment.""" + env_store = getattr(request.app.state, "env_store", None) + + if not env_store: + return {"enabled": False} + + status = env_store.get_ducting_status() + if not status: + return {"enabled": False} + + return { + "enabled": True, + **status, + } + + +@router.get("/env/fires") +async def get_fires_data(request: Request): + """Get active wildfire perimeters.""" + env_store = getattr(request.app.state, "env_store", None) + + if not env_store: + return [] + + return env_store.get_active(source="nifc") + + +@router.get("/env/avalanche") +async def get_avalanche_data(request: Request): + """Get avalanche advisories.""" + env_store = getattr(request.app.state, "env_store", None) + + if not env_store: + return {"off_season": True, "advisories": []} + + adapters = getattr(env_store, "_adapters", {}) + avy_adapter = adapters.get("avalanche") + + if avy_adapter and avy_adapter.is_off_season(): + return {"off_season": True, "advisories": []} + + return { + "off_season": False, + "advisories": env_store.get_active(source="avalanche"), + } + + +@router.get("/env/streams") +async def get_streams_data(request: Request): + """Get USGS stream gauge readings.""" + env_store = getattr(request.app.state, "env_store", None) + + if not env_store: + return [] + + return env_store.get_active(source="usgs") + + +@router.get("/env/usgs/lookup/{site_id}") +async def lookup_usgs_site(request: Request, site_id: str): + """Lookup USGS site metadata and NWS flood stages. + + Returns site name, location, and flood stage thresholds from NWS NWPS. + Used by the config UI to auto-populate fields when adding a new gauge. + """ + env_store = getattr(request.app.state, "env_store", None) + + if not env_store: + return {"error": "Environmental feeds not enabled"} + + adapters = getattr(env_store, "_adapters", {}) + usgs_adapter = adapters.get("usgs") + + if not usgs_adapter: + # Create a temporary adapter for lookup + from meshai.env.usgs import USGSStreamsAdapter + from meshai.config import USGSConfig + usgs_adapter = USGSStreamsAdapter(USGSConfig()) + + try: + result = usgs_adapter.lookup_site(site_id) + return result + except Exception as e: + return {"error": str(e), "site_id": site_id} + + +@router.get("/env/traffic") +async def get_traffic_data(request: Request): + """Get TomTom traffic flow data.""" + env_store = getattr(request.app.state, "env_store", None) + + if not env_store: + return [] + + return env_store.get_active(source="traffic") + + +@router.get("/env/roads") +async def get_roads_data(request: Request): + """Get 511 road conditions.""" + env_store = getattr(request.app.state, "env_store", None) + + if not env_store: + return [] + + return env_store.get_active(source="511") + + +@router.get("/env/hotspots") +async def get_hotspots_data(request: Request): + """Get NASA FIRMS satellite fire hotspots.""" + env_store = getattr(request.app.state, "env_store", None) + + if not env_store: + return {"hotspots": [], "new_ignitions": 0} + + firms_adapter = getattr(env_store, "_firms", None) + + if not firms_adapter: + return {"hotspots": [], "new_ignitions": 0, "enabled": False} + + hotspots = env_store.get_active(source="firms") + new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")] + + return { + "enabled": True, + "hotspots": hotspots, + "new_ignitions": len(new_ignitions), + }