feat(dashboard): RF propagation visualizations + live event feed

- SFI/Kp as prominent color-coded values with trend chart
- R/S/G scales as colored severity badges
- Tropospheric ducting condition with refractivity profile
- Environmental feeds replaced with scrolling live event timeline
- Unified activity log across all 9 feed adapters
- Source icons, severity badges, chronological order
- Real-time updates via WebSocket
- SWPC adapter stores Kp/SFI history for charting
- No wasted card space

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-13 14:47:15 +00:00
commit 7286c9ab44
9 changed files with 1631 additions and 1250 deletions

View file

@ -1,102 +1,109 @@
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
import type { MeshHealth, Alert } from '@/lib/api' import type { MeshHealth, Alert, EnvEvent } from '@/lib/api'
interface WebSocketMessage { interface WebSocketMessage {
type: string type: string
data: unknown data?: unknown
} event?: EnvEvent
}
interface UseWebSocketReturn {
connected: boolean interface UseWebSocketReturn {
lastHealth: MeshHealth | null connected: boolean
lastAlert: Alert | null lastHealth: MeshHealth | null
} lastAlert: Alert | null
lastMessage: WebSocketMessage | null
export function useWebSocket(): UseWebSocketReturn { }
const [connected, setConnected] = useState(false)
const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null) export function useWebSocket(): UseWebSocketReturn {
const [lastAlert, setLastAlert] = useState<Alert | null>(null) const [connected, setConnected] = useState(false)
const wsRef = useRef<WebSocket | null>(null) const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null)
const reconnectTimeoutRef = useRef<number | null>(null) const [lastAlert, setLastAlert] = useState<Alert | null>(null)
const reconnectDelayRef = useRef(1000) const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const connect = useCallback(() => { const reconnectTimeoutRef = useRef<number | null>(null)
if (wsRef.current?.readyState === WebSocket.OPEN) { const reconnectDelayRef = useRef(1000)
return
} const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' return
const wsUrl = `${protocol}//${window.location.host}/ws/live` }
try { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(wsUrl) const wsUrl = `${protocol}//${window.location.host}/ws/live`
wsRef.current = ws
try {
ws.onopen = () => { const ws = new WebSocket(wsUrl)
setConnected(true) wsRef.current = ws
reconnectDelayRef.current = 1000 // Reset backoff on successful connection
} ws.onopen = () => {
setConnected(true)
ws.onmessage = (event) => { reconnectDelayRef.current = 1000 // Reset backoff on successful connection
try { }
const message: WebSocketMessage = JSON.parse(event.data)
ws.onmessage = (event) => {
switch (message.type) { try {
case 'health_update': const message: WebSocketMessage = JSON.parse(event.data)
setLastHealth(message.data as MeshHealth)
break // Store all messages for generic handling
case 'alert_fired': setLastMessage(message)
setLastAlert(message.data as Alert)
break switch (message.type) {
} case 'health_update':
} catch (e) { setLastHealth(message.data as MeshHealth)
console.error('Failed to parse WebSocket message:', e) break
} case 'alert_fired':
} setLastAlert(message.data as Alert)
break
ws.onclose = () => { // env_update messages are handled via lastMessage
setConnected(false) }
wsRef.current = null } catch (e) {
console.error('Failed to parse WebSocket message:', e)
// Schedule reconnect with exponential backoff }
const delay = Math.min(reconnectDelayRef.current, 30000) }
reconnectTimeoutRef.current = window.setTimeout(() => {
reconnectDelayRef.current = Math.min(delay * 2, 30000) ws.onclose = () => {
connect() setConnected(false)
}, delay) wsRef.current = null
}
// Schedule reconnect with exponential backoff
ws.onerror = () => { const delay = Math.min(reconnectDelayRef.current, 30000)
ws.close() reconnectTimeoutRef.current = window.setTimeout(() => {
} reconnectDelayRef.current = Math.min(delay * 2, 30000)
connect()
// Keepalive ping every 30 seconds }, delay)
const pingInterval = setInterval(() => { }
if (ws.readyState === WebSocket.OPEN) {
ws.send('ping') ws.onerror = () => {
} ws.close()
}, 30000) }
ws.addEventListener('close', () => { // Keepalive ping every 30 seconds
clearInterval(pingInterval) const pingInterval = setInterval(() => {
}) if (ws.readyState === WebSocket.OPEN) {
} catch (e) { ws.send('ping')
console.error('Failed to create WebSocket:', e) }
} }, 30000)
}, [])
ws.addEventListener('close', () => {
useEffect(() => { clearInterval(pingInterval)
connect() })
} catch (e) {
return () => { console.error('Failed to create WebSocket:', e)
if (reconnectTimeoutRef.current) { }
clearTimeout(reconnectTimeoutRef.current) }, [])
}
if (wsRef.current) { useEffect(() => {
wsRef.current.close() connect()
}
} return () => {
}, [connect]) if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
return { connected, lastHealth, lastAlert } }
} if (wsRef.current) {
wsRef.current.close()
}
}
}, [connect])
return { connected, lastHealth, lastAlert, lastMessage }
}

View file

