mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +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
|
|
@ -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