feat(dashboard): alerts page + toast notifications + polish

- Full Alerts page with active alerts, history table, subscriptions
- Active alert cards with severity styling and acknowledge button
- Alert history table with type/severity filtering and pagination
- Subscription viewer showing mesh subscriptions
- ToastProvider for app-wide toast notifications
- Toast notifications triggered on WebSocket alert_fired messages
- Auto-dismiss toasts after 8 seconds, click to navigate
- Page titles on all pages (Dashboard/Mesh/Environment/Config/Alerts)
- Improved alert_routes.py with proper pending alert handling
- Added AlertHistoryItem, Subscription types to api.ts
- Added fetchAlertHistory, fetchSubscriptions functions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-13 01:36:31 +00:00
commit f8874104ad
14 changed files with 1173 additions and 366 deletions

View file

@ -1,162 +1,176 @@
import { ReactNode, useEffect, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import {
LayoutDashboard,
Radio,
Cloud,
Settings,
Bell,
} from 'lucide-react'
import { fetchStatus, type SystemStatus } from '@/lib/api'
import { useWebSocket } from '@/hooks/useWebSocket'
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 },
]
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 } = useWebSocket()
const [status, setStatus] = useState<SystemStatus | null>(null)
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,
} 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 },
]
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>
)
}

View file

@ -0,0 +1,141 @@
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { AlertTriangle, AlertCircle, Info, X } from 'lucide-react'
import type { Alert } from '@/lib/api'
interface Toast {
id: string
alert: Alert
dismissedAt?: number
}
interface ToastContextValue {
addToast: (alert: Alert) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
function getSeverityStyles(severity: string) {
switch (severity?.toLowerCase()) {
case 'critical':
case 'emergency':
return {
bg: 'bg-red-500/10',
border: 'border-red-500',
icon: AlertCircle,
iconColor: 'text-red-500',
}
case 'warning':
return {
bg: 'bg-amber-500/10',
border: 'border-amber-500',
icon: AlertTriangle,
iconColor: 'text-amber-500',
}
default:
return {
bg: 'bg-blue-500/10',
border: 'border-blue-500',
icon: Info,
iconColor: 'text-blue-500',
}
}
}
function ToastItem({
toast,
onDismiss,
onNavigate,
}: {
toast: Toast
onDismiss: () => void
onNavigate: () => void
}) {
const styles = getSeverityStyles(toast.alert.severity)
const Icon = styles.icon
// Auto-dismiss after 8 seconds
useEffect(() => {
const timer = setTimeout(onDismiss, 8000)
return () => clearTimeout(timer)
}, [onDismiss])
return (
<div
className={`${styles.bg} border ${styles.border} rounded-lg shadow-lg overflow-hidden animate-slide-in cursor-pointer`}
onClick={onNavigate}
role="alert"
>
<div className="flex items-start gap-3 p-4">
{/* Severity bar */}
<div className={`w-1 self-stretch -ml-4 -my-4 ${styles.border.replace('border', 'bg')}`} />
<Icon size={18} className={styles.iconColor} />
<div className="flex-1 min-w-0 pr-2">
<div className="text-sm font-medium text-slate-200 mb-0.5">
{toast.alert.type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</div>
<div className="text-sm text-slate-300 line-clamp-2">
{toast.alert.message}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation()
onDismiss()
}}
className="text-slate-400 hover:text-slate-200 transition-colors"
>
<X size={16} />
</button>
</div>
</div>
)
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const navigate = useNavigate()
const addToast = useCallback((alert: Alert) => {
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
setToasts((prev) => [...prev, { id, alert }])
}, [])
const dismissToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const handleNavigate = useCallback(() => {
navigate('/alerts')
}, [navigate])
return (
<ToastContext.Provider value={{ addToast }}>
{children}
{/* Toast container - fixed bottom right */}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<ToastItem
toast={toast}
onDismiss={() => dismissToast(toast.id)}
onNavigate={handleNavigate}
/>
</div>
))}
</div>
</ToastContext.Provider>
)
}