@ -1,440 +1,479 @@
// API types matching actual backend responses // API types matching actual backend responses
export interface SystemStatus { export interface SystemStatus {
version: string version: string
uptime_seconds: number uptime_seconds: number
bot_name: string bot_name: string
connection_type: string connection_type: string
connection_target: string connection_target: string
connected: boolean connected: boolean
node_count: number node_count: number
source_count: number source_count: number
env_feeds_enabled: boolean env_feeds_enabled: boolean
dashboard_port: number dashboard_port: number
} }
export interface MeshHealth { export interface MeshHealth {
score: number score: number
tier: string tier: string
pillars: { pillars: {
infrastructure: number infrastructure: number
utilization: number utilization: number
behavior: number behavior: number
power: number power: number
} }
infra_online: number infra_online: number
infra_total: number infra_total: number
util_percent: number util_percent: number
flagged_nodes: number flagged_nodes: number
battery_warnings: number battery_warnings: number
total_nodes: number total_nodes: number
total_regions: number total_regions: number
unlocated_count: number unlocated_count: number
last_computed: string last_computed: string
recommendations: string[] recommendations: string[]
} }
export interface NodeInfo { export interface NodeInfo {
node_num: number node_num: number
node_id_hex: string node_id_hex: string
short_name: string short_name: string
long_name: string long_name: string
role: string role: string
latitude: number | null latitude: number | null
longitude: number | null longitude: number | null
last_heard: string | null last_heard: string | null
battery_level: number | null battery_level: number | null
voltage: number | null voltage: number | null
snr: number | null snr: number | null
firmware: string firmware: string
hardware: string hardware: string
uptime: number | null uptime: number | null
sources: string[] sources: string[]
} }
export interface EdgeInfo { export interface EdgeInfo {
from_node: number from_node: number
to_node: number to_node: number
snr: number snr: number
quality: string quality: string
} }
export interface RegionInfo { export interface RegionInfo {
name: string name: string
local_name: string local_name: string
node_count: number node_count: number
infra_count: number infra_count: number
infra_online: number infra_online: number
online_count: number online_count: number
score: number score: number
tier: string tier: string
center_lat: number center_lat: number
center_lon: number center_lon: number
} }
export interface SourceHealth { export interface SourceHealth {
name: string name: string
type: string type: string
url: string url: string
is_loaded: boolean is_loaded: boolean
last_error: string | null last_error: string | null
consecutive_errors: number consecutive_errors: number
response_time_ms: number | null response_time_ms: number | null
tick_count: number tick_count: number
node_count: number node_count: number
} }
export interface Alert { export interface Alert {
type: string type: string
severity: string severity: string
message: string message: string
timestamp: string timestamp: string
scope_type?: string scope_type?: string
scope_value?: string scope_value?: string
} }
export interface AlertHistoryItem { export interface AlertHistoryItem {
id?: number id?: number
type: string type: string
severity: string severity: string
message: string message: string
timestamp: string timestamp: string
duration?: number duration?: number
scope_type?: string scope_type?: string
scope_value?: string scope_value?: string
resolved_at?: string resolved_at?: string
} }
export interface AlertHistoryResponse { export interface AlertHistoryResponse {
items: AlertHistoryItem[] items: AlertHistoryItem[]
total: number total: number
} }
export interface Subscription { export interface Subscription {
id: number id: number
user_id: string user_id: string
sub_type: string sub_type: string
schedule_time?: string schedule_time?: string
schedule_day?: string schedule_day?: string
scope_type: string scope_type: string
scope_value?: string scope_value?: string
enabled: boolean enabled: boolean
} }
export interface EnvStatus { export interface EnvStatus {
enabled: boolean enabled: boolean
feeds: EnvFeedHealth[] feeds: EnvFeedHealth[]
} }
export interface EnvFeedHealth { export interface EnvFeedHealth {
source: string source: string
is_loaded: boolean is_loaded: boolean
last_error: string | null last_error: string | null
consecutive_errors: number consecutive_errors: number
event_count: number event_count: number
last_fetch: number last_fetch: number
} }
export interface EnvEvent { export interface EnvEvent {
source: string source: string
event_id: string event_id: string
event_type: string event_type: string
severity: string severity: string
headline: string headline: string
description?: string description?: string
expires?: number expires?: number
fetched_at: number fetched_at: number
[key: string]: unknown [key: string]: unknown
} }
export interface SWPCStatus { // Kp history entry for charting
enabled: boolean export interface KpHistoryEntry {
kp_current?: number time: string
kp_timestamp?: string value: number
sfi?: number }
r_scale?: number
s_scale?: number // SFI history entry for charting
g_scale?: number export interface SfiHistoryEntry {
active_warnings?: string[] time: string
} value: number
}
export interface DuctingStatus {
enabled: boolean // Refractivity profile entry
condition?: string export interface ProfileEntry {
min_gradient?: number level_hPa: number
duct_thickness_m?: number | null height_m: number
duct_base_m?: number | null N: number
last_update?: string M: number
} T_C: number
RH: number
export interface RFPropagation { }
hf: {
kp_current?: number // Gradient entry
sfi?: number export interface GradientEntry {
r_scale?: number from_level: number
s_scale?: number to_level: number
g_scale?: number from_height_m: number
active_warnings?: string[] to_height_m: number
} gradient: number
uhf_ducting: { }
condition?: string
min_gradient?: number export interface SWPCStatus {
duct_thickness_m?: number | null enabled: boolean
} kp_current?: number
} kp_timestamp?: string
sfi?: number
// API fetch helpers r_scale?: number
s_scale?: number
async function fetchJson<T>(url: string): Promise<T> { g_scale?: number
const response = await fetch(url) active_warnings?: string[]
if (!response.ok) { kp_history?: KpHistoryEntry[]
throw new Error(`API error: ${response.status} ${response.statusText}`) sfi_history?: SfiHistoryEntry[]
} }
return response.json()
} export interface DuctingStatus {
enabled: boolean
export async function fetchStatus(): Promise<SystemStatus> { condition?: string
return fetchJson<SystemStatus>('/api/status') min_gradient?: number
} duct_thickness_m?: number | null
duct_base_m?: number | null
export async function fetchHealth(): Promise<MeshHealth> { last_update?: string
return fetchJson<MeshHealth>('/api/health') profile?: ProfileEntry[]
} gradients?: GradientEntry[]
assessment?: string
export async function fetchNodes(): Promise<NodeInfo[]> { location?: { lat: number; lon: number }
return fetchJson<NodeInfo[]>('/api/nodes') }
}
export interface RFPropagation {
export async function fetchEdges(): Promise<EdgeInfo[]> { hf: {
return fetchJson<EdgeInfo[]>('/api/edges') kp_current?: number
} sfi?: number
r_scale?: number
export async function fetchSources(): Promise<SourceHealth[]> { s_scale?: number
return fetchJson<SourceHealth[]>('/api/sources') g_scale?: number
} active_warnings?: string[]
kp_history?: KpHistoryEntry[]
export async function fetchConfig(section?: string): Promise<unknown> { }
const url = section ? `/api/config/${section}` : '/api/config' uhf_ducting: {
return fetchJson(url) condition?: string
} min_gradient?: number
duct_thickness_m?: number | null
export async function updateConfig( profile?: ProfileEntry[]
section: string, }
data: unknown }
): Promise<{ saved: boolean; restart_required: boolean }> {
const response = await fetch(`/api/config/${section}`, { // API fetch helpers
method: 'PUT',
headers: { 'Content-Type': 'application/json' }, async function fetchJson<T>(url: string): Promise<T> {
body: JSON.stringify(data), const response = await fetch(url)
}) if (!response.ok) {
if (!response.ok) { throw new Error(`API error: ${response.status} ${response.statusText}`)
throw new Error(`API error: ${response.status} ${response.statusText}`) }
} return response.json()
return response.json() }
}
export async function fetchStatus(): Promise<SystemStatus> {
export async function fetchAlerts(): Promise<Alert[]> { return fetchJson<SystemStatus>('/api/status')
return fetchJson<Alert[]>('/api/alerts/active') }
}
export async function fetchHealth(): Promise<MeshHealth> {
export async function fetchAlertHistory( return fetchJson<MeshHealth>('/api/health')
limit: number = 50, }
offset: number = 0,
type?: string, export async function fetchNodes(): Promise<NodeInfo[]> {
severity?: string return fetchJson<NodeInfo[]>('/api/nodes')
): Promise<AlertHistoryResponse | AlertHistoryItem[]> { }
const params = new URLSearchParams()
params.set('limit', limit.toString()) export async function fetchEdges(): Promise<EdgeInfo[]> {
params.set('offset', offset.toString()) return fetchJson<EdgeInfo[]>('/api/edges')
if (type && type !== 'all') params.set('type', type) }
if (severity && severity !== 'all') params.set('severity', severity)
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`) export async function fetchSources(): Promise<SourceHealth[]> {
} return fetchJson<SourceHealth[]>('/api/sources')
}
export async function fetchSubscriptions(): Promise<Subscription[]> {
return fetchJson<Subscription[]>('/api/subscriptions') export async function fetchConfig(section?: string): Promise<unknown> {
} const url = section ? `/api/config/${section}` : '/api/config'
return fetchJson(url)
export async function fetchEnvStatus(): Promise<EnvStatus> { }
return fetchJson<EnvStatus>('/api/env/status')
} export async function updateConfig(
section: string,
export async function fetchEnvActive(): Promise<EnvEvent[]> { data: unknown
return fetchJson<EnvEvent[]>('/api/env/active') ): Promise<{ saved: boolean; restart_required: boolean }> {
} const response = await fetch(`/api/config/${section}`, {
method: 'PUT',
export async function fetchRFPropagation(): Promise<RFPropagation> { headers: { 'Content-Type': 'application/json' },
return fetchJson<RFPropagation>('/api/env/propagation') body: JSON.stringify(data),
} })
if (!response.ok) {
export async function fetchSWPC(): Promise<SWPCStatus> { throw new Error(`API error: ${response.status} ${response.statusText}`)
return fetchJson<SWPCStatus>('/api/env/swpc') }
} return response.json()
}
export async function fetchDucting(): Promise<DuctingStatus> {
return fetchJson<DuctingStatus>('/api/env/ducting') export async function fetchAlerts(): Promise<Alert[]> {
} return fetchJson<Alert[]>('/api/alerts/active')
}
export interface FireEvent {
source: string export async function fetchAlertHistory(
event_id: string limit: number = 50,
event_type: string offset: number = 0,
severity: string type?: string,
headline: string severity?: string
name: string ): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
acres: number const params = new URLSearchParams()
pct_contained: number params.set('limit', limit.toString())
lat: number | null params.set('offset', offset.toString())
lon: number | null if (type && type !== 'all') params.set('type', type)
distance_km: number | null if (severity && severity !== 'all') params.set('severity', severity)
nearest_anchor: string | null return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
state: string }
expires: number
fetched_at: number export async function fetchSubscriptions(): Promise<Subscription[]> {
polygon?: number[][][] return fetchJson<Subscription[]>('/api/subscriptions')
} }
export interface AvalancheEvent { export async function fetchEnvStatus(): Promise<EnvStatus> {
source: string return fetchJson<EnvStatus>('/api/env/status')
event_id: string }
event_type: string
severity: string export async function fetchEnvActive(): Promise<EnvEvent[]> {
headline: string return fetchJson<EnvEvent[]>('/api/env/active')
zone_name: string }
center: string
center_id: string export async function fetchRFPropagation(): Promise<RFPropagation> {
center_link: string return fetchJson<RFPropagation>('/api/env/propagation')
forecast_link: string }
danger: string
danger_level: number export async function fetchSWPC(): Promise<SWPCStatus> {
danger_name: string return fetchJson<SWPCStatus>('/api/env/swpc')
travel_advice: string }
state: string
lat: number | null export async function fetchDucting(): Promise<DuctingStatus> {
lon: number | null return fetchJson<DuctingStatus>('/api/env/ducting')
expires: number }
fetched_at: number
} export interface FireEvent {
source: string
export interface StreamGaugeEvent { event_id: string
source: string event_type: string
event_id: string severity: string
event_type: string headline: string
headline: string name: string
severity: string acres: number
lat?: number pct_contained: number
lon?: number lat: number | null
expires: number lon: number | null
fetched_at: number distance_km: number | null
properties: { nearest_anchor: string | null
site_id: string state: string
site_name: string expires: number
parameter: string fetched_at: number
value: number polygon?: number[][][]
unit: string }
timestamp: string
} export interface AvalancheEvent {
} source: string
event_id: string
export interface TrafficEvent { event_type: string
source: string severity: string
event_id: string headline: string
event_type: string zone_name: string
headline: string center: string
severity: string center_id: string
lat?: number center_link: string
lon?: number forecast_link: string
expires: number danger: string
fetched_at: number danger_level: number
properties: { danger_name: string
corridor: string travel_advice: string
currentSpeed: number state: string
freeFlowSpeed: number lat: number | null
speedRatio: number lon: number | null
currentTravelTime: number expires: number
freeFlowTravelTime: number fetched_at: number
confidence: number }
roadClosure: boolean
} export interface StreamGaugeEvent {
} source: string
event_id: string
export interface RoadEvent { event_type: string
source: string headline: string
event_id: string severity: string
event_type: string lat?: number
headline: string lon?: number
description?: string expires: number
severity: string fetched_at: number
lat?: number properties: {
lon?: number site_id: string
expires: number site_name: string
fetched_at: number parameter: string
properties: { value: number
roadway: string unit: string
is_closure: boolean timestamp: string
last_updated?: string }
} }
}
export interface TrafficEvent {
export interface HotspotEvent { source: string
source: string event_id: string
event_id: string event_type: string
event_type: string headline: string
headline: string severity: string
severity: string lat?: number
lat?: number lon?: number
lon?: number expires: number
expires: number fetched_at: number
fetched_at: number properties: {
properties: { corridor: string
new_ignition: boolean currentSpeed: number
confidence: string freeFlowSpeed: number
frp?: number speedRatio: number
brightness?: number currentTravelTime: number
acq_date: string freeFlowTravelTime: number
acq_time: string confidence: number
near_fire?: string roadClosure: boolean
distance_to_fire_km?: number }
distance_km?: number }
nearest_anchor?: string
} export interface RoadEvent {
} source: string
event_id: string
export interface HotspotsResponse { event_type: string
enabled: boolean headline: string
hotspots: HotspotEvent[] description?: string
new_ignitions: number severity: string
} lat?: number
lon?: number
export interface AvalancheResponse { expires: number
off_season: boolean fetched_at: number
advisories: AvalancheEvent[] properties: {
} roadway: string
is_closure: boolean
export async function fetchFires(): Promise<FireEvent[]> { last_updated?: string
return fetchJson<FireEvent[]>('/api/env/fires') }
} }
export async function fetchAvalanche(): Promise<AvalancheResponse> { export interface HotspotEvent {
return fetchJson<AvalancheResponse>('/api/env/avalanche') source: string
} event_id: string
event_type: string
export async function fetchStreams(): Promise<StreamGaugeEvent[]> { headline: string
return fetchJson<StreamGaugeEvent[]>('/api/env/streams') severity: string
} lat?: number
lon?: number
export async function fetchTraffic(): Promise<TrafficEvent[]> { expires: number
return fetchJson<TrafficEvent[]>('/api/env/traffic') fetched_at: number
} properties: {
new_ignition: boolean
export async function fetchRoads(): Promise<RoadEvent[]> { confidence: string
return fetchJson<RoadEvent[]>('/api/env/roads') frp?: number
} brightness?: number
acq_date: string
export async function fetchHotspots(): Promise<HotspotsResponse> { acq_time: string
return fetchJson<HotspotsResponse>('/api/env/hotspots') near_fire?: string
} distance_to_fire_km?: number
distance_km?: number
export async function fetchRegions(): Promise<RegionInfo[]> { nearest_anchor?: string
return fetchJson<RegionInfo[]>('/api/regions') }
} }
export interface HotspotsResponse {
enabled: boolean
hotspots: HotspotEvent[]
new_ignitions: number
}
export interface AvalancheResponse {
off_season: boolean
advisories: AvalancheEvent[]
}
export async function fetchFires(): Promise<FireEvent[]> {
return fetchJson<FireEvent[]>('/api/env/fires')
}
export async function fetchAvalanche(): Promise<AvalancheResponse> {
return fetchJson<AvalancheResponse>('/api/env/avalanche')
}
export async function fetchStreams(): Promise<StreamGaugeEvent[]> {
return fetchJson<StreamGaugeEvent[]>('/api/env/streams')
}
export async function fetchTraffic(): Promise<TrafficEvent[]> {
return fetchJson<TrafficEvent[]>('/api/env/traffic')
}
export async function fetchRoads(): Promise<RoadEvent[]> {
return fetchJson<RoadEvent[]>('/api/env/roads')
}
export async function fetchHotspots(): Promise<HotspotsResponse> {
return fetchJson<HotspotsResponse>('/api/env/hotspots')
}
export async function fetchRegions(): Promise<RegionInfo[]> {
return fetchJson<RegionInfo[]>('/api/regions')
}

View file

@ -1,15 +1,19 @@
import { useEffect, useState } from 'react' import { useEffect, useState, useMemo } from 'react'
import { import {
fetchHealth, fetchHealth,
fetchSources, fetchSources,
fetchAlerts, fetchAlerts,
fetchEnvStatus, fetchEnvStatus,
fetchRFPropagation, fetchEnvActive,
fetchSWPC,
fetchDucting,
type MeshHealth, type MeshHealth,
type SourceHealth, type SourceHealth,
type Alert, type Alert,
type EnvStatus, type EnvStatus,
type RFPropagation, type EnvEvent,
type SWPCStatus,
type DuctingStatus,
} from '@/lib/api' } from '@/lib/api'
import { useWebSocket } from '@/hooks/useWebSocket' import { useWebSocket } from '@/hooks/useWebSocket'
import { import {
@ -22,13 +26,63 @@ import {
Activity, Activity,
MapPin, MapPin,
Zap, Zap,
Cloud,
Flame,
Mountain,
Droplets,
Car,
Construction,
Satellite,
Sun,
} from 'lucide-react' } from 'lucide-react'
import {
AreaChart,
Area,
XAxis,
YAxis,
ResponsiveContainer,
ReferenceLine,
LineChart,
Line,
} from 'recharts'
// Extended types for history data
interface KpHistoryEntry {
time: string
value: number
}
interface ProfileEntry {
level_hPa: number
height_m: number
N: number
M: number
T_C: number
RH: number
}
interface ExtendedSWPCStatus extends SWPCStatus {
kp_history?: KpHistoryEntry[]
sfi_history?: { time: string; value: number }[]
}
interface ExtendedDuctingStatus extends DuctingStatus {
profile?: ProfileEntry[]
gradients?: {
from_level: number
to_level: number
from_height_m: number
to_height_m: number
gradient: number
}[]
assessment?: string
location?: { lat: number; lon: number }
}
function HealthGauge({ health }: { health: MeshHealth }) { function HealthGauge({ health }: { health: MeshHealth }) {
const score = health.score const score = health.score
const tier = health.tier const tier = health.tier
// Color based on score
const getColor = (s: number) => { const getColor = (s: number) => {
if (s >= 80) return '#22c55e' if (s >= 80) return '#22c55e'
if (s >= 60) return '#f59e0b' if (s >= 60) return '#f59e0b'
@ -42,46 +96,17 @@ function HealthGauge({ health }: { health: MeshHealth }) {
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<svg width="140" height="140" viewBox="0 0 100 100"> <svg width="140" height="140" viewBox="0 0 100 100">
{/* Background circle */} <circle cx="50" cy="50" r="45" fill="none" stroke="#1e2a3a" strokeWidth="8" />
<circle <circle
cx="50" cx="50" cy="50" r="45" fill="none" stroke={color} strokeWidth="8"
cy="50" strokeLinecap="round" strokeDasharray={circumference}
r="45" strokeDashoffset={circumference - progress} transform="rotate(-90 50 50)"
fill="none"
stroke="#1e2a3a"
strokeWidth="8"
/>
{/* Progress arc */}
<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" className="transition-all duration-500"
/> />
{/* Score text */} <text x="50" y="46" textAnchor="middle" className="fill-slate-100 font-mono text-2xl font-bold" style={{ fontSize: '24px' }}>
<text
x="50"
y="46"
textAnchor="middle"
className="fill-slate-100 font-mono text-2xl font-bold"
style={{ fontSize: '24px' }}
>
{score.toFixed(1)} {score.toFixed(1)}
</text> </text>
<text <text x="50" y="62" textAnchor="middle" className="fill-slate-400 text-xs" style={{ fontSize: '10px' }}>
x="50"
y="62"
textAnchor="middle"
className="fill-slate-400 text-xs"
style={{ fontSize: '10px' }}
>
{tier} {tier}
</text> </text>
</svg> </svg>
@ -89,13 +114,7 @@ function HealthGauge({ health }: { health: MeshHealth }) {
) )
} }
function PillarBar({ function PillarBar({ label, value }: { label: string; value: number }) {
label,
value,
}: {
label: string
value: number
}) {
const getColor = (v: number) => { const getColor = (v: number) => {
if (v >= 80) return 'bg-green-500' if (v >= 80) return 'bg-green-500'
if (v >= 60) return 'bg-amber-500' if (v >= 60) return 'bg-amber-500'
@ -106,14 +125,9 @@ function PillarBar({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-24 text-xs text-slate-400 truncate">{label}</div> <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="flex-1 h-2 bg-border rounded-full overflow-hidden">
<div <div className={`h-full ${getColor(value)} transition-all duration-300`} style={{ width: `${value}%` }} />
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>
<div className="w-12 text-right text-xs font-mono text-slate-300">{value.toFixed(1)}</div>
</div> </div>
) )
} }
@ -123,26 +137,11 @@ function AlertItem({ alert }: { alert: Alert }) {
switch (severity.toLowerCase()) { switch (severity.toLowerCase()) {
case 'critical': case 'critical':
case 'emergency': case 'emergency':
return { return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' }
bg: 'bg-red-500/10',
border: 'border-red-500',
icon: AlertCircle,
iconColor: 'text-red-500',
}
case 'warning': case 'warning':
return { return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500' }
bg: 'bg-amber-500/10',
border: 'border-amber-500',
icon: AlertTriangle,
iconColor: 'text-amber-500',
}
default: default:
return { return { bg: 'bg-green-500/10', border: 'border-green-500', icon: Info, iconColor: 'text-green-500' }
bg: 'bg-green-500/10',
border: 'border-green-500',
icon: Info,
iconColor: 'text-green-500',
}
} }
} }
@ -150,15 +149,11 @@ function AlertItem({ alert }: { alert: Alert }) {
const Icon = styles.icon const Icon = styles.icon
return ( return (
<div <div className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}>
className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}
>
<Icon size={16} className={styles.iconColor} /> <Icon size={16} className={styles.iconColor} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-slate-200">{alert.message}</div> <div className="text-sm text-slate-200">{alert.message}</div>
<div className="text-xs text-slate-500 mt-1"> <div className="text-xs text-slate-500 mt-1">{alert.timestamp || 'Just now'}</div>
{alert.timestamp || 'Just now'}
</div>
</div> </div>
</div> </div>
) )
@ -176,25 +171,13 @@ function SourceCard({ source }: { source: SourceHealth }) {
<div className={`w-2 h-2 rounded-full ${getStatusColor()}`} /> <div className={`w-2 h-2 rounded-full ${getStatusColor()}`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-slate-200 truncate">{source.name}</div> <div className="text-sm text-slate-200 truncate">{source.name}</div>
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500">{source.node_count} nodes · {source.type}</div>
{source.node_count} nodes * {source.type}
</div>
</div> </div>
</div> </div>
) )
} }
function StatCard({ function StatCard({ icon: Icon, label, value, subvalue }: { icon: typeof Radio; label: string; value: string | number; subvalue?: string }) {
icon: Icon,
label,
value,
subvalue,
}: {
icon: typeof Radio
label: string
value: string | number
subvalue?: string
}) {
return ( return (
<div className="bg-bg-card border border-border rounded-lg p-4"> <div className="bg-bg-card border border-border rounded-lg p-4">
<div className="flex items-center gap-2 text-slate-400 mb-2"> <div className="flex items-center gap-2 text-slate-400 mb-2">
@ -202,100 +185,362 @@ function StatCard({
<span className="text-xs">{label}</span> <span className="text-xs">{label}</span>
</div> </div>
<div className="font-mono text-xl text-slate-100">{value}</div> <div className="font-mono text-xl text-slate-100">{value}</div>
{subvalue && ( {subvalue && <div className="text-xs text-slate-500 mt-1">{subvalue}</div>}
<div className="text-xs text-slate-500 mt-1">{subvalue}</div>
)}
</div> </div>
) )
} }
function RFPropagationCard({ propagation }: { propagation: RFPropagation | null }) { // Scale badge component for R/S/G
if (!propagation) { function ScaleBadge({ label, value }: { label: string; value: number }) {
return ( const getColor = () => {
<div className="bg-bg-card border border-border rounded-lg p-6"> if (value === 0) return 'bg-green-500/20 text-green-400 border-green-500/50'
<h2 className="text-sm font-medium text-slate-400 mb-4"> if (value <= 2) return 'bg-amber-500/20 text-amber-400 border-amber-500/50'
RF Propagation return 'bg-red-500/20 text-red-400 border-red-500/50'
</h2> }
<div className="text-slate-500">
<p>Loading propagation data...</p> return (
</div> <span className={`px-2 py-1 rounded text-xs font-mono font-medium border ${getColor()}`}>
{label}{value}
</span>
)
}
// Large value display for SFI/Kp
function BigValue({ label, value, unit, getColor }: { label: string; value: number | undefined; unit?: string; getColor: (v: number) => string }) {
const color = value !== undefined ? getColor(value) : 'text-slate-400'
return (
<div className="text-center">
<div className="text-xs text-slate-500 mb-1">{label}</div>
<div className={`font-mono text-3xl font-bold ${color}`}>
{value?.toFixed(0) ?? '—'}
</div> </div>
{unit && <div className="text-xs text-slate-500">{unit}</div>}
</div>
)
}
// Kp trend sparkline chart
function KpTrendChart({ history }: { history: KpHistoryEntry[] }) {
const chartData = useMemo(() => {
if (!history || history.length === 0) return []
// Take last 16 entries (48 hours of 3-hourly data)
return history.slice(-16).map((entry, i) => ({
idx: i,
value: entry.value,
time: entry.time,
}))
}, [history])
if (chartData.length === 0) return null
const maxKp = Math.max(...chartData.map(d => d.value), 5)
const currentKp = chartData[chartData.length - 1]?.value ?? 0
// Gradient color based on max Kp
const getGradientId = () => {
if (maxKp > 5) return 'kpGradientRed'
if (maxKp > 3) return 'kpGradientAmber'
return 'kpGradientGreen'
}
return (
<div className="h-20 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 5, bottom: 5, left: 5 }}>
<defs>
<linearGradient id="kpGradientGreen" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#22c55e" stopOpacity={0.4} />
<stop offset="100%" stopColor="#22c55e" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="kpGradientAmber" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f59e0b" stopOpacity={0.4} />
<stop offset="100%" stopColor="#f59e0b" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="kpGradientRed" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#ef4444" stopOpacity={0.4} />
<stop offset="100%" stopColor="#ef4444" stopOpacity={0.05} />
</linearGradient>
</defs>
<YAxis domain={[0, Math.ceil(maxKp)]} hide />
<XAxis dataKey="idx" hide />
<ReferenceLine y={3} stroke="#f59e0b" strokeDasharray="3 3" strokeOpacity={0.5} />
<ReferenceLine y={5} stroke="#ef4444" strokeDasharray="3 3" strokeOpacity={0.5} />
<Area
type="monotone"
dataKey="value"
stroke={currentKp > 5 ? '#ef4444' : currentKp > 3 ? '#f59e0b' : '#22c55e'}
fill={`url(#${getGradientId()})`}
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
<div className="flex justify-between text-xs text-slate-600 px-1">
<span>48h ago</span>
<span>now</span>
</div>
</div>
)
}
// Refractivity profile chart
function RefractivityChart({ profile }: { profile: ProfileEntry[] }) {
const chartData = useMemo(() => {
if (!profile || profile.length === 0) return []
return [...profile].sort((a, b) => a.height_m - b.height_m).map(p => ({
height: p.height_m,
M: p.M,
}))
}, [profile])
if (chartData.length === 0) return null
return (
<div className="h-24 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 5, right: 10, bottom: 5, left: 5 }}>
<XAxis
dataKey="M"
type="number"
domain={['dataMin - 20', 'dataMax + 20']}
tick={{ fontSize: 10, fill: '#64748b' }}
tickLine={false}
axisLine={{ stroke: '#334155' }}
/>
<YAxis
dataKey="height"
type="number"
domain={[0, 'dataMax']}
tick={{ fontSize: 10, fill: '#64748b' }}
tickLine={false}
axisLine={{ stroke: '#334155' }}
tickFormatter={(v) => `${(v/1000).toFixed(1)}k`}
/>
<Line
type="monotone"
dataKey="M"
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 3, fill: '#3b82f6' }}
/>
</LineChart>
</ResponsiveContainer>
<div className="text-center text-xs text-slate-600">M-units vs Height (km)</div>
</div>
)
}
// RF Propagation Card
function RFPropagationCard({ swpc, ducting }: { swpc: ExtendedSWPCStatus | null; ducting: ExtendedDuctingStatus | null }) {
const getSfiColor = (v: number) => {
if (v >= 120) return 'text-green-400'
if (v >= 80) return 'text-amber-400'
return 'text-red-400'
}
const getKpColor = (v: number) => {
if (v <= 3) return 'text-green-400'
if (v <= 5) return 'text-amber-400'
return 'text-red-400'
}
const getDuctingBadge = (condition?: string) => {
if (!condition) return null
const styles: Record<string, string> = {
normal: 'bg-green-500/20 text-green-400 border-green-500/50',
super_refraction: 'bg-amber-500/20 text-amber-400 border-amber-500/50',
surface_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
elevated_duct: 'bg-blue-500/20 text-blue-400 border-blue-500/50',
}
const labels: Record<string, string> = {
normal: 'Normal',
super_refraction: 'Super Refraction',
surface_duct: 'Surface Duct',
elevated_duct: 'Elevated Duct',
}
return (
<span className={`px-2 py-1 rounded text-xs font-medium border ${styles[condition] || styles.normal}`}>
{labels[condition] || condition}
</span>
) )
} }
const hf = propagation.hf
const ducting = propagation.uhf_ducting
const getDuctingColor = (condition?: string) => {
if (!condition) return 'text-slate-400'
switch (condition) {
case 'normal':
return 'text-green-500'
case 'super_refraction':
return 'text-amber-500'
case 'surface_duct':
case 'elevated_duct':
return 'text-blue-400'
default:
return 'text-slate-400'
}
}
const hasHF = hf && (hf.sfi || hf.kp_current !== undefined)
const hasDucting = ducting && ducting.condition
return ( return (
<div className="bg-bg-card border border-border rounded-lg p-6"> <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"> <h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Zap size={14} /> <Zap size={14} />
RF Propagation RF Propagation
</h2> </h2>
{/* Solar/Geomagnetic Indices */} {/* Top row: SFI and Kp big values */}
<div className="mb-4"> <div className="flex justify-around mb-4">
<div className="text-xs text-slate-500 mb-1">Solar/Geomagnetic</div> <BigValue label="SFI" value={swpc?.sfi} getColor={getSfiColor} />
{hasHF ? ( <div className="w-px bg-border" />
<div className="space-y-1"> <BigValue label="Kp" value={swpc?.kp_current} getColor={getKpColor} />
<div className="text-sm font-mono text-slate-200">
SFI {hf.sfi?.toFixed(0) || '?'} / Kp {hf.kp_current?.toFixed(1) || '?'}
</div>
<div className="text-xs text-slate-400">
R{hf.r_scale ?? 0} / S{hf.s_scale ?? 0} / G{hf.g_scale ?? 0}
</div>
{hf.r_scale !== undefined && hf.r_scale > 0 && (
<div className="text-xs text-amber-500">
R{hf.r_scale} Radio Blackout
</div>
)}
</div>
) : (
<div className="text-sm text-slate-500">No data</div>
)}
</div> </div>
{/* Tropospheric Ducting */} {/* R/S/G Scale badges */}
<div> <div className="flex justify-center gap-2 mb-4">
<div className="text-xs text-slate-500 mb-1">Tropospheric</div> <ScaleBadge label="R" value={swpc?.r_scale ?? 0} />
{hasDucting ? ( <ScaleBadge label="S" value={swpc?.s_scale ?? 0} />
<div className="space-y-1"> <ScaleBadge label="G" value={swpc?.g_scale ?? 0} />
<div className={`text-sm font-medium ${getDuctingColor(ducting.condition)}`}>
{ducting.condition === 'normal'
? 'Normal'
: ducting.condition?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
</div>
<div className="text-xs text-slate-400 font-mono">
dM/dz: {ducting.min_gradient ?? '?'} M-units/km
</div>
{ducting.duct_thickness_m && (
<div className="text-xs text-slate-400">
Duct: ~{ducting.duct_thickness_m}m thick
</div>
)}
</div>
) : (
<div className="text-sm text-slate-500">No ducting data</div>
)}
</div> </div>
{/* Kp Trend Chart */}
{swpc?.kp_history && swpc.kp_history.length > 0 && (
<div className="mb-4">
<div className="text-xs text-slate-500 mb-1">Kp Trend (48h)</div>
<KpTrendChart history={swpc.kp_history} />
</div>
)}
{/* Divider */}
<div className="border-t border-border my-3" />
{/* Tropospheric section */}
<div className="flex items-center gap-2 mb-2">
<Cloud size={14} className="text-slate-400" />
<span className="text-xs text-slate-500">Tropospheric</span>
{getDuctingBadge(ducting?.condition)}
</div>
{ducting?.min_gradient !== undefined && (
<div className="text-xs text-slate-400 font-mono mb-2">
dM/dz: {ducting.min_gradient.toFixed(1)} M-units/km
</div>
)}
{/* Refractivity profile chart */}
{ducting?.profile && ducting.profile.length > 0 && (
<RefractivityChart profile={ducting.profile} />
)}
{/* SWPC Warnings */}
{swpc?.active_warnings && swpc.active_warnings.length > 0 && (
<div className="mt-auto pt-3 border-t border-border">
<div className="text-xs text-slate-500 mb-1">SWPC Alerts</div>
<div className="flex flex-wrap gap-1">
{swpc.active_warnings.slice(0, 3).map((w, i) => (
<span key={i} className="px-2 py-0.5 rounded text-xs bg-amber-500/20 text-amber-400 border border-amber-500/30 truncate max-w-full">
{w.replace('Space Weather Message Code: ', '')}
</span>
))}
</div>
</div>
)}
</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
const SEVERITY_COLORS: Record<string, string> = {
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
advisory: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
moderate: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
watch: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
warning: 'bg-red-500/20 text-red-400 border-red-500/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',
}
function EventFeedItem({ event }: { event: EnvEvent }) {
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' })
}
return (
<div className="flex items-start gap-2 py-2 border-b border-border/50 last:border-0">
<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>
<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>
<div className="text-sm text-slate-200 truncate">{event.headline}</div>
</div>
</div>
)
}
// Live Event Feed Card
function LiveEventFeed({ events, envStatus }: { events: EnvEvent[]; envStatus: EnvStatus | null }) {
const sortedEvents = useMemo(() => {
return [...events].sort((a, b) => (b.fetched_at || 0) - (a.fetched_at || 0))
}, [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) => (
<EventFeedItem key={event.event_id || i} event={event} />
))}
</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> </div>
) )
} }
@ -305,11 +550,13 @@ export default function Dashboard() {
const [sources, setSources] = useState<SourceHealth[]>([]) const [sources, setSources] = useState<SourceHealth[]>([])
const [alerts, setAlerts] = useState<Alert[]>([]) const [alerts, setAlerts] = useState<Alert[]>([])
const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null) const [envStatus, setEnvStatus] = useState<EnvStatus | null>(null)
const [rfProp, setRFProp] = useState<RFPropagation | null>(null) const [envEvents, setEnvEvents] = useState<EnvEvent[]>([])
const [swpc, setSwpc] = useState<ExtendedSWPCStatus | null>(null)
const [ducting, setDucting] = useState<ExtendedDuctingStatus | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { lastHealth } = useWebSocket() const { lastHealth, lastMessage } = useWebSocket()
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@ -317,21 +564,25 @@ export default function Dashboard() {
fetchSources(), fetchSources(),
fetchAlerts(), fetchAlerts(),
fetchEnvStatus(), fetchEnvStatus(),
fetchRFPropagation().catch(() => null), fetchEnvActive().catch(() => []),
fetchSWPC().catch(() => null),
fetchDucting().catch(() => null),
]) ])
.then(([h, src, a, e, rf]) => { .then(([h, src, a, e, events, sw, duct]) => {
setHealth(h) setHealth(h)
setSources(src) setSources(src)
setAlerts(a) setAlerts(a)
setEnvStatus(e) setEnvStatus(e)
setRFProp(rf) setEnvEvents(events)
setLoading(false) setSwpc(sw as ExtendedSWPCStatus)
document.title = 'Dashboard — MeshAI' setDucting(duct as ExtendedDuctingStatus)
setLoading(false)
document.title = 'Dashboard — MeshAI'
}) })
.catch((err) => { .catch((err) => {
setError(err.message) setError(err.message)
setLoading(false) setLoading(false)
document.title = 'Dashboard — MeshAI' document.title = 'Dashboard — MeshAI'
}) })
}, []) }, [])
@ -342,6 +593,18 @@ export default function Dashboard() {
} }
}, [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) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@ -359,114 +622,76 @@ export default function Dashboard() {
} }
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="space-y-6">
{/* Mesh Health */} {/* Top row: Health + Alerts + Stats */}
<div className="bg-bg-card border border-border rounded-lg p-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Health</h2> {/* Mesh Health */}
{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} />
<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"> <div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4"> <h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Health</h2>
Active Alerts {health && (
</h2> <>
{alerts.length > 0 ? ( <HealthGauge health={health} />
<div className="space-y-3"> <div className="mt-6 space-y-3">
{alerts.map((alert, i) => ( <PillarBar label="Infrastructure" value={health.pillars?.infrastructure ?? 0} />
<AlertItem key={i} alert={alert} /> <PillarBar label="Utilization" value={health.pillars?.utilization ?? 0} />
))} <PillarBar label="Behavior" value={health.pillars?.behavior ?? 0} />
</div> <PillarBar label="Power" value={health.pillars?.power ?? 0} />
) : ( </div>
<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>
)} )}
</div> </div>
{/* Quick Stats */} {/* Alerts + Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="lg:col-span-2 space-y-6">
<StatCard {/* Active Alerts */}
icon={Radio} <div className="bg-bg-card border border-border rounded-lg p-6">
label="Nodes Online" <h2 className="text-sm font-medium text-slate-400 mb-4">Active Alerts</h2>
value={health?.total_nodes || 0} {alerts.length > 0 ? (
subvalue={`${health?.unlocated_count || 0} unlocated`} <div className="space-y-3 max-h-48 overflow-y-auto">
/> {alerts.map((alert, i) => (
<StatCard <AlertItem key={i} alert={alert} />
icon={Cpu} ))}
label="Infrastructure" </div>
value={`${health?.infra_online || 0}/${health?.infra_total || 0}`} ) : (
subvalue={ <div className="flex items-center gap-2 text-slate-500 py-4">
health?.infra_online === health?.infra_total <CheckCircle size={16} className="text-green-500" />
? 'All online' <span>No active alerts</span>
: 'Some offline' </div>
} )}
/> </div>
<StatCard
icon={Activity} {/* Quick Stats */}
label="Utilization" <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
value={`${health?.util_percent?.toFixed(1) || 0}%`} <StatCard icon={Radio} label="Nodes Online" value={health?.total_nodes || 0} subvalue={`${health?.unlocated_count || 0} unlocated`} />
subvalue={`${health?.flagged_nodes || 0} flagged`} <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 <StatCard icon={MapPin} label="Regions" value={health?.total_regions || 0} subvalue={`${health?.battery_warnings || 0} battery warnings`} />
icon={MapPin} </div>
label="Regions"
value={health?.total_regions || 0}
subvalue={`${health?.battery_warnings || 0} battery warnings`}
/>
</div> </div>
</div> </div>
{/* Mesh Sources */} {/* Middle row: Sources + RF Propagation + Live Feed */}
<div className="bg-bg-card border border-border rounded-lg p-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<h2 className="text-sm font-medium text-slate-400 mb-4"> {/* Mesh Sources */}
Mesh Sources ({sources.length}) <div className="bg-bg-card border border-border rounded-lg p-6">
</h2> <h2 className="text-sm font-medium text-slate-400 mb-4">Mesh Sources ({sources.length})</h2>
{sources.length > 0 ? ( {sources.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{sources.map((source, i) => ( {sources.map((source, i) => (
<SourceCard key={i} source={source} /> <SourceCard key={i} source={source} />
))} ))}
</div> </div>
) : ( ) : (
<div className="text-slate-500 py-4">No sources configured</div> <div className="text-slate-500 py-4">No sources configured</div>
)} )}
</div> </div>
{/* Environmental Feeds */} {/* RF Propagation */}
<div className="bg-bg-card border border-border rounded-lg p-6"> <RFPropagationCard swpc={swpc} ducting={ducting} />
<h2 className="text-sm font-medium text-slate-400 mb-4">
Environmental Feeds
</h2>
{envStatus?.enabled ? (
<div className="text-slate-400">
{envStatus.feeds.length} feeds active
</div>
) : (
<div className="text-slate-500">
<p>Environmental feeds not enabled.</p>
<p className="text-xs mt-2">
Enable in config.yaml
</p>
</div>
)}
</div>
{/* RF Propagation */} {/* Live Event Feed */}
<RFPropagationCard propagation={rfProp} /> <LiveEventFeed events={envEvents} envStatus={envStatus} />
</div>
</div> </div>
) )
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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-utMF5PG3.js"></script> <script type="module" crossorigin src="/assets/index-DrKrP8CJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D0mCSizv.css"> <link rel="stylesheet" crossorigin href="/assets/index-E1oMzltW.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

