mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-22 07:34:47 +02:00
Merge origin/feature/mesh-intelligence into feature/mesh-intelligence
Merged remote changes with local notification verification system: - Kept local: channels.py, router.py, notification_routes.py, Notifications.tsx (contains the new end-to-end verification system) - Accepted remote: Config, Environment, Reference pages, new commands, categories, summarizer, and other supporting files Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
5b78e38d2e
43 changed files with 8966 additions and 4183 deletions
156
dashboard-frontend/src/components/ChannelPicker.tsx
Normal file
156
dashboard-frontend/src/components/ChannelPicker.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface Channel {
|
||||
index: number
|
||||
name: string
|
||||
role: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface ChannelPickerSingleProps {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
helper?: string
|
||||
info?: string
|
||||
mode: 'single'
|
||||
includeDisabled?: boolean // Include a "Disabled (-1)" option
|
||||
}
|
||||
|
||||
interface ChannelPickerMultiProps {
|
||||
label: string
|
||||
value: number[]
|
||||
onChange: (value: number[]) => void
|
||||
helper?: string
|
||||
info?: string
|
||||
mode: 'multi'
|
||||
}
|
||||
|
||||
type ChannelPickerProps = ChannelPickerSingleProps | ChannelPickerMultiProps
|
||||
|
||||
export default function ChannelPicker(props: ChannelPickerProps) {
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/channels')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setChannels(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setChannels([])
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const formatChannel = (ch: Channel): string => {
|
||||
const roleLabel = ch.role === 'PRIMARY' ? 'Primary' :
|
||||
ch.role === 'SECONDARY' ? 'Secondary' : ''
|
||||
return `${ch.index}: ${ch.name}${roleLabel ? ` (${roleLabel})` : ''}`
|
||||
}
|
||||
|
||||
// Fallback to number input if no channels loaded
|
||||
if (!loading && channels.length === 0) {
|
||||
if (props.mode === 'single') {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{props.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(Number(e.target.value))}
|
||||
min={props.includeDisabled ? -1 : 0}
|
||||
max={7}
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{props.helper && <p className="text-xs text-slate-600">{props.helper}</p>}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{props.label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.value.join(', ')}
|
||||
onChange={(e) => {
|
||||
const nums = e.target.value.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n))
|
||||
props.onChange(nums)
|
||||
}}
|
||||
placeholder="Enter channel numbers separated by commas"
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{props.helper && <p className="text-xs text-slate-600">{props.helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Single select mode - dropdown
|
||||
if (props.mode === 'single') {
|
||||
const { value, onChange, label, helper, includeDisabled } = props
|
||||
const enabledChannels = channels.filter(ch => ch.enabled)
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||
>
|
||||
{includeDisabled && (
|
||||
<option value={-1}>Disabled</option>
|
||||
)}
|
||||
{enabledChannels.map((ch) => (
|
||||
<option key={ch.index} value={ch.index}>
|
||||
{formatChannel(ch)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Multi select mode - checkboxes
|
||||
const { value, onChange, label, helper } = props
|
||||
const enabledChannels = channels.filter(ch => ch.enabled)
|
||||
|
||||
const toggleChannel = (index: number) => {
|
||||
if (value.includes(index)) {
|
||||
onChange(value.filter(v => v !== index))
|
||||
} else {
|
||||
onChange([...value, index].sort((a, b) => a - b))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
<div className="border border-[#1e2a3a] rounded-lg p-2 space-y-1">
|
||||
{enabledChannels.map((ch) => (
|
||||
<label
|
||||
key={ch.index}
|
||||
onClick={() => toggleChannel(ch.index)}
|
||||
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
||||
value.includes(ch.index) ? 'bg-accent border-accent' : 'border-slate-600'
|
||||
}`}>
|
||||
{value.includes(ch.index) && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-sm text-slate-200">{formatChannel(ch)}</span>
|
||||
</label>
|
||||
))}
|
||||
{enabledChannels.length === 0 && (
|
||||
<div className="text-sm text-slate-500 p-2">No channels available</div>
|
||||
)}
|
||||
</div>
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
210
dashboard-frontend/src/components/NodePicker.tsx
Normal file
210
dashboard-frontend/src/components/NodePicker.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Search, X, Check } from 'lucide-react'
|
||||
|
||||
interface Node {
|
||||
node_num: number
|
||||
node_id_hex: string
|
||||
short_name: string
|
||||
long_name: string
|
||||
role: string
|
||||
is_infrastructure?: boolean
|
||||
}
|
||||
|
||||
interface NodePickerProps {
|
||||
label: string
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
helper?: string
|
||||
info?: string
|
||||
roleFilter?: string // e.g., "ROUTER" to show only infrastructure
|
||||
valueType?: 'short_name' | 'node_num' | 'node_id_hex' // What to store in value
|
||||
}
|
||||
|
||||
export default function NodePicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
helper,
|
||||
info: _info,
|
||||
roleFilter,
|
||||
valueType = 'short_name',
|
||||
}: NodePickerProps) {
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/nodes')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
setNodes(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
setNodes([])
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const filteredNodes = useMemo(() => {
|
||||
let result = nodes
|
||||
|
||||
// Filter by role if specified
|
||||
if (roleFilter) {
|
||||
result = result.filter(n => {
|
||||
if (roleFilter === 'ROUTER' || roleFilter === 'infrastructure') {
|
||||
return n.is_infrastructure ||
|
||||
n.role === 'ROUTER' ||
|
||||
n.role === 'ROUTER_CLIENT' ||
|
||||
n.role === 'REPEATER'
|
||||
}
|
||||
return n.role === roleFilter
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase()
|
||||
result = result.filter(n =>
|
||||
n.short_name?.toLowerCase().includes(s) ||
|
||||
n.long_name?.toLowerCase().includes(s) ||
|
||||
n.role?.toLowerCase().includes(s) ||
|
||||
n.node_id_hex?.toLowerCase().includes(s)
|
||||
)
|
||||
}
|
||||
|
||||
return result.sort((a, b) => (a.short_name || '').localeCompare(b.short_name || ''))
|
||||
}, [nodes, search, roleFilter])
|
||||
|
||||
const getNodeValue = (node: Node): string => {
|
||||
switch (valueType) {
|
||||
case 'node_num':
|
||||
return String(node.node_num)
|
||||
case 'node_id_hex':
|
||||
return node.node_id_hex
|
||||
default:
|
||||
return node.short_name || String(node.node_num)
|
||||
}
|
||||
}
|
||||
|
||||
const isSelected = (node: Node): boolean => {
|
||||
const nodeVal = getNodeValue(node)
|
||||
return value.includes(nodeVal)
|
||||
}
|
||||
|
||||
const toggleNode = (node: Node) => {
|
||||
const nodeVal = getNodeValue(node)
|
||||
if (value.includes(nodeVal)) {
|
||||
onChange(value.filter(v => v !== nodeVal))
|
||||
} else {
|
||||
onChange([...value, nodeVal])
|
||||
}
|
||||
}
|
||||
|
||||
const formatNodeDisplay = (node: Node): string => {
|
||||
const parts = [node.short_name]
|
||||
if (node.long_name && node.long_name !== node.short_name) {
|
||||
parts.push(`— ${node.long_name}`)
|
||||
}
|
||||
if (node.role) {
|
||||
parts.push(`(${node.role})`)
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
// Fallback to text input if no nodes loaded
|
||||
if (!loading && nodes.length === 0) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value.join(', ')}
|
||||
onChange={(e) => onChange(e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
|
||||
placeholder="Enter node IDs separated by commas"
|
||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
|
||||
/>
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
|
||||
|
||||
{/* Selected nodes display */}
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{value.map((v) => {
|
||||
const node = nodes.find(n => getNodeValue(n) === v)
|
||||
return (
|
||||
<span
|
||||
key={v}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-accent/20 text-accent rounded text-sm"
|
||||
>
|
||||
{node ? node.short_name : v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value.filter(val => val !== v))}
|
||||
className="hover:text-white"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and dropdown */}
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
placeholder={loading ? "Loading nodes..." : "Search nodes..."}
|
||||
className="w-full pl-9 pr-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpen && !loading && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-64 overflow-y-auto bg-[#0a0e17] border border-[#1e2a3a] rounded-lg shadow-xl">
|
||||
{filteredNodes.length === 0 ? (
|
||||
<div className="p-3 text-sm text-slate-500 text-center">
|
||||
No nodes found
|
||||
</div>
|
||||
) : (
|
||||
filteredNodes.map((node) => (
|
||||
<button
|
||||
key={node.node_num}
|
||||
type="button"
|
||||
onClick={() => toggleNode(node)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[#1e2a3a] ${
|
||||
isSelected(node) ? 'bg-accent/10' : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
||||
isSelected(node) ? 'bg-accent border-accent' : 'border-slate-600'
|
||||
}`}>
|
||||
{isSelected(node) && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
<span className="text-slate-200">{formatNodeDisplay(node)}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{helper && <p className="text-xs text-slate-600">{helper}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
dashboard-frontend/src/components/ToastProvider.tsx
Normal file
141
dashboard-frontend/src/components/ToastProvider.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { AlertTriangle, AlertCircle, Info, X } from 'lucide-react'
|
||||
import type { Alert } from '@/lib/api'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
alert: Alert
|
||||
dismissedAt?: number
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
addToast: (alert: Alert) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
function getSeverityStyles(severity: string) {
|
||||
switch (severity?.toLowerCase()) {
|
||||
case 'critical':
|
||||
case 'emergency':
|
||||
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',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-blue-500/10',
|
||||
border: 'border-blue-500',
|
||||
icon: Info,
|
||||
iconColor: 'text-blue-500',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ToastItem({
|
||||
toast,
|
||||
onDismiss,
|
||||
onNavigate,
|
||||
}: {
|
||||
toast: Toast
|
||||
onDismiss: () => void
|
||||
onNavigate: () => void
|
||||
}) {
|
||||
const styles = getSeverityStyles(toast.alert.severity)
|
||||
const Icon = styles.icon
|
||||
|
||||
// Auto-dismiss after 8 seconds
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onDismiss, 8000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [onDismiss])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.bg} border ${styles.border} rounded-lg shadow-lg overflow-hidden animate-slide-in cursor-pointer`}
|
||||
onClick={onNavigate}
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
{/* Severity bar */}
|
||||
<div className={`w-1 self-stretch -ml-4 -my-4 ${styles.border.replace('border', 'bg')}`} />
|
||||
|
||||
<Icon size={18} className={styles.iconColor} />
|
||||
|
||||
<div className="flex-1 min-w-0 pr-2">
|
||||
<div className="text-sm font-medium text-slate-200 mb-0.5">
|
||||
{toast.alert.type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</div>
|
||||
<div className="text-sm text-slate-300 line-clamp-2">
|
||||
{toast.alert.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDismiss()
|
||||
}}
|
||||
className="text-slate-400 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const navigate = useNavigate()
|
||||
|
||||
const addToast = useCallback((alert: Alert) => {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
setToasts((prev) => [...prev, { id, alert }])
|
||||
}, [])
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const handleNavigate = useCallback(() => {
|
||||
navigate('/alerts')
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ addToast }}>
|
||||
{children}
|
||||
|
||||
{/* Toast container - fixed bottom right */}
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className="pointer-events-auto">
|
||||
<ToastItem
|
||||
toast={toast}
|
||||
onDismiss={() => dismissToast(toast.id)}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,3 +47,28 @@ body {
|
|||
.animate-pulse-slow {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
|
||||
/* Toast slide-in animation */
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Line clamp utility */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,34 @@ export interface Alert {
|
|||
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[]
|
||||
|
|
@ -119,6 +147,37 @@ export interface EnvEvent {
|
|||
[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
|
||||
|
|
@ -128,6 +187,8 @@ export interface SWPCStatus {
|
|||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
kp_history?: KpHistoryEntry[]
|
||||
sfi_history?: SfiHistoryEntry[]
|
||||
}
|
||||
|
||||
export interface DuctingStatus {
|
||||
|
|
@ -137,6 +198,10 @@ export interface DuctingStatus {
|
|||
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 {
|
||||
|
|
@ -147,11 +212,13 @@ export interface RFPropagation {
|
|||
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[]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -209,6 +276,24 @@ 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')
|
||||
}
|
||||
|
|
@ -270,6 +355,96 @@ export interface AvalancheEvent {
|
|||
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[]
|
||||
|
|
@ -283,6 +458,22 @@ 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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,572 @@
|
|||
import { Bell } from 'lucide-react'
|
||||
|
||||
export default function Alerts() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-bg-card border border-border flex items-center justify-center mb-6">
|
||||
<Bell size={32} className="text-slate-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-300 mb-2">Alerts</h2>
|
||||
<p className="text-slate-500 max-w-md">
|
||||
Alert history and subscriptions coming in Phase 11
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Bell,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Filter,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Radio,
|
||||
Zap,
|
||||
|
||||
Cloud,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Battery,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
fetchAlerts,
|
||||
fetchAlertHistory,
|
||||
fetchSubscriptions,
|
||||
type Alert,
|
||||
type AlertHistoryItem,
|
||||
type Subscription,
|
||||
} from '@/lib/api'
|
||||
|
||||
interface Node {
|
||||
node_num: number
|
||||
node_id_hex: string
|
||||
short_name: string
|
||||
long_name: string
|
||||
}
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
|
||||
// Alert type icons mapping
|
||||
const alertTypeIcons: Record<string, typeof Bell> = {
|
||||
infra_offline: WifiOff,
|
||||
infra_recovery: Wifi,
|
||||
battery_warning: Battery,
|
||||
battery_critical: Battery,
|
||||
battery_emergency: Battery,
|
||||
hf_blackout: Zap,
|
||||
uhf_ducting: Radio,
|
||||
weather_warning: Cloud,
|
||||
weather_watch: Cloud,
|
||||
new_router: Radio,
|
||||
packet_flood: AlertTriangle,
|
||||
sustained_high_util: AlertTriangle,
|
||||
region_blackout: AlertCircle,
|
||||
default: Bell,
|
||||
}
|
||||
|
||||
function getAlertIcon(type: string) {
|
||||
return alertTypeIcons[type] || alertTypeIcons.default
|
||||
}
|
||||
|
||||
function getSeverityStyles(severity: string) {
|
||||
switch (severity?.toLowerCase()) {
|
||||
case 'critical':
|
||||
case 'emergency':
|
||||
return {
|
||||
bg: 'bg-red-500/10',
|
||||
border: 'border-red-500',
|
||||
badge: 'bg-red-500/20 text-red-400',
|
||||
iconColor: 'text-red-500',
|
||||
}
|
||||
case 'warning':
|
||||
return {
|
||||
bg: 'bg-amber-500/10',
|
||||
border: 'border-amber-500',
|
||||
badge: 'bg-amber-500/20 text-amber-400',
|
||||
iconColor: 'text-amber-500',
|
||||
}
|
||||
case 'watch':
|
||||
return {
|
||||
bg: 'bg-yellow-500/10',
|
||||
border: 'border-yellow-500',
|
||||
badge: 'bg-yellow-500/20 text-yellow-400',
|
||||
iconColor: 'text-yellow-500',
|
||||
}
|
||||
case 'advisory':
|
||||
case 'info':
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-blue-500/10',
|
||||
border: 'border-blue-500',
|
||||
badge: 'bg-blue-500/20 text-blue-400',
|
||||
iconColor: 'text-blue-500',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp: string | number): string {
|
||||
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffSec = Math.floor(diffMs / 1000)
|
||||
const diffMin = Math.floor(diffSec / 60)
|
||||
const diffHour = Math.floor(diffMin / 60)
|
||||
const diffDay = Math.floor(diffHour / 24)
|
||||
|
||||
if (diffSec < 60) return 'Just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
if (diffHour < 24) return `${diffHour}h ago`
|
||||
return `${diffDay}d ago`
|
||||
}
|
||||
|
||||
function formatDateTime(timestamp: string | number): string {
|
||||
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
|
||||
return `${Math.floor(seconds / 86400)}d`
|
||||
}
|
||||
|
||||
// Active Alert Card Component
|
||||
function ActiveAlertCard({
|
||||
alert,
|
||||
onAcknowledge,
|
||||
}: {
|
||||
alert: Alert
|
||||
onAcknowledge: (alert: Alert) => void
|
||||
}) {
|
||||
const styles = getSeverityStyles(alert.severity)
|
||||
const Icon = getAlertIcon(alert.type)
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-lg ${styles.bg} border-l-4 ${styles.border}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon size={20} className={styles.iconColor} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
|
||||
{alert.severity?.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{alert.type}</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-200">{alert.message}</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{alert.timestamp ? formatTimeAgo(alert.timestamp) : 'Just now'}
|
||||
</span>
|
||||
{alert.scope_value && (
|
||||
<span>{alert.scope_type}: {alert.scope_value}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAcknowledge(alert)}
|
||||
className="px-3 py-1 text-xs text-slate-400 hover:text-slate-200 border border-border rounded hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
Acknowledge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Alert History Table Component
|
||||
function AlertHistoryTable({
|
||||
history,
|
||||
typeFilter,
|
||||
severityFilter,
|
||||
onTypeFilterChange,
|
||||
onSeverityFilterChange,
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: {
|
||||
history: AlertHistoryItem[]
|
||||
typeFilter: string
|
||||
severityFilter: string
|
||||
onTypeFilterChange: (v: string) => void
|
||||
onSeverityFilterChange: (v: string) => void
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (p: number) => void
|
||||
}) {
|
||||
const alertTypes = [
|
||||
'all',
|
||||
'infra_offline',
|
||||
'infra_recovery',
|
||||
'battery_warning',
|
||||
'battery_critical',
|
||||
'hf_blackout',
|
||||
'uhf_ducting',
|
||||
'weather_warning',
|
||||
'new_router',
|
||||
'packet_flood',
|
||||
]
|
||||
|
||||
const severities = ['all', 'critical', 'warning', 'watch', 'info']
|
||||
|
||||
return (
|
||||
<div className="bg-bg-card border border-border rounded-lg">
|
||||
{/* Filters */}
|
||||
<div className="p-4 border-b border-border flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter size={14} className="text-slate-400" />
|
||||
<span className="text-sm text-slate-400">Filter:</span>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => onTypeFilterChange(e.target.value)}
|
||||
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{alertTypes.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t === 'all' ? 'All Types' : t.replace(/_/g, ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={(e) => onSeverityFilterChange(e.target.value)}
|
||||
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{severities.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s === 'all' ? 'All Severities' : s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Time</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Type</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Severity</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Message</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 p-4">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.length > 0 ? (
|
||||
history.map((item, i) => {
|
||||
const styles = getSeverityStyles(item.severity)
|
||||
return (
|
||||
<tr key={item.id || i} className="border-b border-border hover:bg-bg-hover">
|
||||
<td className="p-4 text-sm text-slate-400 font-mono whitespace-nowrap">
|
||||
{formatDateTime(item.timestamp)}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-300">
|
||||
{item.type.replace(/_/g, ' ')}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
|
||||
{item.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-200 max-w-md truncate">
|
||||
{item.message}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-slate-400 font-mono">
|
||||
{item.duration ? formatDuration(item.duration) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-slate-500">
|
||||
No alert history available
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="p-4 border-t border-border flex items-center justify-between">
|
||||
<span className="text-sm text-slate-400">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Subscription Card Component
|
||||
function SubscriptionCard({ subscription, nodes }: { subscription: Subscription; nodes: Node[] }) {
|
||||
const resolveNodeName = (userId: string): string => {
|
||||
const node = nodes.find(n =>
|
||||
n.node_id_hex === userId ||
|
||||
String(n.node_num) === userId ||
|
||||
n.short_name === userId
|
||||
)
|
||||
if (node) {
|
||||
return node.long_name && node.long_name !== node.short_name
|
||||
? `${node.short_name} (${node.long_name})`
|
||||
: node.short_name
|
||||
}
|
||||
return userId
|
||||
}
|
||||
const formatSchedule = () => {
|
||||
if (subscription.sub_type === 'alerts') {
|
||||
return 'Real-time'
|
||||
}
|
||||
const time = subscription.schedule_time || '0000'
|
||||
const hours = parseInt(time.slice(0, 2))
|
||||
const minutes = time.slice(2)
|
||||
const period = hours >= 12 ? 'PM' : 'AM'
|
||||
const displayHour = hours % 12 || 12
|
||||
let schedule = `${displayHour}:${minutes} ${period}`
|
||||
if (subscription.sub_type === 'weekly' && subscription.schedule_day) {
|
||||
schedule += ` ${subscription.schedule_day.charAt(0).toUpperCase()}${subscription.schedule_day.slice(1)}`
|
||||
}
|
||||
return schedule
|
||||
}
|
||||
|
||||
const getTypeIcon = () => {
|
||||
switch (subscription.sub_type) {
|
||||
case 'alerts':
|
||||
return Bell
|
||||
case 'daily':
|
||||
return Clock
|
||||
case 'weekly':
|
||||
return Clock
|
||||
default:
|
||||
return Bell
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = getTypeIcon()
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-lg bg-bg-hover border border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Icon size={18} className="text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-slate-200 font-medium">
|
||||
{subscription.sub_type.charAt(0).toUpperCase() + subscription.sub_type.slice(1)}
|
||||
{subscription.scope_type !== 'mesh' && subscription.scope_value && (
|
||||
<span className="text-slate-400 font-normal ml-2">
|
||||
({subscription.scope_type}: {subscription.scope_value})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
{formatSchedule()} • {resolveNodeName(subscription.user_id)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-2 h-2 rounded-full ${subscription.enabled ? 'bg-green-500' : 'bg-slate-500'}`} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Alerts() {
|
||||
const [activeAlerts, setActiveAlerts] = useState<Alert[]>([])
|
||||
const [history, setHistory] = useState<AlertHistoryItem[]>([])
|
||||
const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Filters and pagination
|
||||
const [typeFilter, setTypeFilter] = useState('all')
|
||||
const [severityFilter, setSeverityFilter] = useState('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const pageSize = 20
|
||||
|
||||
// Acknowledged alerts (local state only)
|
||||
const [acknowledged, setAcknowledged] = useState<Set<string>>(new Set())
|
||||
|
||||
const { lastAlert } = useWebSocket()
|
||||
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
document.title = 'Alerts — MeshAI'
|
||||
}, [])
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetchAlerts().catch(() => []),
|
||||
fetchAlertHistory(pageSize, 0).catch(() => ({ items: [], total: 0 })),
|
||||
fetchSubscriptions().catch(() => []),
|
||||
fetch('/api/nodes').then(r => r.json()).catch(() => []),
|
||||
])
|
||||
.then(([alerts, historyData, subs, nodeData]) => {
|
||||
setActiveAlerts(alerts)
|
||||
if (Array.isArray(historyData)) {
|
||||
setHistory(historyData)
|
||||
setTotalPages(1)
|
||||
} else {
|
||||
setHistory(historyData.items || [])
|
||||
setTotalPages(Math.ceil((historyData.total || 0) / pageSize))
|
||||
}
|
||||
setSubscriptions(subs)
|
||||
setNodes(nodeData)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Handle new alerts from WebSocket
|
||||
useEffect(() => {
|
||||
if (lastAlert) {
|
||||
setActiveAlerts((prev) => {
|
||||
// Avoid duplicates
|
||||
const exists = prev.some(
|
||||
(a) => a.type === lastAlert.type && a.message === lastAlert.message
|
||||
)
|
||||
if (exists) return prev
|
||||
return [lastAlert, ...prev]
|
||||
})
|
||||
}
|
||||
}, [lastAlert])
|
||||
|
||||
// Reload history when filters or page change
|
||||
useEffect(() => {
|
||||
const offset = (page - 1) * pageSize
|
||||
fetchAlertHistory(pageSize, offset, typeFilter, severityFilter)
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setHistory(data)
|
||||
setTotalPages(1)
|
||||
} else {
|
||||
setHistory(data.items || [])
|
||||
setTotalPages(Math.ceil((data.total || 0) / pageSize))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep current data on error
|
||||
})
|
||||
}, [page, typeFilter, severityFilter])
|
||||
|
||||
const handleAcknowledge = useCallback((alert: Alert) => {
|
||||
const key = `${alert.type}-${alert.message}-${alert.timestamp}`
|
||||
setAcknowledged((prev) => new Set([...prev, key]))
|
||||
}, [])
|
||||
|
||||
// Filter out acknowledged alerts
|
||||
const visibleAlerts = activeAlerts.filter((alert) => {
|
||||
const key = `${alert.type}-${alert.message}-${alert.timestamp}`
|
||||
return !acknowledged.has(key)
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-slate-400">Loading alerts...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-red-400">Error: {error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 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 flex items-center gap-2">
|
||||
<AlertTriangle size={14} />
|
||||
Active Alerts ({visibleAlerts.length})
|
||||
</h2>
|
||||
{visibleAlerts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{visibleAlerts.map((alert, i) => (
|
||||
<ActiveAlertCard
|
||||
key={`${alert.type}-${alert.timestamp}-${i}`}
|
||||
alert={alert}
|
||||
onAcknowledge={handleAcknowledge}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-slate-500 py-8">
|
||||
<CheckCircle size={20} className="text-green-500" />
|
||||
<span>No active alerts — all systems nominal</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alert History */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Clock size={14} />
|
||||
Alert History
|
||||
</h2>
|
||||
<AlertHistoryTable
|
||||
history={history}
|
||||
typeFilter={typeFilter}
|
||||
severityFilter={severityFilter}
|
||||
onTypeFilterChange={(v) => {
|
||||
setTypeFilter(v)
|
||||
setPage(1)
|
||||
}}
|
||||
onSeverityFilterChange={(v) => {
|
||||
setSeverityFilter(v)
|
||||
setPage(1)
|
||||
}}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subscriptions */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Users size={14} />
|
||||
Mesh Subscriptions ({subscriptions.length})
|
||||
</h2>
|
||||
{subscriptions.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{subscriptions.map((sub) => (
|
||||
<SubscriptionCard key={sub.id} subscription={sub} nodes={nodes} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-slate-500 py-4">
|
||||
<p>No active subscriptions.</p>
|
||||
<p className="text-xs mt-2">
|
||||
Manage subscriptions via <code className="text-blue-400">!subscribe</code> on mesh
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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,19 +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)
|
||||
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'
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
|
@ -340,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">
|
||||
|
|
@ -357,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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Cloud,
|
||||
Sun,
|
||||
|
|
@ -10,42 +10,10 @@ import {
|
|||
Wind,
|
||||
Flame,
|
||||
Mountain,
|
||||
HelpCircle,
|
||||
Droplets,
|
||||
Car,
|
||||
Satellite,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Info button component with popover
|
||||
function InfoButton({ info }: { info: string }) {
|
||||
const [show, setShow] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setShow(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow(!show)}
|
||||
className="ml-1 text-slate-500 hover:text-slate-300 transition-colors"
|
||||
aria-label="More information"
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
</button>
|
||||
{show && (
|
||||
<div className="absolute z-50 left-0 mt-1 w-64 p-3 bg-[#1a1f2e] border border-[#2a3548] rounded-lg shadow-xl text-xs text-slate-300 leading-relaxed">
|
||||
{info}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import {
|
||||
fetchEnvStatus,
|
||||
fetchEnvActive,
|
||||
|
|
@ -53,12 +21,21 @@ import {
|
|||
fetchDucting,
|
||||
fetchFires,
|
||||
fetchAvalanche,
|
||||
fetchStreams,
|
||||
fetchTraffic,
|
||||
fetchRoads,
|
||||
fetchHotspots,
|
||||
type EnvStatus,
|
||||
type EnvEvent,
|
||||
type SWPCStatus,
|
||||
type DuctingStatus,
|
||||
type FireEvent,
|
||||
type AvalancheResponse,
|
||||
type StreamGaugeEvent,
|
||||
type TrafficEvent,
|
||||
type RoadEvent,
|
||||
type HotspotEvent,
|
||||
|
||||
} from '@/lib/api'
|
||||
|
||||
function FeedStatusCard({ feed }: { feed: { source: string; is_loaded: boolean; last_error: string | null; consecutive_errors: number; event_count: number; last_fetch: number } }) {
|
||||
|
|
@ -185,7 +162,6 @@ function SolarIndicesPanel({ swpc }: { swpc: SWPCStatus | null }) {
|
|||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Sun size={14} />
|
||||
Solar/Geomagnetic Indices
|
||||
<InfoButton info="Space weather data from NOAA SWPC. Solar Flux Index (SFI) indicates HF propagation quality. Kp index measures geomagnetic disturbance. Higher values can degrade or enhance radio propagation." />
|
||||
</h2>
|
||||
<div className="text-slate-500">Data not available</div>
|
||||
</div>
|
||||
|
|
@ -212,16 +188,12 @@ function SolarIndicesPanel({ swpc }: { swpc: SWPCStatus | null }) {
|
|||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Sun size={14} />
|
||||
Solar/Geomagnetic Indices
|
||||
<InfoButton info="Space weather data from NOAA SWPC. Solar Flux Index (SFI) indicates HF propagation quality (higher=better). Kp index measures geomagnetic disturbance (lower=better). R/S/G scales: R=Radio Blackout, S=Solar Radiation, G=Geomagnetic Storm." />
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
{/* SFI */}
|
||||
<div className="bg-bg-hover rounded-lg p-3">
|
||||
<div className="text-xs text-slate-500 mb-1 flex items-center">
|
||||
Solar Flux Index
|
||||
<InfoButton info="10.7cm solar radio flux. <70=poor HF, 70-90=fair, 90-120=good, 120-150=very good, >150=excellent. Measured daily at noon UTC." />
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mb-1">Solar Flux Index</div>
|
||||
<div className="text-2xl font-mono text-slate-100">
|
||||
{swpc.sfi?.toFixed(0) ?? '—'}
|
||||
</div>
|
||||
|
|
@ -230,10 +202,7 @@ function SolarIndicesPanel({ swpc }: { swpc: SWPCStatus | null }) {
|
|||
|
||||
{/* Kp */}
|
||||
<div className="bg-bg-hover rounded-lg p-3">
|
||||
<div className="text-xs text-slate-500 mb-1 flex items-center">
|
||||
Planetary K-Index
|
||||
<InfoButton info="Geomagnetic disturbance scale 0-9. Kp 0-2=quiet, 3-4=unsettled, 5=minor storm (G1), 6=moderate (G2), 7=strong (G3), 8=severe (G4), 9=extreme (G5). Higher Kp degrades HF at high latitudes." />
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mb-1">Planetary K-Index</div>
|
||||
<div className={`text-2xl font-mono ${getKpColor(swpc.kp_current)}`}>
|
||||
{swpc.kp_current?.toFixed(1) ?? '—'}
|
||||
</div>
|
||||
|
|
@ -294,7 +263,6 @@ function DuctingPanel({ ducting }: { ducting: DuctingStatus | null }) {
|
|||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Wind size={14} />
|
||||
Tropospheric Ducting
|
||||
<InfoButton info="Atmospheric conditions that trap VHF/UHF signals, allowing propagation far beyond normal line-of-sight. Measured as refractivity gradient (dM/dz) in M-units/km. Negative values indicate ducting." />
|
||||
</h2>
|
||||
<div className="text-slate-500">Data not available</div>
|
||||
</div>
|
||||
|
|
@ -325,15 +293,11 @@ function DuctingPanel({ ducting }: { ducting: DuctingStatus | null }) {
|
|||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Wind size={14} />
|
||||
Tropospheric Ducting
|
||||
<InfoButton info="Atmospheric conditions that trap VHF/UHF signals, allowing propagation far beyond normal line-of-sight. Ducting can extend 900 MHz Meshtastic range significantly. Surface ducts form near the ground; elevated ducts form aloft." />
|
||||
</h2>
|
||||
|
||||
{/* Condition */}
|
||||
<div className="bg-bg-hover rounded-lg p-4 mb-4">
|
||||
<div className="text-xs text-slate-500 mb-1 flex items-center">
|
||||
Condition
|
||||
<InfoButton info="Normal: Standard refraction. Super-refraction: Signals bend more than normal, slightly extended range. Ducting: Signals trapped in atmospheric layer, significantly extended range possible." />
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mb-1">Condition</div>
|
||||
<div className={`text-xl font-medium ${getConditionColor(ducting.condition)}`}>
|
||||
{formatCondition(ducting.condition)}
|
||||
</div>
|
||||
|
|
@ -396,10 +360,16 @@ export default function Environment() {
|
|||
const [ducting, setDucting] = useState<DuctingStatus | null>(null)
|
||||
const [fires, setFires] = useState<FireEvent[]>([])
|
||||
const [avalanche, setAvalanche] = useState<AvalancheResponse | null>(null)
|
||||
const [streams, setStreams] = useState<StreamGaugeEvent[]>([])
|
||||
const [traffic, setTraffic] = useState<TrafficEvent[]>([])
|
||||
const [roads, setRoads] = useState<RoadEvent[]>([])
|
||||
const [hotspots, setHotspots] = useState<HotspotEvent[]>([])
|
||||
const [newIgnitions, setNewIgnitions] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Environment — MeshAI'
|
||||
Promise.all([
|
||||
fetchEnvStatus().catch(() => null),
|
||||
fetchEnvActive().catch(() => []),
|
||||
|
|
@ -407,14 +377,23 @@ export default function Environment() {
|
|||
fetchDucting().catch(() => null),
|
||||
fetchFires().catch(() => []),
|
||||
fetchAvalanche().catch(() => null),
|
||||
fetchStreams().catch(() => []),
|
||||
fetchTraffic().catch(() => []),
|
||||
fetchRoads().catch(() => []),
|
||||
fetchHotspots().catch(() => ({ hotspots: [], new_ignitions: 0 })),
|
||||
])
|
||||
.then(([status, active, swpcData, ductingData, firesData, avyData]) => {
|
||||
.then(([status, active, swpcData, ductingData, firesData, avyData, streamsData, trafficData, roadsData, hotspotsData]) => {
|
||||
setEnvStatus(status)
|
||||
setEvents(active)
|
||||
setSWPC(swpcData)
|
||||
setDucting(ductingData)
|
||||
setFires(firesData)
|
||||
setAvalanche(avyData)
|
||||
setStreams(streamsData || [])
|
||||
setTraffic(trafficData || [])
|
||||
setRoads(roadsData || [])
|
||||
setHotspots(hotspotsData?.hotspots || [])
|
||||
setNewIgnitions(hotspotsData?.new_ignitions || 0)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
|
|
@ -611,6 +590,170 @@ export default function Environment() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stream Gauges */}
|
||||
{streams.length > 0 && (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Droplets size={14} />
|
||||
Stream Gauges ({streams.length})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{streams.map((stream) => (
|
||||
<div
|
||||
key={stream.event_id}
|
||||
className={`p-3 rounded-lg ${
|
||||
stream.severity === 'warning'
|
||||
? 'bg-amber-500/10 border-l-2 border-amber-500'
|
||||
: 'bg-blue-500/10 border-l-2 border-blue-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-200">
|
||||
{stream.properties?.site_name || 'Unknown Site'}
|
||||
</span>
|
||||
<span className="text-sm font-mono text-slate-300">
|
||||
{stream.properties?.value?.toLocaleString()} {stream.properties?.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
{stream.properties?.parameter}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Road Conditions */}
|
||||
{(traffic.length > 0 || roads.length > 0) && (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Car size={14} />
|
||||
Road Conditions
|
||||
</h2>
|
||||
{traffic.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="text-xs text-slate-500 mb-2 uppercase">Traffic Flow</div>
|
||||
<div className="space-y-2">
|
||||
{traffic.map((t) => (
|
||||
<div
|
||||
key={t.event_id}
|
||||
className={`p-3 rounded-lg ${
|
||||
t.properties?.roadClosure
|
||||
? 'bg-red-500/10 border-l-2 border-red-500'
|
||||
: t.properties?.speedRatio < 0.5
|
||||
? 'bg-amber-500/10 border-l-2 border-amber-500'
|
||||
: t.properties?.speedRatio < 0.8
|
||||
? 'bg-yellow-500/10 border-l-2 border-yellow-500'
|
||||
: 'bg-green-500/10 border-l-2 border-green-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-200">
|
||||
{t.properties?.corridor || 'Unknown'}
|
||||
</span>
|
||||
<span className="text-sm font-mono text-slate-300">
|
||||
{t.properties?.roadClosure ? 'CLOSED' : `${Math.round(t.properties?.currentSpeed || 0)}mph`}
|
||||
</span>
|
||||
</div>
|
||||
{!t.properties?.roadClosure && (
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
{Math.round((t.properties?.speedRatio || 1) * 100)}% of free flow ({Math.round(t.properties?.freeFlowSpeed || 0)}mph)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{roads.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 mb-2 uppercase">Road Events</div>
|
||||
<div className="space-y-2">
|
||||
{roads.map((r) => (
|
||||
<div
|
||||
key={r.event_id}
|
||||
className={`p-3 rounded-lg ${
|
||||
r.properties?.is_closure
|
||||
? 'bg-red-500/10 border-l-2 border-red-500'
|
||||
: 'bg-amber-500/10 border-l-2 border-amber-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{r.properties?.is_closure && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
|
||||
CLOSURE
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-slate-200 line-clamp-1">
|
||||
{r.headline}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1 uppercase">
|
||||
{r.event_type}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Satellite Hotspots */}
|
||||
{hotspots.length > 0 && (
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
<Satellite size={14} />
|
||||
Satellite Hotspots ({hotspots.length})
|
||||
{newIgnitions > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs rounded-full bg-red-500/20 text-red-400 animate-pulse">
|
||||
{newIgnitions} NEW
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{hotspots.map((h) => (
|
||||
<div
|
||||
key={h.event_id}
|
||||
className={`p-3 rounded-lg ${
|
||||
h.properties?.new_ignition
|
||||
? 'bg-red-500/10 border-l-2 border-red-500'
|
||||
: h.severity === 'watch'
|
||||
? 'bg-amber-500/10 border-l-2 border-amber-500'
|
||||
: 'bg-orange-500/10 border-l-2 border-orange-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{h.properties?.new_ignition && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-slate-200">
|
||||
{h.headline}
|
||||
</span>
|
||||
</div>
|
||||
{h.properties?.frp && (
|
||||
<span className="text-sm font-mono text-orange-400">
|
||||
{Math.round(h.properties.frp)} MW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1 flex items-center gap-3">
|
||||
<span>Conf: {h.properties?.confidence || 'N/A'}</span>
|
||||
{h.properties?.acq_time && <span>@{h.properties.acq_time}Z</span>}
|
||||
{h.properties?.near_fire && (
|
||||
<span>Near: {h.properties.near_fire}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Events */}
|
||||
<div className="bg-bg-card border border-border rounded-lg p-6">
|
||||
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export default function Mesh() {
|
|||
|
||||
// Fetch data on mount
|
||||
useEffect(() => {
|
||||
document.title = 'Mesh — MeshAI'
|
||||
Promise.all([fetchNodes(), fetchEdges(), fetchRegions()])
|
||||
.then(([n, e, r]) => {
|
||||
setNodes(n)
|
||||
|
|
|
|||
|
|
@ -712,17 +712,126 @@ export default function Reference() {
|
|||
{/* Mesh Health */}
|
||||
<TopicSection id="mesh-health" title="Mesh Health">
|
||||
<SectionHeader>Health Score</SectionHeader>
|
||||
<p>MeshAI computes a 0-100 health score for your mesh network by looking at five areas:</p>
|
||||
<p>MeshAI computes a 0-100 health score for your mesh network by looking at five areas, each weighted differently:</p>
|
||||
<RefTable
|
||||
headers={['Area', 'Weight', 'What It Checks']}
|
||||
headers={['Pillar', 'Weight', 'What It Measures']}
|
||||
rows={[
|
||||
['Infrastructure', '30%', 'Are your routers and repeaters online and healthy?'],
|
||||
['Utilization', '25%', 'Is the radio channel getting congested?'],
|
||||
['Coverage', '20%', 'Do nodes have backup paths, or single points of failure?'],
|
||||
['Behavior', '15%', 'Are nodes behaving normally (packet patterns, responsiveness)?'],
|
||||
['Power', '10%', 'Battery levels, solar charging, power stability'],
|
||||
[<strong>Infrastructure</strong>, '30%', 'Are your routers online?'],
|
||||
[<strong>Utilization</strong>, '25%', 'Is the radio channel congested?'],
|
||||
[<strong>Coverage</strong>, '20%', 'Do nodes have redundant paths to gateways?'],
|
||||
[<strong>Behavior</strong>, '15%', 'Are any nodes flooding the channel?'],
|
||||
[<strong>Power</strong>, '10%', 'Are battery-powered nodes running low?'],
|
||||
]}
|
||||
/>
|
||||
<p>The overall score is the weighted sum:</p>
|
||||
<p className="p-3 bg-slate-800 rounded font-mono text-sm">
|
||||
Score = (Infrastructure × 30%) + (Utilization × 25%) + (Coverage × 20%) + (Behavior × 15%) + (Power × 10%)
|
||||
</p>
|
||||
|
||||
<SectionHeader>How Each Pillar Is Calculated</SectionHeader>
|
||||
|
||||
<SubHeader>Infrastructure (30%)</SubHeader>
|
||||
<p>
|
||||
This is the simplest pillar — what percentage of your infrastructure nodes are currently online?
|
||||
</p>
|
||||
<p className="p-3 bg-slate-800 rounded font-mono text-sm">
|
||||
(routers online ÷ total routers) × 100
|
||||
</p>
|
||||
<p>
|
||||
Only nodes with the <Mono>ROUTER</Mono>, <Mono>ROUTER_LATE</Mono>, or <Mono>ROUTER_CLIENT</Mono> role count as infrastructure. Regular client nodes going offline doesn't affect this score. If you have 5 routers and 3 are online, infrastructure scores 60.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Special case:</strong> If you have no routers at all (all clients), this pillar scores 100. You're not penalized for not having infrastructure — you just don't have any to track.
|
||||
</p>
|
||||
|
||||
<SubHeader>Utilization (25%)</SubHeader>
|
||||
<p>
|
||||
MeshAI reads the channel utilization that each router reports in its telemetry — this is the firmware's own measurement of how busy the radio channel is. MeshAI uses the <strong>highest</strong> value from any infrastructure node because the busiest router is the bottleneck for the whole mesh.
|
||||
</p>
|
||||
<p>
|
||||
<strong>How it works:</strong>
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-4">
|
||||
<li>Collect <Mono>channel_utilization</Mono> from all infrastructure nodes that report it</li>
|
||||
<li>If no infra nodes have telemetry, try all nodes</li>
|
||||
<li>Use the <strong>maximum</strong> value for scoring (busiest node = bottleneck)</li>
|
||||
<li>If no nodes report utilization (older firmware), fall back to packet count estimate</li>
|
||||
</ol>
|
||||
<p className="mt-4">
|
||||
<strong>Fallback method</strong> (when telemetry unavailable): estimates from packet counts using 200ms/packet airtime. This is less accurate — it assumes MediumFast preset and sums packets across all nodes.
|
||||
</p>
|
||||
<RefTable
|
||||
headers={['Channel Utilization', 'Score', 'What It Means']}
|
||||
rows={[
|
||||
['Under 20%', '100', 'Channel is clear — this is the goal'],
|
||||
['20-25%', '75-100', 'Slight degradation, occasional collisions'],
|
||||
['25-35%', '50-75', 'Severe degradation — firmware throttling active'],
|
||||
['35-45%', '25-50', 'Mesh struggling badly — reliability dropping'],
|
||||
['Over 45%', '0-25', 'Mesh is effectively unusable'],
|
||||
]}
|
||||
/>
|
||||
<p>
|
||||
<strong>Special case:</strong> If no utilization data is available (no telemetry and no packet data), this pillar scores 100. You're not penalized for missing data.
|
||||
</p>
|
||||
|
||||
<SubHeader>Coverage (20%)</SubHeader>
|
||||
<p>
|
||||
Measures gateway redundancy — how many of your data sources can "see" each node. A node reported by all 3 of your gateways has full coverage. A node only seen by 1 gateway is a single point of failure.
|
||||
</p>
|
||||
<p className="p-3 bg-slate-800 rounded font-mono text-sm">
|
||||
coverage_ratio = average_gateways_per_node ÷ total_sources<br/>
|
||||
single_gw_penalty = (single_gateway_nodes ÷ total_nodes) × 40
|
||||
</p>
|
||||
<p>
|
||||
If a node is seen by 2 out of 3 sources, its coverage ratio is 0.67. Infrastructure nodes with only single-gateway coverage get an extra penalty — they're critical but have no backup path.
|
||||
</p>
|
||||
<RefTable
|
||||
headers={['Coverage Ratio', 'Base Score', 'After Penalty']}
|
||||
rows={[
|
||||
['100% (all sources)', '100', '100 minus single-gw penalty'],
|
||||
['70-99%', '90', 'Minus penalties'],
|
||||
['50-69%', '70', 'Minus penalties'],
|
||||
['Under 50%', '50 or less', 'Heavy penalty'],
|
||||
]}
|
||||
/>
|
||||
<p>
|
||||
<strong>Special case:</strong> With only 1 data source, this pillar can't score well — there's no redundancy to measure. Coverage becomes meaningful when you have 2+ sources (MeshMonitor + MQTT, multiple gateways, etc.).
|
||||
</p>
|
||||
|
||||
<SubHeader>Behavior (15%)</SubHeader>
|
||||
<p>
|
||||
Counts how many nodes are sending an unusually high number of non-text packets. This catches firmware bugs, stuck transmitters, and misconfigured nodes that are flooding the channel.
|
||||
</p>
|
||||
<p>
|
||||
<strong>What counts as flooding:</strong> More than 500 non-text packets in 24 hours. Text messages don't count — the behavior pillar only flags telemetry, position, and routing packet floods.
|
||||
</p>
|
||||
<RefTable
|
||||
headers={['Flagged Nodes', 'Score']}
|
||||
rows={[
|
||||
['0', '100'],
|
||||
['1', '80'],
|
||||
['2-3', '60'],
|
||||
['4-5', '40'],
|
||||
['6+', '20'],
|
||||
]}
|
||||
/>
|
||||
<p>
|
||||
A single misbehaving node only drops the score to 80. It takes multiple problem nodes to seriously hurt the behavior pillar.
|
||||
</p>
|
||||
|
||||
<SubHeader>Power (10%)</SubHeader>
|
||||
<p>
|
||||
Measures what fraction of battery-powered nodes are below the warning threshold (default 20%).
|
||||
</p>
|
||||
<p className="p-3 bg-slate-800 rounded font-mono text-sm">
|
||||
100 × (1 − low_battery_nodes ÷ total_battery_nodes)
|
||||
</p>
|
||||
<p>
|
||||
If 2 out of 10 battery nodes are below 20%, power scores 80.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Important:</strong> USB-powered nodes are excluded from this calculation. Many nodes report 100% battery even when running on wall power with no battery installed. Only nodes actually running on batteries affect this pillar.
|
||||
</p>
|
||||
|
||||
<SectionHeader>Health Tiers</SectionHeader>
|
||||
<RefTable
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue