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

View file

@ -1,440 +1,479 @@
// API types matching actual backend responses
export interface SystemStatus {
version: string
uptime_seconds: number
bot_name: string
connection_type: string
connection_target: string
connected: boolean
node_count: number
source_count: number
env_feeds_enabled: boolean
dashboard_port: number
}
export interface MeshHealth {
score: number
tier: string
pillars: {
infrastructure: number
utilization: number
behavior: number
power: number
}
infra_online: number
infra_total: number
util_percent: number
flagged_nodes: number
battery_warnings: number
total_nodes: number
total_regions: number
unlocated_count: number
last_computed: string
recommendations: string[]
}
export interface NodeInfo {
node_num: number
node_id_hex: string
short_name: string
long_name: string
role: string
latitude: number | null
longitude: number | null
last_heard: string | null
battery_level: number | null
voltage: number | null
snr: number | null
firmware: string
hardware: string
uptime: number | null
sources: string[]
}
export interface EdgeInfo {
from_node: number
to_node: number
snr: number
quality: string
}
export interface RegionInfo {
name: string
local_name: string
node_count: number
infra_count: number
infra_online: number
online_count: number
score: number
tier: string
center_lat: number
center_lon: number
}
export interface SourceHealth {
name: string
type: string
url: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
response_time_ms: number | null
tick_count: number
node_count: number
}
export interface Alert {
type: string
severity: string
message: string
timestamp: string
scope_type?: string
scope_value?: string
}
export interface AlertHistoryItem {
id?: number
type: string
severity: string
message: string
timestamp: string
duration?: number
scope_type?: string
scope_value?: string
resolved_at?: string
}
export interface AlertHistoryResponse {
items: AlertHistoryItem[]
total: number
}
export interface Subscription {
id: number
user_id: string
sub_type: string
schedule_time?: string
schedule_day?: string
scope_type: string
scope_value?: string
enabled: boolean
}
export interface EnvStatus {
enabled: boolean
feeds: EnvFeedHealth[]
}
export interface EnvFeedHealth {
source: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
event_count: number
last_fetch: number
}
export interface EnvEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
description?: string
expires?: number
fetched_at: number
[key: string]: unknown
}
export interface SWPCStatus {
enabled: boolean
kp_current?: number
kp_timestamp?: string
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
}
export interface DuctingStatus {
enabled: boolean
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
duct_base_m?: number | null
last_update?: string
}
export interface RFPropagation {
hf: {
kp_current?: number
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
}
uhf_ducting: {
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
}
}
// API fetch helpers
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function fetchStatus(): Promise<SystemStatus> {
return fetchJson<SystemStatus>('/api/status')
}
export async function fetchHealth(): Promise<MeshHealth> {
return fetchJson<MeshHealth>('/api/health')
}
export async function fetchNodes(): Promise<NodeInfo[]> {
return fetchJson<NodeInfo[]>('/api/nodes')
}
export async function fetchEdges(): Promise<EdgeInfo[]> {
return fetchJson<EdgeInfo[]>('/api/edges')
}
export async function fetchSources(): Promise<SourceHealth[]> {
return fetchJson<SourceHealth[]>('/api/sources')
}
export async function fetchConfig(section?: string): Promise<unknown> {
const url = section ? `/api/config/${section}` : '/api/config'
return fetchJson(url)
}
export async function updateConfig(
section: string,
data: unknown
): Promise<{ saved: boolean; restart_required: boolean }> {
const response = await fetch(`/api/config/${section}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function fetchAlerts(): Promise<Alert[]> {
return fetchJson<Alert[]>('/api/alerts/active')
}
export async function fetchAlertHistory(
limit: number = 50,
offset: number = 0,
type?: string,
severity?: string
): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
const params = new URLSearchParams()
params.set('limit', limit.toString())
params.set('offset', offset.toString())
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 fetchSubscriptions(): Promise<Subscription[]> {
return fetchJson<Subscription[]>('/api/subscriptions')
}
export async function fetchEnvStatus(): Promise<EnvStatus> {
return fetchJson<EnvStatus>('/api/env/status')
}
export async function fetchEnvActive(): Promise<EnvEvent[]> {
return fetchJson<EnvEvent[]>('/api/env/active')
}
export async function fetchRFPropagation(): Promise<RFPropagation> {
return fetchJson<RFPropagation>('/api/env/propagation')
}
export async function fetchSWPC(): Promise<SWPCStatus> {
return fetchJson<SWPCStatus>('/api/env/swpc')
}
export async function fetchDucting(): Promise<DuctingStatus> {
return fetchJson<DuctingStatus>('/api/env/ducting')
}
export interface FireEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
name: string
acres: number
pct_contained: number
lat: number | null
lon: number | null
distance_km: number | null
nearest_anchor: string | null
state: string
expires: number
fetched_at: number
polygon?: number[][][]
}
export interface AvalancheEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
zone_name: string
center: string
center_id: string
center_link: string
forecast_link: string
danger: string
danger_level: number
danger_name: string
travel_advice: string
state: string
lat: number | null
lon: number | null
expires: number
fetched_at: number
}
export interface StreamGaugeEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
site_id: string
site_name: string
parameter: string
value: number
unit: string
timestamp: string
}
}
export interface TrafficEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
corridor: string
currentSpeed: number
freeFlowSpeed: number
speedRatio: number
currentTravelTime: number
freeFlowTravelTime: number
confidence: number
roadClosure: boolean
}
}
export interface RoadEvent {
source: string
event_id: string
event_type: string
headline: string
description?: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
roadway: string
is_closure: boolean
last_updated?: string
}
}
export interface HotspotEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
new_ignition: boolean
confidence: string
frp?: number
brightness?: number
acq_date: string
acq_time: string
near_fire?: string
distance_to_fire_km?: number
distance_km?: number
nearest_anchor?: string
}
}
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')
}
// API types matching actual backend responses
export interface SystemStatus {
version: string
uptime_seconds: number
bot_name: string
connection_type: string
connection_target: string
connected: boolean
node_count: number
source_count: number
env_feeds_enabled: boolean
dashboard_port: number
}
export interface MeshHealth {
score: number
tier: string
pillars: {
infrastructure: number
utilization: number
behavior: number
power: number
}
infra_online: number
infra_total: number
util_percent: number
flagged_nodes: number
battery_warnings: number
total_nodes: number
total_regions: number
unlocated_count: number
last_computed: string
recommendations: string[]
}
export interface NodeInfo {
node_num: number
node_id_hex: string
short_name: string
long_name: string
role: string
latitude: number | null
longitude: number | null
last_heard: string | null
battery_level: number | null
voltage: number | null
snr: number | null
firmware: string
hardware: string
uptime: number | null
sources: string[]
}
export interface EdgeInfo {
from_node: number
to_node: number
snr: number
quality: string
}
export interface RegionInfo {
name: string
local_name: string
node_count: number
infra_count: number
infra_online: number
online_count: number
score: number
tier: string
center_lat: number
center_lon: number
}
export interface SourceHealth {
name: string
type: string
url: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
response_time_ms: number | null
tick_count: number
node_count: number
}
export interface Alert {
type: string
severity: string
message: string
timestamp: string
scope_type?: string
scope_value?: string
}
export interface AlertHistoryItem {
id?: number
type: string
severity: string
message: string
timestamp: string
duration?: number
scope_type?: string
scope_value?: string
resolved_at?: string
}
export interface AlertHistoryResponse {
items: AlertHistoryItem[]
total: number
}
export interface Subscription {
id: number
user_id: string
sub_type: string
schedule_time?: string
schedule_day?: string
scope_type: string
scope_value?: string
enabled: boolean
}
export interface EnvStatus {
enabled: boolean
feeds: EnvFeedHealth[]
}
export interface EnvFeedHealth {
source: string
is_loaded: boolean
last_error: string | null
consecutive_errors: number
event_count: number
last_fetch: number
}
export interface EnvEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
description?: string
expires?: number
fetched_at: number
[key: string]: unknown
}
// Kp history entry for charting
export interface KpHistoryEntry {
time: string
value: number
}
// SFI history entry for charting
export interface SfiHistoryEntry {
time: string
value: number
}
// Refractivity profile entry
export interface ProfileEntry {
level_hPa: number
height_m: number
N: number
M: number
T_C: number
RH: number
}
// Gradient entry
export interface GradientEntry {
from_level: number
to_level: number
from_height_m: number
to_height_m: number
gradient: number
}
export interface SWPCStatus {
enabled: boolean
kp_current?: number
kp_timestamp?: string
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
kp_history?: KpHistoryEntry[]
sfi_history?: SfiHistoryEntry[]
}
export interface DuctingStatus {
enabled: boolean
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
duct_base_m?: number | null
last_update?: string
profile?: ProfileEntry[]
gradients?: GradientEntry[]
assessment?: string
location?: { lat: number; lon: number }
}
export interface RFPropagation {
hf: {
kp_current?: number
sfi?: number
r_scale?: number
s_scale?: number
g_scale?: number
active_warnings?: string[]
kp_history?: KpHistoryEntry[]
}
uhf_ducting: {
condition?: string
min_gradient?: number
duct_thickness_m?: number | null
profile?: ProfileEntry[]
}
}
// API fetch helpers
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function fetchStatus(): Promise<SystemStatus> {
return fetchJson<SystemStatus>('/api/status')
}
export async function fetchHealth(): Promise<MeshHealth> {
return fetchJson<MeshHealth>('/api/health')
}
export async function fetchNodes(): Promise<NodeInfo[]> {
return fetchJson<NodeInfo[]>('/api/nodes')
}
export async function fetchEdges(): Promise<EdgeInfo[]> {
return fetchJson<EdgeInfo[]>('/api/edges')
}
export async function fetchSources(): Promise<SourceHealth[]> {
return fetchJson<SourceHealth[]>('/api/sources')
}
export async function fetchConfig(section?: string): Promise<unknown> {
const url = section ? `/api/config/${section}` : '/api/config'
return fetchJson(url)
}
export async function updateConfig(
section: string,
data: unknown
): Promise<{ saved: boolean; restart_required: boolean }> {
const response = await fetch(`/api/config/${section}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function fetchAlerts(): Promise<Alert[]> {
return fetchJson<Alert[]>('/api/alerts/active')
}
export async function fetchAlertHistory(
limit: number = 50,
offset: number = 0,
type?: string,
severity?: string
): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
const params = new URLSearchParams()
params.set('limit', limit.toString())
params.set('offset', offset.toString())
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 fetchSubscriptions(): Promise<Subscription[]> {
return fetchJson<Subscription[]>('/api/subscriptions')
}
export async function fetchEnvStatus(): Promise<EnvStatus> {
return fetchJson<EnvStatus>('/api/env/status')
}
export async function fetchEnvActive(): Promise<EnvEvent[]> {
return fetchJson<EnvEvent[]>('/api/env/active')
}
export async function fetchRFPropagation(): Promise<RFPropagation> {
return fetchJson<RFPropagation>('/api/env/propagation')
}
export async function fetchSWPC(): Promise<SWPCStatus> {
return fetchJson<SWPCStatus>('/api/env/swpc')
}
export async function fetchDucting(): Promise<DuctingStatus> {
return fetchJson<DuctingStatus>('/api/env/ducting')
}
export interface FireEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
name: string
acres: number
pct_contained: number
lat: number | null
lon: number | null
distance_km: number | null
nearest_anchor: string | null
state: string
expires: number
fetched_at: number
polygon?: number[][][]
}
export interface AvalancheEvent {
source: string
event_id: string
event_type: string
severity: string
headline: string
zone_name: string
center: string
center_id: string
center_link: string
forecast_link: string
danger: string
danger_level: number
danger_name: string
travel_advice: string
state: string
lat: number | null
lon: number | null
expires: number
fetched_at: number
}
export interface StreamGaugeEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
site_id: string
site_name: string
parameter: string
value: number
unit: string
timestamp: string
}
}
export interface TrafficEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
corridor: string
currentSpeed: number
freeFlowSpeed: number
speedRatio: number
currentTravelTime: number
freeFlowTravelTime: number
confidence: number
roadClosure: boolean
}
}
export interface RoadEvent {
source: string
event_id: string
event_type: string
headline: string
description?: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
roadway: string
is_closure: boolean
last_updated?: string
}
}
export interface HotspotEvent {
source: string
event_id: string
event_type: string
headline: string
severity: string
lat?: number
lon?: number
expires: number
fetched_at: number
properties: {
new_ignition: boolean
confidence: string
frp?: number
brightness?: number
acq_date: string
acq_time: string
near_fire?: string
distance_to_fire_km?: number
distance_km?: number
nearest_anchor?: string
}
}
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 {
fetchHealth,
fetchSources,
fetchAlerts,
fetchEnvStatus,
fetchRFPropagation,
fetchEnvActive,
fetchSWPC,
fetchDucting,
type MeshHealth,
type SourceHealth,
type Alert,
type EnvStatus,
type RFPropagation,
type EnvEvent,
type SWPCStatus,
type DuctingStatus,
} from '@/lib/api'
import { useWebSocket } from '@/hooks/useWebSocket'
import {
@ -22,13 +26,63 @@ import {
Activity,
MapPin,
Zap,
Cloud,
Flame,
Mountain,
Droplets,
Car,
Construction,
Satellite,
Sun,
} 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 }) {
const score = health.score
const tier = health.tier
// Color based on score
const getColor = (s: number) => {
if (s >= 80) return '#22c55e'
if (s >= 60) return '#f59e0b'
@ -42,46 +96,17 @@ function HealthGauge({ health }: { health: MeshHealth }) {
return (
<div className="flex flex-col items-center">
<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
cx="50"
cy="50"
r="45"
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)"
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"
/>
{/* 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)}
</text>
<text
x="50"
y="62"
textAnchor="middle"
className="fill-slate-400 text-xs"
style={{ fontSize: '10px' }}
>
<text x="50" y="62" textAnchor="middle" className="fill-slate-400 text-xs" style={{ fontSize: '10px' }}>
{tier}
</text>
</svg>
@ -89,13 +114,7 @@ function HealthGauge({ health }: { health: MeshHealth }) {
)
}
function PillarBar({
label,
value,
}: {
label: string
value: number
}) {
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'
@ -106,14 +125,9 @@ function PillarBar({
<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 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>
)
}
@ -123,26 +137,11 @@ function AlertItem({ alert }: { alert: Alert }) {
switch (severity.toLowerCase()) {
case 'critical':
case 'emergency':
return {
bg: 'bg-red-500/10',
border: 'border-red-500',
icon: AlertCircle,
iconColor: 'text-red-500',
}
return { bg: 'bg-red-500/10', border: 'border-red-500', icon: AlertCircle, iconColor: 'text-red-500' }
case 'warning':
return {
bg: 'bg-amber-500/10',
border: 'border-amber-500',
icon: AlertTriangle,
iconColor: 'text-amber-500',
}
return { bg: 'bg-amber-500/10', border: 'border-amber-500', icon: AlertTriangle, iconColor: 'text-amber-500' }
default:
return {
bg: 'bg-green-500/10',
border: 'border-green-500',
icon: Info,
iconColor: 'text-green-500',
}
return { 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
return (
<div
className={`p-3 rounded-lg ${styles.bg} border-l-2 ${styles.border} flex items-start gap-3`}
>
<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 className="text-xs text-slate-500 mt-1">{alert.timestamp || 'Just now'}</div>
</div>
</div>
)
@ -176,25 +171,13 @@ function SourceCard({ source }: { source: SourceHealth }) {
<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 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
}) {
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">
@ -202,100 +185,362 @@ function StatCard({
<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>
)}
{subvalue && <div className="text-xs text-slate-500 mt-1">{subvalue}</div>}
</div>
)
}
function RFPropagationCard({ propagation }: { propagation: RFPropagation | null }) {
if (!propagation) {
return (
<div className="bg-bg-card border border-border rounded-lg p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4">
RF Propagation
</h2>
<div className="text-slate-500">
<p>Loading propagation data...</p>
</div>
// Scale badge component for R/S/G
function ScaleBadge({ label, value }: { label: string; value: number }) {
const getColor = () => {
if (value === 0) return 'bg-green-500/20 text-green-400 border-green-500/50'
if (value <= 2) return 'bg-amber-500/20 text-amber-400 border-amber-500/50'
return 'bg-red-500/20 text-red-400 border-red-500/50'
}
return (
<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>
{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 (
<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">
<Zap size={14} />
RF Propagation
</h2>
{/* Solar/Geomagnetic Indices */}
<div className="mb-4">
<div className="text-xs text-slate-500 mb-1">Solar/Geomagnetic</div>
{hasHF ? (
<div className="space-y-1">
<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>
)}
{/* Top row: SFI and Kp big values */}
<div className="flex justify-around mb-4">
<BigValue label="SFI" value={swpc?.sfi} getColor={getSfiColor} />
<div className="w-px bg-border" />
<BigValue label="Kp" value={swpc?.kp_current} getColor={getKpColor} />
</div>
{/* Tropospheric Ducting */}
<div>
<div className="text-xs text-slate-500 mb-1">Tropospheric</div>
{hasDucting ? (
<div className="space-y-1">
<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>
)}
{/* R/S/G Scale badges */}
<div className="flex justify-center gap-2 mb-4">
<ScaleBadge label="R" value={swpc?.r_scale ?? 0} />
<ScaleBadge label="S" value={swpc?.s_scale ?? 0} />
<ScaleBadge label="G" value={swpc?.g_scale ?? 0} />
</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>
)
}
@ -305,11 +550,13 @@ export default function Dashboard() {
const [sources, setSources] = useState<SourceHealth[]>([])
const [alerts, setAlerts] = useState<Alert[]>([])
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 [error, setError] = useState<string | null>(null)
const { lastHealth } = useWebSocket()
const { lastHealth, lastMessage } = useWebSocket()
useEffect(() => {
Promise.all([
@ -317,21 +564,25 @@ export default function Dashboard() {
fetchSources(),
fetchAlerts(),
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)
setSources(src)
setAlerts(a)
setEnvStatus(e)
setRFProp(rf)
setLoading(false)
document.title = 'Dashboard — MeshAI'
setEnvEvents(events)
setSwpc(sw as ExtendedSWPCStatus)
setDucting(duct as ExtendedDuctingStatus)
setLoading(false)
document.title = 'Dashboard — MeshAI'
})
.catch((err) => {
setError(err.message)
setLoading(false)
document.title = 'Dashboard — MeshAI'
setLoading(false)
document.title = 'Dashboard — MeshAI'
})
}, [])
@ -342,6 +593,18 @@ export default function Dashboard() {
}
}, [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">
@ -359,114 +622,76 @@ export default function Dashboard() {
}
return (
<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} />
<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="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">
Active Alerts
</h2>
{alerts.length > 0 ? (
<div className="space-y-3">
{alerts.map((alert, i) => (
<AlertItem key={i} alert={alert} />
))}
</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>
<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} />
<PillarBar label="Behavior" value={health.pillars?.behavior ?? 0} />
<PillarBar label="Power" value={health.pillars?.power ?? 0} />
</div>
</>
)}
</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`}
/>
{/* 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>
) : (
<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>
{/* 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>
{/* 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>
{/* 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>
{/* Environmental Feeds */}
<div className="bg-bg-card border border-border rounded-lg p-6">
<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 */}
<RFPropagationCard swpc={swpc} ducting={ducting} />
{/* RF Propagation */}
<RFPropagationCard propagation={rfProp} />
{/* Live Event Feed */}
<LiveEventFeed events={envEvents} envStatus={envStatus} />
</div>
</div>
)
}