45
meshai/env/swpc.py vendored
View file

@ -140,15 +140,36 @@ class SWPCAdapter:
"""Parse noaa-planetary-k-index.json. """Parse noaa-planetary-k-index.json.
Data format: array of objects with time_tag, Kp, a_running, station_count Data format: array of objects with time_tag, Kp, a_running, station_count
Last entry is most recent. Last entry is most recent. Store full history for charting.
""" """
if not data: if not data:
return return
# Get last entry (most recent) # Store full history (last 24-48 hours of readings)
last_entry = data[-1] kp_history = []
for entry in data:
if isinstance(entry, dict):
try:
kp_history.append({
"time": entry.get("time_tag", ""),
"value": float(entry.get("Kp", 0)),
})
except (ValueError, TypeError):
continue
elif isinstance(entry, list) and len(entry) > 1:
# Legacy array format fallback
try:
kp_history.append({
"time": entry[0] if len(entry) > 0 else "",
"value": float(entry[1]),
})
except (ValueError, TypeError):
continue
# Handle both dict format (new API) and list format (legacy) self._status["kp_history"] = kp_history
# Get last entry (most recent) for current value
last_entry = data[-1]
if isinstance(last_entry, dict): if isinstance(last_entry, dict):
try: try:
self._status["kp_current"] = float(last_entry.get("Kp", 0)) self._status["kp_current"] = float(last_entry.get("Kp", 0))
@ -184,10 +205,26 @@ class SWPCAdapter:
"""Parse f107_cm_flux.json. """Parse f107_cm_flux.json.
Data format: array of objects with time_tag, flux Data format: array of objects with time_tag, flux
Store history for potential charting.
""" """
if not data: if not data:
return return
# Store SFI history (last 30 days of readings)
sfi_history = []
if isinstance(data, list):
for entry in data[-30:]: # Last 30 entries
if isinstance(entry, dict):
try:
sfi_history.append({
"time": entry.get("time_tag", ""),
"value": float(entry.get("flux", 0)),
})
except (ValueError, TypeError):
continue
self._status["sfi_history"] = sfi_history
# Get most recent entry (last in list) # Get most recent entry (last in list)
if isinstance(data, list) and data: if isinstance(data, list) and data:
last = data[-1] last = data[-1]