Update-availability fixes: - The backend read its version from npm_package_version, which is unset when the container runs `node dist/index.js` directly, so current was always "0.0.0" and every release looked newer — the "update available" banner never cleared. Read the bundled package.json instead (routes/system.ts), falling back to npm_package_version. - Move the update notification out of the top-of-app banner into the Operations dashboard (a dedicated notice under the network-status hero), shown only when genuinely behind and cleared once up to date. Security banner: - Replace the now-dead jwt_secret_default / encryption_key_default checks with the current secrets_not_persisted warning (auto-generated secrets that couldn't be persisted), matching the self-healing secrets model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1300 lines
61 KiB
TypeScript
1300 lines
61 KiB
TypeScript
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 (
|
||
<span
|
||
style={{
|
||
width: size, height: size, borderRadius: 999, display: 'inline-block',
|
||
background: color, flexShrink: 0,
|
||
boxShadow: glow ? `0 0 0 2px ${color}33, 0 0 8px ${color}88` : 'none',
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<svg width={w} height={h} style={{ display: 'block', overflow: 'visible' }}>
|
||
{area && (
|
||
<path d={areaD} fill={color} fillOpacity="0.12" stroke="none" />
|
||
)}
|
||
<path d={d} fill="none" stroke={color} strokeWidth="1.4" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function TypePill({ type }: { type: string }) {
|
||
const map: Record<string, { label: string; color: string }> = {
|
||
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 (
|
||
<span
|
||
className="mono text-[10.5px] font-medium px-[6px] py-[2px] rounded-full"
|
||
style={{ color, border: '1px solid var(--line)' }}
|
||
>
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ─── Device Locations Map ─────────────────────────────────────────────────────
|
||
|
||
function DeviceLocationsMap({ devices }: { devices: Device[] }) {
|
||
const containerRef = useRef<HTMLDivElement>(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<string, Device[]>();
|
||
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 `<b>${d.name}</b> <span style="color:${c};font-size:11px">● ${d.status}</span>`;
|
||
}).join('<br/>');
|
||
const address = groupDevices[0].location_address
|
||
? `<br/><span style="color:#8a877e;font-size:11px">${groupDevices[0].location_address}</span>`
|
||
: '';
|
||
const tooltipHtml = groupDevices.map(d =>
|
||
`${d.status === 'online' ? '●' : '○'} ${d.name}`
|
||
).join('<br/>');
|
||
|
||
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: `<div style="background:${colorHex};color:#0e0f12;border:2px solid #fff;border-radius:50%;width:${size}px;height:${size}px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;box-shadow:0 0 0 4px ${colorHex}33,0 0 12px ${colorHex}88;cursor:pointer">${count}</div>`,
|
||
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 (
|
||
<div className="flex items-center justify-center text-sm" style={{ height: 220, color: 'var(--ink-4)' }}>
|
||
No device locations configured — add addresses via Device › Overview › Physical Details
|
||
</div>
|
||
);
|
||
}
|
||
return <div ref={containerRef} style={{ height: 220, width: '100%', borderRadius: 8 }} />;
|
||
}
|
||
|
||
// ─── 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 (
|
||
<div className="card" style={{ padding: '18px 22px' }}>
|
||
<div className="flex flex-wrap items-center gap-7">
|
||
{/* Status block */}
|
||
<div className="flex items-center gap-3 flex-shrink-0">
|
||
<div
|
||
className="w-9 h-9 rounded-[8px] flex items-center justify-center flex-shrink-0"
|
||
style={{ background: 'var(--accent-soft)' }}
|
||
>
|
||
<StatusDot
|
||
color={allOnline ? 'var(--accent)' : 'var(--warn)'}
|
||
glow size={10}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div className="text-[14px] font-semibold" style={{ color: 'var(--ink)' }}>
|
||
{allOnline ? 'All systems nominal' : `${summary?.devices.offline ?? 0} device(s) unreachable`}
|
||
</div>
|
||
<div className="text-[12px]" style={{ color: 'var(--ink-3)' }}>
|
||
{summary?.devices.online ?? 0} of {summary?.devices.total ?? 0} reachable
|
||
{(summary?.alerts.critical ?? 0) > 0 && ` · ${summary!.alerts.critical} critical alert`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Reachability bar */}
|
||
<div className="flex-1 min-w-[120px]" style={{ paddingLeft: 20, borderLeft: '1px solid var(--line)' }}>
|
||
<div className="flex justify-between text-[11px] mb-[6px]" style={{ color: 'var(--ink-3)' }}>
|
||
<span>Reachability</span>
|
||
<span className="mono">{summary?.devices.online ?? 0}/{summary?.devices.total ?? 0} online</span>
|
||
</div>
|
||
<div className="rounded-full overflow-hidden" style={{ height: 8, background: 'var(--surface-3)' }}>
|
||
<div
|
||
className="h-full rounded-full transition-all duration-700"
|
||
style={{
|
||
width: `${reachFraction * 100}%`,
|
||
background: reachFraction === 1 ? 'var(--accent)' : reachFraction > 0.5 ? 'var(--warn)' : 'var(--bad)',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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 }) => (
|
||
<div key={label} style={{ minWidth: 110, paddingLeft: 20, borderLeft: '1px solid var(--line)' }}>
|
||
<div className="text-[11px] mb-[2px]" style={{ color: 'var(--ink-3)' }}>{label}</div>
|
||
<div className="text-[18px] font-semibold num-tab mb-[4px]" style={{ color: 'var(--ink)' }}>{value}</div>
|
||
{spark.length >= 2 && <Sparkline data={spark} w={100} h={18} color={color} />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 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<T>(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<string, any>) {
|
||
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<string, number> = {};
|
||
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<string, { label: string; color: string }> = {
|
||
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 (
|
||
<div className="space-y-4">
|
||
<HealthBar
|
||
summary={summary}
|
||
devices={devices}
|
||
wirelessCount={wirelessCount}
|
||
clientSparkline={clientSparkline}
|
||
/>
|
||
|
||
{/* Main grid: clients chart + devices list */}
|
||
<div className="grid gap-4" style={{ gridTemplateColumns: '1.6fr 1fr' }}>
|
||
{/* Clients chart */}
|
||
<div className="card" style={{ padding: '16px 18px' }}>
|
||
<div className="flex items-baseline justify-between mb-3">
|
||
<div>
|
||
<div className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Connected clients</div>
|
||
<div className="mono text-[11px]" style={{ color: 'var(--ink-3)' }}>last {chartRange} · 5min buckets</div>
|
||
</div>
|
||
<div className="flex items-baseline gap-2">
|
||
<span className="text-[22px] font-semibold num-tab" style={{ color: 'var(--ink)' }}>
|
||
{summary?.clients.active ?? '—'}
|
||
</span>
|
||
<div className="flex gap-1">
|
||
{(['1h', '6h', '24h', '7d'] as const).map((r) => (
|
||
<button
|
||
key={r}
|
||
onClick={() => {
|
||
setChartRange(r);
|
||
localStorage.setItem('dashboard:chart-range', r);
|
||
}}
|
||
className="mono text-[11px] px-[8px] py-[3px] rounded-[5px] transition-colors"
|
||
style={{
|
||
background: chartRange === r ? 'var(--surface-3)' : 'transparent',
|
||
color: chartRange === r ? 'var(--ink)' : 'var(--ink-3)',
|
||
border: '1px solid var(--line)',
|
||
}}
|
||
>
|
||
{r}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{clientsOverTime.length > 0 ? (
|
||
<ResponsiveContainer width="100%" height={180}>
|
||
<BarChart data={chartData} margin={{ top: 4, right: 4, left: -24, bottom: 0 }} barCategoryGap="12%">
|
||
<CartesianGrid strokeDasharray="3 3" stroke="var(--line-soft)" vertical={false} />
|
||
<XAxis
|
||
dataKey="ts"
|
||
tick={{ fontSize: 10, fill: 'var(--ink-4)', fontFamily: 'Geist Mono' }}
|
||
interval={tickEvery}
|
||
minTickGap={20}
|
||
tickFormatter={(t) => format(new Date(t as number), chartRange === '7d' ? 'MMM d' : 'HH:mm')}
|
||
/>
|
||
<YAxis tick={{ fontSize: 10, fill: 'var(--ink-4)' }} allowDecimals={false} />
|
||
<Tooltip
|
||
cursor={{ fill: 'var(--surface-3)', opacity: 0.4 }}
|
||
formatter={(v: number) => [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)',
|
||
}}
|
||
/>
|
||
<Bar dataKey="value" fill="var(--accent)" radius={[3, 3, 0, 0]} />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
) : (
|
||
<div className="flex items-center justify-center text-[13px]" style={{ height: 180, color: 'var(--ink-4)' }}>
|
||
No data yet — metrics appear after devices are polled
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Compact devices list */}
|
||
<div className="card" style={{ padding: '16px 18px' }}>
|
||
<div className="flex items-baseline justify-between mb-3">
|
||
<div className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Devices</div>
|
||
<button
|
||
onClick={() => navigate('/devices')}
|
||
className="text-[11px] transition-colors"
|
||
style={{ color: 'var(--ink-3)' }}
|
||
>
|
||
Manage →
|
||
</button>
|
||
</div>
|
||
<div className="space-y-[1px]">
|
||
{(devices as Device[]).map((d) => (
|
||
<div
|
||
key={d.id}
|
||
className="flex items-center gap-[10px] px-[6px] py-[7px] rounded-[5px] cursor-pointer transition-colors"
|
||
style={{ color: 'var(--ink)' }}
|
||
onClick={() => navigate(`/devices/${d.id}`)}
|
||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-3)')}
|
||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||
>
|
||
<StatusDot
|
||
color={d.status === 'online' ? 'var(--good)' : d.status === 'offline' ? 'var(--bad)' : 'var(--ink-4)'}
|
||
glow={d.status === 'online'}
|
||
size={6}
|
||
/>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-[13px] font-medium truncate">{d.name}</div>
|
||
<div className="mono text-[10.5px] truncate" style={{ color: 'var(--ink-3)' }}>{d.model}</div>
|
||
</div>
|
||
<div className="mono text-[11px] flex items-center gap-1" style={{ color: d.firmware_update_available ? 'var(--warn)' : 'var(--ink-3)' }}>
|
||
{d.firmware_update_available && <span>↑</span>}
|
||
{d.ros_version}
|
||
</div>
|
||
<TypePill type={d.device_type} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Second row: Mix + Top talkers + Activity */}
|
||
<div className="grid gap-4" style={{ gridTemplateColumns: '1fr 1.2fr 1fr' }}>
|
||
{/* Device mix */}
|
||
<div className="card" style={{ padding: '16px 18px' }}>
|
||
<div className="text-[13px] font-semibold mb-3" style={{ color: 'var(--ink)' }}>Mix</div>
|
||
<div className="space-y-[10px]">
|
||
{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 (
|
||
<div key={type}>
|
||
<div className="flex justify-between text-[12px] mb-[5px]">
|
||
<span style={{ color: 'var(--ink-2)' }}>{label}</span>
|
||
<span className="mono num-tab" style={{ color: 'var(--ink-3)' }}>{count}</span>
|
||
</div>
|
||
<div className="rounded-full overflow-hidden" style={{ height: 4, background: 'var(--surface-3)' }}>
|
||
<div className="h-full rounded-full" style={{ width: `${frac * 100}%`, background: color }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Top talkers — NetFlow-backed (same numbers as the Traffic page) when
|
||
the collector has data; otherwise device-reported connection counters. */}
|
||
<div className="card" style={{ padding: '16px 18px' }}>
|
||
<div className="flex items-baseline justify-between mb-3">
|
||
<div className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Top talkers</div>
|
||
<span
|
||
className="mono text-[11px]"
|
||
style={{ color: 'var(--ink-3)' }}
|
||
title={usingNetflowTop
|
||
? 'Measured by the NetFlow collector over the last 24 hours'
|
||
: 'Device-reported counters since each client connected (enable NetFlow for time-windowed data)'}
|
||
>
|
||
{usingNetflowTop ? 'by bytes · 24h' : 'since connection'}
|
||
</span>
|
||
</div>
|
||
{(topClients as { mac_address: string; hostname?: string; total_bytes: number }[]).length > 0 ? (
|
||
<div className="space-y-[1px]">
|
||
{(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 (
|
||
<div
|
||
key={c.mac_address}
|
||
className="grid items-center gap-[10px]"
|
||
style={{ gridTemplateColumns: '16px 1fr 70px', padding: '5px 0', cursor: isRealMac ? 'pointer' : 'default' }}
|
||
onClick={() => isRealMac && navigate(`/clients/${encodeURIComponent(c.mac_address)}`)}
|
||
title={isRealMac ? `${c.mac_address} — open client` : undefined}
|
||
>
|
||
<span className="mono text-[10px] num-tab text-right" style={{ color: 'var(--ink-4)' }}>
|
||
{String(i + 1).padStart(2, '0')}
|
||
</span>
|
||
<div className="min-w-0">
|
||
<div className="mono text-[11.5px] truncate mb-[3px]" style={{ color: 'var(--ink)' }}>
|
||
{label.length > 20 ? label.slice(0, 20) + '…' : label}
|
||
</div>
|
||
<div className="rounded-full overflow-hidden" style={{ height: 3, background: 'var(--surface-3)' }}>
|
||
<div className="h-full rounded-full" style={{ width: `${(c.total_bytes / maxB) * 100}%`, background: 'var(--accent)' }} />
|
||
</div>
|
||
</div>
|
||
<span className="mono num-tab text-right text-[11px]" style={{ color: 'var(--ink-2)' }}>
|
||
{formatBytes(c.total_bytes)}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center justify-center text-[12px]" style={{ height: 80, color: 'var(--ink-4)' }}>
|
||
No active clients
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Activity */}
|
||
<div className="card" style={{ padding: '16px 18px' }}>
|
||
<div className="flex items-baseline justify-between mb-3">
|
||
<div className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Activity</div>
|
||
<div className="flex items-center gap-2">
|
||
{(['error', 'warning', 'info'] as const).map((sev) => (
|
||
<label key={sev} className="flex items-center gap-1 cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={severities.has(sev)}
|
||
onChange={() => toggleSeverity(sev)}
|
||
className="w-3 h-3 rounded cursor-pointer"
|
||
/>
|
||
<span className="text-[10px]" style={{
|
||
color: sev === 'error' ? 'var(--bad)' : sev === 'warning' ? 'var(--warn)' : 'var(--info)',
|
||
}}>
|
||
{sev[0].toUpperCase()}
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
{((recentEvents?.events ?? []) as DeviceEvent[]).length > 0 ? (
|
||
((recentEvents?.events ?? []) as DeviceEvent[]).map((ev, i) => (
|
||
<div
|
||
key={ev.id}
|
||
className="grid gap-[10px] py-[5px]"
|
||
style={{
|
||
gridTemplateColumns: '60px 1fr',
|
||
fontSize: 11.5,
|
||
borderTop: i ? '1px solid var(--line-soft)' : 'none',
|
||
}}
|
||
>
|
||
<div className="mono" style={{ color: 'var(--ink-4)' }}>
|
||
{format(new Date(ev.event_time), 'HH:mm:ss')}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-[6px] mb-[2px]">
|
||
<StatusDot
|
||
color={ev.severity === 'error' || ev.severity === 'critical' ? 'var(--bad)' : ev.severity === 'warning' ? 'var(--warn)' : 'var(--ink-3)'}
|
||
size={5}
|
||
/>
|
||
<span className="mono text-[10.5px] truncate" style={{ color: 'var(--ink-2)' }}>
|
||
{ev.device_name}
|
||
</span>
|
||
</div>
|
||
<span className="truncate block" style={{ color: 'var(--ink-2)' }}>{ev.message}</span>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="flex items-center justify-center text-[12px]" style={{ height: 80, color: 'var(--ink-4)' }}>
|
||
No recent events
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Locations map */}
|
||
<div className="card" style={{ overflow: 'hidden' }}>
|
||
<div
|
||
className="flex items-center justify-between px-[18px] py-[14px]"
|
||
style={{ borderBottom: '1px solid var(--line)' }}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<MapPin className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||
<span className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Locations</span>
|
||
</div>
|
||
<span className="mono text-[11px]" style={{ color: 'var(--ink-3)' }}>
|
||
{(devices as Device[]).filter(d => d.location_lat != null).length} of {(devices as Device[]).length} mapped
|
||
</span>
|
||
</div>
|
||
<div style={{ padding: '0 0' }}>
|
||
<DeviceLocationsMap devices={devices as Device[]} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── 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<string | null>(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<string>) {
|
||
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 (
|
||
<div className="space-y-4">
|
||
{/* Hero status card */}
|
||
<div className="card relative overflow-hidden" style={{ padding: '32px 36px' }}>
|
||
<div
|
||
className="absolute inset-0 pointer-events-none"
|
||
style={{ background: 'radial-gradient(circle at 95% 0%, rgba(193,241,126,0.06), transparent 60%)' }}
|
||
/>
|
||
<div className="relative flex flex-wrap items-center gap-8">
|
||
<div className="flex items-center gap-5">
|
||
<div
|
||
className="w-16 h-16 rounded-[14px] flex items-center justify-center flex-shrink-0"
|
||
style={{ background: 'radial-gradient(circle, rgba(193,241,126,0.2), transparent)' }}
|
||
>
|
||
<StatusDot color={statusColor} glow size={20} />
|
||
</div>
|
||
<div>
|
||
<div className="text-[11px] uppercase tracking-[0.1em] mb-[6px]" style={{ color: 'var(--ink-3)' }}>Network status</div>
|
||
<div className="text-[32px] font-semibold leading-none" style={{ color: 'var(--ink)', letterSpacing: '-0.02em' }}>
|
||
{statusText}
|
||
</div>
|
||
<div className="text-[13px] mt-[6px]" style={{ color: 'var(--ink-3)' }}>
|
||
{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' : ''}`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="ml-auto flex gap-6">
|
||
{[
|
||
{ 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 }) => (
|
||
<div key={label}>
|
||
<div className="text-[10px] tracking-[0.1em] mb-1" style={{ color: 'var(--ink-4)' }}>{label}</div>
|
||
<div className="text-[22px] font-semibold num-tab" style={{ color }}>{value}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Platform update available */}
|
||
{versionInfo?.update_available && versionInfo.latest && (
|
||
<div className="card flex items-center gap-3 px-5 py-[13px]" style={{ borderLeft: '3px solid var(--info)' }}>
|
||
<ArrowUpCircle className="w-[18px] h-[18px] flex-shrink-0" style={{ color: 'var(--info)' }} />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>
|
||
Platform update available — v{versionInfo.latest}
|
||
</div>
|
||
<div className="text-[12px]" style={{ color: 'var(--ink-3)' }}>
|
||
You're on v{versionInfo.current}. Run{' '}
|
||
<code className="mono text-[11px]" style={{ color: 'var(--ink-2)' }}>docker compose pull && docker compose up -d</code>{' '}
|
||
to update.
|
||
</div>
|
||
</div>
|
||
<a
|
||
href="https://github.com/2GT-Media-Group-LLC/mikrotik-manager/releases"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="text-[12px] font-medium flex-shrink-0 hover:underline"
|
||
style={{ color: 'var(--info)' }}
|
||
>
|
||
Release notes →
|
||
</a>
|
||
</div>
|
||
)}
|
||
|
||
{/* Things to handle + Quick actions */}
|
||
<div className="grid gap-4" style={{ gridTemplateColumns: '1.3fr 1fr' }}>
|
||
{/* Things to handle */}
|
||
<div className="card" style={{ overflow: 'hidden' }}>
|
||
<div
|
||
className="flex items-center gap-[10px] px-5 py-[14px]"
|
||
style={{ borderBottom: '1px solid var(--line)' }}
|
||
>
|
||
<div
|
||
className="w-[18px] h-[18px] rounded-[4px] flex items-center justify-center text-[12px] font-bold flex-shrink-0"
|
||
style={{ background: 'var(--warn)', color: 'var(--accent-fg)' }}
|
||
>!
|
||
</div>
|
||
<span className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Things to handle</span>
|
||
<span className="mono text-[11px]" style={{ color: 'var(--ink-3)' }}>
|
||
{thingsToHandle.length} item{thingsToHandle.length !== 1 ? 's' : ''}
|
||
</span>
|
||
</div>
|
||
{thingsToHandle.length > 0 ? thingsToHandle.map((item, i) => (
|
||
<div
|
||
key={i}
|
||
className="grid items-start gap-[14px] px-5 py-[14px]"
|
||
style={{
|
||
gridTemplateColumns: '3px 1fr auto',
|
||
borderBottom: i < thingsToHandle.length - 1 ? '1px solid var(--line-soft)' : 'none',
|
||
}}
|
||
>
|
||
<div className="self-stretch rounded-full" style={{ background: sevColor(item.sev), width: 3 }} />
|
||
<div>
|
||
<div className="text-[13.5px] font-medium mb-[3px]" style={{ color: 'var(--ink)' }}>{item.title}</div>
|
||
<div className="text-[12.5px] leading-relaxed" style={{ color: 'var(--ink-2)' }}>{item.body}</div>
|
||
</div>
|
||
<button
|
||
onClick={() => navigate(item.path)}
|
||
className="text-[11.5px] rounded-[5px] px-3 py-[6px] flex-shrink-0 transition-colors"
|
||
style={{ background: 'transparent', border: '1px solid var(--line)', color: 'var(--ink-2)' }}
|
||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-3)')}
|
||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||
>
|
||
{item.action} →
|
||
</button>
|
||
</div>
|
||
)) : (
|
||
<div className="flex items-center justify-center gap-2 py-8" style={{ color: 'var(--ink-3)' }}>
|
||
<StatusDot color="var(--good)" glow size={8} />
|
||
<span className="text-[13px]">Nothing needs attention</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Quick actions */}
|
||
<div className="card" style={{ padding: '18px 20px' }}>
|
||
<div className="text-[13px] font-semibold mb-3" style={{ color: 'var(--ink)' }}>Quick actions</div>
|
||
<div className="grid gap-2" style={{ gridTemplateColumns: '1fr 1fr' }}>
|
||
{actions.map(({ key, label, sub, icon: Icon, color, run }) => {
|
||
const isBusy = busy === key;
|
||
return (
|
||
<button
|
||
key={key}
|
||
onClick={run}
|
||
disabled={!!busy}
|
||
className="text-left rounded-[6px] px-[14px] py-[12px] transition-colors disabled:opacity-60"
|
||
style={{ background: 'var(--surface-2)', border: '1px solid var(--line)' }}
|
||
onMouseEnter={(e) => { if (!busy) e.currentTarget.style.background = 'var(--surface-3)'; }}
|
||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--surface-2)')}
|
||
>
|
||
<div className="flex items-center gap-2 mb-[3px]">
|
||
{isBusy
|
||
? <RefreshCw className="w-3.5 h-3.5 flex-shrink-0 animate-spin" style={{ color }} />
|
||
: <Icon className="w-3.5 h-3.5 flex-shrink-0" style={{ color }} />}
|
||
<span className="text-[12.5px] font-semibold" style={{ color }}>{label}</span>
|
||
</div>
|
||
<div className="text-[11px]" style={{ color: 'var(--ink-3)' }}>{isBusy ? 'Working…' : sub}</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{actionMsg && (
|
||
<div
|
||
className="mt-3 flex items-center gap-2 text-[12px] rounded-[6px] px-3 py-2"
|
||
style={{
|
||
background: actionMsg.ok ? 'var(--good-bg, rgba(34,197,94,0.1))' : 'var(--bad-bg, rgba(239,68,68,0.1))',
|
||
color: actionMsg.ok ? 'var(--good)' : 'var(--bad)',
|
||
}}
|
||
>
|
||
{actionMsg.ok ? <CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" /> : <AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" />}
|
||
{actionMsg.text}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Capacity + Security rollup */}
|
||
<div className="grid gap-4" style={{ gridTemplateColumns: '1.3fr 1fr' }}>
|
||
{/* Capacity / health */}
|
||
<div className="card" style={{ overflow: 'hidden' }}>
|
||
<div className="flex items-center gap-2 px-5 py-[14px]" style={{ borderBottom: '1px solid var(--line)' }}>
|
||
<Cpu className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||
<span className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Capacity & health</span>
|
||
<span className="mono text-[11px] ml-auto" style={{ color: 'var(--ink-3)' }}>CPU · MEM</span>
|
||
</div>
|
||
{sortedCapacity.length === 0 ? (
|
||
<div className="py-8 text-center text-[13px]" style={{ color: 'var(--ink-3)' }}>No resource data yet.</div>
|
||
) : (
|
||
<div className="px-5 py-3 space-y-3">
|
||
{sortedCapacity.slice(0, 8).map((c) => (
|
||
<button key={c.id} onClick={() => navigate(`/devices/${c.id}`)}
|
||
className="w-full text-left flex items-center gap-3 group">
|
||
<div className="text-[12.5px] font-medium truncate" style={{ color: 'var(--ink)', width: 130 }}>{c.name}</div>
|
||
<div className="flex-1 flex items-center gap-3">
|
||
{([['CPU', c.cpu], ['MEM', c.mem_pct]] as const).map(([lbl, v]) => (
|
||
<div key={lbl} className="flex-1 flex items-center gap-1.5">
|
||
<span className="mono text-[9px]" style={{ color: 'var(--ink-4)', width: 22 }}>{lbl}</span>
|
||
<div className="flex-1 h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--surface-3)' }}>
|
||
<div className="h-full rounded-full" style={{ width: `${Math.min(100, v)}%`, background: meterColor(v) }} />
|
||
</div>
|
||
<span className="mono text-[10px] num-tab" style={{ color: 'var(--ink-3)', width: 30, textAlign: 'right' }}>{v}%</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Security posture rollup */}
|
||
<button
|
||
onClick={() => navigate('/security')}
|
||
className="card text-left transition-colors"
|
||
style={{ overflow: 'hidden', padding: 0 }}
|
||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--line-strong, var(--line))')}
|
||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = '')}
|
||
>
|
||
<div className="flex items-center gap-2 px-5 py-[14px]" style={{ borderBottom: '1px solid var(--line)' }}>
|
||
<Shield className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||
<span className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Security posture</span>
|
||
<ChevronRight className="w-4 h-4 ml-auto" style={{ color: 'var(--ink-3)' }} />
|
||
</div>
|
||
<div className="px-5 py-4">
|
||
<div className="flex items-end gap-3 mb-3">
|
||
<span className="text-[34px] font-semibold leading-none num-tab" style={{ color: scoreColor(avgScore) }}>
|
||
{avgScore ?? '—'}
|
||
</span>
|
||
<span className="text-[12px] mb-1" style={{ color: 'var(--ink-3)' }}>
|
||
avg hardening · {scored.length} scanned
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[12px] mb-3" style={{ color: highFindings > 0 ? 'var(--bad)' : 'var(--ink-3)' }}>
|
||
<AlertTriangle className="w-3.5 h-3.5" />
|
||
{highFindings} high-severity finding{highFindings !== 1 ? 's' : ''}
|
||
</div>
|
||
{worstDevices.length > 0 && (
|
||
<div className="space-y-1.5">
|
||
{worstDevices.map((d) => (
|
||
<div key={d.id} className="flex items-center gap-2 text-[12px]">
|
||
<StatusDot color={scoreColor(d.score)} size={6} />
|
||
<span className="truncate" style={{ color: 'var(--ink-2)' }}>{d.name}</span>
|
||
<span className="mono ml-auto num-tab" style={{ color: scoreColor(d.score) }}>{d.score ?? '—'}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Activity feed */}
|
||
<div className="card" style={{ overflow: 'hidden' }}>
|
||
<div className="flex items-center gap-2 px-5 py-[14px]" style={{ borderBottom: '1px solid var(--line)' }}>
|
||
<Clock className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||
<span className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Recent activity</span>
|
||
</div>
|
||
{activity.length === 0 ? (
|
||
<div className="py-8 text-center text-[13px]" style={{ color: 'var(--ink-3)' }}>No recent activity.</div>
|
||
) : (
|
||
<div>
|
||
{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 (
|
||
<div key={i} className="flex items-start gap-3 px-5 py-[11px]"
|
||
style={{ borderBottom: i < activity.length - 1 ? '1px solid var(--line-soft)' : 'none' }}>
|
||
<Icon className="w-3.5 h-3.5 flex-shrink-0 mt-[2px]" style={{ color }} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-[12.5px] truncate" style={{ color: 'var(--ink)' }}>{a.title}</div>
|
||
<div className="text-[11px]" style={{ color: 'var(--ink-3)' }}>{a.sub}</div>
|
||
</div>
|
||
<span className="mono text-[10.5px] flex-shrink-0" style={{ color: 'var(--ink-4)' }}>
|
||
{formatDistanceToNow(new Date(a.at), { addSuffix: false })}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Fleet strip */}
|
||
<div className="card" style={{ padding: '18px 20px' }}>
|
||
<div className="flex items-baseline justify-between mb-3">
|
||
<div className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Fleet</div>
|
||
<span className="mono text-[11px]" style={{ color: 'var(--ink-3)' }}>
|
||
{(devices as Device[]).length} device{(devices as Device[]).length !== 1 ? 's' : ''}
|
||
</span>
|
||
</div>
|
||
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${Math.min((devices as Device[]).length, 5)}, 1fr)` }}>
|
||
{(devices as Device[]).map((d) => {
|
||
const hasAlert = d.firmware_update_available || d.routerboard_upgrade_available;
|
||
return (
|
||
<button
|
||
key={d.id}
|
||
onClick={() => navigate(`/devices/${d.id}`)}
|
||
className="text-left rounded-[8px] p-[14px] transition-colors"
|
||
style={{ background: 'var(--surface-2)', border: '1px solid var(--line)' }}
|
||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-3)')}
|
||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--surface-2)')}
|
||
>
|
||
<div className="flex items-center gap-2 mb-[10px]">
|
||
<StatusDot
|
||
color={d.status === 'online' ? 'var(--good)' : d.status === 'offline' ? 'var(--bad)' : 'var(--warn)'}
|
||
glow size={6}
|
||
/>
|
||
<span className="mono text-[10px]" style={{ color: 'var(--ink-3)' }}>
|
||
{d.device_type === 'wireless_ap' ? 'AP' : d.device_type === 'router' ? 'RTR' : 'SW'}
|
||
</span>
|
||
{hasAlert && (
|
||
<span
|
||
className="ml-auto text-[10px] font-bold px-[5px] py-[1px] rounded-full"
|
||
style={{ background: 'var(--warn-bg)', color: 'var(--warn)' }}
|
||
>!</span>
|
||
)}
|
||
</div>
|
||
<div className="text-[13px] font-semibold truncate mb-[3px]" style={{ color: 'var(--ink)' }}>{d.name}</div>
|
||
<div className="mono text-[10.5px]" style={{ color: 'var(--ink-3)' }}>{d.ros_version}</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Terminal device picker */}
|
||
{pickTerminal && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
|
||
onClick={() => setPickTerminal(false)}>
|
||
<div className="card w-full max-w-sm" style={{ overflow: 'hidden' }} onClick={(e) => e.stopPropagation()}>
|
||
<div className="flex items-center gap-2 px-5 py-[14px]" style={{ borderBottom: '1px solid var(--line)' }}>
|
||
<Terminal className="w-4 h-4" style={{ color: 'var(--violet)' }} />
|
||
<span className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Open terminal — pick a device</span>
|
||
<button onClick={() => setPickTerminal(false)} className="ml-auto" style={{ color: 'var(--ink-3)' }}>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
<div className="max-h-[360px] overflow-y-auto">
|
||
{[...deviceList].sort((a, b) => (a.status === 'online' ? 0 : 1) - (b.status === 'online' ? 0 : 1)).map((d) => (
|
||
<button key={d.id} disabled={d.status !== 'online'}
|
||
onClick={() => { setTerminalDevice({ id: d.id, name: d.name }); setPickTerminal(false); }}
|
||
className="w-full text-left flex items-center gap-3 px-5 py-[11px] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||
style={{ borderBottom: '1px solid var(--line-soft)' }}
|
||
onMouseEnter={(e) => { if (d.status === 'online') e.currentTarget.style.background = 'var(--surface-3)'; }}
|
||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
||
<StatusDot color={d.status === 'online' ? 'var(--good)' : 'var(--bad)'} size={6} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-[13px] font-medium truncate" style={{ color: 'var(--ink)' }}>{d.name}</div>
|
||
<div className="mono text-[10.5px]" style={{ color: 'var(--ink-3)' }}>{d.ip_address}</div>
|
||
</div>
|
||
{d.status !== 'online' && <span className="text-[10px]" style={{ color: 'var(--ink-4)' }}>offline</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{terminalDevice && (
|
||
<TerminalModal
|
||
deviceId={terminalDevice.id}
|
||
deviceName={terminalDevice.name}
|
||
onClose={() => setTerminalDevice(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||
|
||
const ALL_SEVERITIES = ['error', 'warning', 'info'] as const;
|
||
const SEVERITY_KEY = 'dashboard:severities';
|
||
|
||
function loadSeverities(): Set<string> {
|
||
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<string>(() =>
|
||
localStorage.getItem('dashboard:chart-range') ?? '24h'
|
||
);
|
||
const [severities, setSeverities] = useState<Set<string>>(loadSeverities);
|
||
const [dismissedFirmwareIds, setDismissedFirmwareIds] = useState<number[]>([]);
|
||
|
||
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 (
|
||
<div className="space-y-4">
|
||
{/* Page header */}
|
||
<div className="flex items-baseline gap-3 flex-wrap">
|
||
<h1 className="text-[28px] font-semibold leading-none" style={{ color: 'var(--ink)', letterSpacing: '-0.01em' }}>
|
||
Dashboard
|
||
</h1>
|
||
<span className="mono text-[11px]" style={{ color: 'var(--ink-3)' }}>
|
||
{devices.length} device{devices.length !== 1 ? 's' : ''}
|
||
</span>
|
||
<div className="ml-auto flex gap-1">
|
||
{(['summary', 'operations'] as const).map((v) => (
|
||
<button
|
||
key={v}
|
||
onClick={() => {
|
||
setDashView(v);
|
||
localStorage.setItem('dashboard:view', v);
|
||
}}
|
||
className="text-[12px] font-medium px-3 py-[5px] rounded-[6px] transition-colors capitalize"
|
||
style={{
|
||
background: dashView === v ? 'var(--surface-3)' : 'transparent',
|
||
color: dashView === v ? 'var(--ink)' : 'var(--ink-3)',
|
||
border: '1px solid var(--line)',
|
||
}}
|
||
>
|
||
{v === 'summary' ? 'Summary' : 'Operations'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Update banner */}
|
||
{showUpdateBanner && (
|
||
<div
|
||
className="rounded-[8px] px-4 py-3 flex items-start gap-3"
|
||
style={{
|
||
background: 'var(--warn-bg)',
|
||
border: '1px solid var(--warn)',
|
||
}}
|
||
>
|
||
<ArrowUpCircle className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: 'var(--warn)' }} />
|
||
<div className="flex-1 text-[13px] space-y-1" style={{ color: 'var(--ink-2)' }}>
|
||
<span className="font-semibold" style={{ color: 'var(--warn)' }}>Updates available</span>
|
||
{devicesWithRosUpdates.length > 0 && (
|
||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||
<span className="font-medium flex items-center gap-1">
|
||
<ArrowUpCircle className="w-3 h-3" />
|
||
RouterOS ({devicesWithRosUpdates.length}):
|
||
</span>
|
||
{devicesWithRosUpdates.map((d, i) => (
|
||
<span key={d.id}>
|
||
{i > 0 && <span style={{ color: 'var(--ink-3)' }}> · </span>}
|
||
<button
|
||
onClick={() => navigate(`/devices/${d.id}?tab=config`)}
|
||
className="underline underline-offset-2"
|
||
>
|
||
{d.name}
|
||
{d.latest_ros_version && <span className="opacity-70"> ({d.latest_ros_version})</span>}
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{devicesWithRbUpgrades.length > 0 && (
|
||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||
<span className="font-medium flex items-center gap-1">
|
||
<Cpu className="w-3 h-3" />
|
||
RouterBOOT ({devicesWithRbUpgrades.length}):
|
||
</span>
|
||
{devicesWithRbUpgrades.map((d, i) => (
|
||
<span key={d.id}>
|
||
{i > 0 && <span style={{ color: 'var(--ink-3)' }}> · </span>}
|
||
<button
|
||
onClick={() => navigate(`/devices/${d.id}?tab=config`)}
|
||
className="underline underline-offset-2"
|
||
>
|
||
{d.name}
|
||
{d.upgrade_firmware_version && <span className="opacity-70"> ({d.upgrade_firmware_version})</span>}
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={() => setDismissedFirmwareIds(ids => [
|
||
...ids,
|
||
...devicesWithRosUpdates.map(d => d.id),
|
||
...devicesWithRbUpgrades.map(d => d.id),
|
||
])}
|
||
className="p-0.5 rounded flex-shrink-0"
|
||
style={{ color: 'var(--warn)' }}
|
||
title="Dismiss"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* View content */}
|
||
{dashView === 'summary' ? (
|
||
<SummaryView
|
||
summary={summary}
|
||
devices={devices}
|
||
wirelessCount={wirelessCount}
|
||
clientSparkline={clientSparkline}
|
||
clientsOverTime={clientsOverTime}
|
||
chartRange={chartRange}
|
||
setChartRange={setChartRange}
|
||
topClients={topClients}
|
||
usingNetflowTop={usingNetflowTop}
|
||
recentEvents={recentEvents}
|
||
severities={severities}
|
||
toggleSeverity={toggleSeverity}
|
||
navigate={navigate}
|
||
/>
|
||
) : (
|
||
<OperationsView
|
||
summary={summary}
|
||
devices={devices}
|
||
navigate={navigate}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|