2026-05-13 19:05:50 -06:00
import { useEffect , useState , useMemo } from 'react'
import {
fetchHealth ,
fetchSources ,
fetchAlerts ,
fetchEnvStatus ,
fetchEnvActive ,
fetchSWPC ,
type MeshHealth ,
type SourceHealth ,
type Alert ,
type EnvStatus ,
type EnvEvent ,
2026-06-10 05:53:55 +00:00
type BandConditionsStatus ,
2026-05-13 19:05:50 -06:00
} from '@/lib/api'
import { useWebSocket } from '@/hooks/useWebSocket'
import {
AlertTriangle ,
AlertCircle ,
Info ,
CheckCircle ,
Radio ,
Cpu ,
Activity ,
MapPin ,
Zap ,
Cloud ,
Flame ,
Mountain ,
Droplets ,
Car ,
Construction ,
Satellite ,
Sun ,
} from 'lucide-react'
function HealthGauge ( { health } : { health : MeshHealth } ) {
const score = health . score
const tier = health . tier
const getColor = ( s : number ) = > {
if ( s >= 80 ) return '#22c55e'
if ( s >= 60 ) return '#f59e0b'
return '#ef4444'
}
const color = getColor ( score )
const circumference = 2 * Math . PI * 45
const progress = ( score / 100 ) * circumference
return (
< div className = "flex flex-col items-center" >
< svg width = "140" height = "140" viewBox = "0 0 100 100" >
< circle cx = "50" cy = "50" r = "45" fill = "none" stroke = "#1e2a3a" strokeWidth = "8" / >
< circle
cx = "50" cy = "50" r = "45" fill = "none" stroke = { color } strokeWidth = "8"
strokeLinecap = "round" strokeDasharray = { circumference }
strokeDashoffset = { circumference - progress } transform = "rotate(-90 50 50)"
className = "transition-all duration-500"
/ >
< text x = "50" y = "46" textAnchor = "middle" className = "fill-slate-100 font-mono text-2xl font-bold" style = { { fontSize : '24px' } } >
{ score . toFixed ( 1 ) }
< / text >
< text x = "50" y = "62" textAnchor = "middle" className = "fill-slate-400 text-xs" style = { { fontSize : '10px' } } >
{ tier }
< / text >
< / svg >
< / div >
)
}
function PillarBar ( { label , value } : { label : string ; value : number } ) {
const getColor = ( v : number ) = > {
if ( v >= 80 ) return 'bg-green-500'
if ( v >= 60 ) return 'bg-amber-500'
return 'bg-red-500'
}
return (
< div className = "flex items-center gap-3" >
< div className = "w-24 text-xs text-slate-400 truncate" > { label } < / div >
< div className = "flex-1 h-2 bg-border rounded-full overflow-hidden" >
< div className = { ` h-full ${ getColor ( value ) } transition-all duration-300 ` } style = { { width : ` ${ value } % ` } } / >
< / div >
< div className = "w-12 text-right text-xs font-mono text-slate-300" > { value . toFixed ( 1 ) } < / div >
< / div >
)
}
function AlertItem ( { alert } : { alert : Alert } ) {
const getSeverityStyles = ( severity : string ) = > {
switch ( severity . toLowerCase ( ) ) {
case 'critical' :
case 'emergency' :
case 'immediate' :
return { bg : 'bg-red-500/10' , border : 'border-red-500' , icon : AlertCircle , iconColor : 'text-red-500' }
case 'warning' :
case 'priority' :
return { bg : 'bg-amber-500/10' , border : 'border-amber-500' , icon : AlertTriangle , iconColor : 'text-amber-500' }
case 'routine' :
default :
return { bg : 'bg-blue-500/10' , border : 'border-blue-500' , icon : Info , iconColor : 'text-blue-500' }
}
}
const styles = getSeverityStyles ( alert . severity )
const Icon = styles . icon
return (
< div className = { ` p-3 rounded-lg ${ styles . bg } border-l-2 ${ styles . border } flex items-start gap-3 ` } >
< Icon size = { 16 } className = { styles . iconColor } / >
< div className = "flex-1 min-w-0" >
< div className = "text-sm text-slate-200" > { alert . message } < / div >
< div className = "text-xs text-slate-500 mt-1" > { alert . timestamp || 'Just now' } < / div >
< / div >
< / div >
)
}
function SourceCard ( { source } : { source : SourceHealth } ) {
const getStatusColor = ( ) = > {
if ( ! source . is_loaded ) return 'bg-red-500'
if ( source . last_error ) return 'bg-amber-500'
return 'bg-green-500'
}
return (
< div className = "flex items-center gap-3 p-3 rounded-lg bg-bg-hover" >
< div className = { ` w-2 h-2 rounded-full ${ getStatusColor ( ) } ` } / >
< div className = "flex-1 min-w-0" >
< div className = "text-sm text-slate-200 truncate" > { source . name } < / div >
< div className = "text-xs text-slate-500" > { source . node_count } nodes · { source . type } < / div >
< / div >
< / div >
)
}
function StatCard ( { icon : Icon , label , value , subvalue } : { icon : typeof Radio ; label : string ; value : string | number ; subvalue? : string } ) {
return (
< div className = "bg-bg-card border border-border rounded-lg p-4" >
< div className = "flex items-center gap-2 text-slate-400 mb-2" >
< Icon size = { 14 } / >
< span className = "text-xs" > { label } < / span >
< / div >
< div className = "font-mono text-xl text-slate-100" > { value } < / div >
{ subvalue && < div className = "text-xs text-slate-500 mt-1" > { subvalue } < / div > }
< / div >
)
}
2026-06-10 05:53:55 +00:00
// Band Conditions Card
function BandConditionsCard ( { bandConditions } : { bandConditions : BandConditionsStatus | null } ) {
const getRatingEmoji = ( rating? : string ) = > {
switch ( rating ) {
case 'Good' : return '\ud83d\udfe2' // green circle
case 'Fair' : return '\ud83d\udfe1' // yellow circle
case 'Poor' : return '\ud83d\udd34' // red circle
default : return '\u2014'
}
2026-05-13 19:05:50 -06:00
}
2026-06-10 05:53:55 +00:00
const getSlotEmoji = ( label? : string ) = > {
if ( ! label ) return ''
return label . includes ( 'Night' ) ? '\ud83c\udf19' : '\u2600\ufe0f'
2026-05-13 19:05:50 -06:00
}
2026-06-10 05:53:55 +00:00
if ( ! bandConditions ? . enabled || ! bandConditions ? . ratings ) {
2026-05-13 19:05:50 -06:00
return (
2026-06-10 05:53:55 +00:00
< div className = "bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full" >
< h2 className = "text-sm font-medium text-slate-400 mb-4 flex items-center gap-2" >
< Zap size = { 14 } / >
RF Propagation
< / h2 >
< div className = "flex-1 flex items-center justify-center" >
< div className = "text-center py-8" >
< div className = "text-slate-400" > No band conditions data < / div >
< / div >
< / div >
< / div >
2026-05-13 19:05:50 -06:00
)
}
2026-06-10 05:53:55 +00:00
const bands = [ '80-40m' , '30-20m' , '17-15m' , '12-10m' ] as const
2026-05-13 19:05:50 -06:00
return (
< div className = "bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full" >
< h2 className = "text-sm font-medium text-slate-400 mb-4 flex items-center gap-2" >
< Zap size = { 14 } / >
2026-06-10 05:53:55 +00:00
RF Propagation
2026-05-13 19:05:50 -06:00
< / h2 >
2026-06-10 05:53:55 +00:00
{ /* Slot label */ }
< div className = "text-center mb-4" >
< span className = "text-lg" > { getSlotEmoji ( bandConditions . slot_label ) } < / span >
< span className = "text-sm text-slate-300 ml-2" > { bandConditions . slot_label } < / span >
2026-05-13 19:05:50 -06:00
< / div >
2026-06-10 05:53:55 +00:00
{ /* Band conditions header */ }
< div className = "text-xs text-slate-500 mb-3 flex items-center gap-1" >
< span > \ ud83d \ udce1 < / span > Band Conditions :
2026-05-13 19:05:50 -06:00
< / div >
2026-06-10 05:53:55 +00:00
{ /* Band rows */ }
< div className = "space-y-2" >
{ bands . map ( band = > {
const rating = bandConditions . ratings ? . [ band ]
return (
< div key = { band } className = "flex items-center justify-between px-2 py-1.5 rounded bg-bg-hover" >
< span className = "text-sm font-mono text-slate-300" > { band } < / span >
< span className = "text-sm" >
{ getRatingEmoji ( rating ) } < span className = "text-slate-300 ml-1" > { rating || '\u2014' } < / span >
< / span >
< / div >
)
} ) }
2026-05-13 19:05:50 -06:00
< / div >
2026-06-10 05:53:55 +00:00
{ /* Footer: source and time */ }
< div className = "mt-auto pt-3 border-t border-border text-xs text-slate-500" >
{ bandConditions . source && (
< span > { bandConditions . source === 'swpc_local' ? 'SWPC' : 'HamQSL' } < / span >
) }
{ bandConditions . sent_at && (
< span className = "ml-2" >
{ new Date ( bandConditions . sent_at * 1000 ) . toLocaleTimeString ( [ ] , { hour : '2-digit' , minute : '2-digit' } ) }
< / span >
) }
< / div >
2026-05-13 19:05:50 -06:00
< / div >
)
}
// Source icon mapping
const SOURCE_ICONS : Record < string , { icon : typeof Cloud ; color : string ; label : string } > = {
nws : { icon : Cloud , color : 'text-blue-400' , label : 'NWS' } ,
swpc : { icon : Sun , color : 'text-yellow-400' , label : 'SWPC' } ,
ducting : { icon : Radio , color : 'text-cyan-400' , label : 'Tropo' } ,
nifc : { icon : Flame , color : 'text-orange-400' , label : 'NIFC' } ,
firms : { icon : Satellite , color : 'text-red-400' , label : 'FIRMS' } ,
avalanche : { icon : Mountain , color : 'text-slate-300' , label : 'Avy' } ,
usgs : { icon : Droplets , color : 'text-blue-300' , label : 'USGS' } ,
traffic : { icon : Car , color : 'text-purple-400' , label : 'Traffic' } ,
roads : { icon : Construction , color : 'text-amber-400' , label : '511' } ,
}
// Severity badge colors (3-level system + legacy support)
const SEVERITY_COLORS : Record < string , string > = {
// New 3-level system
routine : 'bg-blue-500/20 text-blue-400 border-blue-500/30' ,
priority : 'bg-amber-500/20 text-amber-400 border-amber-500/30' ,
immediate : 'bg-red-600/20 text-red-300 border-red-600/30' ,
// NWS native (for raw event display)
info : 'bg-blue-500/20 text-blue-400 border-blue-500/30' ,
advisory : 'bg-blue-500/20 text-blue-400 border-blue-500/30' ,
moderate : 'bg-amber-500/20 text-amber-400 border-amber-500/30' ,
watch : 'bg-amber-500/20 text-amber-400 border-amber-500/30' ,
warning : 'bg-amber-500/20 text-amber-400 border-amber-500/30' ,
severe : 'bg-red-500/20 text-red-400 border-red-500/30' ,
extreme : 'bg-red-600/20 text-red-300 border-red-600/30' ,
critical : 'bg-red-600/20 text-red-300 border-red-600/30' ,
emergency : 'bg-red-700/20 text-red-200 border-red-700/30' ,
}
2026-05-13 20:33:48 -06:00
function EventFeedItem ( { event , isLocal } : { event : EnvEvent ; isLocal? : boolean } ) {
2026-05-13 19:05:50 -06:00
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
// Format timestamp
const formatTime = ( ts : number ) = > {
const date = new Date ( ts * 1000 )
const now = new Date ( )
const diffMs = now . getTime ( ) - date . getTime ( )
const diffMins = Math . floor ( diffMs / 60000 )
if ( diffMins < 1 ) return 'just now'
if ( diffMins < 60 ) return ` ${ diffMins } m ago `
if ( diffMins < 1440 ) return ` ${ Math . floor ( diffMins / 60 ) } h ago `
return date . toLocaleDateString ( undefined , { month : 'short' , day : 'numeric' } )
}
2026-05-13 20:33:48 -06:00
// 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
2026-05-13 19:05:50 -06:00
return (
2026-05-13 20:33:48 -06:00
< 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' : '' } ` } >
2026-05-13 19:05:50 -06:00
< Icon size = { 14 } className = { ` mt-0.5 flex-shrink-0 ${ sourceConfig . color } ` } / >
< div className = "flex-1 min-w-0" >
< div className = "flex items-center gap-2 mb-0.5" >
< span className = { ` px-1.5 py-0.5 rounded text-xs border ${ severityStyle } ` } >
{ event . severity || 'info' }
< / span >
2026-05-13 20:33:48 -06:00
{ isLocal && (
docs(v0.7): comprehensive dashboard docs rewrite -- Reference +8 sections, per-page tooltips, component polish
All three approved tiers in one commit. Reference.tsx is the deep docs
hub (8 new sections); the 10 other pages get short helper text +
tooltips that cross-reference back into Reference; 3 components get
operational-context tooltips. No new features land here -- this is the
copy that catches the GUI up to v0.6 + v0.7 system behavior.
Decisions applied per Matt's call:
- Keep both bang commands AND the LLM DM path (bangs are short on a
mesh-constrained interface; LLM is the anything-else path). Cross-
references between the two land in Reference -> Commands and
Reference -> LLM DM Queries.
- Rename "wire-string rendering" to "broadcast text" in user-facing
copy on TownAnchors.tsx, GaugeSites.tsx, and the Curation section of
Reference.tsx.
- Keep the "AND-model anti-pattern" tooltip as-is on Environment.tsx +
GaugeSites.tsx (specificity is the value for advanced users); the
OR-not-AND Reference section is its home definition that other
tooltips can link to.
Ham terminology preserved:
- Reference.tsx solar/Kp section retains "Quiet sun" / "Quiet HF
conditions" language (SFI/Kp vocabulary, not the deleted Quiet Hours
feature -- confirmed via direct grep before writing).
Tier 1: Reference.tsx (the depth doc) -- 8 new sections, ordered for
readability:
- "Fire Tracker (Fusion)": Phases 1-4 unified. Six fire-family alert
categories with example wire strings (wildfire_declared,
wildfire_growth, wildfire_halted, wildfire_spotting,
unattributed_hotspot_cluster, wildfire_incident). Attribution
mechanics (spread_radius_mi default, centroid as 24h median).
Movement mechanics (pass_id bucketing, per-pass centroid, 8-way
bearing, mi/h drift). Spotting mechanics (convex-hull perimeter +
vertex-distance approximation + per-fire cooldown). Daily LLM digest
(twice-daily summary broadcaster). The 10 fires.* adapter_config
knobs with defaults.
- "Broadcast Types": the three prefix categories -- New: (first sight),
Update: (material change), Active: (clock-driven reminder).
- "Reminder System": cadences per adapter (WFIGS 8h, SWPC 8h, ITD 511
per-zone). The tombstone (fires.tombstoned_at) termination. The
per-adapter reminder_enabled flag.
- "LLM DM (Natural-Language Queries)": all 7 env_reporter adapter
blocks (build_fires_detail / build_alerts_detail / build_quakes_detail
/ build_traffic_detail / build_gauges_detail / build_swpc_detail /
build_drop_audit) with example questions that hit each one. The
grounding clause behavior ("No active X right now" when an adapter
block is empty -- the v0.7-fire-tracker-4-final clamp). The
include_in_llm_context per-adapter toggle.
- "OR-not-AND Architecture": the per-adapter Central vs native
contract. Mutually exclusive. The AND-mode anti-pattern definition
(referenced by the Environment + GaugeSites tooltips). The Spokane
fix context.
- "Adapter Config & the CODE Rule": the GUI knob hub. The CONFIG-vs-
CODE split (thresholds in CONFIG, sentence templates / emoji /
translation maps in CODE). Restart-required vs live keys. The
include_in_llm_context toggle.
- "Curation: Gauges & Towns": Gauge Sites (NWS-AHPS thresholds, USGS
lookup, Action/Minor/Moderate/Major). Town Anchors (broadcast text
suffix lookup chain: Photon -> this table -> landclass -> county
-> coords). Example output "3 mi N of Almo".
- "Schema Migrations": light touch. v11-v16 schema additions tagged
with the phase they shipped under.
Tier 2: per-page tooltips and cross-references (10 pages):
- AdapterConfig.tsx: header paragraph extended with the CODE rule
pointer + LLM context toggle explanation.
- Alerts.tsx: !subscribe blurb extended with the three broadcast types
and links to Reference -> Broadcast Types + Reminder System.
- Config.tsx: environmental section description updated to point at
Environment.tsx for adapter knobs + Reference -> OR-not-AND for the
architecture.
- Dashboard.tsx: RF Propagation title carries SWPC R/S/G + Kp legend
tooltip; LOCAL badge defines what counts as local.
- Environment.tsx: Central region-token helper now references the
OR-not-AND section; tick_seconds defined inline as the native-mode
poll interval.
- GaugeSites.tsx: page description rewritten -- replaces "envelope
time" jargon with operational language, explains USGS lookup
mechanics, points at Reference -> OR-not-AND for the central-feed
disable.
- Mesh.tsx: Topology + Geographic buttons get tooltips defining the
rendering model.
- Notifications.tsx: band-conditions block extended with the daily
fire digest pointer + Reference -> Fire Tracker + Broadcast Types
cross-refs.
- TownAnchors.tsx: page description rewritten -- "wire-string
rendering" -> "broadcast text", chain fallback explained ("Photon
-> this table -> landclass -> county/state -> coords"), example
output included.
Tier 3: component tooltip polish (3 components):
- NodeTable.tsx: Battery + Last Heard column headers get title-bearing
spans with the voltage chart + offline-threshold legend.
- NodeDetail.tsx: SNR quality bands documented as a comment in the
neighbor render block (the legend lives next to where the colored
quality dots are computed).
- RestartBanner.tsx: banner copy extended with the restart-required
catalog (Config -> environmental, LLM backend swap, dispatcher
cold-start grace) so operators know what touched it.
Build verification:
- tsc + vite build green (one warning about chunk size > 500kB --
pre-existing).
- All 8 new TOPICS ids resolve in the served bundle:
adapter-config, broadcast-types, curation, fire-tracker,
llm-dm, or-not-and, reminders, schema.
- Distinctive new strings present in the bundle ("3 mi N of Almo",
"Photon nearest-town", "AND-mode anti-pattern", "R (Radio Blackouts").
- "Quiet sun" preserved (the ham SFI/Kp vocabulary in the Solar
section, not the deleted Quiet Hours feature).
- Container Up healthy, 0 tracebacks in 2 min post-rebuild.
Changelog: v0.7-docs-rewrite.md (per-page strip / rewrite / add table).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 15:24:34 +00:00
< span className = "px-1.5 py-0.5 rounded text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30" title = "LOCAL: event coordinates fall inside the mesh's monitoring area (per the adapter's bbox config on Environment) — operators in this region are directly affected." >
2026-05-13 20:33:48 -06:00
LOCAL
< / span >
) }
2026-05-13 19:05:50 -06:00
< span className = "text-xs text-slate-500" > { sourceConfig . label } < / span >
< span className = "text-xs text-slate-600 ml-auto" > { formatTime ( event . fetched_at ) } < / span >
< / div >
2026-05-13 20:33:48 -06:00
< 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 >
) }
2026-05-13 19:05:50 -06:00
< / div >
< / div >
)
}
// Live Event Feed Card
function LiveEventFeed ( { events , envStatus } : { events : EnvEvent [ ] ; envStatus : EnvStatus | null } ) {
2026-05-13 20:33:48 -06:00
// Severity order for sorting
const severityOrder : Record < string , number > = { immediate : 0 , priority : 1 , routine : 2 }
2026-05-13 19:05:50 -06:00
const sortedEvents = useMemo ( ( ) = > {
2026-05-13 20:33:48 -06:00
// 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
} )
2026-05-13 19:05:50 -06:00
} , [ events ] )
// Calculate feed health summary
const feedSummary = useMemo ( ( ) = > {
if ( ! envStatus ? . feeds ) return null
const total = envStatus . feeds . length
const active = envStatus . feeds . filter ( f = > f . is_loaded && ! f . last_error ) . length
const errors = envStatus . feeds . filter ( f = > f . last_error ) . map ( f = > f . source )
const lastFetch = Math . max ( . . . envStatus . feeds . map ( f = > f . last_fetch || 0 ) )
const secAgo = lastFetch ? Math . floor ( ( Date . now ( ) / 1000 ) - lastFetch ) : null
return { total , active , errors , secAgo }
} , [ envStatus ] )
return (
< div className = "bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full" >
< h2 className = "text-sm font-medium text-slate-400 mb-3 flex items-center gap-2" >
< Activity size = { 14 } / >
Live Event Feed
< / h2 >
{ sortedEvents . length > 0 ? (
< div className = "flex-1 overflow-y-auto max-h-80 pr-1 -mr-1" >
{ sortedEvents . map ( ( event , i ) = > (
2026-05-13 20:33:48 -06:00
< EventFeedItem
key = { event . event_id || i }
event = { event }
isLocal = { ( event as Record < string , unknown > ) . is_local as boolean | undefined }
/ >
2026-05-13 19:05:50 -06:00
) ) }
< / div >
) : (
< div className = "flex-1 flex items-center justify-center" >
< div className = "text-center py-8" >
< CheckCircle size = { 24 } className = "text-green-500 mx-auto mb-2" / >
< div className = "text-slate-400" > No active events < / div >
< div className = "text-xs text-slate-500" > All clear < / div >
< / div >
< / div >
) }
{ /* Feed health summary */ }
{ feedSummary && (
< div className = { ` text-xs mt-3 pt-3 border-t border-border ${ feedSummary . errors . length > 0 ? 'text-amber-400' : 'text-slate-500' } ` } >
{ feedSummary . active } of { feedSummary . total } feeds active
{ feedSummary . secAgo !== null && ` · Last update ${ feedSummary . secAgo } s ago ` }
{ feedSummary . errors . length > 0 && (
< span className = "text-amber-400" > · { feedSummary . errors . join ( ', ' ) } : error < / span >
) }
< / div >
) }
< / div >
)
}
export default function Dashboard() {
const [ health , setHealth ] = useState < MeshHealth | null > ( null )
const [ sources , setSources ] = useState < SourceHealth [ ] > ( [ ] )
const [ alerts , setAlerts ] = useState < Alert [ ] > ( [ ] )
const [ envStatus , setEnvStatus ] = useState < EnvStatus | null > ( null )
const [ envEvents , setEnvEvents ] = useState < EnvEvent [ ] > ( [ ] )
2026-06-10 05:53:55 +00:00
const [ bandConditions , setBandConditions ] = useState < BandConditionsStatus | null > ( null )
2026-05-13 19:05:50 -06:00
const [ loading , setLoading ] = useState ( true )
const [ error , setError ] = useState < string | null > ( null )
const { lastHealth , lastMessage } = useWebSocket ( )
useEffect ( ( ) = > {
Promise . all ( [
fetchHealth ( ) ,
fetchSources ( ) ,
fetchAlerts ( ) ,
fetchEnvStatus ( ) ,
fetchEnvActive ( ) . catch ( ( ) = > [ ] ) ,
fetchSWPC ( ) . catch ( ( ) = > null ) ,
] )
2026-06-10 05:53:55 +00:00
. then ( ( [ h , src , a , e , events , bc ] ) = > {
2026-05-13 19:05:50 -06:00
setHealth ( h )
setSources ( src )
setAlerts ( a )
setEnvStatus ( e )
setEnvEvents ( events )
2026-06-10 05:53:55 +00:00
setBandConditions ( bc as BandConditionsStatus )
2026-05-13 19:05:50 -06:00
setLoading ( false )
document . title = 'Dashboard — MeshAI'
} )
. catch ( ( err ) = > {
setError ( err . message )
setLoading ( false )
document . title = 'Dashboard — MeshAI'
} )
} , [ ] )
// Update health from WebSocket
useEffect ( ( ) = > {
if ( lastHealth ) {
setHealth ( lastHealth )
}
} , [ lastHealth ] )
// Handle WebSocket env_update messages
useEffect ( ( ) = > {
if ( lastMessage ? . type === 'env_update' && lastMessage . event ) {
setEnvEvents ( prev = > {
// Add new event, dedupe by event_id
const newEvent = lastMessage . event as EnvEvent
const filtered = prev . filter ( e = > e . event_id !== newEvent . event_id )
return [ newEvent , . . . filtered ] . slice ( 0 , 100 ) // Keep last 100
} )
}
} , [ lastMessage ] )
if ( loading ) {
return (
< div className = "flex items-center justify-center h-64" >
< div className = "text-slate-400" > Loading . . . < / div >
< / div >
)
}
if ( error ) {
return (
< div className = "flex items-center justify-center h-64" >
< div className = "text-red-400" > Error : { error } < / div >
< / div >
)
}
return (
< div className = "space-y-6" >
{ /* Top row: Health + Alerts + Stats */ }
< div className = "grid grid-cols-1 lg:grid-cols-3 gap-6" >
{ /* Mesh Health */ }
< div className = "bg-bg-card border border-border rounded-lg p-6" >
< h2 className = "text-sm font-medium text-slate-400 mb-4" > Mesh Health < / h2 >
{ health && (
< >
< HealthGauge health = { health } / >
< div className = "mt-6 space-y-3" >
< PillarBar label = "Infrastructure" value = { health . pillars ? . infrastructure ? ? 0 } / >
< PillarBar label = "Utilization" value = { health . pillars ? . utilization ? ? 0 } / >
2026-06-10 03:56:12 +00:00
< PillarBar label = "Coverage" value = { health . pillars ? . coverage ? ? 0 } / >
2026-05-13 19:05:50 -06:00
< PillarBar label = "Behavior" value = { health . pillars ? . behavior ? ? 0 } / >
< PillarBar label = "Power" value = { health . pillars ? . power ? ? 0 } / >
< / div >
< / >
) }
< / div >
{ /* Alerts + Stats */ }
< div className = "lg:col-span-2 space-y-6" >
{ /* Active Alerts */ }
< div className = "bg-bg-card border border-border rounded-lg p-6" >
< h2 className = "text-sm font-medium text-slate-400 mb-4" > Active Alerts < / h2 >
{ alerts . length > 0 ? (
< div className = "space-y-3 max-h-48 overflow-y-auto" >
{ alerts . map ( ( alert , i ) = > (
< AlertItem key = { i } alert = { alert } / >
) ) }
< / div >
2026-06-10 03:56:12 +00:00
) : ( ( ) = > {
const highSeverityEnv = envEvents
. filter ( e = > e . severity === 'immediate' || e . severity === 'priority' )
. sort ( ( a , b ) = > {
const ord : Record < string , number > = { immediate : 0 , priority : 1 }
const diff = ( ord [ a . severity ] ? ? 2 ) - ( ord [ b . severity ] ? ? 2 )
if ( diff !== 0 ) return diff
return ( b . fetched_at || 0 ) - ( a . fetched_at || 0 )
} )
. slice ( 0 , 5 )
if ( highSeverityEnv . length > 0 ) {
return (
< div className = "space-y-3 max-h-48 overflow-y-auto" >
{ highSeverityEnv . map ( ( ev , i ) = > {
const sevStyle = ev . severity === 'immediate'
? { bg : 'bg-red-500/10' , border : 'border-red-500' , icon : AlertCircle , iconColor : 'text-red-500' }
: { bg : 'bg-amber-500/10' , border : 'border-amber-500' , icon : AlertTriangle , iconColor : 'text-amber-500' }
const Icon = sevStyle . icon
return (
< div key = { ev . event_id || i } className = { ` p-3 rounded-lg ${ sevStyle . bg } border-l-2 ${ sevStyle . border } flex items-start gap-3 ` } >
< Icon size = { 16 } className = { sevStyle . iconColor } / >
< div className = "flex-1 min-w-0" >
< div className = "flex items-center gap-2" >
< span className = "px-1.5 py-0.5 rounded text-xs bg-slate-500/20 text-slate-400 border border-slate-500/30 font-mono" > ENV < / span >
< span className = "text-xs text-slate-500" > { ev . severity } < / span >
< / div >
< div className = "text-sm text-slate-200 mt-1" > { ev . headline } < / div >
< div className = "text-xs text-slate-500 mt-1" > { ev . source } · { new Date ( ev . fetched_at * 1000 ) . toLocaleTimeString ( ) } < / div >
< / div >
< / div >
)
} ) }
< / div >
)
}
return (
< div className = "flex items-center gap-2 text-slate-500 py-4" >
< CheckCircle size = { 16 } className = "text-green-500" / >
< span > No active alerts < / span >
< / div >
)
} ) ( ) }
2026-05-13 19:05:50 -06:00
< / div >
{ /* Quick Stats */ }
< div className = "grid grid-cols-2 lg:grid-cols-4 gap-4" >
< StatCard icon = { Radio } label = "Nodes Online" value = { health ? . total_nodes || 0 } subvalue = { ` ${ health ? . unlocated_count || 0 } unlocated ` } / >
< StatCard icon = { Cpu } label = "Infrastructure" value = { ` ${ health ? . infra_online || 0 } / ${ health ? . infra_total || 0 } ` } subvalue = { health ? . infra_online === health ? . infra_total ? 'All online' : 'Some offline' } / >
< StatCard icon = { Activity } label = "Utilization" value = { ` ${ health ? . util_percent ? . toFixed ( 1 ) || 0 } % ` } subvalue = { ` ${ health ? . flagged_nodes || 0 } flagged ` } / >
< StatCard icon = { MapPin } label = "Regions" value = { health ? . total_regions || 0 } subvalue = { ` ${ health ? . battery_warnings || 0 } battery warnings ` } / >
< / div >
< / div >
< / div >
{ /* Middle row: Sources + RF Propagation + Live Feed */ }
< div className = "grid grid-cols-1 lg:grid-cols-3 gap-6" >
{ /* Mesh Sources */ }
< div className = "bg-bg-card border border-border rounded-lg p-6" >
< h2 className = "text-sm font-medium text-slate-400 mb-4" > Mesh Sources ( { sources . length } ) < / h2 >
{ sources . length > 0 ? (
< div className = "space-y-2" >
{ sources . map ( ( source , i ) = > (
< SourceCard key = { i } source = { source } / >
) ) }
< / div >
) : (
< div className = "text-slate-500 py-4" > No sources configured < / div >
) }
< / div >
{ /* RF Propagation */ }
2026-06-10 05:53:55 +00:00
< BandConditionsCard bandConditions = { bandConditions } / >
2026-05-13 19:05:50 -06:00
{ /* Live Event Feed */ }
< LiveEventFeed events = { envEvents } envStatus = { envStatus } / >
< / div >
< / div >
)
}