mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-22 07:34:47 +02:00
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:
parent
d90b787c12
commit
7286c9ab44
9 changed files with 1631 additions and 1250 deletions
|
|
@ -1,21 +1,24 @@
|
||||||
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 {
|
interface UseWebSocketReturn {
|
||||||
connected: boolean
|
connected: boolean
|
||||||
lastHealth: MeshHealth | null
|
lastHealth: MeshHealth | null
|
||||||
lastAlert: Alert | null
|
lastAlert: Alert | null
|
||||||
|
lastMessage: WebSocketMessage | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWebSocket(): UseWebSocketReturn {
|
export function useWebSocket(): UseWebSocketReturn {
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null)
|
const [lastHealth, setLastHealth] = useState<MeshHealth | null>(null)
|
||||||
const [lastAlert, setLastAlert] = useState<Alert | null>(null)
|
const [lastAlert, setLastAlert] = useState<Alert | null>(null)
|
||||||
|
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null)
|
||||||
const wsRef = useRef<WebSocket | null>(null)
|
const wsRef = useRef<WebSocket | null>(null)
|
||||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
const reconnectTimeoutRef = useRef<number | null>(null)
|
||||||
const reconnectDelayRef = useRef(1000)
|
const reconnectDelayRef = useRef(1000)
|
||||||
|
|
@ -41,6 +44,9 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||||
try {
|
try {
|
||||||
const message: WebSocketMessage = JSON.parse(event.data)
|
const message: WebSocketMessage = JSON.parse(event.data)
|
||||||
|
|
||||||
|
// Store all messages for generic handling
|
||||||
|
setLastMessage(message)
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'health_update':
|
case 'health_update':
|
||||||
setLastHealth(message.data as MeshHealth)
|
setLastHealth(message.data as MeshHealth)
|
||||||
|
|
@ -48,6 +54,7 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||||
case 'alert_fired':
|
case 'alert_fired':
|
||||||
setLastAlert(message.data as Alert)
|
setLastAlert(message.data as Alert)
|
||||||
break
|
break
|
||||||
|
// env_update messages are handled via lastMessage
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse WebSocket message:', e)
|
console.error('Failed to parse WebSocket message:', e)
|
||||||
|
|
@ -98,5 +105,5 @@ export function useWebSocket(): UseWebSocketReturn {
|
||||||
}
|
}
|
||||||
}, [connect])
|
}, [connect])
|
||||||
|
|
||||||
return { connected, lastHealth, lastAlert }
|
return { connected, lastHealth, lastAlert, lastMessage }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,37 @@ export interface EnvEvent {
|
||||||
[key: string]: unknown
|
[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 {
|
export interface SWPCStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
kp_current?: number
|
kp_current?: number
|
||||||
|
|
@ -156,6 +187,8 @@ export interface SWPCStatus {
|
||||||
s_scale?: number
|
s_scale?: number
|
||||||
g_scale?: number
|
g_scale?: number
|
||||||
active_warnings?: string[]
|
active_warnings?: string[]
|
||||||
|
kp_history?: KpHistoryEntry[]
|
||||||
|
sfi_history?: SfiHistoryEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DuctingStatus {
|
export interface DuctingStatus {
|
||||||
|
|
@ -165,6 +198,10 @@ export interface DuctingStatus {
|
||||||
duct_thickness_m?: number | null
|
duct_thickness_m?: number | null
|
||||||
duct_base_m?: number | null
|
duct_base_m?: number | null
|
||||||
last_update?: string
|
last_update?: string
|
||||||
|
profile?: ProfileEntry[]
|
||||||
|
gradients?: GradientEntry[]
|
||||||
|
assessment?: string
|
||||||
|
location?: { lat: number; lon: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RFPropagation {
|
export interface RFPropagation {
|
||||||
|
|
@ -175,11 +212,13 @@ export interface RFPropagation {
|
||||||
s_scale?: number
|
s_scale?: number
|
||||||
g_scale?: number
|
g_scale?: number
|
||||||
active_warnings?: string[]
|
active_warnings?: string[]
|
||||||
|
kp_history?: KpHistoryEntry[]
|
||||||
}
|
}
|
||||||
uhf_ducting: {
|
uhf_ducting: {
|
||||||
condition?: string
|
condition?: string
|
||||||
min_gradient?: number
|
min_gradient?: number
|
||||||
duct_thickness_m?: number | null
|
duct_thickness_m?: number | null
|
||||||
|
profile?: ProfileEntry[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }) {
|
||||||
|
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 (
|
return (
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
<span className={`px-2 py-1 rounded text-xs font-mono font-medium border ${getColor()}`}>
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4">
|
{label}{value}
|
||||||
RF Propagation
|
</span>
|
||||||
</h2>
|
)
|
||||||
<div className="text-slate-500">
|
}
|
||||||
<p>Loading propagation data...</p>
|
|
||||||
|
// 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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hf = propagation.hf
|
// Refractivity profile chart
|
||||||
const ducting = propagation.uhf_ducting
|
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])
|
||||||
|
|
||||||
const getDuctingColor = (condition?: string) => {
|
if (chartData.length === 0) return null
|
||||||
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="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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-bg-card border border-border rounded-lg p-4 flex flex-col h-full">
|
||||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
<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>
|
||||||
<div className="text-xs text-slate-400 font-mono">
|
|
||||||
dM/dz: {ducting.min_gradient ?? '?'} M-units/km
|
{/* Kp Trend Chart */}
|
||||||
</div>
|
{swpc?.kp_history && swpc.kp_history.length > 0 && (
|
||||||
{ducting.duct_thickness_m && (
|
<div className="mb-4">
|
||||||
<div className="text-xs text-slate-400">
|
<div className="text-xs text-slate-500 mb-1">Kp Trend (48h)</div>
|
||||||
Duct: ~{ducting.duct_thickness_m}m thick
|
<KpTrendChart history={swpc.kp_history} />
|
||||||
</div>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-slate-500">No ducting data</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>
|
</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,14 +564,18 @@ 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)
|
||||||
|
setSwpc(sw as ExtendedSWPCStatus)
|
||||||
|
setDucting(duct as ExtendedDuctingStatus)
|
||||||
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,6 +622,8 @@ export default function Dashboard() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Top row: Health + Alerts + Stats */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Mesh Health */}
|
{/* Mesh Health */}
|
||||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||||
|
|
@ -380,11 +645,9 @@ export default function Dashboard() {
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
{/* Active Alerts */}
|
{/* 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">Active Alerts</h2>
|
||||||
Active Alerts
|
|
||||||
</h2>
|
|
||||||
{alerts.length > 0 ? (
|
{alerts.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 max-h-48 overflow-y-auto">
|
||||||
{alerts.map((alert, i) => (
|
{alerts.map((alert, i) => (
|
||||||
<AlertItem key={i} alert={alert} />
|
<AlertItem key={i} alert={alert} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -399,42 +662,19 @@ export default function Dashboard() {
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard
|
<StatCard icon={Radio} label="Nodes Online" value={health?.total_nodes || 0} subvalue={`${health?.unlocated_count || 0} unlocated`} />
|
||||||
icon={Radio}
|
<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'} />
|
||||||
label="Nodes Online"
|
<StatCard icon={Activity} label="Utilization" value={`${health?.util_percent?.toFixed(1) || 0}%`} subvalue={`${health?.flagged_nodes || 0} flagged`} />
|
||||||
value={health?.total_nodes || 0}
|
<StatCard icon={MapPin} label="Regions" value={health?.total_regions || 0} subvalue={`${health?.battery_warnings || 0} battery warnings`} />
|
||||||
subvalue={`${health?.unlocated_count || 0} unlocated`}
|
</div>
|
||||||
/>
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Middle row: Sources + RF Propagation + Live Feed */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Mesh Sources */}
|
{/* Mesh Sources */}
|
||||||
<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 Sources ({sources.length})</h2>
|
||||||
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) => (
|
||||||
|
|
@ -446,27 +686,12 @@ export default function Dashboard() {
|
||||||
)}
|
)}
|
||||||
</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 */}
|
{/* RF Propagation */}
|
||||||
<RFPropagationCard propagation={rfProp} />
|
<RFPropagationCard swpc={swpc} ducting={ducting} />
|
||||||
|
|
||||||
|
{/* Live Event Feed */}
|
||||||
|
<LiveEventFeed events={envEvents} envStatus={envStatus} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
503
meshai/dashboard/static/assets/index-DrKrP8CJ.js
Normal file
503
meshai/dashboard/static/assets/index-DrKrP8CJ.js
Normal file
File diff suppressed because one or more lines are too long
1
meshai/dashboard/static/assets/index-E1oMzltW.css
Normal file
1
meshai/dashboard/static/assets/index-E1oMzltW.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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
45
meshai/env/swpc.py
vendored
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue