mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
build: normalize all line endings to LF
One-time renormalization pass under the .gitattributes added in the previous commit. Every tracked text file now uses LF. No semantic changes — verified via git diff --cached --ignore-all-space showing zero real differences. Future diffs will only show real content changes. This commit will appear huge in git log --stat but represents zero behavior change. Use git log --follow --ignore-all-space or git blame -w when archaeologically tracing through this commit.
This commit is contained in:
parent
211c642b60
commit
d6bc6b2b89
46 changed files with 11450 additions and 11450 deletions
|
|
@ -1,156 +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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,180 +1,180 @@
|
|||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Radio,
|
||||
Cloud,
|
||||
Settings,
|
||||
Bell,
|
||||
BellRing,
|
||||
BookOpen,
|
||||
} from 'lucide-react'
|
||||
import { fetchStatus, type SystemStatus } from '@/lib/api'
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
import { useToast } from './ToastProvider'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ path: '/mesh', label: 'Mesh', icon: Radio },
|
||||
{ path: '/environment', label: 'Environment', icon: Cloud },
|
||||
{ path: '/config', label: 'Config', icon: Settings },
|
||||
{ path: '/alerts', label: 'Alerts', icon: Bell },
|
||||
{ path: '/notifications', label: 'Notifications', icon: BellRing },
|
||||
{ path: '/reference', label: 'Reference', icon: BookOpen },
|
||||
]
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
if (hours > 0) return `${hours}h ${mins}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
function getPageTitle(pathname: string): string {
|
||||
const item = navItems.find((i) => i.path === pathname)
|
||||
return item?.label || 'Dashboard'
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation()
|
||||
const { connected, lastAlert } = useWebSocket()
|
||||
const { addToast } = useToast()
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null)
|
||||
const [lastAlertId, setLastAlertId] = useState<string | null>(null)
|
||||
|
||||
// Trigger toast on new alerts
|
||||
useEffect(() => {
|
||||
if (lastAlert) {
|
||||
const alertId = `${lastAlert.type}-${lastAlert.message}-${lastAlert.timestamp}`
|
||||
if (alertId !== lastAlertId) {
|
||||
setLastAlertId(alertId)
|
||||
addToast(lastAlert)
|
||||
}
|
||||
}
|
||||
}, [lastAlert, lastAlertId, addToast])
|
||||
const [currentTime, setCurrentTime] = useState(new Date())
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus().then(setStatus).catch(console.error)
|
||||
const interval = setInterval(() => {
|
||||
fetchStatus().then(setStatus).catch(console.error)
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setCurrentTime(new Date()), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const timeStr = currentTime.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-bg text-slate-200">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-[220px] flex-shrink-0 bg-bg-card border-r border-border flex flex-col overflow-y-auto">
|
||||
{/* Logo */}
|
||||
<div className="p-5 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center text-white font-bold text-xl">
|
||||
M
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-lg">MeshAI</div>
|
||||
<div className="text-xs text-slate-500 font-mono">
|
||||
v{status?.version || '...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-3 px-5 py-3 text-sm transition-colors relative ${
|
||||
isActive
|
||||
? 'text-blue-400 bg-blue-500/10'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-blue-500" />
|
||||
)}
|
||||
<Icon size={18} />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="p-5 border-t border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
status?.connected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">
|
||||
{status?.connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 font-mono truncate">
|
||||
{status?.connection_type?.toUpperCase()}: {status?.connection_target}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Uptime: {status ? formatUptime(status.uptime_seconds) : '...'}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="h-14 flex-shrink-0 border-b border-border bg-bg-card flex items-center justify-between px-6">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{getPageTitle(location.pathname)}
|
||||
</h1>
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Live indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
connected ? 'bg-green-500 animate-pulse-slow' : 'bg-slate-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
{/* Clock */}
|
||||
<div className="text-sm font-mono text-slate-400">
|
||||
{timeStr} MT
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Radio,
|
||||
Cloud,
|
||||
Settings,
|
||||
Bell,
|
||||
BellRing,
|
||||
BookOpen,
|
||||
} from 'lucide-react'
|
||||
import { fetchStatus, type SystemStatus } from '@/lib/api'
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
import { useToast } from './ToastProvider'
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ path: '/mesh', label: 'Mesh', icon: Radio },
|
||||
{ path: '/environment', label: 'Environment', icon: Cloud },
|
||||
{ path: '/config', label: 'Config', icon: Settings },
|
||||
{ path: '/alerts', label: 'Alerts', icon: Bell },
|
||||
{ path: '/notifications', label: 'Notifications', icon: BellRing },
|
||||
{ path: '/reference', label: 'Reference', icon: BookOpen },
|
||||
]
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
if (hours > 0) return `${hours}h ${mins}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
function getPageTitle(pathname: string): string {
|
||||
const item = navItems.find((i) => i.path === pathname)
|
||||
return item?.label || 'Dashboard'
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation()
|
||||
const { connected, lastAlert } = useWebSocket()
|
||||
const { addToast } = useToast()
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null)
|
||||
const [lastAlertId, setLastAlertId] = useState<string | null>(null)
|
||||
|
||||
// Trigger toast on new alerts
|
||||
useEffect(() => {
|
||||
if (lastAlert) {
|
||||
const alertId = `${lastAlert.type}-${lastAlert.message}-${lastAlert.timestamp}`
|
||||
if (alertId !== lastAlertId) {
|
||||
setLastAlertId(alertId)
|
||||
addToast(lastAlert)
|
||||
}
|
||||
}
|
||||
}, [lastAlert, lastAlertId, addToast])
|
||||
const [currentTime, setCurrentTime] = useState(new Date())
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus().then(setStatus).catch(console.error)
|
||||
const interval = setInterval(() => {
|
||||
fetchStatus().then(setStatus).catch(console.error)
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setCurrentTime(new Date()), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const timeStr = currentTime.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-bg text-slate-200">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-[220px] flex-shrink-0 bg-bg-card border-r border-border flex flex-col overflow-y-auto">
|
||||
{/* Logo */}
|
||||
<div className="p-5 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center text-white font-bold text-xl">
|
||||
M
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-lg">MeshAI</div>
|
||||
<div className="text-xs text-slate-500 font-mono">
|
||||
v{status?.version || '...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-3 px-5 py-3 text-sm transition-colors relative ${
|
||||
isActive
|
||||
? 'text-blue-400 bg-blue-500/10'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-0.5 bg-blue-500" />
|
||||
)}
|
||||
<Icon size={18} />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Connection status */}
|
||||
<div className="p-5 border-t border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
status?.connected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">
|
||||
{status?.connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 font-mono truncate">
|
||||
{status?.connection_type?.toUpperCase()}: {status?.connection_target}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Uptime: {status ? formatUptime(status.uptime_seconds) : '...'}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="h-14 flex-shrink-0 border-b border-border bg-bg-card flex items-center justify-between px-6">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{getPageTitle(location.pathname)}
|
||||
</h1>
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Live indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
connected ? 'bg-green-500 animate-pulse-slow' : 'bg-slate-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
{/* Clock */}
|
||||
<div className="text-sm font-mono text-slate-400">
|
||||
{timeStr} MT
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,210 +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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,141 +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>
|
||||
)
|
||||
}
|
||||
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,109 +1,109 @@
|
|||
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 }
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,479 +1,479 @@
|
|||
// API types matching actual backend responses
|
||||
|
||||
export interface SystemStatus {
|
||||
version: string
|
||||
uptime_seconds: number
|
||||
bot_name: string
|
||||
connection_type: string
|
||||
connection_target: string
|
||||
connected: boolean
|
||||
node_count: number
|
||||
source_count: number
|
||||
env_feeds_enabled: boolean
|
||||
dashboard_port: number
|
||||
}
|
||||
|
||||
export interface MeshHealth {
|
||||
score: number
|
||||
tier: string
|
||||
pillars: {
|
||||
infrastructure: number
|
||||
utilization: number
|
||||
behavior: number
|
||||
power: number
|
||||
}
|
||||
infra_online: number
|
||||
infra_total: number
|
||||
util_percent: number
|
||||
flagged_nodes: number
|
||||
battery_warnings: number
|
||||
total_nodes: number
|
||||
total_regions: number
|
||||
unlocated_count: number
|
||||
last_computed: string
|
||||
recommendations: string[]
|
||||
}
|
||||
|
||||
export interface NodeInfo {
|
||||
node_num: number
|
||||
node_id_hex: string
|
||||
short_name: string
|
||||
long_name: string
|
||||
role: string
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
last_heard: string | null
|
||||
battery_level: number | null
|
||||
voltage: number | null
|
||||
snr: number | null
|
||||
firmware: string
|
||||
hardware: string
|
||||
uptime: number | null
|
||||
sources: string[]
|
||||
}
|
||||
|
||||
export interface EdgeInfo {
|
||||
from_node: number
|
||||
to_node: number
|
||||
snr: number
|
||||
quality: string
|
||||
}
|
||||
|
||||
export interface RegionInfo {
|
||||
name: string
|
||||
local_name: string
|
||||
node_count: number
|
||||
infra_count: number
|
||||
infra_online: number
|
||||
online_count: number
|
||||
score: number
|
||||
tier: string
|
||||
center_lat: number
|
||||
center_lon: number
|
||||
}
|
||||
|
||||
export interface SourceHealth {
|
||||
name: string
|
||||
type: string
|
||||
url: string
|
||||
is_loaded: boolean
|
||||
last_error: string | null
|
||||
consecutive_errors: number
|
||||
response_time_ms: number | null
|
||||
tick_count: number
|
||||
node_count: number
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
type: string
|
||||
severity: string
|
||||
message: string
|
||||
timestamp: string
|
||||
scope_type?: string
|
||||
scope_value?: string
|
||||
}
|
||||
|
||||
export interface AlertHistoryItem {
|
||||
id?: number
|
||||
type: string
|
||||
severity: string
|
||||
message: string
|
||||
timestamp: string
|
||||
duration?: number
|
||||
scope_type?: string
|
||||
scope_value?: string
|
||||
resolved_at?: string
|
||||
}
|
||||
|
||||
export interface AlertHistoryResponse {
|
||||
items: AlertHistoryItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: number
|
||||
user_id: string
|
||||
sub_type: string
|
||||
schedule_time?: string
|
||||
schedule_day?: string
|
||||
scope_type: string
|
||||
scope_value?: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface EnvStatus {
|
||||
enabled: boolean
|
||||
feeds: EnvFeedHealth[]
|
||||
}
|
||||
|
||||
export interface EnvFeedHealth {
|
||||
source: string
|
||||
is_loaded: boolean
|
||||
last_error: string | null
|
||||
consecutive_errors: number
|
||||
event_count: number
|
||||
last_fetch: number
|
||||
}
|
||||
|
||||
export interface EnvEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
description?: string
|
||||
expires?: number
|
||||
fetched_at: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// Kp history entry for charting
|
||||
export interface KpHistoryEntry {
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
|
||||
// SFI history entry for charting
|
||||
export interface SfiHistoryEntry {
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
|
||||
// Refractivity profile entry
|
||||
export interface ProfileEntry {
|
||||
level_hPa: number
|
||||
height_m: number
|
||||
N: number
|
||||
M: number
|
||||
T_C: number
|
||||
RH: number
|
||||
}
|
||||
|
||||
// Gradient entry
|
||||
export interface GradientEntry {
|
||||
from_level: number
|
||||
to_level: number
|
||||
from_height_m: number
|
||||
to_height_m: number
|
||||
gradient: number
|
||||
}
|
||||
|
||||
export interface SWPCStatus {
|
||||
enabled: boolean
|
||||
kp_current?: number
|
||||
kp_timestamp?: string
|
||||
sfi?: number
|
||||
r_scale?: number
|
||||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
kp_history?: KpHistoryEntry[]
|
||||
sfi_history?: SfiHistoryEntry[]
|
||||
}
|
||||
|
||||
export interface DuctingStatus {
|
||||
enabled: boolean
|
||||
condition?: string
|
||||
min_gradient?: number
|
||||
duct_thickness_m?: number | null
|
||||
duct_base_m?: number | null
|
||||
last_update?: string
|
||||
profile?: ProfileEntry[]
|
||||
gradients?: GradientEntry[]
|
||||
assessment?: string
|
||||
location?: { lat: number; lon: number }
|
||||
}
|
||||
|
||||
export interface RFPropagation {
|
||||
hf: {
|
||||
kp_current?: number
|
||||
sfi?: number
|
||||
r_scale?: number
|
||||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
kp_history?: KpHistoryEntry[]
|
||||
}
|
||||
uhf_ducting: {
|
||||
condition?: string
|
||||
min_gradient?: number
|
||||
duct_thickness_m?: number | null
|
||||
profile?: ProfileEntry[]
|
||||
}
|
||||
}
|
||||
|
||||
// API fetch helpers
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function fetchStatus(): Promise<SystemStatus> {
|
||||
return fetchJson<SystemStatus>('/api/status')
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<MeshHealth> {
|
||||
return fetchJson<MeshHealth>('/api/health')
|
||||
}
|
||||
|
||||
export async function fetchNodes(): Promise<NodeInfo[]> {
|
||||
return fetchJson<NodeInfo[]>('/api/nodes')
|
||||
}
|
||||
|
||||
export async function fetchEdges(): Promise<EdgeInfo[]> {
|
||||
return fetchJson<EdgeInfo[]>('/api/edges')
|
||||
}
|
||||
|
||||
export async function fetchSources(): Promise<SourceHealth[]> {
|
||||
return fetchJson<SourceHealth[]>('/api/sources')
|
||||
}
|
||||
|
||||
export async function fetchConfig(section?: string): Promise<unknown> {
|
||||
const url = section ? `/api/config/${section}` : '/api/config'
|
||||
return fetchJson(url)
|
||||
}
|
||||
|
||||
export async function updateConfig(
|
||||
section: string,
|
||||
data: unknown
|
||||
): Promise<{ saved: boolean; restart_required: boolean }> {
|
||||
const response = await fetch(`/api/config/${section}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function fetchAlerts(): Promise<Alert[]> {
|
||||
return fetchJson<Alert[]>('/api/alerts/active')
|
||||
}
|
||||
|
||||
export async function fetchAlertHistory(
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
type?: string,
|
||||
severity?: string
|
||||
): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', limit.toString())
|
||||
params.set('offset', offset.toString())
|
||||
if (type && type !== 'all') params.set('type', type)
|
||||
if (severity && severity !== 'all') params.set('severity', severity)
|
||||
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function fetchSubscriptions(): Promise<Subscription[]> {
|
||||
return fetchJson<Subscription[]>('/api/subscriptions')
|
||||
}
|
||||
|
||||
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
||||
return fetchJson<EnvStatus>('/api/env/status')
|
||||
}
|
||||
|
||||
export async function fetchEnvActive(): Promise<EnvEvent[]> {
|
||||
return fetchJson<EnvEvent[]>('/api/env/active')
|
||||
}
|
||||
|
||||
export async function fetchRFPropagation(): Promise<RFPropagation> {
|
||||
return fetchJson<RFPropagation>('/api/env/propagation')
|
||||
}
|
||||
|
||||
export async function fetchSWPC(): Promise<SWPCStatus> {
|
||||
return fetchJson<SWPCStatus>('/api/env/swpc')
|
||||
}
|
||||
|
||||
export async function fetchDucting(): Promise<DuctingStatus> {
|
||||
return fetchJson<DuctingStatus>('/api/env/ducting')
|
||||
}
|
||||
|
||||
export interface FireEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
name: string
|
||||
acres: number
|
||||
pct_contained: number
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
distance_km: number | null
|
||||
nearest_anchor: string | null
|
||||
state: string
|
||||
expires: number
|
||||
fetched_at: number
|
||||
polygon?: number[][][]
|
||||
}
|
||||
|
||||
export interface AvalancheEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
zone_name: string
|
||||
center: string
|
||||
center_id: string
|
||||
center_link: string
|
||||
forecast_link: string
|
||||
danger: string
|
||||
danger_level: number
|
||||
danger_name: string
|
||||
travel_advice: string
|
||||
state: string
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
expires: number
|
||||
fetched_at: number
|
||||
}
|
||||
|
||||
export interface StreamGaugeEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
headline: string
|
||||
severity: string
|
||||
lat?: number
|
||||
lon?: number
|
||||
expires: number
|
||||
fetched_at: number
|
||||
properties: {
|
||||
site_id: string
|
||||
site_name: string
|
||||
parameter: string
|
||||
value: number
|
||||
unit: string
|
||||
timestamp: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface TrafficEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
headline: string
|
||||
severity: string
|
||||
lat?: number
|
||||
lon?: number
|
||||
expires: number
|
||||
fetched_at: number
|
||||
properties: {
|
||||
corridor: string
|
||||
currentSpeed: number
|
||||
freeFlowSpeed: number
|
||||
speedRatio: number
|
||||
currentTravelTime: number
|
||||
freeFlowTravelTime: number
|
||||
confidence: number
|
||||
roadClosure: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface RoadEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
headline: string
|
||||
description?: string
|
||||
severity: string
|
||||
lat?: number
|
||||
lon?: number
|
||||
expires: number
|
||||
fetched_at: number
|
||||
properties: {
|
||||
roadway: string
|
||||
is_closure: boolean
|
||||
last_updated?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface HotspotEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
headline: string
|
||||
severity: string
|
||||
lat?: number
|
||||
lon?: number
|
||||
expires: number
|
||||
fetched_at: number
|
||||
properties: {
|
||||
new_ignition: boolean
|
||||
confidence: string
|
||||
frp?: number
|
||||
brightness?: number
|
||||
acq_date: string
|
||||
acq_time: string
|
||||
near_fire?: string
|
||||
distance_to_fire_km?: number
|
||||
distance_km?: number
|
||||
nearest_anchor?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface HotspotsResponse {
|
||||
enabled: boolean
|
||||
hotspots: HotspotEvent[]
|
||||
new_ignitions: number
|
||||
}
|
||||
|
||||
export interface AvalancheResponse {
|
||||
off_season: boolean
|
||||
advisories: AvalancheEvent[]
|
||||
}
|
||||
|
||||
export async function fetchFires(): Promise<FireEvent[]> {
|
||||
return fetchJson<FireEvent[]>('/api/env/fires')
|
||||
}
|
||||
|
||||
export async function fetchAvalanche(): Promise<AvalancheResponse> {
|
||||
return fetchJson<AvalancheResponse>('/api/env/avalanche')
|
||||
}
|
||||
|
||||
export async function fetchStreams(): Promise<StreamGaugeEvent[]> {
|
||||
return fetchJson<StreamGaugeEvent[]>('/api/env/streams')
|
||||
}
|
||||
|
||||
export async function fetchTraffic(): Promise<TrafficEvent[]> {
|
||||
return fetchJson<TrafficEvent[]>('/api/env/traffic')
|
||||
}
|
||||
|
||||
export async function fetchRoads(): Promise<RoadEvent[]> {
|
||||
return fetchJson<RoadEvent[]>('/api/env/roads')
|
||||
}
|
||||
|
||||
export async function fetchHotspots(): Promise<HotspotsResponse> {
|
||||
return fetchJson<HotspotsResponse>('/api/env/hotspots')
|
||||
}
|
||||
|
||||
export async function fetchRegions(): Promise<RegionInfo[]> {
|
||||
return fetchJson<RegionInfo[]>('/api/regions')
|
||||
}
|
||||
// API types matching actual backend responses
|
||||
|
||||
export interface SystemStatus {
|
||||
version: string
|
||||
uptime_seconds: number
|
||||
bot_name: string
|
||||
connection_type: string
|
||||
connection_target: string
|
||||
connected: boolean
|
||||
node_count: number
|
||||
source_count: number
|
||||
env_feeds_enabled: boolean
|
||||
dashboard_port: number
|
||||
}
|
||||
|
||||
export interface MeshHealth {
|
||||
score: number
|
||||
tier: string
|
||||
pillars: {
|
||||
infrastructure: number
|
||||
utilization: number
|
||||
behavior: number
|
||||
power: number
|
||||
}
|
||||
infra_online: number
|
||||
infra_total: number
|
||||
util_percent: number
|
||||
flagged_nodes: number
|
||||
battery_warnings: number
|
||||
total_nodes: number
|
||||
total_regions: number
|
||||
unlocated_count: number
|
||||
last_computed: string
|
||||
recommendations: string[]
|
||||
}
|
||||
|
||||
export interface NodeInfo {
|
||||
node_num: number
|
||||
node_id_hex: string
|
||||
short_name: string
|
||||
long_name: string
|
||||
role: string
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
last_heard: string | null
|
||||
battery_level: number | null
|
||||
voltage: number | null
|
||||
snr: number | null
|
||||
firmware: string
|
||||
hardware: string
|
||||
uptime: number | null
|
||||
sources: string[]
|
||||
}
|
||||
|
||||
export interface EdgeInfo {
|
||||
from_node: number
|
||||
to_node: number
|
||||
snr: number
|
||||
quality: string
|
||||
}
|
||||
|
||||
export interface RegionInfo {
|
||||
name: string
|
||||
local_name: string
|
||||
node_count: number
|
||||
infra_count: number
|
||||
infra_online: number
|
||||
online_count: number
|
||||
score: number
|
||||
tier: string
|
||||
center_lat: number
|
||||
center_lon: number
|
||||
}
|
||||
|
||||
export interface SourceHealth {
|
||||
name: string
|
||||
type: string
|
||||
url: string
|
||||
is_loaded: boolean
|
||||
last_error: string | null
|
||||
consecutive_errors: number
|
||||
response_time_ms: number | null
|
||||
tick_count: number
|
||||
node_count: number
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
type: string
|
||||
severity: string
|
||||
message: string
|
||||
timestamp: string
|
||||
scope_type?: string
|
||||
scope_value?: string
|
||||
}
|
||||
|
||||
export interface AlertHistoryItem {
|
||||
id?: number
|
||||
type: string
|
||||
severity: string
|
||||
message: string
|
||||
timestamp: string
|
||||
duration?: number
|
||||
scope_type?: string
|
||||
scope_value?: string
|
||||
resolved_at?: string
|
||||
}
|
||||
|
||||
export interface AlertHistoryResponse {
|
||||
items: AlertHistoryItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: number
|
||||
user_id: string
|
||||
sub_type: string
|
||||
schedule_time?: string
|
||||
schedule_day?: string
|
||||
scope_type: string
|
||||
scope_value?: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface EnvStatus {
|
||||
enabled: boolean
|
||||
feeds: EnvFeedHealth[]
|
||||
}
|
||||
|
||||
export interface EnvFeedHealth {
|
||||
source: string
|
||||
is_loaded: boolean
|
||||
last_error: string | null
|
||||
consecutive_errors: number
|
||||
event_count: number
|
||||
last_fetch: number
|
||||
}
|
||||
|
||||
export interface EnvEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
description?: string
|
||||
expires?: number
|
||||
fetched_at: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// Kp history entry for charting
|
||||
export interface KpHistoryEntry {
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
|
||||
// SFI history entry for charting
|
||||
export interface SfiHistoryEntry {
|
||||
time: string
|
||||
value: number
|
||||
}
|
||||
|
||||
// Refractivity profile entry
|
||||
export interface ProfileEntry {
|
||||
level_hPa: number
|
||||
height_m: number
|
||||
N: number
|
||||
M: number
|
||||
T_C: number
|
||||
RH: number
|
||||
}
|
||||
|
||||
// Gradient entry
|
||||
export interface GradientEntry {
|
||||
from_level: number
|
||||
to_level: number
|
||||
from_height_m: number
|
||||
to_height_m: number
|
||||
gradient: number
|
||||
}
|
||||
|
||||
export interface SWPCStatus {
|
||||
enabled: boolean
|
||||
kp_current?: number
|
||||
kp_timestamp?: string
|
||||
sfi?: number
|
||||
r_scale?: number
|
||||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
kp_history?: KpHistoryEntry[]
|
||||
sfi_history?: SfiHistoryEntry[]
|
||||
}
|
||||
|
||||
export interface DuctingStatus {
|
||||
enabled: boolean
|
||||
condition?: string
|
||||
min_gradient?: number
|
||||
duct_thickness_m?: number | null
|
||||
duct_base_m?: number | null
|
||||
last_update?: string
|
||||
profile?: ProfileEntry[]
|
||||
gradients?: GradientEntry[]
|
||||
assessment?: string
|
||||
location?: { lat: number; lon: number }
|
||||
}
|
||||
|
||||
export interface RFPropagation {
|
||||
hf: {
|
||||
kp_current?: number
|
||||
sfi?: number
|
||||
r_scale?: number
|
||||
s_scale?: number
|
||||
g_scale?: number
|
||||
active_warnings?: string[]
|
||||
kp_history?: KpHistoryEntry[]
|
||||
}
|
||||
uhf_ducting: {
|
||||
condition?: string
|
||||
min_gradient?: number
|
||||
duct_thickness_m?: number | null
|
||||
profile?: ProfileEntry[]
|
||||
}
|
||||
}
|
||||
|
||||
// API fetch helpers
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function fetchStatus(): Promise<SystemStatus> {
|
||||
return fetchJson<SystemStatus>('/api/status')
|
||||
}
|
||||
|
||||
export async function fetchHealth(): Promise<MeshHealth> {
|
||||
return fetchJson<MeshHealth>('/api/health')
|
||||
}
|
||||
|
||||
export async function fetchNodes(): Promise<NodeInfo[]> {
|
||||
return fetchJson<NodeInfo[]>('/api/nodes')
|
||||
}
|
||||
|
||||
export async function fetchEdges(): Promise<EdgeInfo[]> {
|
||||
return fetchJson<EdgeInfo[]>('/api/edges')
|
||||
}
|
||||
|
||||
export async function fetchSources(): Promise<SourceHealth[]> {
|
||||
return fetchJson<SourceHealth[]>('/api/sources')
|
||||
}
|
||||
|
||||
export async function fetchConfig(section?: string): Promise<unknown> {
|
||||
const url = section ? `/api/config/${section}` : '/api/config'
|
||||
return fetchJson(url)
|
||||
}
|
||||
|
||||
export async function updateConfig(
|
||||
section: string,
|
||||
data: unknown
|
||||
): Promise<{ saved: boolean; restart_required: boolean }> {
|
||||
const response = await fetch(`/api/config/${section}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function fetchAlerts(): Promise<Alert[]> {
|
||||
return fetchJson<Alert[]>('/api/alerts/active')
|
||||
}
|
||||
|
||||
export async function fetchAlertHistory(
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
type?: string,
|
||||
severity?: string
|
||||
): Promise<AlertHistoryResponse | AlertHistoryItem[]> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('limit', limit.toString())
|
||||
params.set('offset', offset.toString())
|
||||
if (type && type !== 'all') params.set('type', type)
|
||||
if (severity && severity !== 'all') params.set('severity', severity)
|
||||
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function fetchSubscriptions(): Promise<Subscription[]> {
|
||||
return fetchJson<Subscription[]>('/api/subscriptions')
|
||||
}
|
||||
|
||||
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
||||
return fetchJson<EnvStatus>('/api/env/status')
|
||||
}
|
||||
|
||||
export async function fetchEnvActive(): Promise<EnvEvent[]> {
|
||||
return fetchJson<EnvEvent[]>('/api/env/active')
|
||||
}
|
||||
|
||||
export async function fetchRFPropagation(): Promise<RFPropagation> {
|
||||
return fetchJson<RFPropagation>('/api/env/propagation')
|
||||
}
|
||||
|
||||
export async function fetchSWPC(): Promise<SWPCStatus> {
|
||||
return fetchJson<SWPCStatus>('/api/env/swpc')
|
||||
}
|
||||
|
||||
export async function fetchDucting(): Promise<DuctingStatus> {
|
||||
return fetchJson<DuctingStatus>('/api/env/ducting')
|
||||
}
|
||||
|
||||
export interface FireEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
name: string
|
||||
acres: number
|
||||
pct_contained: number
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
distance_km: number | null
|
||||
nearest_anchor: string | null
|
||||
state: string
|
||||
expires: number
|
||||
fetched_at: number
|
||||
polygon?: number[][][]
|
||||
}
|
||||
|
||||
export interface AvalancheEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
severity: string
|
||||
headline: string
|
||||
zone_name: string
|
||||
center: string
|
||||
center_id: string
|
||||
center_link: string
|
||||
forecast_link: string
|
||||
danger: string
|
||||
danger_level: number
|
||||
danger_name: string
|
||||
travel_advice: string
|
||||
state: string
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
expires: number
|
||||
fetched_at: number
|
||||
}
|
||||
|
||||
export interface StreamGaugeEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
headline: string
|
||||
severity: string
|
||||
lat?: number
|
||||
lon?: number
|
||||
expires: number
|
||||
fetched_at: number
|
||||
properties: {
|
||||
site_id: string
|
||||
site_name: string
|
||||
parameter: string
|
||||
value: number
|
||||
unit: string
|
||||
timestamp: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface TrafficEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
headline: string
|
||||
severity: string
|
||||
lat?: number
|
||||
lon?: number
|
||||
expires: number
|
||||
fetched_at: number
|
||||
properties: {
|
||||
corridor: string
|
||||
currentSpeed: number
|
||||
freeFlowSpeed: number
|
||||
speedRatio: number
|
||||
currentTravelTime: number
|
||||
freeFlowTravelTime: number
|
||||
confidence: number
|
||||
roadClosure: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface RoadEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
headline: string
|
||||
description?: string
|
||||
severity: string
|
||||
lat?: number
|
||||
lon?: number
|
||||
expires: number
|
||||
fetched_at: number
|
||||
properties: {
|
||||
roadway: string
|
||||
is_closure: boolean
|
||||
last_updated?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface HotspotEvent {
|
||||
source: string
|
||||
event_id: string
|
||||
event_type: string
|
||||
headline: string
|
||||
severity: string
|
||||
lat?: number
|
||||
lon?: number
|
||||
expires: number
|
||||
fetched_at: number
|
||||
properties: {
|
||||
new_ignition: boolean
|
||||
confidence: string
|
||||
frp?: number
|
||||
brightness?: number
|
||||
acq_date: string
|
||||
acq_time: string
|
||||
near_fire?: string
|
||||
distance_to_fire_km?: number
|
||||
distance_km?: number
|
||||
nearest_anchor?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface HotspotsResponse {
|
||||
enabled: boolean
|
||||
hotspots: HotspotEvent[]
|
||||
new_ignitions: number
|
||||
}
|
||||
|
||||
export interface AvalancheResponse {
|
||||
off_season: boolean
|
||||
advisories: AvalancheEvent[]
|
||||
}
|
||||
|
||||
export async function fetchFires(): Promise<FireEvent[]> {
|
||||
return fetchJson<FireEvent[]>('/api/env/fires')
|
||||
}
|
||||
|
||||
export async function fetchAvalanche(): Promise<AvalancheResponse> {
|
||||
return fetchJson<AvalancheResponse>('/api/env/avalanche')
|
||||
}
|
||||
|
||||
export async function fetchStreams(): Promise<StreamGaugeEvent[]> {
|
||||
return fetchJson<StreamGaugeEvent[]>('/api/env/streams')
|
||||
}
|
||||
|
||||
export async function fetchTraffic(): Promise<TrafficEvent[]> {
|
||||
return fetchJson<TrafficEvent[]>('/api/env/traffic')
|
||||
}
|
||||
|
||||
export async function fetchRoads(): Promise<RoadEvent[]> {
|
||||
return fetchJson<RoadEvent[]>('/api/env/roads')
|
||||
}
|
||||
|
||||
export async function fetchHotspots(): Promise<HotspotsResponse> {
|
||||
return fetchJson<HotspotsResponse>('/api/env/hotspots')
|
||||
}
|
||||
|
||||
export async function fetchRegions(): Promise<RegionInfo[]> {
|
||||
return fetchJson<RegionInfo[]>('/api/regions')
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue