import { useState, useEffect, useRef, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts'; import { Users, AlertTriangle, Wifi, MapPin, ArrowUpCircle, Cpu, X, RefreshCw, Terminal, HardDrive, GitBranch, Activity, Shield, Clock, FileText, Bell, ChevronRight, CheckCircle2, } from 'lucide-react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { metricsApi, eventsApi, devicesApi, clientsApi, trafficApi, operationsApi, topologyApi, systemApi } from '../services/api'; import type { OpsAttentionItem, OpsCapacityRow, OpsActivityItem } from '../services/api'; import TerminalModal from '../components/TerminalModal'; import { useSocket } from '../hooks/useSocket'; import { useCanWrite } from '../hooks/useCanWrite'; import { useQueryClient } from '@tanstack/react-query'; import { format, formatDistanceToNow } from 'date-fns'; import type { Device, DeviceEvent } from '../types'; import clsx from 'clsx'; // ─── Primitives ─────────────────────────────────────────────────────────────── function StatusDot({ color = 'var(--good)', glow = false, size = 6 }: { color?: string; glow?: boolean; size?: number; }) { return ( ); } function Sparkline({ data, w = 120, h = 24, color = 'var(--accent)', area = true }: { data: number[]; w?: number; h?: number; color?: string; area?: boolean; }) { if (data.length < 2) return null; const max = Math.max(...data), min = Math.min(...data); const range = max - min || 1; const pts = data.map((v, i) => [ (i / (data.length - 1)) * w, h - ((v - min) / range) * (h - 2) - 1, ]); const d = 'M' + pts.map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' L'); const areaD = d + ` L ${w},${h} L 0,${h} Z`; return ( {area && ( )} ); } function TypePill({ type }: { type: string }) { const map: Record = { wireless_ap: { label: 'AP', color: 'var(--info)' }, switch: { label: 'SW', color: 'var(--accent)' }, router: { label: 'RTR', color: 'var(--violet)' }, }; const { label, color } = map[type] ?? { label: type.slice(0, 3).toUpperCase(), color: 'var(--ink-3)' }; return ( {label} ); } // ─── Device Locations Map ───────────────────────────────────────────────────── function DeviceLocationsMap({ devices }: { devices: Device[] }) { const containerRef = useRef(null); const mapped = useMemo(() => devices.filter(d => d.location_lat != null && d.location_lng != null && !isNaN(Number(d.location_lat)) && !isNaN(Number(d.location_lng))), [devices] ); useEffect(() => { if (!containerRef.current || mapped.length === 0) return; const map = L.map(containerRef.current, { scrollWheelZoom: false }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', }).addTo(map); const groups = new Map(); for (const d of mapped) { const key = `${Number(d.location_lat).toFixed(6)},${Number(d.location_lng).toFixed(6)}`; if (!groups.has(key)) groups.set(key, []); groups.get(key)!.push(d); } const markers: L.Layer[] = []; groups.forEach((groupDevices) => { const lat = Number(groupDevices[0].location_lat); const lng = Number(groupDevices[0].location_lng); const count = groupDevices.length; const hasOffline = groupDevices.some(d => d.status === 'offline'); const color = hasOffline ? 'var(--bad)' : 'var(--accent)'; const colorHex = hasOffline ? '#f08a8a' : '#c1f17e'; const deviceRows = groupDevices.map(d => { const c = d.status === 'online' ? '#8de08a' : '#f08a8a'; return `${d.name} ● ${d.status}`; }).join('
'); const address = groupDevices[0].location_address ? `
${groupDevices[0].location_address}` : ''; const tooltipHtml = groupDevices.map(d => `${d.status === 'online' ? '●' : '○'} ${d.name}` ).join('
'); let marker: L.Layer; if (count === 1) { marker = L.circleMarker([lat, lng], { radius: 9, color: '#fff', weight: 2, fillColor: colorHex, fillOpacity: 0.9, }).bindPopup(deviceRows + address).bindTooltip(tooltipHtml, { direction: 'top', offset: [0, -10] }); } else { const size = count > 9 ? 32 : 28, half = size / 2; const icon = L.divIcon({ className: '', html: `
${count}
`, iconSize: [size, size], iconAnchor: [half, half], popupAnchor: [0, -half], }); marker = L.marker([lat, lng], { icon }) .bindPopup(deviceRows + address).bindTooltip(tooltipHtml, { direction: 'top' }); } marker.addTo(map); markers.push(marker); }); if (markers.length === 1) { map.setView([Number(mapped[0].location_lat), Number(mapped[0].location_lng)], 13); } else { map.fitBounds(L.featureGroup(markers).getBounds(), { padding: [40, 40] }); } return () => { map.remove(); }; }, [mapped]); if (mapped.length === 0) { return (
No device locations configured — add addresses via Device › Overview › Physical Details
); } return
; } // ─── Health Bar ─────────────────────────────────────────────────────────────── function HealthBar({ summary, devices, wirelessCount, clientSparkline, }: { summary: { devices: { total: number; online: number; offline: number }; clients: { active: number; total: number }; alerts: { critical: number; warning: number }; availability?: { fleetUptimePct30d: number } } | undefined; devices: Device[]; wirelessCount: number; clientSparkline: number[]; }) { const allOnline = (summary?.devices.offline ?? 0) === 0 && (summary?.devices.total ?? 0) > 0; const reachFraction = summary?.devices.total ? (summary.devices.online / summary.devices.total) : 1; return (
{/* Status block */}
{allOnline ? 'All systems nominal' : `${summary?.devices.offline ?? 0} device(s) unreachable`}
{summary?.devices.online ?? 0} of {summary?.devices.total ?? 0} reachable {(summary?.alerts.critical ?? 0) > 0 && ` · ${summary!.alerts.critical} critical alert`}
{/* Reachability bar */}
Reachability {summary?.devices.online ?? 0}/{summary?.devices.total ?? 0} online
0.5 ? 'var(--warn)' : 'var(--bad)', }} />
{/* Mini-stats */} {[ { label: 'Active clients', value: String(summary?.clients.active ?? '—'), spark: clientSparkline, color: 'var(--accent)', }, { label: 'Wireless', value: String(wirelessCount ?? '—'), spark: [] as number[], color: 'var(--info)', }, { label: 'Alerts 24h', value: (summary?.alerts.critical ?? 0) > 0 ? `${summary!.alerts.critical} critical` : (summary?.alerts.warning ?? 0) > 0 ? `${summary!.alerts.warning} warn` : 'none', spark: [] as number[], color: (summary?.alerts.critical ?? 0) > 0 ? 'var(--bad)' : 'var(--ink-3)', }, { label: 'Uptime 30d', value: summary?.availability != null ? `${summary.availability.fleetUptimePct30d.toFixed(1)}%` : '—', spark: [] as number[], color: (summary?.availability?.fleetUptimePct30d ?? 100) >= 99 ? 'var(--accent)' : (summary?.availability?.fleetUptimePct30d ?? 100) >= 95 ? 'var(--warn)' : 'var(--bad)', }, ].map(({ label, value, spark, color }) => (
{label}
{value}
{spark.length >= 2 && }
))}
); } // ─── Summary View ───────────────────────────────────────────────────────────── // Cap the number of bars so they render as readable columns instead of hairline // slivers on dense ranges. Strides through the series (a point-in-time client // gauge, so sampling preserves the trend) and always keeps the latest point. function downsample(arr: T[], max: number): T[] { if (arr.length <= max) return arr; const step = Math.ceil(arr.length / max); const out: T[] = []; for (let i = 0; i < arr.length; i += step) out.push(arr[i]); const last = arr[arr.length - 1]; if (out[out.length - 1] !== last) out.push(last); return out; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function SummaryView(props: Record) { const { summary, devices, wirelessCount, clientSparkline, clientsOverTime, chartRange, setChartRange, topClients, usingNetflowTop, recentEvents, severities, toggleSeverity, navigate } = props; const formatBytes = (bytes: number) => { if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`; if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`; if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)} KB`; return `${bytes} B`; }; const deviceTypeCounts = useMemo(() => { const counts: Record = {}; for (const d of devices as Device[]) { const t = d.device_type || 'other'; counts[t] = (counts[t] || 0) + 1; } return counts; }, [devices]); const typeConfig: Record = { switch: { label: 'Switches', color: 'var(--accent)' }, wireless_ap: { label: 'Wireless APs', color: 'var(--info)' }, router: { label: 'Routers', color: 'var(--violet)' }, }; // Bars over a category axis fill their slots (thick, minimal gaps) rather than // sitting as thin fixed-width marks on a continuous time axis. Downsample to // keep them chunky even on long ranges. const chartData = downsample(clientsOverTime as { ts: number; value: number }[], 60); const tickEvery = Math.max(0, Math.ceil(chartData.length / 6) - 1); return (
{/* Main grid: clients chart + devices list */}
{/* Clients chart */}
Connected clients
last {chartRange} · 5min buckets
{summary?.clients.active ?? '—'}
{(['1h', '6h', '24h', '7d'] as const).map((r) => ( ))}
{clientsOverTime.length > 0 ? ( format(new Date(t as number), chartRange === '7d' ? 'MMM d' : 'HH:mm')} /> [v, 'Clients']} labelFormatter={(t) => format(new Date(t as number), 'MMM d, HH:mm')} contentStyle={{ background: 'var(--surface)', border: '1px solid var(--line)', borderRadius: 8, fontSize: 12, color: 'var(--ink)', }} /> ) : (
No data yet — metrics appear after devices are polled
)}
{/* Compact devices list */}
Devices
{(devices as Device[]).map((d) => (
navigate(`/devices/${d.id}`)} onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-3)')} onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} >
{d.name}
{d.model}
{d.firmware_update_available && ↑} {d.ros_version}
))}
{/* Second row: Mix + Top talkers + Activity */}
{/* Device mix */}
Mix
{Object.entries(typeConfig).map(([type, { label, color }]) => { const count = deviceTypeCounts[type] ?? 0; const frac = (devices as Device[]).length ? count / (devices as Device[]).length : 0; return (
{label} {count}
); })}
{/* Top talkers — NetFlow-backed (same numbers as the Traffic page) when the collector has data; otherwise device-reported connection counters. */}
Top talkers
{usingNetflowTop ? 'by bytes · 24h' : 'since connection'}
{(topClients as { mac_address: string; hostname?: string; total_bytes: number }[]).length > 0 ? (
{(topClients as { mac_address: string; hostname?: string; total_bytes: number }[]).slice(0, 5).map((c, i) => { const maxB = (topClients as { total_bytes: number }[])[0]?.total_bytes ?? 1; const label = c.hostname || c.mac_address; const isRealMac = c.mac_address !== 'unknown' && c.mac_address !== 'other'; return (
isRealMac && navigate(`/clients/${encodeURIComponent(c.mac_address)}`)} title={isRealMac ? `${c.mac_address} — open client` : undefined} > {String(i + 1).padStart(2, '0')}
{label.length > 20 ? label.slice(0, 20) + '…' : label}
{formatBytes(c.total_bytes)}
); })}
) : (
No active clients
)}
{/* Activity */}
Activity
{(['error', 'warning', 'info'] as const).map((sev) => ( ))}
{((recentEvents?.events ?? []) as DeviceEvent[]).length > 0 ? ( ((recentEvents?.events ?? []) as DeviceEvent[]).map((ev, i) => (
{format(new Date(ev.event_time), 'HH:mm:ss')}
{ev.device_name}
{ev.message}
)) ) : (
No recent events
)}
{/* Locations map */}
Locations
{(devices as Device[]).filter(d => d.location_lat != null).length} of {(devices as Device[]).length} mapped
); } // ─── Operations View ────────────────────────────────────────────────────────── function OperationsView({ summary, devices, navigate, }: { // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; }) { const qc = useQueryClient(); const canWrite = useCanWrite(); const allOnline = (summary?.devices.offline ?? 0) === 0 && (summary?.devices.total ?? 0) > 0; const statusText = allOnline ? "Everything's running." : `${summary?.devices.offline} device${summary?.devices.offline !== 1 ? 's' : ''} unreachable.`; const statusColor = allOnline ? 'var(--accent)' : 'var(--warn)'; const deviceList = devices as Device[]; const onlineDevices = deviceList.filter(d => d.status === 'online'); // Server-aggregated operational insights (attention, capacity, activity) const { data: insights } = useQuery({ queryKey: ['ops-insights'], queryFn: () => operationsApi.insights().then(r => r.data), refetchInterval: 60_000, }); const thingsToHandle: OpsAttentionItem[] = insights?.attention ?? []; const capacity: OpsCapacityRow[] = insights?.capacity ?? []; const activity: OpsActivityItem[] = insights?.activity ?? []; // Platform update availability (checked against GitHub, cached server-side) const { data: versionInfo } = useQuery({ queryKey: ['version-check'], queryFn: () => systemApi.versionCheck().then(r => r.data), staleTime: 60 * 60_000, refetchOnWindowFocus: false, }); // Fleet security posture rollup (live per-device; cached 5 min, lazy) const { data: posture } = useQuery({ queryKey: ['ops-security', onlineDevices.map(d => d.id).join(',')], enabled: onlineDevices.length > 0, staleTime: 5 * 60_000, refetchOnWindowFocus: false, queryFn: async () => { const settled = await Promise.allSettled( onlineDevices.map(d => devicesApi.getSecurityPosture(d.id).then(r => ({ id: d.id, name: d.name, score: r.data.score, checks: r.data.checks })) ) ); return settled.flatMap(s => s.status === 'fulfilled' ? [s.value] : []); }, }); // Action state const [busy, setBusy] = useState(null); const [actionMsg, setActionMsg] = useState<{ text: string; ok: boolean } | null>(null); const [pickTerminal, setPickTerminal] = useState(false); const [terminalDevice, setTerminalDevice] = useState<{ id: number; name: string } | null>(null); async function runAction(key: string, fn: () => Promise) { setBusy(key); setActionMsg(null); try { setActionMsg({ text: await fn(), ok: true }); } catch (e) { const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error || (e as Error).message || 'Action failed'; setActionMsg({ text: msg, ok: false }); } finally { setBusy(null); } } const actions = [ { key: 'discovery', label: 'Run discovery', sub: 'Scan ARP / CDP / MNDP', icon: Wifi, color: 'var(--accent)', run: () => runAction('discovery', async () => { const r = await topologyApi.discover(); return (r.data as { message?: string })?.message || 'Discovery triggered'; }) }, { key: 'backup', label: 'Backup all', sub: `${onlineDevices.length} online`, icon: HardDrive, color: 'var(--info)', run: () => runAction('backup', async () => { const r = await operationsApi.backupAll(); const ok = r.data.results.filter(x => x.ok).length; return `Backed up ${ok}/${r.data.total} device${r.data.total !== 1 ? 's' : ''}`; }) }, ...(canWrite ? [{ key: 'terminal', label: 'Open terminal', sub: 'Pick a device', icon: Terminal, color: 'var(--violet)', run: () => { setActionMsg(null); setPickTerminal(true); } }] : []), { key: 'sync', label: 'Sync config', sub: 'Pull latest /export', icon: RefreshCw, color: 'var(--ink-2)', run: () => runAction('sync', async () => { const r = await operationsApi.syncAll(); const ok = r.data.results.filter(x => x.ok).length; qc.invalidateQueries({ queryKey: ['ops-insights'] }); return `Synced ${ok}/${r.data.total} device${r.data.total !== 1 ? 's' : ''}`; }) }, { key: 'events', label: 'Tail events', sub: 'Live syslog stream', icon: Activity, color: 'var(--ink-2)', run: () => navigate('/events') }, { key: 'topology', label: 'Open topology', sub: 'Visualize links', icon: GitBranch, color: 'var(--ink-2)', run: () => navigate('/topology') }, ]; // Security rollup derived values const scored = (posture ?? []).filter(p => p.score !== null); const avgScore = scored.length ? Math.round(scored.reduce((s, p) => s + (p.score ?? 0), 0) / scored.length) : null; const highFindings = (posture ?? []).reduce((s, p) => s + p.checks.filter(c => c.severity === 'high').length, 0); const worstDevices = [...scored].sort((a, b) => (a.score ?? 0) - (b.score ?? 0)).slice(0, 3); const scoreColor = (s: number | null) => s == null ? 'var(--ink-3)' : s >= 85 ? 'var(--good)' : s >= 60 ? 'var(--warn)' : 'var(--bad)'; const sortedCapacity = [...capacity].sort((a, b) => Math.max(b.cpu, b.mem_pct) - Math.max(a.cpu, a.mem_pct)); const meterColor = (v: number) => v >= 90 ? 'var(--bad)' : v >= 75 ? 'var(--warn)' : 'var(--accent)'; const sevColor = (s: string) => s === 'error' ? 'var(--bad)' : s === 'warn' ? 'var(--warn)' : 'var(--info)'; const activityIcon = (kind: string) => kind === 'config' ? FileText : kind === 'alert' ? Bell : Activity; return (
{/* Hero status card */}
Network status
{statusText}
{summary?.devices.online ?? 0} of {summary?.devices.total ?? 0} devices reachable {(summary?.alerts.warning ?? 0) > 0 && ` · ${summary!.alerts.warning} warning${summary!.alerts.warning !== 1 ? 's' : ''}`}
{[ { label: 'CLIENTS', value: String(summary?.clients.active ?? '—'), color: 'var(--accent)' }, { label: 'DEVICES', value: `${summary?.devices.online ?? '—'}/${summary?.devices.total ?? '—'}`, color: 'var(--info)' }, { label: 'ALERTS', value: String((summary?.alerts.critical ?? 0) + (summary?.alerts.warning ?? 0)), color: (summary?.alerts.critical ?? 0) > 0 ? 'var(--bad)' : 'var(--ink-3)' }, ].map(({ label, value, color }) => (
{label}
{value}
))}
{/* Platform update available */} {versionInfo?.update_available && versionInfo.latest && (
Platform update available — v{versionInfo.latest}
You're on v{versionInfo.current}. Run{' '} docker compose pull && docker compose up -d{' '} to update.
Release notes →
)} {/* Things to handle + Quick actions */}
{/* Things to handle */}
!
Things to handle {thingsToHandle.length} item{thingsToHandle.length !== 1 ? 's' : ''}
{thingsToHandle.length > 0 ? thingsToHandle.map((item, i) => (
{item.title}
{item.body}
)) : (
Nothing needs attention
)}
{/* Quick actions */}
Quick actions
{actions.map(({ key, label, sub, icon: Icon, color, run }) => { const isBusy = busy === key; return ( ); })}
{actionMsg && (
{actionMsg.ok ? : } {actionMsg.text}
)}
{/* Capacity + Security rollup */}
{/* Capacity / health */}
Capacity & health CPU · MEM
{sortedCapacity.length === 0 ? (
No resource data yet.
) : (
{sortedCapacity.slice(0, 8).map((c) => ( ))}
)}
{/* Security posture rollup */}
{/* Activity feed */}
Recent activity
{activity.length === 0 ? (
No recent activity.
) : (
{activity.map((a, i) => { const Icon = activityIcon(a.kind); const color = a.kind === 'alert' ? sevColor(a.sev === 'error' ? 'error' : 'warn') : a.kind === 'config' ? 'var(--info)' : 'var(--ink-3)'; return (
{a.title}
{a.sub}
{formatDistanceToNow(new Date(a.at), { addSuffix: false })}
); })}
)}
{/* Fleet strip */}
Fleet
{(devices as Device[]).length} device{(devices as Device[]).length !== 1 ? 's' : ''}
{(devices as Device[]).map((d) => { const hasAlert = d.firmware_update_available || d.routerboard_upgrade_available; return ( ); })}
{/* Terminal device picker */} {pickTerminal && (
setPickTerminal(false)}>
e.stopPropagation()}>
Open terminal — pick a device
{[...deviceList].sort((a, b) => (a.status === 'online' ? 0 : 1) - (b.status === 'online' ? 0 : 1)).map((d) => ( ))}
)} {terminalDevice && ( setTerminalDevice(null)} /> )}
); } // ─── Main Page ──────────────────────────────────────────────────────────────── const ALL_SEVERITIES = ['error', 'warning', 'info'] as const; const SEVERITY_KEY = 'dashboard:severities'; function loadSeverities(): Set { try { const saved = localStorage.getItem(SEVERITY_KEY); if (saved) return new Set(JSON.parse(saved)); } catch { /* ignore */ } return new Set(ALL_SEVERITIES); } export default function DashboardPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const [dashView, setDashView] = useState<'summary' | 'operations'>(() => (localStorage.getItem('dashboard:view') as 'summary' | 'operations') ?? 'summary' ); const [chartRange, setChartRange] = useState(() => localStorage.getItem('dashboard:chart-range') ?? '24h' ); const [severities, setSeverities] = useState>(loadSeverities); const [dismissedFirmwareIds, setDismissedFirmwareIds] = useState([]); const toggleSeverity = (s: string) => setSeverities((prev) => { const next = new Set(prev); if (next.has(s)) next.delete(s); else next.add(s); localStorage.setItem(SEVERITY_KEY, JSON.stringify([...next])); return next; }); const { data: summary } = useQuery({ queryKey: ['metrics-summary'], queryFn: () => metricsApi.summary().then(r => r.data), refetchInterval: 30_000, }); const { data: clientsOverTimeRaw = [] } = useQuery({ queryKey: ['clients-over-time', chartRange], queryFn: () => metricsApi.clientsOverTime(chartRange).then(r => r.data), refetchInterval: 60_000, }); const clientsOverTime = clientsOverTimeRaw.map(p => ({ ...p, ts: new Date(p.time).getTime() })); // Top talkers: prefer NetFlow data (same source as the Traffic page) so the // numbers match everywhere; fall back to the device-reported per-connection // counters when the collector has no data (e.g. NetFlow disabled). const { data: netflowTopClients = [], isLoading: netflowTopLoading } = useQuery({ queryKey: ['traffic-top-clients', '24h', 8], queryFn: () => trafficApi.topClients('24h', 8).then(r => r.data), refetchInterval: 60_000, }); const usingNetflowTop = netflowTopClients.length > 0; const { data: counterTopClients = [] } = useQuery({ queryKey: ['top-clients'], queryFn: () => metricsApi.topClients(8).then(r => r.data), refetchInterval: 60_000, enabled: !netflowTopLoading && !usingNetflowTop, }); const topClients = usingNetflowTop ? netflowTopClients.map((c) => ({ mac_address: c.mac, hostname: c.mac === 'unknown' ? 'Unattributed (local)' : c.mac === 'other' ? 'Other clients' : c.custom_name || c.hostname || c.vendor || undefined, total_bytes: c.total_bytes, })) : counterTopClients; const { data: wirelessClientsData } = useQuery({ queryKey: ['wireless-clients-active'], queryFn: () => clientsApi.list({ active: true, client_type: 'wireless', limit: 1 }).then(r => r.data), refetchInterval: 30_000, }); const severityParam = severities.size === 0 || severities.size === ALL_SEVERITIES.length ? undefined : [...severities].join(','); const { data: recentEvents } = useQuery({ queryKey: ['events-recent', severityParam], queryFn: () => eventsApi.list({ limit: 5, severity: severityParam }).then(r => r.data), refetchInterval: 30_000, }); const { data: devices = [] } = useQuery({ queryKey: ['devices'], queryFn: () => devicesApi.list().then(r => r.data), refetchInterval: 30_000, }); useSocket({ 'device:updated': () => { queryClient.invalidateQueries({ queryKey: ['metrics-summary'] }); queryClient.invalidateQueries({ queryKey: ['devices'] }); }, 'clients:updated': () => { queryClient.invalidateQueries({ queryKey: ['top-clients'] }); queryClient.invalidateQueries({ queryKey: ['metrics-summary'] }); queryClient.invalidateQueries({ queryKey: ['wireless-clients-active'] }); }, 'events:updated': () => queryClient.invalidateQueries({ queryKey: ['events-recent'] }), 'device:status': () => queryClient.invalidateQueries({ queryKey: ['metrics-summary'] }), }); const devicesWithRosUpdates = devices.filter( d => d.firmware_update_available && !dismissedFirmwareIds.includes(d.id) ); const devicesWithRbUpgrades = devices.filter( d => d.routerboard_upgrade_available && !dismissedFirmwareIds.includes(d.id) ); const showUpdateBanner = devicesWithRosUpdates.length > 0 || devicesWithRbUpgrades.length > 0; // Extract client count sparkline from time-series const clientSparkline = useMemo(() => { if (clientsOverTime.length < 2) return []; const recent = clientsOverTime.slice(-24); return recent.map(p => p.value); }, [clientsOverTime]); const wirelessCount = wirelessClientsData?.total ?? 0; return (
{/* Page header */}

Dashboard

{devices.length} device{devices.length !== 1 ? 's' : ''}
{(['summary', 'operations'] as const).map((v) => ( ))}
{/* Update banner */} {showUpdateBanner && (
Updates available {devicesWithRosUpdates.length > 0 && (
RouterOS ({devicesWithRosUpdates.length}): {devicesWithRosUpdates.map((d, i) => ( {i > 0 && · } ))}
)} {devicesWithRbUpgrades.length > 0 && (
RouterBOOT ({devicesWithRbUpgrades.length}): {devicesWithRbUpgrades.map((d, i) => ( {i > 0 && · } ))}
)}
)} {/* View content */} {dashView === 'summary' ? ( ) : ( )}
); }