mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 15:14:45 +02:00
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
This commit is contained in:
parent
839bf322d9
commit
21d6520ffd
2 changed files with 267 additions and 197 deletions
|
|
@ -465,7 +465,7 @@ const SEVERITY_COLORS: Record<string, string> = {
|
||||||
emergency: 'bg-red-700/20 text-red-200 border-red-700/30',
|
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 sourceConfig = SOURCE_ICONS[event.source] || { icon: Info, color: 'text-slate-400', label: event.source }
|
||||||
const Icon = sourceConfig.icon
|
const Icon = sourceConfig.icon
|
||||||
const severityStyle = SEVERITY_COLORS[event.severity?.toLowerCase()] || SEVERITY_COLORS.info
|
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' })
|
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<string, unknown>).event_type as string | undefined
|
||||||
|
const areaDesc = (event as Record<string, unknown>).area_desc as string | undefined
|
||||||
|
const description = (event as Record<string, unknown>).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 (
|
return (
|
||||||
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
|
<div className={`flex items-start gap-2 py-2 border-b border-border/50 last:border-0 ${isLocal ? 'border-l-2 border-l-blue-500 pl-2 -ml-2' : ''}`}>
|
||||||
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
|
<Icon size={14} className={`mt-0.5 flex-shrink-0 ${sourceConfig.color}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
|
<span className={`px-1.5 py-0.5 rounded text-xs border ${severityStyle}`}>
|
||||||
{event.severity || 'info'}
|
{event.severity || 'info'}
|
||||||
</span>
|
</span>
|
||||||
|
{isLocal && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30">
|
||||||
|
LOCAL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-xs text-slate-500">{sourceConfig.label}</span>
|
<span className="text-xs text-slate-500">{sourceConfig.label}</span>
|
||||||
<span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span>
|
<span className="text-xs text-slate-600 ml-auto">{formatTime(event.fetched_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-slate-200 truncate">{event.headline}</div>
|
<div className={`text-sm truncate ${isLocal ? 'text-slate-100' : 'text-slate-300'}`}>{title}</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div className="text-xs text-slate-500 truncate mt-0.5">{subtitle}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -502,8 +527,31 @@ function EventFeedItem({ event }: { event: EnvEvent }) {
|
||||||
|
|
||||||
// Live Event Feed Card
|
// Live Event Feed Card
|
||||||
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
|
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
|
||||||
|
// Severity order for sorting
|
||||||
|
const severityOrder: Record<string, number> = { immediate: 0, priority: 1, routine: 2 }
|
||||||
|
|
||||||
const sortedEvents = useMemo(() => {
|
const sortedEvents = useMemo(() => {
|
||||||
return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0))
|
// Dedup by event_id
|
||||||
|
const seen = new Set<string>()
|
||||||
|
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<string, unknown>).is_local ? 1 : 0
|
||||||
|
const bLocal = (b as Record<string, unknown>).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])
|
}, [events])
|
||||||
|
|
||||||
// Calculate feed health summary
|
// Calculate feed health summary
|
||||||
|
|
@ -528,7 +576,11 @@ function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: E
|
||||||
{sortedEvents.length > 0 ? (
|
{sortedEvents.length > 0 ? (
|
||||||
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
|
<div className="flex-1 overflow-y-auto max-h-80 pr-1 -mr-1">
|
||||||
{sortedEvents.map((event, i) => (
|
{sortedEvents.map((event, i) => (
|
||||||
<EventFeedItem key={event.event_id || i} event={event} />
|
<EventFeedItem
|
||||||
|
key={event.event_id || i}
|
||||||
|
event={event}
|
||||||
|
isLocal={(event as Record<string, unknown>).is_local as boolean | undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,192 +1,210 @@
|
||||||
"""Environmental data API routes."""
|
"""Environmental data API routes."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
|
|
||||||
router = APIRouter(tags=["environment"])
|
router = APIRouter(tags=["environment"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/status")
|
@router.get("/env/status")
|
||||||
async def get_env_status(request: Request):
|
async def get_env_status(request: Request):
|
||||||
"""Get environmental feeds status."""
|
"""Get environmental feeds status."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"enabled": False, "feeds": []}
|
return {"enabled": False, "feeds": []}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"feeds": env_store.get_source_health(),
|
"feeds": env_store.get_source_health(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/active")
|
@router.get("/env/active")
|
||||||
async def get_active_env(request: Request):
|
async def get_active_env(request: Request):
|
||||||
"""Get active environmental events."""
|
"""Get active environmental events with local zone marking."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return env_store.get_active()
|
events = env_store.get_active()
|
||||||
|
mesh_zones = set(getattr(env_store, '_mesh_zones', []))
|
||||||
|
|
||||||
@router.get("/env/swpc")
|
# Dedup by event_id and add is_local field
|
||||||
async def get_swpc_data(request: Request):
|
seen_ids = set()
|
||||||
"""Get SWPC space weather data."""
|
result = []
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
for event in events:
|
||||||
|
event_id = event.get("event_id")
|
||||||
if not env_store:
|
if event_id and event_id in seen_ids:
|
||||||
return {"enabled": False}
|
continue
|
||||||
|
if event_id:
|
||||||
status = env_store.get_swpc_status()
|
seen_ids.add(event_id)
|
||||||
if not status:
|
|
||||||
return {"enabled": False}
|
# Mark as local if event zones overlap with configured mesh zones
|
||||||
|
event_zones = set(event.get("areas", []))
|
||||||
return {
|
event["is_local"] = bool(event_zones & mesh_zones)
|
||||||
"enabled": True,
|
result.append(event)
|
||||||
**status,
|
|
||||||
}
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/propagation")
|
@router.get("/env/swpc")
|
||||||
async def get_rf_propagation(request: Request):
|
async def get_swpc_data(request: Request):
|
||||||
"""Get combined HF + UHF propagation data for dashboard."""
|
"""Get SWPC space weather data."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return {"hf": {}, "uhf_ducting": {}}
|
return {"enabled": False}
|
||||||
|
|
||||||
return env_store.get_rf_propagation()
|
status = env_store.get_swpc_status()
|
||||||
|
if not status:
|
||||||
|
return {"enabled": False}
|
||||||
@router.get("/env/ducting")
|
|
||||||
async def get_ducting_data(request: Request):
|
return {
|
||||||
"""Get tropospheric ducting assessment."""
|
"enabled": True,
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
**status,
|
||||||
|
}
|
||||||
if not env_store:
|
|
||||||
return {"enabled": False}
|
|
||||||
|
@router.get("/env/propagation")
|
||||||
status = env_store.get_ducting_status()
|
async def get_rf_propagation(request: Request):
|
||||||
if not status:
|
"""Get combined HF + UHF propagation data for dashboard."""
|
||||||
return {"enabled": False}
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
return {
|
if not env_store:
|
||||||
"enabled": True,
|
return {"hf": {}, "uhf_ducting": {}}
|
||||||
**status,
|
|
||||||
}
|
return env_store.get_rf_propagation()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/env/fires")
|
@router.get("/env/ducting")
|
||||||
async def get_fires_data(request: Request):
|
async def get_ducting_data(request: Request):
|
||||||
"""Get active wildfire perimeters."""
|
"""Get tropospheric ducting assessment."""
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
if not env_store:
|
if not env_store:
|
||||||
return []
|
return {"enabled": False}
|
||||||
|
|
||||||
return env_store.get_active(source="nifc")
|
status = env_store.get_ducting_status()
|
||||||
|
if not status:
|
||||||
|
return {"enabled": False}
|
||||||
@router.get("/env/avalanche")
|
|
||||||
async def get_avalanche_data(request: Request):
|
return {
|
||||||
"""Get avalanche advisories."""
|
"enabled": True,
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
**status,
|
||||||
|
}
|
||||||
if not env_store:
|
|
||||||
return {"off_season": True, "advisories": []}
|
|
||||||
|
@router.get("/env/fires")
|
||||||
adapters = getattr(env_store, "_adapters", {})
|
async def get_fires_data(request: Request):
|
||||||
avy_adapter = adapters.get("avalanche")
|
"""Get active wildfire perimeters."""
|
||||||
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
if avy_adapter and avy_adapter.is_off_season():
|
|
||||||
return {"off_season": True, "advisories": []}
|
if not env_store:
|
||||||
|
return []
|
||||||
return {
|
|
||||||
"off_season": False,
|
return env_store.get_active(source="nifc")
|
||||||
"advisories": env_store.get_active(source="avalanche"),
|
|
||||||
}
|
|
||||||
|
@router.get("/env/avalanche")
|
||||||
|
async def get_avalanche_data(request: Request):
|
||||||
@router.get("/env/streams")
|
"""Get avalanche advisories."""
|
||||||
async def get_streams_data(request: Request):
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
"""Get USGS stream gauge readings."""
|
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
if not env_store:
|
||||||
|
return {"off_season": True, "advisories": []}
|
||||||
if not env_store:
|
|
||||||
return []
|
adapters = getattr(env_store, "_adapters", {})
|
||||||
|
avy_adapter = adapters.get("avalanche")
|
||||||
return env_store.get_active(source="usgs")
|
|
||||||
|
if avy_adapter and avy_adapter.is_off_season():
|
||||||
|
return {"off_season": True, "advisories": []}
|
||||||
@router.get("/env/usgs/lookup/{site_id}")
|
|
||||||
async def lookup_usgs_site(request: Request, site_id: str):
|
return {
|
||||||
"""Lookup USGS site metadata and NWS flood stages.
|
"off_season": False,
|
||||||
|
"advisories": env_store.get_active(source="avalanche"),
|
||||||
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)
|
@router.get("/env/streams")
|
||||||
|
async def get_streams_data(request: Request):
|
||||||
if not env_store:
|
"""Get USGS stream gauge readings."""
|
||||||
return {"error": "Environmental feeds not enabled"}
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
adapters = getattr(env_store, "_adapters", {})
|
if not env_store:
|
||||||
usgs_adapter = adapters.get("usgs")
|
return []
|
||||||
|
|
||||||
if not usgs_adapter:
|
return env_store.get_active(source="usgs")
|
||||||
# Create a temporary adapter for lookup
|
|
||||||
from meshai.env.usgs import USGSStreamsAdapter
|
|
||||||
from meshai.config import USGSConfig
|
@router.get("/env/usgs/lookup/{site_id}")
|
||||||
usgs_adapter = USGSStreamsAdapter(USGSConfig())
|
async def lookup_usgs_site(request: Request, site_id: str):
|
||||||
|
"""Lookup USGS site metadata and NWS flood stages.
|
||||||
try:
|
|
||||||
result = usgs_adapter.lookup_site(site_id)
|
Returns site name, location, and flood stage thresholds from NWS NWPS.
|
||||||
return result
|
Used by the config UI to auto-populate fields when adding a new gauge.
|
||||||
except Exception as e:
|
"""
|
||||||
return {"error": str(e), "site_id": site_id}
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
|
|
||||||
|
if not env_store:
|
||||||
@router.get("/env/traffic")
|
return {"error": "Environmental feeds not enabled"}
|
||||||
async def get_traffic_data(request: Request):
|
|
||||||
"""Get TomTom traffic flow data."""
|
adapters = getattr(env_store, "_adapters", {})
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
usgs_adapter = adapters.get("usgs")
|
||||||
|
|
||||||
if not env_store:
|
if not usgs_adapter:
|
||||||
return []
|
# Create a temporary adapter for lookup
|
||||||
|
from meshai.env.usgs import USGSStreamsAdapter
|
||||||
return env_store.get_active(source="traffic")
|
from meshai.config import USGSConfig
|
||||||
|
usgs_adapter = USGSStreamsAdapter(USGSConfig())
|
||||||
|
|
||||||
@router.get("/env/roads")
|
try:
|
||||||
async def get_roads_data(request: Request):
|
result = usgs_adapter.lookup_site(site_id)
|
||||||
"""Get 511 road conditions."""
|
return result
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
except Exception as e:
|
||||||
|
return {"error": str(e), "site_id": site_id}
|
||||||
if not env_store:
|
|
||||||
return []
|
|
||||||
|
@router.get("/env/traffic")
|
||||||
return env_store.get_active(source="511")
|
async def get_traffic_data(request: Request):
|
||||||
|
"""Get TomTom traffic flow data."""
|
||||||
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
@router.get("/env/hotspots")
|
|
||||||
async def get_hotspots_data(request: Request):
|
if not env_store:
|
||||||
"""Get NASA FIRMS satellite fire hotspots."""
|
return []
|
||||||
env_store = getattr(request.app.state, "env_store", None)
|
|
||||||
|
return env_store.get_active(source="traffic")
|
||||||
if not env_store:
|
|
||||||
return {"hotspots": [], "new_ignitions": 0}
|
|
||||||
|
@router.get("/env/roads")
|
||||||
firms_adapter = getattr(env_store, "_firms", None)
|
async def get_roads_data(request: Request):
|
||||||
|
"""Get 511 road conditions."""
|
||||||
if not firms_adapter:
|
env_store = getattr(request.app.state, "env_store", None)
|
||||||
return {"hotspots": [], "new_ignitions": 0, "enabled": False}
|
|
||||||
|
if not env_store:
|
||||||
hotspots = env_store.get_active(source="firms")
|
return []
|
||||||
new_ignitions = [h for h in hotspots if h.get("properties", {}).get("new_ignition")]
|
|
||||||
|
return env_store.get_active(source="511")
|
||||||
return {
|
|
||||||
"enabled": True,
|
|
||||||
"hotspots": hotspots,
|
@router.get("/env/hotspots")
|
||||||
"new_ignitions": len(new_ignitions),
|
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),
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue