Initial commit baby!
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mikrotik Manager</title>
|
||||
<meta name="description" content="Self-hosted Mikrotik network management platform" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3752
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext .ts,.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.17.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.0.4",
|
||||
"axios": "^1.6.5",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.309.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"recharts": "^2.10.3",
|
||||
"socket.io-client": "^4.7.3",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#2563eb"/>
|
||||
<path d="M6 16h4M22 16h4M10 16a6 6 0 0 1 12 0" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="16" cy="16" r="3" fill="white"/>
|
||||
<circle cx="8" cy="16" r="2" fill="#93c5fd"/>
|
||||
<circle cx="24" cy="16" r="2" fill="#93c5fd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 B |
@@ -0,0 +1,73 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from './store/authStore';
|
||||
import AppLayout from './components/layout/AppLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import DevicesPage from './pages/DevicesPage';
|
||||
import DeviceDetailPage from './pages/DeviceDetailPage';
|
||||
import ClientsPage from './pages/ClientsPage';
|
||||
import EventsPage from './pages/EventsPage';
|
||||
import TopologyPage from './pages/TopologyPage';
|
||||
import BackupsPage from './pages/BackupsPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import SwitchesOverviewPage from './pages/SwitchesOverviewPage';
|
||||
import SwitchesSettingsPage from './pages/SwitchesSettingsPage';
|
||||
import RoutersPage from './pages/RoutersPage';
|
||||
import RouterSettingsPage from './pages/RouterSettingsPage';
|
||||
import WirelessPage from './pages/WirelessPage';
|
||||
import WirelessSettingsPage from './pages/WirelessSettingsPage';
|
||||
import WirelessClientsPage from './pages/WirelessClientsPage';
|
||||
import ClientDetailPage from './pages/ClientDetailPage';
|
||||
import NetworkServicesOverviewPage from './pages/NetworkServicesOverviewPage';
|
||||
import NetworkServicesDHCPPage from './pages/NetworkServicesDHCPPage';
|
||||
import NetworkServicesDNSPage from './pages/NetworkServicesDNSPage';
|
||||
import NetworkServicesNTPPage from './pages/NetworkServicesNTPPage';
|
||||
import NetworkServicesWireGuardPage from './pages/NetworkServicesWireGuardPage';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
if (!token) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="devices" element={<DevicesPage />} />
|
||||
<Route path="devices/:id" element={<DeviceDetailPage />} />
|
||||
<Route path="clients" element={<ClientsPage />} />
|
||||
<Route path="clients/:mac" element={<ClientDetailPage />} />
|
||||
<Route path="events" element={<EventsPage />} />
|
||||
<Route path="topology" element={<TopologyPage />} />
|
||||
<Route path="backups" element={<BackupsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="switches" element={<SwitchesOverviewPage />} />
|
||||
<Route path="switches/settings" element={<SwitchesSettingsPage />} />
|
||||
<Route path="routers" element={<RoutersPage />} />
|
||||
<Route path="routers/settings" element={<RouterSettingsPage />} />
|
||||
<Route path="wireless" element={<WirelessPage />} />
|
||||
<Route path="wireless/settings" element={<WirelessSettingsPage />} />
|
||||
<Route path="wireless/clients" element={<WirelessClientsPage />} />
|
||||
<Route path="network-services" element={<NetworkServicesOverviewPage />} />
|
||||
<Route path="network-services/dhcp" element={<NetworkServicesDHCPPage />} />
|
||||
<Route path="network-services/dns" element={<NetworkServicesDNSPage />} />
|
||||
<Route path="network-services/ntp" element={<NetworkServicesNTPPage />} />
|
||||
<Route path="network-services/wireguard" element={<NetworkServicesWireGuardPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Point { x: number; y: number }
|
||||
interface Seg { from: Point; to: Point }
|
||||
interface Trace { segs: Seg[]; bright: boolean }
|
||||
interface Pulse {
|
||||
trace: Trace;
|
||||
segIdx: number;
|
||||
t: number; // [0,1] within current segment
|
||||
speed: number; // segments per second
|
||||
hue: number; // HSL hue
|
||||
trail: Point[];
|
||||
}
|
||||
|
||||
interface ThemePalette {
|
||||
bg: string;
|
||||
traceBright: string;
|
||||
traceDim: string;
|
||||
padBright: string;
|
||||
padDim: string;
|
||||
viaStroke: string;
|
||||
viaFill: string;
|
||||
pulseHues: number[];
|
||||
trailLightness: number; // % lightness for trail hsla
|
||||
glowLightness: number; // % lightness for glow center
|
||||
coreLightness: number; // % lightness for core dot
|
||||
coreSaturation: number; // % saturation for core dot
|
||||
}
|
||||
|
||||
// ─── Theme palettes ───────────────────────────────────────────────────────────
|
||||
|
||||
const DARK_PALETTE: ThemePalette = {
|
||||
bg: '#040c07',
|
||||
traceBright: '#112b1a',
|
||||
traceDim: '#0a1f10',
|
||||
padBright: '#183322',
|
||||
padDim: '#0f2219',
|
||||
viaStroke: '#1a3d25',
|
||||
viaFill: '#0d2218',
|
||||
pulseHues: [140, 150, 155, 160, 165, 170, 175, 180, 185],
|
||||
trailLightness: 65,
|
||||
glowLightness: 80,
|
||||
coreLightness: 96,
|
||||
coreSaturation: 60,
|
||||
};
|
||||
|
||||
const LIGHT_PALETTE: ThemePalette = {
|
||||
bg: '#e8f2f7',
|
||||
traceBright: '#7aafc8',
|
||||
traceDim: '#9dc4d8',
|
||||
padBright: '#689fbc',
|
||||
padDim: '#88b8cc',
|
||||
viaStroke: '#5590ac',
|
||||
viaFill: '#c2dce8',
|
||||
pulseHues: [185, 190, 195, 200, 205, 210, 215, 220, 170],
|
||||
trailLightness: 38,
|
||||
glowLightness: 28,
|
||||
coreLightness: 22,
|
||||
coreSaturation: 75,
|
||||
};
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const CELL = 56;
|
||||
const TRAIL = 28;
|
||||
const PULSE_COUNT = 22;
|
||||
const DX = [1, 0, -1, 0];
|
||||
const DY = [0, 1, 0, -1];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function pt(c: number, r: number): Point {
|
||||
return { x: c * CELL, y: r * CELL };
|
||||
}
|
||||
|
||||
function buildTraces(w: number, h: number): Trace[] {
|
||||
const cols = Math.floor(w / CELL) + 2;
|
||||
const rows = Math.floor(h / CELL) + 2;
|
||||
const count = 40 + Math.floor((w * h) / 40000);
|
||||
const traces: Trace[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
let c = Math.floor(Math.random() * cols);
|
||||
let r = Math.floor(Math.random() * rows);
|
||||
let dir = Math.floor(Math.random() * 4);
|
||||
const bright = Math.random() < 0.35;
|
||||
const maxLen = bright
|
||||
? 6 + Math.floor(Math.random() * 14)
|
||||
: 3 + Math.floor(Math.random() * 8);
|
||||
const segs: Seg[] = [];
|
||||
|
||||
for (let step = 0; step < maxLen; step++) {
|
||||
const nc = c + DX[dir];
|
||||
const nr = r + DY[dir];
|
||||
if (nc < 0 || nc >= cols || nr < 0 || nr >= rows) break;
|
||||
segs.push({ from: pt(c, r), to: pt(nc, nr) });
|
||||
c = nc;
|
||||
r = nr;
|
||||
if (Math.random() < 0.28) {
|
||||
dir = (dir + (Math.random() < 0.5 ? 1 : 3)) % 4;
|
||||
}
|
||||
if (step >= 2 && Math.random() < 0.06) break;
|
||||
}
|
||||
|
||||
if (segs.length >= 2) traces.push({ segs, bright });
|
||||
}
|
||||
|
||||
return traces;
|
||||
}
|
||||
|
||||
function buildPulses(traces: Trace[], palette: ThemePalette): Pulse[] {
|
||||
const candidates = traces.filter((t) => t.segs.length >= 3 && t.bright);
|
||||
const pool = candidates.length > 0 ? candidates : traces.filter((t) => t.segs.length >= 3);
|
||||
const pulses: Pulse[] = [];
|
||||
|
||||
for (let i = 0; i < PULSE_COUNT; i++) {
|
||||
const trace = pool[Math.floor(Math.random() * pool.length)];
|
||||
if (!trace) continue;
|
||||
pulses.push({
|
||||
trace,
|
||||
segIdx: Math.floor(Math.random() * trace.segs.length),
|
||||
t: Math.random(),
|
||||
speed: 0.45 + Math.random() * 0.85,
|
||||
hue: palette.pulseHues[Math.floor(Math.random() * palette.pulseHues.length)],
|
||||
trail: [],
|
||||
});
|
||||
}
|
||||
return pulses;
|
||||
}
|
||||
|
||||
function lerp(a: Point, b: Point, t: number): Point {
|
||||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
theme?: 'dark' | 'light';
|
||||
}
|
||||
|
||||
export default function CircuitBackground({ theme = 'dark' }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef<{ traces: Trace[]; pulses: Pulse[]; raf: number }>({
|
||||
traces: [], pulses: [], raf: 0,
|
||||
});
|
||||
|
||||
const palette = theme === 'light' ? LIGHT_PALETTE : DARK_PALETTE;
|
||||
|
||||
const init = useCallback((w: number, h: number) => {
|
||||
const traces = buildTraces(w, h);
|
||||
const pulses = buildPulses(traces, palette);
|
||||
stateRef.current.traces = traces;
|
||||
stateRef.current.pulses = pulses;
|
||||
}, [palette]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
if (!ctx) return;
|
||||
|
||||
let lastTime = performance.now();
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
init(canvas.width, canvas.height);
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// ── Draw helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function drawTraces(traces: Trace[]) {
|
||||
for (const trace of traces) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = trace.bright ? palette.traceBright : palette.traceDim;
|
||||
ctx.lineWidth = trace.bright ? 1.5 : 1;
|
||||
ctx.lineCap = 'square';
|
||||
ctx.moveTo(trace.segs[0].from.x, trace.segs[0].from.y);
|
||||
for (const seg of trace.segs) {
|
||||
ctx.lineTo(seg.to.x, seg.to.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
const padCol = trace.bright ? palette.padBright : palette.padDim;
|
||||
for (const seg of trace.segs) {
|
||||
ctx.fillStyle = padCol;
|
||||
ctx.beginPath();
|
||||
ctx.arc(seg.to.x, seg.to.y, 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (trace.bright && Math.random() < 0.4) {
|
||||
const last = trace.segs[trace.segs.length - 1].to;
|
||||
ctx.strokeStyle = palette.viaStroke;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(last.x, last.y, 5, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = palette.viaFill;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawPulse(pulse: Pulse) {
|
||||
const len = pulse.trail.length;
|
||||
if (len < 2) return;
|
||||
|
||||
const { trailLightness, glowLightness, coreLightness, coreSaturation } = palette;
|
||||
|
||||
for (let i = 1; i < len; i++) {
|
||||
const alpha = Math.pow(i / len, 1.6) * 0.75;
|
||||
const width = (i / len) * 2.5;
|
||||
ctx.strokeStyle = `hsla(${pulse.hue}, 90%, ${trailLightness}%, ${alpha})`;
|
||||
ctx.lineWidth = width;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pulse.trail[i - 1].x, pulse.trail[i - 1].y);
|
||||
ctx.lineTo(pulse.trail[i].x, pulse.trail[i].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const head = pulse.trail[len - 1];
|
||||
const glow = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 16);
|
||||
glow.addColorStop(0, `hsla(${pulse.hue}, 90%, ${glowLightness}%, 0.7)`);
|
||||
glow.addColorStop(0.35, `hsla(${pulse.hue}, 90%, ${trailLightness}%, 0.25)`);
|
||||
glow.addColorStop(1, `hsla(${pulse.hue}, 90%, ${trailLightness}%, 0)`);
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, 16, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = `hsl(${pulse.hue}, ${coreSaturation}%, ${coreLightness}%)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, 2.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── Animation loop ────────────────────────────────────────────────────────
|
||||
|
||||
const animate = (now: number) => {
|
||||
const dt = Math.min((now - lastTime) / 1000, 0.05);
|
||||
lastTime = now;
|
||||
|
||||
const { traces, pulses } = stateRef.current;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.fillStyle = palette.bg;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
drawTraces(traces);
|
||||
|
||||
for (const pulse of pulses) {
|
||||
pulse.t += pulse.speed * dt;
|
||||
while (pulse.t >= 1) {
|
||||
pulse.t -= 1;
|
||||
pulse.segIdx = (pulse.segIdx + 1) % pulse.trace.segs.length;
|
||||
}
|
||||
|
||||
const seg = pulse.trace.segs[pulse.segIdx];
|
||||
const pos = lerp(seg.from, seg.to, pulse.t);
|
||||
pulse.trail.push(pos);
|
||||
if (pulse.trail.length > TRAIL) pulse.trail.shift();
|
||||
|
||||
drawPulse(pulse);
|
||||
}
|
||||
|
||||
stateRef.current.raf = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
stateRef.current.raf = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
cancelAnimationFrame(stateRef.current.raf);
|
||||
};
|
||||
}, [init, palette]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { X, Maximize2, Minimize2, TerminalSquare, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
const MIN_W = 480;
|
||||
const MIN_H = 300;
|
||||
const DEFAULT_W = 900;
|
||||
const DEFAULT_H = 560;
|
||||
|
||||
interface Props {
|
||||
deviceId: number;
|
||||
deviceName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ConnState = 'connecting' | 'ready' | 'error' | 'closed';
|
||||
|
||||
const RESIZE_HANDLES = [
|
||||
{ dir: 'n', cursor: 'ns-resize', style: { top: 0, left: 6, right: 6, height: 6 } },
|
||||
{ dir: 'ne', cursor: 'nesw-resize', style: { top: 0, right: 0, width: 10, height: 10 } },
|
||||
{ dir: 'e', cursor: 'ew-resize', style: { top: 6, right: 0, bottom: 6, width: 6 } },
|
||||
{ dir: 'se', cursor: 'nwse-resize', style: { bottom: 0, right: 0, width: 10, height: 10 } },
|
||||
{ dir: 's', cursor: 'ns-resize', style: { bottom: 0, left: 6, right: 6, height: 6 } },
|
||||
{ dir: 'sw', cursor: 'nesw-resize', style: { bottom: 0, left: 0, width: 10, height: 10 } },
|
||||
{ dir: 'w', cursor: 'ew-resize', style: { top: 6, left: 0, bottom: 6, width: 6 } },
|
||||
{ dir: 'nw', cursor: 'nwse-resize', style: { top: 0, left: 0, width: 10, height: 10 } },
|
||||
] as const;
|
||||
|
||||
export default function TerminalModal({ deviceId, deviceName, onClose }: Props) {
|
||||
const termRef = useRef<HTMLDivElement>(null);
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
const [connState, setConnState] = useState<ConnState>('connecting');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
const [pos, setPos] = useState(() => ({
|
||||
x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
|
||||
y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
|
||||
}));
|
||||
const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
|
||||
const savedGeometry = useRef({ pos: { x: 0, y: 0 }, size: { w: DEFAULT_W, h: DEFAULT_H } });
|
||||
|
||||
const token = useAuthStore((s) => s.token);
|
||||
|
||||
// ─── SSH connection ────────────────────────────────────────────────────────
|
||||
const connect = useCallback(() => {
|
||||
socketRef.current?.disconnect();
|
||||
xtermRef.current?.clear();
|
||||
setConnState('connecting');
|
||||
setErrorMsg('');
|
||||
|
||||
const socket = io('/terminal', {
|
||||
path: '/socket.io',
|
||||
auth: { token },
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
const fit = fitRef.current;
|
||||
fit?.fit();
|
||||
socket.emit('start', {
|
||||
deviceId,
|
||||
cols: xtermRef.current?.cols ?? 80,
|
||||
rows: xtermRef.current?.rows ?? 24,
|
||||
});
|
||||
});
|
||||
socket.on('ready', () => { setConnState('ready'); xtermRef.current?.focus(); });
|
||||
socket.on('data', (d: string) => { xtermRef.current?.write(d); });
|
||||
socket.on('close', () => { setConnState('closed'); xtermRef.current?.write('\r\n\x1b[33m[Session closed]\x1b[0m\r\n'); });
|
||||
socket.on('error', (msg: string)=> { setConnState('error'); setErrorMsg(msg); });
|
||||
socket.on('connect_error',(err) => { setConnState('error'); setErrorMsg(err.message); });
|
||||
socket.on('disconnect', () => { if (connState === 'ready') setConnState('closed'); });
|
||||
}, [deviceId, token]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ─── Terminal init (once) ─────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!termRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", Menlo, Monaco, monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
theme: {
|
||||
background: '#0f172a', foreground: '#e2e8f0', cursor: '#3b82f6',
|
||||
black: '#1e293b', red: '#ef4444', green: '#22c55e', yellow: '#eab308',
|
||||
blue: '#3b82f6', magenta: '#a855f7', cyan: '#06b6d4', white: '#f1f5f9',
|
||||
brightBlack: '#475569', brightRed: '#f87171', brightGreen: '#4ade80',
|
||||
brightYellow: '#fbbf24', brightBlue: '#60a5fa', brightMagenta: '#c084fc',
|
||||
brightCyan: '#22d3ee', brightWhite: '#ffffff',
|
||||
},
|
||||
allowProposedApi: false,
|
||||
});
|
||||
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(termRef.current);
|
||||
fit.fit();
|
||||
|
||||
xtermRef.current = term;
|
||||
fitRef.current = fit;
|
||||
|
||||
const disposable = term.onData((data) => {
|
||||
if (socketRef.current?.connected) socketRef.current.emit('data', data);
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
fit.fit();
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.emit('resize', { cols: term.cols, rows: term.rows });
|
||||
}
|
||||
});
|
||||
if (termRef.current) ro.observe(termRef.current);
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
ro.disconnect();
|
||||
term.dispose();
|
||||
socketRef.current?.disconnect();
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-fit after maximize toggle
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
fitRef.current?.fit();
|
||||
if (socketRef.current?.connected && xtermRef.current) {
|
||||
socketRef.current.emit('resize', { cols: xtermRef.current.cols, rows: xtermRef.current.rows });
|
||||
}
|
||||
}, 50);
|
||||
}, [maximized]);
|
||||
|
||||
// ─── Maximize / restore ───────────────────────────────────────────────────
|
||||
const toggleMaximize = () => {
|
||||
if (!maximized) {
|
||||
savedGeometry.current = { pos: { ...pos }, size: { ...size } };
|
||||
setMaximized(true);
|
||||
} else {
|
||||
setMaximized(false);
|
||||
setPos(savedGeometry.current.pos);
|
||||
setSize(savedGeometry.current.size);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Drag (title bar) ─────────────────────────────────────────────────────
|
||||
const handleTitleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (maximized || e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
const el = windowRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const origX = el.offsetLeft;
|
||||
const origY = el.offsetTop;
|
||||
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
el.style.left = `${Math.max(0, origX + me.clientX - startX)}px`;
|
||||
el.style.top = `${Math.max(0, origY + me.clientY - startY)}px`;
|
||||
};
|
||||
const onUp = () => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
setPos({ x: el.offsetLeft, y: el.offsetTop });
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// ─── Resize (edge / corner handles) ───────────────────────────────────────
|
||||
const handleResizeMouseDown = (e: React.MouseEvent, dir: string) => {
|
||||
if (maximized || e.button !== 0) return;
|
||||
const el = windowRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const origL = el.offsetLeft;
|
||||
const origT = el.offsetTop;
|
||||
const origW = el.offsetWidth;
|
||||
const origH = el.offsetHeight;
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
const dx = me.clientX - startX;
|
||||
const dy = me.clientY - startY;
|
||||
let l = origL, t = origT, w = origW, h = origH;
|
||||
|
||||
if (dir.includes('e')) w = Math.max(MIN_W, origW + dx);
|
||||
if (dir.includes('s')) h = Math.max(MIN_H, origH + dy);
|
||||
if (dir.includes('w')) { w = Math.max(MIN_W, origW - dx); l = origL + origW - w; }
|
||||
if (dir.includes('n')) { h = Math.max(MIN_H, origH - dy); t = origT + origH - h; }
|
||||
|
||||
el.style.left = `${l}px`;
|
||||
el.style.top = `${t}px`;
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
};
|
||||
const onUp = () => {
|
||||
document.body.style.userSelect = '';
|
||||
setPos ({ x: el.offsetLeft, y: el.offsetTop });
|
||||
setSize({ w: el.offsetWidth, h: el.offsetHeight });
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleReconnect = () => { xtermRef.current?.clear(); connect(); };
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[9999] bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Window */}
|
||||
<div
|
||||
ref={windowRef}
|
||||
className="flex flex-col bg-slate-900 shadow-2xl border border-slate-700 overflow-hidden"
|
||||
style={maximized
|
||||
? { position: 'fixed', inset: 0, zIndex: 10000, borderRadius: 0 }
|
||||
: { position: 'fixed', left: pos.x, top: pos.y, width: size.w, height: size.h,
|
||||
zIndex: 10000, borderRadius: '0.75rem', minWidth: MIN_W, minHeight: MIN_H }
|
||||
}
|
||||
>
|
||||
{/* Resize handles */}
|
||||
{!maximized && RESIZE_HANDLES.map(({ dir, cursor, style }) => (
|
||||
<div
|
||||
key={dir}
|
||||
style={{ position: 'absolute', zIndex: 10, cursor, ...style }}
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, dir)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Title bar — drag target */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-slate-800 border-b border-slate-700 select-none shrink-0"
|
||||
style={{ cursor: maximized ? 'default' : 'grab' }}
|
||||
onMouseDown={handleTitleMouseDown}
|
||||
onDoubleClick={toggleMaximize}
|
||||
>
|
||||
<TerminalSquare className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-slate-200 flex-1 truncate">
|
||||
SSH — {deviceName}
|
||||
</span>
|
||||
|
||||
{connState === 'connecting' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-yellow-400">
|
||||
<RefreshCw className="w-3 h-3 animate-spin" /> Connecting…
|
||||
</span>
|
||||
)}
|
||||
{connState === 'ready' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-green-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" /> Connected
|
||||
</span>
|
||||
)}
|
||||
{(connState === 'error' || connState === 'closed') && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300 px-2 py-0.5 rounded border border-blue-700 hover:border-blue-500 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Reconnect
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={toggleMaximize}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
||||
title={maximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
{maximized ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded text-slate-400 hover:text-red-400 hover:bg-slate-700 transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{connState === 'error' && errorMsg && (
|
||||
<div className="flex items-start gap-2 px-4 py-2.5 bg-red-900/40 border-b border-red-800 text-sm text-red-300">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal */}
|
||||
<div className="flex-1 min-h-0 p-1 bg-[#0f172a]">
|
||||
<div ref={termRef} className="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
MapPin, Building2, Hash, FileText, Pencil, Check, X, RefreshCw, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import type { Device } from '../../types';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
interface Props {
|
||||
device: Device;
|
||||
}
|
||||
|
||||
interface LocationForm {
|
||||
location_address: string;
|
||||
rack_name: string;
|
||||
rack_slot: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`,
|
||||
{ headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
|
||||
}
|
||||
} catch {
|
||||
// ignore geocoding failures
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapEmbed({ lat, lng, address }: { lat: number | string; lng: number | string; address: string }) {
|
||||
const latN = Number(lat);
|
||||
const lngN = Number(lng);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const map = L.map(containerRef.current, { scrollWheelZoom: false });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
L.circleMarker([latN, lngN], {
|
||||
radius: 9, color: '#fff', weight: 2, fillColor: '#3b82f6', fillOpacity: 0.9,
|
||||
}).bindPopup(`<b>${address}</b>`).addTo(map);
|
||||
map.setView([latN, lngN], 15);
|
||||
return () => { map.remove(); };
|
||||
}, [latN, lngN, address]);
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden h-full flex flex-col">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-slate-700">
|
||||
<MapPin className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white flex-1 truncate">
|
||||
{address}
|
||||
</h3>
|
||||
<a
|
||||
href={`https://www.openstreetmap.org/?mlat=${latN}&mlon=${lngN}#map=16/${latN}/${lngN}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-500 hover:text-blue-400 whitespace-nowrap"
|
||||
>
|
||||
Open map ↗
|
||||
</a>
|
||||
</div>
|
||||
<div ref={containerRef} style={{ height: 280, isolation: 'isolate' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceLocationSection({ device }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [geocoding, setGeocoding] = useState(false);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [form, setForm] = useState<LocationForm>({
|
||||
location_address: device.location_address ?? '',
|
||||
rack_name: device.rack_name ?? '',
|
||||
rack_slot: device.rack_slot ?? '',
|
||||
notes: device.notes ?? '',
|
||||
});
|
||||
|
||||
const hasMap = device.location_lat != null && device.location_lng != null
|
||||
&& !isNaN(Number(device.location_lat)) && !isNaN(Number(device.location_lng));
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (f: LocationForm) => {
|
||||
setSaveError('');
|
||||
let lat: number | null = device.location_lat ?? null;
|
||||
let lng: number | null = device.location_lng ?? null;
|
||||
|
||||
// Re-geocode if address changed
|
||||
const addressChanged = f.location_address !== (device.location_address ?? '');
|
||||
if (f.location_address && addressChanged) {
|
||||
setGeocoding(true);
|
||||
const coords = await geocodeAddress(f.location_address);
|
||||
setGeocoding(false);
|
||||
if (coords) {
|
||||
lat = coords.lat;
|
||||
lng = coords.lng;
|
||||
} else {
|
||||
lat = null;
|
||||
lng = null;
|
||||
}
|
||||
}
|
||||
// Clear coords if address was cleared
|
||||
if (!f.location_address) { lat = null; lng = null; }
|
||||
|
||||
return devicesApi.patchLocation(device.id, {
|
||||
location_address: f.location_address || null,
|
||||
location_lat: lat,
|
||||
location_lng: lng,
|
||||
rack_name: f.rack_name || null,
|
||||
rack_slot: f.rack_slot || null,
|
||||
notes: f.notes || null,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['device', device.id] });
|
||||
setEditing(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
setGeocoding(false);
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setSaveError(msg || 'Failed to save');
|
||||
},
|
||||
});
|
||||
|
||||
const openEdit = () => {
|
||||
setForm({
|
||||
location_address: device.location_address ?? '',
|
||||
rack_name: device.rack_name ?? '',
|
||||
rack_slot: device.rack_slot ?? '',
|
||||
notes: device.notes ?? '',
|
||||
});
|
||||
setSaveError('');
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEdit = () => { setEditing(false); setSaveError(''); };
|
||||
|
||||
const isPending = mutation.isPending || geocoding;
|
||||
|
||||
return (
|
||||
<div className={`grid gap-4 ${hasMap && !editing ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{/* Info / edit card */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Physical Details</h3>
|
||||
{canWrite && !editing && (
|
||||
<button onClick={openEdit} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<Pencil className="w-3.5 h-3.5" /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-500" /> Physical Location
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.location_address}
|
||||
onChange={(e) => setForm((f) => ({ ...f, location_address: e.target.value }))}
|
||||
placeholder="123 Main St, City, State 00000"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Address will be geocoded and shown on the map.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<Building2 className="w-3.5 h-3.5 text-blue-500" /> Rack Name
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.rack_name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, rack_name: e.target.value }))}
|
||||
placeholder="Rack-A1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<Hash className="w-3.5 h-3.5 text-blue-500" /> Rack Slot
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.rack_slot}
|
||||
onChange={(e) => setForm((f) => ({ ...f, rack_slot: e.target.value }))}
|
||||
placeholder="U3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<FileText className="w-3.5 h-3.5 text-blue-500" /> Notes
|
||||
</label>
|
||||
<textarea
|
||||
className="input resize-none"
|
||||
rows={4}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
placeholder="Any relevant notes about this device..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{saveError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => mutation.mutate(form)}
|
||||
disabled={isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
{isPending
|
||||
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" />{geocoding ? 'Geocoding…' : 'Saving…'}</>
|
||||
: <><Check className="w-3.5 h-3.5" />Save</>
|
||||
}
|
||||
</button>
|
||||
<button onClick={cancelEdit} disabled={isPending} className="btn-secondary text-sm">
|
||||
<X className="w-3.5 h-3.5 inline mr-1" />Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm">
|
||||
<InfoRow icon={<MapPin className="w-3.5 h-3.5 text-blue-500" />} label="Location"
|
||||
value={device.location_address}
|
||||
emptyText="No location set" />
|
||||
<InfoRow icon={<Building2 className="w-3.5 h-3.5 text-blue-500" />} label="Rack"
|
||||
value={device.rack_name}
|
||||
emptyText="No rack assigned" />
|
||||
<InfoRow icon={<Hash className="w-3.5 h-3.5 text-blue-500" />} label="Rack Slot"
|
||||
value={device.rack_slot}
|
||||
emptyText="—" />
|
||||
<div className="pt-1 border-t border-gray-100 dark:border-slate-700">
|
||||
<div className="flex items-start gap-2 mt-2">
|
||||
<FileText className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide block mb-1">Notes</span>
|
||||
{device.notes
|
||||
? <p className="text-gray-700 dark:text-slate-300 whitespace-pre-wrap leading-relaxed">{device.notes}</p>
|
||||
: <p className="text-gray-400 dark:text-slate-500 italic">No notes</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Map card — only when we have coordinates and not editing */}
|
||||
{hasMap && !editing && (
|
||||
<MapEmbed
|
||||
lat={device.location_lat!}
|
||||
lng={device.location_lng!}
|
||||
address={device.location_address!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
icon, label, value, emptyText,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
emptyText: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide block mb-0.5">
|
||||
{label}
|
||||
</span>
|
||||
{value
|
||||
? <span className="text-gray-900 dark:text-white">{value}</span>
|
||||
: <span className="text-gray-400 dark:text-slate-500 italic">{emptyText}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Pencil, AlertCircle, RefreshCw, Check, X, ArrowRightLeft, Shield } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ─── Shared types ─────────────────────────────────────────────────────────────
|
||||
interface FirewallRule extends Record<string, string> {
|
||||
'.id': string;
|
||||
chain: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
interface NatRule extends Record<string, string> {
|
||||
'.id': string;
|
||||
chain: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
// ─── Firewall constants ───────────────────────────────────────────────────────
|
||||
const COMMON_CHAINS = ['forward', 'input', 'output'];
|
||||
const FW_ACTIONS = ['accept', 'drop', 'reject', 'log', 'passthrough', 'jump', 'return'];
|
||||
const PROTOCOLS = ['', 'tcp', 'udp', 'icmp', 'ip', 'gre', 'ospf'];
|
||||
const CONN_STATES = ['new', 'established', 'related', 'invalid'];
|
||||
|
||||
const ACTION_COLOR: Record<string, string> = {
|
||||
accept: 'text-green-600 dark:text-green-400',
|
||||
drop: 'text-red-600 dark:text-red-400',
|
||||
reject: 'text-red-500 dark:text-red-400',
|
||||
log: 'text-yellow-600 dark:text-yellow-400',
|
||||
passthrough: 'text-blue-600 dark:text-blue-400',
|
||||
jump: 'text-purple-600 dark:text-purple-400',
|
||||
return: 'text-indigo-500 dark:text-indigo-400',
|
||||
masquerade: 'text-orange-600 dark:text-orange-400',
|
||||
'src-nat': 'text-blue-600 dark:text-blue-400',
|
||||
'dst-nat': 'text-cyan-600 dark:text-cyan-400',
|
||||
netmap: 'text-teal-600 dark:text-teal-400',
|
||||
redirect: 'text-violet-600 dark:text-violet-400',
|
||||
};
|
||||
|
||||
// ─── NAT constants ────────────────────────────────────────────────────────────
|
||||
const NAT_CHAINS = ['srcnat', 'dstnat'];
|
||||
const NAT_ACTIONS = ['masquerade', 'src-nat', 'dst-nat', 'netmap', 'redirect', 'accept', 'drop', 'return', 'jump', 'passthrough'];
|
||||
|
||||
// ─── Firewall rule form ───────────────────────────────────────────────────────
|
||||
interface RuleForm {
|
||||
chain: string; action: string;
|
||||
src_address: string; dst_address: string;
|
||||
protocol: string; src_port: string; dst_port: string;
|
||||
in_interface: string; out_interface: string;
|
||||
connection_state: string; jump_target: string;
|
||||
comment: string; disabled: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FW_FORM: RuleForm = {
|
||||
chain: 'forward', action: 'accept',
|
||||
src_address: '', dst_address: '', protocol: '',
|
||||
src_port: '', dst_port: '',
|
||||
in_interface: '', out_interface: '',
|
||||
connection_state: '', jump_target: '',
|
||||
comment: '', disabled: false,
|
||||
};
|
||||
|
||||
function ruleToForm(r: FirewallRule): RuleForm {
|
||||
const states = (r['connection-state'] ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
return {
|
||||
chain: r.chain ?? 'forward', action: r.action ?? 'accept',
|
||||
src_address: r['src-address'] ?? '', dst_address: r['dst-address'] ?? '',
|
||||
protocol: r.protocol ?? '',
|
||||
src_port: r['src-port'] ?? '', dst_port: r['dst-port'] ?? '',
|
||||
in_interface: r['in-interface'] ?? '', out_interface: r['out-interface'] ?? '',
|
||||
connection_state: states.join(','), jump_target: r['jump-target'] ?? '',
|
||||
comment: r.comment ?? '', disabled: r.disabled === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
function formToPayload(f: RuleForm): Record<string, unknown> {
|
||||
const p: Record<string, unknown> = { chain: f.chain, action: f.action, disabled: f.disabled ? 'yes' : 'no' };
|
||||
if (f.src_address) p.src_address = f.src_address;
|
||||
if (f.dst_address) p.dst_address = f.dst_address;
|
||||
if (f.protocol) p.protocol = f.protocol;
|
||||
if (f.src_port) p.src_port = f.src_port;
|
||||
if (f.dst_port) p.dst_port = f.dst_port;
|
||||
if (f.in_interface) p.in_interface = f.in_interface;
|
||||
if (f.out_interface) p.out_interface = f.out_interface;
|
||||
if (f.connection_state) p.connection_state = f.connection_state;
|
||||
if (f.jump_target) p.jump_target = f.jump_target;
|
||||
if (f.comment) p.comment = f.comment;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ─── NAT rule form ────────────────────────────────────────────────────────────
|
||||
interface NatForm {
|
||||
chain: string; action: string;
|
||||
src_address: string; dst_address: string;
|
||||
protocol: string; src_port: string; dst_port: string;
|
||||
in_interface: string; out_interface: string;
|
||||
to_addresses: string; to_ports: string;
|
||||
comment: string; disabled: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_NAT_FORM: NatForm = {
|
||||
chain: 'srcnat', action: 'masquerade',
|
||||
src_address: '', dst_address: '', protocol: '',
|
||||
src_port: '', dst_port: '',
|
||||
in_interface: '', out_interface: '',
|
||||
to_addresses: '', to_ports: '',
|
||||
comment: '', disabled: false,
|
||||
};
|
||||
|
||||
function natRuleToForm(r: NatRule): NatForm {
|
||||
return {
|
||||
chain: r.chain ?? 'srcnat', action: r.action ?? 'masquerade',
|
||||
src_address: r['src-address'] ?? '', dst_address: r['dst-address'] ?? '',
|
||||
protocol: r.protocol ?? '',
|
||||
src_port: r['src-port'] ?? '', dst_port: r['dst-port'] ?? '',
|
||||
in_interface: r['in-interface'] ?? '', out_interface: r['out-interface'] ?? '',
|
||||
to_addresses: r['to-addresses'] ?? '', to_ports: r['to-ports'] ?? '',
|
||||
comment: r.comment ?? '', disabled: r.disabled === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
function natFormToPayload(f: NatForm): Record<string, unknown> {
|
||||
const p: Record<string, unknown> = { chain: f.chain, action: f.action, disabled: f.disabled ? 'yes' : 'no' };
|
||||
if (f.src_address) p.src_address = f.src_address;
|
||||
if (f.dst_address) p.dst_address = f.dst_address;
|
||||
if (f.protocol) p.protocol = f.protocol;
|
||||
if (f.src_port) p.src_port = f.src_port;
|
||||
if (f.dst_port) p.dst_port = f.dst_port;
|
||||
if (f.in_interface) p.in_interface = f.in_interface;
|
||||
if (f.out_interface)p.out_interface= f.out_interface;
|
||||
if (f.to_addresses) p.to_addresses = f.to_addresses;
|
||||
if (f.to_ports) p.to_ports = f.to_ports;
|
||||
if (f.comment) p.comment = f.comment;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function errMsg(err: unknown) {
|
||||
return (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Operation failed';
|
||||
}
|
||||
|
||||
function DisabledToggle({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-slate-300">Disabled</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!value)}
|
||||
className={clsx('relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
value ? 'bg-gray-300 dark:bg-slate-600' : 'bg-blue-600')}
|
||||
>
|
||||
<span className={clsx('inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
||||
value ? 'translate-x-1' : 'translate-x-6')} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Firewall Rule Modal ──────────────────────────────────────────────────────
|
||||
function RuleModal({
|
||||
title, form, setForm, onSave, onClose, isPending, error,
|
||||
}: {
|
||||
title: string; form: RuleForm;
|
||||
setForm: React.Dispatch<React.SetStateAction<RuleForm>>;
|
||||
onSave: () => void; onClose: () => void;
|
||||
isPending: boolean; error: string;
|
||||
}) {
|
||||
const set = (key: keyof RuleForm, val: string | boolean) => setForm(f => ({ ...f, [key]: val }));
|
||||
|
||||
const toggleConnState = (state: string) => {
|
||||
const current = form.connection_state ? form.connection_state.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
const next = current.includes(state) ? current.filter(s => s !== state) : [...current, state];
|
||||
set('connection_state', next.join(','));
|
||||
};
|
||||
|
||||
const connStates = form.connection_state ? form.connection_state.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
const hasPorts = form.protocol === 'tcp' || form.protocol === 'udp' || form.protocol === '';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Basic</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Chain</label>
|
||||
<input list="chains-list" className="input" value={form.chain} onChange={e => set('chain', e.target.value)} placeholder="forward" />
|
||||
<datalist id="chains-list">{COMMON_CHAINS.map(c => <option key={c} value={c} />)}</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Action</label>
|
||||
<select className="input" value={form.action} onChange={e => set('action', e.target.value)}>
|
||||
{FW_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{form.action === 'jump' && (
|
||||
<div>
|
||||
<label className="label">Jump Target Chain</label>
|
||||
<input className="input" value={form.jump_target} onChange={e => set('jump_target', e.target.value)} placeholder="custom-chain" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="label">Comment</label>
|
||||
<input className="input" value={form.comment} onChange={e => set('comment', e.target.value)} placeholder="Optional description…" />
|
||||
</div>
|
||||
<DisabledToggle value={form.disabled} onChange={v => set('disabled', v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Address & Port</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">Protocol</label>
|
||||
<select className="input" value={form.protocol} onChange={e => set('protocol', e.target.value)}>
|
||||
<option value="">— any —</option>
|
||||
{PROTOCOLS.filter(Boolean).map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Src Address</label>
|
||||
<input className="input font-mono" value={form.src_address} onChange={e => set('src_address', e.target.value)} placeholder="0.0.0.0/0" /></div>
|
||||
<div><label className="label">Dst Address</label>
|
||||
<input className="input font-mono" value={form.dst_address} onChange={e => set('dst_address', e.target.value)} placeholder="0.0.0.0/0" /></div>
|
||||
</div>
|
||||
{hasPorts && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Src Port</label>
|
||||
<input className="input font-mono" value={form.src_port} onChange={e => set('src_port', e.target.value)} placeholder="e.g. 80 or 1024-2048" /></div>
|
||||
<div><label className="label">Dst Port</label>
|
||||
<input className="input font-mono" value={form.dst_port} onChange={e => set('dst_port', e.target.value)} placeholder="e.g. 443" /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Interface</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">In Interface</label>
|
||||
<input className="input font-mono" value={form.in_interface} onChange={e => set('in_interface', e.target.value)} placeholder="ether1" /></div>
|
||||
<div><label className="label">Out Interface</label>
|
||||
<input className="input font-mono" value={form.out_interface} onChange={e => set('out_interface', e.target.value)} placeholder="ether2" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Connection State</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CONN_STATES.map(s => (
|
||||
<button key={s} type="button" onClick={() => toggleConnState(s)}
|
||||
className={clsx('px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors',
|
||||
connStates.includes(s)
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 border-gray-300 dark:border-slate-600 text-gray-600 dark:text-slate-300 hover:border-blue-400'
|
||||
)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="btn-secondary">Cancel</button>
|
||||
<button onClick={onSave} disabled={isPending} className="btn-primary flex items-center gap-2">
|
||||
{isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} Save Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── NAT Rule Modal ───────────────────────────────────────────────────────────
|
||||
function NatModal({
|
||||
title, form, setForm, onSave, onClose, isPending, error,
|
||||
}: {
|
||||
title: string; form: NatForm;
|
||||
setForm: React.Dispatch<React.SetStateAction<NatForm>>;
|
||||
onSave: () => void; onClose: () => void;
|
||||
isPending: boolean; error: string;
|
||||
}) {
|
||||
const set = (key: keyof NatForm, val: string | boolean) => setForm(f => ({ ...f, [key]: val }));
|
||||
|
||||
const needsTarget = ['src-nat', 'dst-nat', 'netmap'].includes(form.action);
|
||||
const needsPort = ['redirect', 'dst-nat', 'src-nat'].includes(form.action);
|
||||
const hasPorts = form.protocol === 'tcp' || form.protocol === 'udp' || form.protocol === '';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Basic */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Basic</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Chain</label>
|
||||
<select className="input" value={form.chain} onChange={e => set('chain', e.target.value)}>
|
||||
{NAT_CHAINS.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Action</label>
|
||||
<select className="input" value={form.action} onChange={e => set('action', e.target.value)}>
|
||||
{NAT_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* action description */}
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500">
|
||||
{form.action === 'masquerade' && 'Replaces source IP with router\'s outgoing interface IP. Best for dynamic WAN IPs.'}
|
||||
{form.action === 'src-nat' && 'Replaces source IP with a static address (specify in To Addresses).'}
|
||||
{form.action === 'dst-nat' && 'Replaces destination IP/port, used for port forwarding.'}
|
||||
{form.action === 'netmap' && 'One-to-one address translation. Requires To Addresses.'}
|
||||
{form.action === 'redirect' && 'Redirects packets to the router itself (specify To Ports for target port).'}
|
||||
</p>
|
||||
<div>
|
||||
<label className="label">Comment</label>
|
||||
<input className="input" value={form.comment} onChange={e => set('comment', e.target.value)} placeholder="Optional description…" />
|
||||
</div>
|
||||
<DisabledToggle value={form.disabled} onChange={v => set('disabled', v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Match Criteria</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">Protocol</label>
|
||||
<select className="input" value={form.protocol} onChange={e => set('protocol', e.target.value)}>
|
||||
<option value="">— any —</option>
|
||||
{PROTOCOLS.filter(Boolean).map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Src Address</label>
|
||||
<input className="input font-mono" value={form.src_address} onChange={e => set('src_address', e.target.value)} placeholder="0.0.0.0/0" /></div>
|
||||
<div><label className="label">Dst Address</label>
|
||||
<input className="input font-mono" value={form.dst_address} onChange={e => set('dst_address', e.target.value)} placeholder="0.0.0.0/0" /></div>
|
||||
</div>
|
||||
{hasPorts && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Src Port</label>
|
||||
<input className="input font-mono" value={form.src_port} onChange={e => set('src_port', e.target.value)} placeholder="e.g. 80" /></div>
|
||||
<div><label className="label">Dst Port</label>
|
||||
<input className="input font-mono" value={form.dst_port} onChange={e => set('dst_port', e.target.value)} placeholder="e.g. 443" /></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">In Interface</label>
|
||||
<input className="input font-mono" value={form.in_interface} onChange={e => set('in_interface', e.target.value)} placeholder="ether1" /></div>
|
||||
<div><label className="label">Out Interface</label>
|
||||
<input className="input font-mono" value={form.out_interface} onChange={e => set('out_interface', e.target.value)} placeholder="ether2" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translation target */}
|
||||
{(needsTarget || needsPort) && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Translation Target</h4>
|
||||
<div className="space-y-3">
|
||||
{needsTarget && (
|
||||
<div><label className="label">To Addresses</label>
|
||||
<input className="input font-mono" value={form.to_addresses} onChange={e => set('to_addresses', e.target.value)} placeholder="e.g. 192.168.1.10 or 192.168.1.10-192.168.1.20" /></div>
|
||||
)}
|
||||
{needsPort && (
|
||||
<div><label className="label">To Ports</label>
|
||||
<input className="input font-mono" value={form.to_ports} onChange={e => set('to_ports', e.target.value)} placeholder="e.g. 8080 or 8080-8090" /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="btn-secondary">Cancel</button>
|
||||
<button onClick={onSave} disabled={isPending} className="btn-primary flex items-center gap-2">
|
||||
{isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} Save Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── NAT Card (router-only) ───────────────────────────────────────────────────
|
||||
function NatCard({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const [chainFilter, setChainFilter] = useState('all');
|
||||
const [editingRule, setEditingRule] = useState<NatRule | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState<NatForm>(EMPTY_NAT_FORM);
|
||||
const [mutError, setMutError] = useState('');
|
||||
|
||||
const { data: rules = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['nat', deviceId],
|
||||
queryFn: () => devicesApi.getNat(deviceId).then(r => r.data as NatRule[]),
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['nat', deviceId] });
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => devicesApi.addNatRule(deviceId, data),
|
||||
onSuccess: () => { invalidate(); setShowAdd(false); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
devicesApi.updateNatRule(deviceId, id, data),
|
||||
onSuccess: () => { invalidate(); setEditingRule(null); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => devicesApi.deleteNatRule(deviceId, id),
|
||||
onSuccess: () => { invalidate(); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) =>
|
||||
devicesApi.updateNatRule(deviceId, id, { disabled: disabled ? 'yes' : 'no' }),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
|
||||
const openAdd = () => {
|
||||
setForm({ ...EMPTY_NAT_FORM, chain: chainFilter !== 'all' ? chainFilter : 'srcnat' });
|
||||
setMutError('');
|
||||
setShowAdd(true);
|
||||
};
|
||||
|
||||
const openEdit = (rule: NatRule) => {
|
||||
setForm(natRuleToForm(rule));
|
||||
setMutError('');
|
||||
setEditingRule(rule);
|
||||
};
|
||||
|
||||
const allChains = ['all', ...Array.from(new Set(rules.map(r => r.chain).filter(Boolean))).sort()];
|
||||
const filtered = chainFilter === 'all' ? rules : rules.filter(r => r.chain === chainFilter);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<div className="w-7 h-7 bg-orange-50 dark:bg-orange-900/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<ArrowRightLeft className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Network Address Translation (NAT)</h3>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-slate-800 p-1 rounded-lg">
|
||||
{allChains.map(c => (
|
||||
<button key={c} onClick={() => setChainFilter(c)}
|
||||
className={clsx('px-3 py-1 text-xs font-medium rounded-md transition-colors capitalize',
|
||||
chainFilter === c
|
||||
? 'bg-white dark:bg-slate-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200'
|
||||
)}>
|
||||
{c === 'all' ? `All (${rules.length})` : `${c} (${rules.filter(r => r.chain === c).length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button onClick={openAdd} className="btn-primary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<Plus className="w-3.5 h-3.5" /> Add Rule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mutError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{mutError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-6 text-gray-400"><RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading NAT rules…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="card p-8 text-center text-gray-400 dark:text-slate-500">
|
||||
No NAT rules{chainFilter !== 'all' ? ` in chain "${chainFilter}"` : ''}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-3 py-2.5 text-left w-8">#</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Chain</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Action</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Proto</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Src</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Dst</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Ports</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">To</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Comment</th>
|
||||
{canWrite && <th className="table-header px-3 py-2.5 w-24" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{filtered.map((r, i) => {
|
||||
const disabled = r.disabled === 'true';
|
||||
const srcPort = r['src-port'];
|
||||
const dstPort = r['dst-port'];
|
||||
const portCell = [srcPort && `src:${srcPort}`, dstPort && `dst:${dstPort}`].filter(Boolean).join(' ');
|
||||
const toCell = [
|
||||
r['to-addresses'] && r['to-addresses'],
|
||||
r['to-ports'] && `:${r['to-ports']}`,
|
||||
].filter(Boolean).join('');
|
||||
return (
|
||||
<tr key={r['.id'] ?? i} className={clsx('hover:bg-gray-50 dark:hover:bg-slate-700/30', disabled && 'opacity-40')}>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{i + 1}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700 dark:text-slate-300">{r.chain}</td>
|
||||
<td className={clsx('px-3 py-2 text-xs font-bold uppercase', ACTION_COLOR[r.action] ?? 'text-gray-500')}>
|
||||
{r.action}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">{r.protocol || 'any'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{[r['src-address'], r['in-interface'] && `in:${r['in-interface']}`].filter(Boolean).join(' ') || 'any'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{[r['dst-address'], r['out-interface'] && `out:${r['out-interface']}`].filter(Boolean).join(' ') || 'any'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{portCell || '—'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-orange-600 dark:text-orange-400">{toCell || '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-400 dark:text-slate-500 italic max-w-[120px] truncate">{r.comment || ''}</td>
|
||||
{canWrite && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button
|
||||
title={disabled ? 'Enable' : 'Disable'}
|
||||
onClick={() => toggleMutation.mutate({ id: r['.id'], disabled: !disabled })}
|
||||
className={clsx('p-1 rounded transition-colors', disabled ? 'text-gray-400 hover:text-green-500' : 'text-green-500 hover:text-gray-400')}
|
||||
>
|
||||
<span className={clsx('inline-block w-2 h-2 rounded-full', disabled ? 'bg-gray-400' : 'bg-green-500')} />
|
||||
</button>
|
||||
<button onClick={() => openEdit(r)} className="p-1 rounded text-gray-400 hover:text-blue-500 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Delete this NAT rule?')) deleteMutation.mutate(r['.id']); }}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<NatModal
|
||||
title="Add NAT Rule"
|
||||
form={form} setForm={setForm}
|
||||
onSave={() => addMutation.mutate(natFormToPayload(form))}
|
||||
onClose={() => setShowAdd(false)}
|
||||
isPending={addMutation.isPending}
|
||||
error={mutError}
|
||||
/>
|
||||
)}
|
||||
{editingRule && (
|
||||
<NatModal
|
||||
title={`Edit NAT Rule #${rules.indexOf(editingRule) + 1}`}
|
||||
form={form} setForm={setForm}
|
||||
onSave={() => updateMutation.mutate({ id: editingRule['.id'], data: natFormToPayload(form) })}
|
||||
onClose={() => setEditingRule(null)}
|
||||
isPending={updateMutation.isPending}
|
||||
error={mutError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main FirewallTab ─────────────────────────────────────────────────────────
|
||||
export default function FirewallTab({ deviceId, deviceType }: { deviceId: number; deviceType?: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const [chainFilter, setChainFilter] = useState('all');
|
||||
const [editingRule, setEditingRule] = useState<FirewallRule | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState<RuleForm>(EMPTY_FW_FORM);
|
||||
const [mutError, setMutError] = useState('');
|
||||
|
||||
const { data: rules = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['firewall', deviceId],
|
||||
queryFn: () => devicesApi.getFirewall(deviceId).then(r => r.data as FirewallRule[]),
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['firewall', deviceId] });
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => devicesApi.addFirewallRule(deviceId, data),
|
||||
onSuccess: () => { invalidate(); setShowAdd(false); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
devicesApi.updateFirewallRule(deviceId, id, data),
|
||||
onSuccess: () => { invalidate(); setEditingRule(null); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => devicesApi.deleteFirewallRule(deviceId, id),
|
||||
onSuccess: () => invalidate(),
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) =>
|
||||
devicesApi.updateFirewallRule(deviceId, id, { disabled: disabled ? 'yes' : 'no' }),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
|
||||
const openAdd = () => {
|
||||
setForm({ ...EMPTY_FW_FORM, chain: chainFilter !== 'all' ? chainFilter : 'forward' });
|
||||
setMutError('');
|
||||
setShowAdd(true);
|
||||
};
|
||||
|
||||
const openEdit = (rule: FirewallRule) => {
|
||||
setForm(ruleToForm(rule));
|
||||
setMutError('');
|
||||
setEditingRule(rule);
|
||||
};
|
||||
|
||||
const allChains = ['all', ...Array.from(new Set(rules.map(r => r.chain).filter(Boolean))).sort()];
|
||||
const filtered = chainFilter === 'all' ? rules : rules.filter(r => r.chain === chainFilter);
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section heading */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 bg-blue-50 dark:bg-blue-900/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Shield className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Firewall Rules</h3>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-slate-800 p-1 rounded-lg flex-wrap">
|
||||
{allChains.map(c => (
|
||||
<button key={c} onClick={() => setChainFilter(c)}
|
||||
className={clsx('px-3 py-1 text-xs font-medium rounded-md transition-colors capitalize',
|
||||
chainFilter === c
|
||||
? 'bg-white dark:bg-slate-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200'
|
||||
)}>
|
||||
{c === 'all' ? `All (${rules.length})` : `${c} (${rules.filter(r => r.chain === c).length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button onClick={openAdd} className="btn-primary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<Plus className="w-3.5 h-3.5" /> Add Rule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mutError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{mutError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="card p-8 text-center text-gray-400 dark:text-slate-500">
|
||||
No firewall rules{chainFilter !== 'all' ? ` in chain "${chainFilter}"` : ''}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-3 py-2.5 text-left w-8">#</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Chain</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Action</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Protocol</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Src</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Dst</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Ports</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">State</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Comment</th>
|
||||
{canWrite && <th className="table-header px-3 py-2.5 w-24" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{filtered.map((r, i) => {
|
||||
const disabled = r.disabled === 'true';
|
||||
const srcPort = r['src-port'];
|
||||
const dstPort = r['dst-port'];
|
||||
const portCell = [srcPort && `src:${srcPort}`, dstPort && `dst:${dstPort}`].filter(Boolean).join(' ');
|
||||
return (
|
||||
<tr key={r['.id'] ?? i} className={clsx('hover:bg-gray-50 dark:hover:bg-slate-700/30', disabled && 'opacity-40')}>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{i + 1}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700 dark:text-slate-300">{r.chain}</td>
|
||||
<td className={clsx('px-3 py-2 text-xs font-bold uppercase', ACTION_COLOR[r.action] ?? 'text-gray-500')}>
|
||||
{r.action}{r['jump-target'] ? ` → ${r['jump-target']}` : ''}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">{r.protocol || 'any'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{[r['src-address'], r['in-interface'] && `in:${r['in-interface']}`].filter(Boolean).join(' ') || 'any'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{[r['dst-address'], r['out-interface'] && `out:${r['out-interface']}`].filter(Boolean).join(' ') || 'any'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{portCell || '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">{r['connection-state'] || '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-400 dark:text-slate-500 italic max-w-[140px] truncate">{r.comment || ''}</td>
|
||||
{canWrite && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button
|
||||
title={disabled ? 'Enable' : 'Disable'}
|
||||
onClick={() => toggleMutation.mutate({ id: r['.id'], disabled: !disabled })}
|
||||
className={clsx('p-1 rounded transition-colors', disabled ? 'text-gray-400 hover:text-green-500' : 'text-green-500 hover:text-gray-400')}
|
||||
>
|
||||
<span className={clsx('inline-block w-2 h-2 rounded-full', disabled ? 'bg-gray-400' : 'bg-green-500')} />
|
||||
</button>
|
||||
<button onClick={() => openEdit(r)} className="p-1 rounded text-gray-400 hover:text-blue-500 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Delete this firewall rule?')) deleteMutation.mutate(r['.id']); }}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<RuleModal
|
||||
title="Add Firewall Rule"
|
||||
form={form} setForm={setForm}
|
||||
onSave={() => addMutation.mutate(formToPayload(form))}
|
||||
onClose={() => setShowAdd(false)}
|
||||
isPending={addMutation.isPending}
|
||||
error={mutError}
|
||||
/>
|
||||
)}
|
||||
{editingRule && (
|
||||
<RuleModal
|
||||
title={`Edit Rule #${rules.indexOf(editingRule) + 1}`}
|
||||
form={form} setForm={setForm}
|
||||
onSave={() => updateMutation.mutate({ id: editingRule['.id'], data: formToPayload(form) })}
|
||||
onClose={() => setEditingRule(null)}
|
||||
isPending={updateMutation.isPending}
|
||||
error={mutError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* NAT card — routers only */}
|
||||
{deviceType === 'router' && (
|
||||
<>
|
||||
<div className="border-t border-gray-200 dark:border-slate-700 pt-2" />
|
||||
<NatCard deviceId={deviceId} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { metricsApi, devicesApi } from '../../services/api';
|
||||
import type { Interface, TrafficPoint, ResourcePoint } from '../../types';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
interface Props {
|
||||
deviceId: number;
|
||||
}
|
||||
|
||||
function formatBps(val: number): string {
|
||||
if (val >= 1e9) return `${(val / 1e9).toFixed(2)} GB/s`;
|
||||
if (val >= 1e6) return `${(val / 1e6).toFixed(2)} MB/s`;
|
||||
if (val >= 1e3) return `${(val / 1e3).toFixed(1)} KB/s`;
|
||||
return `${Math.round(val)} B/s`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string, compact: boolean): string {
|
||||
const d = new Date(iso);
|
||||
if (compact) {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleString([], {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const TRAFFIC_RANGES = ['1h', '3h', '6h', '12h', '24h'] as const;
|
||||
const RESOURCE_RANGES = ['6h', '12h', '24h', '7d'] as const;
|
||||
|
||||
export default function GraphsTab({ deviceId }: Props) {
|
||||
const [trafficIface, setTrafficIface] = useState('');
|
||||
const [trafficRange, setTrafficRange] = useState<string>('1h');
|
||||
const [resourceRange, setResourceRange] = useState<string>('24h');
|
||||
|
||||
const { data: interfaces = [] } = useQuery({
|
||||
queryKey: ['interfaces', deviceId],
|
||||
queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data as Interface[]),
|
||||
});
|
||||
|
||||
const { data: rawTraffic = [] } = useQuery({
|
||||
queryKey: ['traffic', deviceId, trafficIface, trafficRange],
|
||||
queryFn: () =>
|
||||
metricsApi.interfaceTraffic(deviceId, trafficIface, trafficRange).then((r) => r.data),
|
||||
enabled: !!trafficIface,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: rawResources = [] } = useQuery({
|
||||
queryKey: ['resources-history', deviceId, resourceRange],
|
||||
queryFn: () =>
|
||||
metricsApi.deviceResources(deviceId, resourceRange).then((r) => r.data as ResourcePoint[]),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const compact = trafficRange === '1h' || trafficRange === '3h';
|
||||
const resourceCompact = resourceRange === '6h' || resourceRange === '12h' || resourceRange === '24h';
|
||||
|
||||
const trafficData = (rawTraffic as TrafficPoint[]).map((p) => ({
|
||||
time: formatTime(p.time, compact),
|
||||
rx: Math.round(p.rx),
|
||||
tx: Math.round(p.tx),
|
||||
}));
|
||||
|
||||
const resourceData = (rawResources as ResourcePoint[]).map((p) => {
|
||||
const memPct =
|
||||
p.memory_total && p.memory_total > 0
|
||||
? Math.round(((p.memory_used || 0) / p.memory_total) * 100)
|
||||
: 0;
|
||||
return {
|
||||
time: formatTime(p.time, resourceCompact),
|
||||
cpu: Math.round(p.cpu_load || 0),
|
||||
mem: memPct,
|
||||
};
|
||||
});
|
||||
|
||||
const RangeButtons = ({
|
||||
ranges,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
ranges: readonly string[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) => (
|
||||
<div className="flex gap-1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => onChange(r)}
|
||||
className={`px-2 py-1 text-xs rounded font-medium transition-colors ${
|
||||
value === r
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Interface Traffic ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Interface Traffic</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="input py-1.5 text-sm"
|
||||
value={trafficIface}
|
||||
onChange={(e) => setTrafficIface(e.target.value)}
|
||||
>
|
||||
<option value="">Select interface...</option>
|
||||
{(interfaces as Interface[])
|
||||
.filter((i) => !i.disabled)
|
||||
.map((i) => (
|
||||
<option key={i.name} value={i.name}>
|
||||
{i.name}
|
||||
{i.speed ? ` (${i.speed})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<RangeButtons ranges={TRAFFIC_RANGES} value={trafficRange} onChange={setTrafficRange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!trafficIface ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
Select an interface to view traffic history
|
||||
</div>
|
||||
) : trafficData.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No traffic data yet for this interface in the selected range
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={trafficData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-gray-200 dark:text-slate-600" opacity={0.5} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tickFormatter={formatBps} tick={{ fontSize: 10 }} width={72} />
|
||||
<Tooltip formatter={(v) => formatBps(v as number)} contentStyle={{ fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="rx" stroke="#3b82f6" name="RX (download)" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="tx" stroke="#10b981" name="TX (upload)" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── CPU & Memory ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">CPU & Memory Usage</h3>
|
||||
<RangeButtons ranges={RESOURCE_RANGES} value={resourceRange} onChange={setResourceRange} />
|
||||
</div>
|
||||
|
||||
{resourceData.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No resource history available yet — data accumulates over time
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={resourceData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-gray-200 dark:text-slate-600" opacity={0.5} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 10 }} />
|
||||
<Tooltip formatter={(v) => `${v}%`} contentStyle={{ fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="cpu" stroke="#f59e0b" name="CPU Load" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="mem" stroke="#8b5cf6" name="Memory Used" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Thermometer, Zap, Wind, Power, Activity, HelpCircle, RefreshCw, HardDrive } from 'lucide-react';
|
||||
import { devicesApi, metricsApi } from '../../services/api';
|
||||
import type { ResourcePoint } from '../../types';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Legend,
|
||||
} from 'recharts';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface HealthMetric {
|
||||
name: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
// RouterOS health comes in two formats depending on version:
|
||||
// New (v7+): [{name:'temperature', value:'42', type:'C'}, ...]
|
||||
// Old (v6): [{'cpu-temperature':'42', voltage:'23.9', ...}]
|
||||
function normalize(raw: Record<string, string>[]): HealthMetric[] {
|
||||
if (!raw.length) return [];
|
||||
|
||||
if ('name' in raw[0]) {
|
||||
return raw.map((r) => ({
|
||||
name: r['name'] || '',
|
||||
value: r['value'] || '',
|
||||
unit: r['type'] || '',
|
||||
}));
|
||||
}
|
||||
|
||||
const obj = raw[0];
|
||||
return Object.entries(obj)
|
||||
.filter(([k]) => !k.startsWith('.'))
|
||||
.map(([name, value]) => ({ name, value, unit: inferUnit(name, value) }));
|
||||
}
|
||||
|
||||
function inferUnit(name: string, value: string): string {
|
||||
const n = name.toLowerCase();
|
||||
if (n.includes('temperature')) return 'C';
|
||||
if (n.includes('voltage')) return 'V';
|
||||
if (n.includes('current')) return 'A';
|
||||
if (n.includes('power') && /^\d/.test(value)) return 'W';
|
||||
if (n.includes('fan') && /^\d+$/.test(value)) return 'RPM';
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatName(name: string): string {
|
||||
return name
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.replace(/\bPsu\b/g, 'PSU')
|
||||
.replace(/\bNtp\b/g, 'NTP');
|
||||
}
|
||||
|
||||
// ─── Disk helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Parse sizes in either raw-bytes ("134217728") or human-readable ("7.5GiB", "128MiB") form
|
||||
function parseBytes(val: string): number {
|
||||
if (!val) return 0;
|
||||
const n = parseFloat(val);
|
||||
if (isNaN(n)) return 0;
|
||||
const unit = val.replace(/[\d.\s]/g, '').toUpperCase();
|
||||
const map: Record<string, number> = {
|
||||
'': 1, B: 1,
|
||||
KB: 1e3, KIB: 1024,
|
||||
MB: 1e6, MIB: 1024 ** 2,
|
||||
GB: 1e9, GIB: 1024 ** 3,
|
||||
TB: 1e12, TIB: 1024 ** 4,
|
||||
};
|
||||
return n * (map[unit] ?? 1);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
|
||||
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KiB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function diskUsageColor(pct: number): string {
|
||||
if (pct >= 85) return 'bg-red-500';
|
||||
if (pct >= 70) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
// ─── Category helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function isTemperature(m: HealthMetric) {
|
||||
return m.unit === 'C' || m.name.toLowerCase().includes('temperature');
|
||||
}
|
||||
function isVoltage(m: HealthMetric) {
|
||||
return m.unit === 'V' || m.name.toLowerCase().includes('voltage');
|
||||
}
|
||||
function isFan(m: HealthMetric) {
|
||||
return m.unit === 'RPM' || m.name.toLowerCase().includes('fan');
|
||||
}
|
||||
function isPsu(m: HealthMetric) {
|
||||
const n = m.name.toLowerCase();
|
||||
return n.includes('psu') || n.includes('power-supply');
|
||||
}
|
||||
function isPower(m: HealthMetric) {
|
||||
return m.unit === 'W' || (m.name.toLowerCase().includes('power') && !isPsu(m));
|
||||
}
|
||||
function isCurrent(m: HealthMetric) {
|
||||
return m.unit === 'A' || m.name.toLowerCase().includes('current');
|
||||
}
|
||||
|
||||
// ─── Color helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function tempColor(celsius: number): string {
|
||||
if (celsius >= 70) return 'text-red-500 dark:text-red-400';
|
||||
if (celsius >= 50) return 'text-yellow-500 dark:text-yellow-400';
|
||||
return 'text-green-500 dark:text-green-400';
|
||||
}
|
||||
|
||||
function fanColor(rpm: number): string {
|
||||
return rpm === 0 ? 'text-red-500 dark:text-red-400' : 'text-green-500 dark:text-green-400';
|
||||
}
|
||||
|
||||
function psuColor(state: string): string {
|
||||
const s = state.toLowerCase();
|
||||
if (s === 'ok' || s === 'good') return 'text-green-500 dark:text-green-400';
|
||||
return 'text-red-500 dark:text-red-400';
|
||||
}
|
||||
|
||||
// ─── Card components ──────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ title, icon, children, grid = true }: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
grid?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-blue-500">{icon}</span>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
</div>
|
||||
{grid ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">{children}</div>
|
||||
) : (
|
||||
<div className="space-y-3">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, unit, valueClass }: {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
valueClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400 mb-1">{label}</div>
|
||||
<div className={clsx('text-lg font-bold', valueClass || 'text-gray-900 dark:text-white')}>
|
||||
{value}
|
||||
{unit && <span className="text-xs font-normal text-gray-500 dark:text-slate-400 ml-1">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiskCard({ disk }: { disk: Record<string, string> }) {
|
||||
const totalBytes = parseBytes(disk['total'] || '0');
|
||||
const freeBytes = parseBytes(disk['free'] || '0');
|
||||
const usedBytes = Math.max(0, totalBytes - freeBytes);
|
||||
const pct = totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0;
|
||||
|
||||
const label = disk['label'] || formatName(disk['name'] || 'Disk');
|
||||
const type = disk['type'] || '';
|
||||
const model = disk['model'] || '';
|
||||
const serial = disk['serial'] || '';
|
||||
const fs = disk['filesystem'] || disk['fs'] || '';
|
||||
const slot = disk['slot'] || '';
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-slate-700/50 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm text-gray-900 dark:text-white">{label}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400 mt-0.5 space-x-2">
|
||||
{type && <span className="uppercase">{type}</span>}
|
||||
{slot && <span>· {slot}</span>}
|
||||
{fs && <span>· {fs}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-bold text-gray-900 dark:text-white">{pct}%</div>
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400">used</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full mb-2">
|
||||
<div
|
||||
className={clsx('h-full rounded-full transition-all', diskUsageColor(pct))}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-gray-500 dark:text-slate-400">
|
||||
<span>{formatBytes(usedBytes)} used</span>
|
||||
<span>{formatBytes(freeBytes)} free / {formatBytes(totalBytes)}</span>
|
||||
</div>
|
||||
|
||||
{(model || serial) && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-slate-600 text-xs text-gray-400 dark:text-slate-500 space-y-0.5">
|
||||
{model && <div>Model: {model}</div>}
|
||||
{serial && <div>S/N: {serial}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toDisplayTemp(celsius: number, unit: 'C' | 'F'): string {
|
||||
if (unit === 'F') return String(Math.round(celsius * 9 / 5 + 32));
|
||||
return String(celsius % 1 === 0 ? celsius : celsius.toFixed(1));
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
const RESOURCE_RANGES = ['6h', '12h', '24h', '7d'] as const;
|
||||
|
||||
function ResourceChart({ deviceId }: { deviceId: number }) {
|
||||
const [range, setRange] = useState<string>('24h');
|
||||
|
||||
const { data: rawResources = [] } = useQuery({
|
||||
queryKey: ['resources-history', deviceId, range],
|
||||
queryFn: () => metricsApi.deviceResources(deviceId, range).then((r) => r.data as ResourcePoint[]),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const compact = range === '6h' || range === '12h' || range === '24h';
|
||||
const resourceData = rawResources.map((p) => {
|
||||
const memPct =
|
||||
p.memory_total && p.memory_total > 0
|
||||
? Math.round(((p.memory_used || 0) / p.memory_total) * 100)
|
||||
: 0;
|
||||
return {
|
||||
time: new Date(p.time).toLocaleTimeString([], {
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
...(compact ? {} : { month: 'short', day: 'numeric' }),
|
||||
}),
|
||||
cpu: Math.round(p.cpu_load || 0),
|
||||
mem: memPct,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">CPU & Memory Usage</h3>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{RESOURCE_RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded font-medium transition-colors',
|
||||
range === r
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-600'
|
||||
)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resourceData.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No resource history available yet — data accumulates over time
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={resourceData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 10 }} />
|
||||
<Tooltip
|
||||
formatter={(v) => `${v}%`}
|
||||
contentStyle={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="cpu" stroke="#f59e0b" name="CPU Load" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="mem" stroke="#8b5cf6" name="Memory Used" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HardwareTab({ deviceId }: { deviceId: number }) {
|
||||
const [tempUnit, setTempUnit] = useState<'C' | 'F'>(() =>
|
||||
(localStorage.getItem('hw-temp-unit') as 'C' | 'F') || 'C'
|
||||
);
|
||||
|
||||
const setAndPersistUnit = (unit: 'C' | 'F') => {
|
||||
setTempUnit(unit);
|
||||
localStorage.setItem('hw-temp-unit', unit);
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||
queryKey: ['device-hardware', deviceId],
|
||||
queryFn: () => devicesApi.getHardware(deviceId).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="card p-8 text-center text-gray-500 dark:text-slate-400">
|
||||
Failed to load hardware data.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metrics = normalize(data?.health || []);
|
||||
const disks = data?.disks || [];
|
||||
|
||||
const temperatures = metrics.filter(isTemperature);
|
||||
const voltages = metrics.filter(isVoltage);
|
||||
const fans = metrics.filter(isFan);
|
||||
const psus = metrics.filter(isPsu);
|
||||
const power = metrics.filter(isPower);
|
||||
const current = metrics.filter(isCurrent);
|
||||
const other = metrics.filter(
|
||||
(m) => !isTemperature(m) && !isVoltage(m) && !isFan(m) && !isPsu(m) && !isPower(m) && !isCurrent(m)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{/* Temperature unit toggle */}
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-slate-600 text-xs font-medium">
|
||||
{(['C', 'F'] as const).map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
onClick={() => setAndPersistUnit(unit)}
|
||||
className={clsx(
|
||||
'px-2.5 py-1 transition-colors',
|
||||
tempUnit === unit
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
°{unit}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CPU & Memory chart — full width at top */}
|
||||
<ResourceChart deviceId={deviceId} />
|
||||
|
||||
{/* Sensor & disk sections in a 2-column grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{disks.length > 0 && (
|
||||
<div className="lg:col-span-2">
|
||||
<Section title="Storage" icon={<HardDrive className="w-4 h-4" />} grid={false}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{disks.map((d, i) => <DiskCard key={d['name'] || i} disk={d} />)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{temperatures.length > 0 && (
|
||||
<Section title="Temperature" icon={<Thermometer className="w-4 h-4" />}>
|
||||
{temperatures.map((m) => {
|
||||
const c = parseFloat(m.value);
|
||||
return (
|
||||
<MetricCard
|
||||
key={m.name}
|
||||
label={formatName(m.name)}
|
||||
value={isNaN(c) ? m.value : toDisplayTemp(c, tempUnit)}
|
||||
unit={`°${tempUnit}`}
|
||||
valueClass={isNaN(c) ? undefined : tempColor(c)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{fans.length > 0 && (
|
||||
<Section title="Fans" icon={<Wind className="w-4 h-4" />}>
|
||||
{fans.map((m) => {
|
||||
const rpm = parseInt(m.value, 10);
|
||||
return (
|
||||
<MetricCard
|
||||
key={m.name}
|
||||
label={formatName(m.name)}
|
||||
value={isNaN(rpm) ? m.value : rpm.toLocaleString()}
|
||||
unit={isNaN(rpm) ? undefined : 'RPM'}
|
||||
valueClass={isNaN(rpm) ? undefined : fanColor(rpm)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{psus.length > 0 && (
|
||||
<Section title="Power Supply" icon={<Power className="w-4 h-4" />}>
|
||||
{psus.map((m) => (
|
||||
<MetricCard
|
||||
key={m.name}
|
||||
label={formatName(m.name)}
|
||||
value={m.value.toUpperCase()}
|
||||
valueClass={psuColor(m.value)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{voltages.length > 0 && (
|
||||
<Section title="Voltage" icon={<Zap className="w-4 h-4" />}>
|
||||
{voltages.map((m) => (
|
||||
<MetricCard key={m.name} label={formatName(m.name)} value={m.value} unit="V" />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(power.length > 0 || current.length > 0) && (
|
||||
<Section title="Power Consumption" icon={<Activity className="w-4 h-4" />}>
|
||||
{power.map((m) => (
|
||||
<MetricCard key={m.name} label={formatName(m.name)} value={m.value} unit="W" />
|
||||
))}
|
||||
{current.map((m) => (
|
||||
<MetricCard key={m.name} label={formatName(m.name)} value={m.value} unit="A" />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{other.length > 0 && (
|
||||
<Section title="Other" icon={<HelpCircle className="w-4 h-4" />}>
|
||||
{other.map((m) => (
|
||||
<MetricCard
|
||||
key={m.name}
|
||||
label={formatName(m.name)}
|
||||
value={m.value}
|
||||
unit={m.unit || undefined}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function InterfacesTab({ deviceId }: { deviceId: number }) {
|
||||
const { data: interfaces = [], isLoading } = useQuery({
|
||||
queryKey: ['interfaces', deviceId],
|
||||
queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading...</div>;
|
||||
if (!interfaces.length) return (
|
||||
<div className="text-center py-8 text-gray-400 dark:text-slate-500">
|
||||
No interfaces found. Sync the device to collect data.
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Type</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Status</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">MAC Address</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">MTU</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Speed</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{interfaces.map((iface) => (
|
||||
<tr key={iface.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-mono font-semibold text-gray-900 dark:text-white">
|
||||
{iface.name}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400 capitalize">
|
||||
{iface.type || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 text-xs font-medium',
|
||||
iface.disabled
|
||||
? 'text-gray-400'
|
||||
: iface.running
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-500 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
iface.disabled ? 'bg-gray-400' : iface.running ? 'bg-green-500' : 'bg-red-500'
|
||||
)}
|
||||
/>
|
||||
{iface.disabled ? 'Disabled' : iface.running ? 'Running' : 'Down'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{iface.mac_address || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
||||
{iface.mtu || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
||||
{iface.speed || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 dark:text-slate-500 italic">
|
||||
{iface.comment || ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,716 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
// ─── Extended devicesApi type (routing protocol methods added at runtime) ──────
|
||||
const dApi = devicesApi as typeof devicesApi & {
|
||||
getOspf: (id: number) => Promise<{ data: Record<string, unknown> }>;
|
||||
getBgp: (id: number) => Promise<{ data: Record<string, unknown> }>;
|
||||
getRoutingTables: (id: number) => Promise<{ data: Record<string, string>[] }>;
|
||||
getRouteFilters: (id: number) => Promise<{ data: Record<string, unknown> }>;
|
||||
getRouterIds: (id: number) => Promise<{ data: Record<string, string>[] }>;
|
||||
addOspfInstance: (id: number, d: Record<string, unknown>) => Promise<unknown>;
|
||||
removeOspfInstance: (id: number, itemId: string) => Promise<unknown>;
|
||||
addOspfArea: (id: number, d: Record<string, unknown>) => Promise<unknown>;
|
||||
removeOspfArea: (id: number, itemId: string) => Promise<unknown>;
|
||||
addBgpConnection: (id: number, d: Record<string, unknown>) => Promise<unknown>;
|
||||
removeBgpConnection: (id: number, itemId: string) => Promise<unknown>;
|
||||
addRoutingTable: (id: number, d: { name: string; fib?: boolean }) => Promise<unknown>;
|
||||
removeRoutingTable: (id: number, itemId: string) => Promise<unknown>;
|
||||
addFilterRule: (id: number, d: Record<string, unknown>) => Promise<unknown>;
|
||||
removeFilterRule: (id: number, itemId: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const SUB_TABS = ['Routes', 'OSPF', 'BGP', 'Route Filters', 'Tables'] as const;
|
||||
type SubTab = (typeof SUB_TABS)[number];
|
||||
|
||||
// ─── Reusable helpers ─────────────────────────────────────────────────────────
|
||||
function errMsg(err: unknown) {
|
||||
return (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Operation failed';
|
||||
}
|
||||
|
||||
function KVTable({ rows }: { rows: Record<string, string>[] }) {
|
||||
if (!rows.length) return <Empty text="No entries." />;
|
||||
const keys = Object.keys(rows[0]).filter(k => k !== '.id');
|
||||
return (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
{keys.map(k => (
|
||||
<th key={k} className="table-header px-4 py-2.5 text-left">{k}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
{keys.map(k => (
|
||||
<td key={k} className="px-4 py-2.5 font-mono text-xs text-gray-700 dark:text-slate-300">
|
||||
{String(r[k] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ text }: { text: string }) {
|
||||
return <p className="text-center py-8 text-gray-400 dark:text-slate-500">{text}</p>;
|
||||
}
|
||||
|
||||
function SectionHeader({ title, onRefresh, isFetching }: { title: string; onRefresh: () => void; isFetching: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
<button onClick={onRefresh} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Routes sub-tab (original) ────────────────────────────────────────────────
|
||||
function RoutesSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data: routes = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing', deviceId],
|
||||
queryFn: () => devicesApi.getRouting(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ dst_address: '', gateway: '', distance: '1', comment: '' });
|
||||
const [addError, setAddError] = useState('');
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => devicesApi.addRoute(deviceId, {
|
||||
dst_address: form.dst_address,
|
||||
gateway: form.gateway,
|
||||
distance: form.distance ? parseInt(form.distance, 10) : undefined,
|
||||
comment: form.comment || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['routing', deviceId] });
|
||||
setForm({ dst_address: '', gateway: '', distance: '1', comment: '' });
|
||||
setShowAdd(false);
|
||||
setAddError('');
|
||||
},
|
||||
onError: (err: unknown) => setAddError(errMsg(err)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (routeId: string) => devicesApi.deleteRoute(deviceId, routeId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['routing', deviceId] });
|
||||
setDeleteError('');
|
||||
},
|
||||
onError: (err: unknown) => setDeleteError(errMsg(err)),
|
||||
});
|
||||
|
||||
const isDynamic = (r: Record<string, string>) =>
|
||||
r['dynamic'] === 'true' || (r['flags'] && /[DCBOE]/.test(r['flags']));
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{deleteError && (
|
||||
<span className="text-sm text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />{deleteError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-2 text-sm">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button onClick={() => { setShowAdd(!showAdd); setAddError(''); }} className="btn-primary flex items-center gap-2 text-sm">
|
||||
<Plus className="w-4 h-4" /> Add Route
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canWrite && showAdd && (
|
||||
<div className="card p-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">New Static Route</h4>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div><label className="label">Destination (CIDR)</label>
|
||||
<input className="input font-mono" value={form.dst_address} onChange={e => setForm(f => ({ ...f, dst_address: e.target.value }))} placeholder="0.0.0.0/0" /></div>
|
||||
<div><label className="label">Gateway</label>
|
||||
<input className="input font-mono" value={form.gateway} onChange={e => setForm(f => ({ ...f, gateway: e.target.value }))} placeholder="192.168.1.1" /></div>
|
||||
<div><label className="label">Distance</label>
|
||||
<input type="number" className="input" value={form.distance} onChange={e => setForm(f => ({ ...f, distance: e.target.value }))} min={1} max={255} /></div>
|
||||
<div><label className="label">Comment</label>
|
||||
<input className="input" value={form.comment} onChange={e => setForm(f => ({ ...f, comment: e.target.value }))} placeholder="Optional" /></div>
|
||||
</div>
|
||||
{addError && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{addError}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMutation.mutate()} disabled={!form.dst_address || !form.gateway || addMutation.isPending} className="btn-primary flex items-center gap-2 text-sm">
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add Route
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{routes.length === 0 ? (
|
||||
<Empty text="No routes found." />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Destination</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Gateway</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Interface</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Dist.</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Status</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Comment</th>
|
||||
{canWrite && <th className="table-header px-4 py-2.5 w-10" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{(routes as Record<string, string>[]).map((r, i) => {
|
||||
const active = r['active'] === 'true' || r['flags']?.includes('A');
|
||||
const dynamic = isDynamic(r);
|
||||
const routeId = r['.id'] || '';
|
||||
return (
|
||||
<tr key={routeId || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-mono text-gray-900 dark:text-white">{r['dst-address'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{r['gateway'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{r['interface'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{r['distance'] || '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={clsx('text-xs font-medium', active ? 'text-green-600 dark:text-green-400' : 'text-gray-400')}>
|
||||
{active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
{dynamic && <span className="text-xs text-gray-400 dark:text-slate-500">(dynamic)</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 dark:text-slate-500 text-xs">{r['comment'] || ''}</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-2.5">
|
||||
{!dynamic && routeId && (
|
||||
<button
|
||||
onClick={() => { if (confirm(`Remove route to ${r['dst-address']}?`)) deleteMutation.mutate(routeId); }}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── OSPF sub-tab ─────────────────────────────────────────────────────────────
|
||||
function OspfSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing-ospf', deviceId],
|
||||
queryFn: () => dApi.getOspf(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAddInstance, setShowAddInstance] = useState(false);
|
||||
const [instanceForm, setInstanceForm] = useState({ name: '', 'router-id': '' });
|
||||
const [showAddArea, setShowAddArea] = useState(false);
|
||||
const [areaForm, setAreaForm] = useState({ name: '', area: '0.0.0.0', instance: '' });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const addInstanceMutation = useMutation({
|
||||
mutationFn: () => dApi.addOspfInstance(deviceId, instanceForm),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-ospf', deviceId] }); setShowAddInstance(false); setInstanceForm({ name: '', 'router-id': '' }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeInstanceMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeOspfInstance(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-ospf', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const addAreaMutation = useMutation({
|
||||
mutationFn: () => dApi.addOspfArea(deviceId, areaForm),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-ospf', deviceId] }); setShowAddArea(false); setAreaForm({ name: '', area: '0.0.0.0', instance: '' }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeAreaMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeOspfArea(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-ospf', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading…</div>;
|
||||
|
||||
const instances = (data?.instances as Record<string, string>[]) || [];
|
||||
const areas = (data?.areas as Record<string, string>[]) || [];
|
||||
const interfaces= (data?.interfaces as Record<string, string>[]) || [];
|
||||
const neighbors = (data?.neighbors as Record<string, string>[]) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{error}</div>}
|
||||
|
||||
{/* Instances */}
|
||||
<div>
|
||||
<SectionHeader title="Instances" onRefresh={refetch} isFetching={isFetching} />
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAddInstance ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Name</label><input className="input" value={instanceForm.name} onChange={e => setInstanceForm(f => ({ ...f, name: e.target.value }))} placeholder="default" /></div>
|
||||
<div><label className="label">Router ID</label><input className="input font-mono" value={instanceForm['router-id']} onChange={e => setInstanceForm(f => ({ ...f, 'router-id': e.target.value }))} placeholder="1.2.3.4" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addInstanceMutation.mutate()} disabled={!instanceForm.name || addInstanceMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addInstanceMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add
|
||||
</button>
|
||||
<button onClick={() => setShowAddInstance(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAddInstance(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Instance</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{instances.length === 0 ? <Empty text="No OSPF instances." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Router ID</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">State</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{instances.map((inst, i) => (
|
||||
<tr key={inst['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{inst['name'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{inst['router-id'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{inst['state'] || inst['status'] || '—'}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{inst['.id'] && <button onClick={() => { if (confirm('Remove instance?')) removeInstanceMutation.mutate(inst['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Areas */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Areas</h3>
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAddArea ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><label className="label">Name</label><input className="input" value={areaForm.name} onChange={e => setAreaForm(f => ({ ...f, name: e.target.value }))} placeholder="backbone" /></div>
|
||||
<div><label className="label">Area ID</label><input className="input font-mono" value={areaForm.area} onChange={e => setAreaForm(f => ({ ...f, area: e.target.value }))} placeholder="0.0.0.0" /></div>
|
||||
<div><label className="label">Instance</label><input className="input" value={areaForm.instance} onChange={e => setAreaForm(f => ({ ...f, instance: e.target.value }))} placeholder="default" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addAreaMutation.mutate()} disabled={!areaForm.name || addAreaMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addAreaMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add
|
||||
</button>
|
||||
<button onClick={() => setShowAddArea(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAddArea(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Area</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{areas.length === 0 ? <Empty text="No OSPF areas." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Area</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Instance</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{areas.map((a, i) => (
|
||||
<tr key={a['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{a['name'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{a['area'] || a['area-id'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{a['instance'] || '—'}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{a['.id'] && <button onClick={() => { if (confirm('Remove area?')) removeAreaMutation.mutate(a['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interfaces (read-only) */}
|
||||
{interfaces.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Interfaces</h3>
|
||||
<KVTable rows={interfaces} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Neighbors (read-only) */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Neighbors</h3>
|
||||
{neighbors.length === 0 ? <Empty text="No OSPF neighbors." /> : <KVTable rows={neighbors} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── BGP sub-tab ──────────────────────────────────────────────────────────────
|
||||
function BgpSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing-bgp', deviceId],
|
||||
queryFn: () => dApi.getBgp(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', 'remote.address': '', 'remote.as': '', 'local.role': 'ebgp' });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => dApi.addBgpConnection(deviceId, form),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-bgp', deviceId] }); setShowAdd(false); setForm({ name: '', 'remote.address': '', 'remote.as': '', 'local.role': 'ebgp' }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeBgpConnection(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-bgp', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading…</div>;
|
||||
|
||||
const connections = (data?.connections as Record<string, string>[]) || [];
|
||||
const sessions = (data?.sessions as Record<string, string>[]) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{error}</div>}
|
||||
|
||||
{/* Connections */}
|
||||
<div>
|
||||
<SectionHeader title="BGP Connections" onRefresh={refetch} isFetching={isFetching} />
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAdd ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div><label className="label">Name</label><input className="input" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="peer-name" /></div>
|
||||
<div><label className="label">Remote Address</label><input className="input font-mono" value={form['remote.address']} onChange={e => setForm(f => ({ ...f, 'remote.address': e.target.value }))} placeholder="10.0.0.1" /></div>
|
||||
<div><label className="label">Remote AS</label><input className="input font-mono" value={form['remote.as']} onChange={e => setForm(f => ({ ...f, 'remote.as': e.target.value }))} placeholder="65000" /></div>
|
||||
<div><label className="label">Role</label>
|
||||
<select className="input" value={form['local.role']} onChange={e => setForm(f => ({ ...f, 'local.role': e.target.value }))}>
|
||||
<option value="ebgp">eBGP</option>
|
||||
<option value="ibgp">iBGP</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMutation.mutate()} disabled={!form.name || !form['remote.address'] || addMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Connection</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{connections.length === 0 ? <Empty text="No BGP connections configured." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Remote Address</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Remote AS</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Role</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{connections.map((c, i) => (
|
||||
<tr key={c['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{c['name'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{c['remote.address'] || c['remote-address'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{c['remote.as'] || c['remote-as'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{c['local.role'] || c['local-role'] || '—'}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{c['.id'] && <button onClick={() => { if (confirm('Remove BGP connection?')) removeMutation.mutate(c['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sessions (read-only) */}
|
||||
{sessions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Active Sessions</h3>
|
||||
<KVTable rows={sessions} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Route Filters sub-tab ────────────────────────────────────────────────────
|
||||
function RouteFiltersSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing-filters', deviceId],
|
||||
queryFn: () => dApi.getRouteFilters(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ chain: '', action: 'accept', prefix: '', comment: '' });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => dApi.addFilterRule(deviceId, { ...form, prefix: form.prefix || undefined }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-filters', deviceId] }); setShowAdd(false); setForm({ chain: '', action: 'accept', prefix: '', comment: '' }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeFilterRule(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-filters', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading…</div>;
|
||||
|
||||
const rules = (data?.rules as Record<string, string>[]) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{error}</div>}
|
||||
<SectionHeader title="Route Filter Rules" onRefresh={refetch} isFetching={isFetching} />
|
||||
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAdd ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div><label className="label">Chain</label><input className="input" value={form.chain} onChange={e => setForm(f => ({ ...f, chain: e.target.value }))} placeholder="my-filter" /></div>
|
||||
<div><label className="label">Action</label>
|
||||
<select className="input" value={form.action} onChange={e => setForm(f => ({ ...f, action: e.target.value }))}>
|
||||
<option value="accept">accept</option>
|
||||
<option value="reject">reject</option>
|
||||
<option value="discard">discard</option>
|
||||
<option value="return">return</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label className="label">Prefix (optional)</label><input className="input font-mono" value={form.prefix} onChange={e => setForm(f => ({ ...f, prefix: e.target.value }))} placeholder="10.0.0.0/8" /></div>
|
||||
<div><label className="label">Comment</label><input className="input" value={form.comment} onChange={e => setForm(f => ({ ...f, comment: e.target.value }))} placeholder="Optional" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMutation.mutate()} disabled={!form.chain || addMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add Rule
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Rule</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rules.length === 0 ? <Empty text="No route filter rules." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">#</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Chain</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Action</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Prefix</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Comment</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{rules.map((r, i) => (
|
||||
<tr key={r['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 text-gray-400 dark:text-slate-500 text-xs">{i + 1}</td>
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{r['chain'] || '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={clsx('text-xs font-medium', r['action'] === 'accept' ? 'text-green-600 dark:text-green-400' : 'text-red-500')}>
|
||||
{r['action'] || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{r['prefix'] || r['dst-address'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500">{r['comment'] || ''}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{r['.id'] && <button onClick={() => { if (confirm('Remove filter rule?')) removeMutation.mutate(r['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Routing Tables sub-tab ───────────────────────────────────────────────────
|
||||
function TablesSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data: tables = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing-tables', deviceId],
|
||||
queryFn: () => dApi.getRoutingTables(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', fib: false });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => dApi.addRoutingTable(deviceId, form),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-tables', deviceId] }); setShowAdd(false); setForm({ name: '', fib: false }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeRoutingTable(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-tables', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{error}</div>}
|
||||
<SectionHeader title="Routing Tables" onRefresh={refetch} isFetching={isFetching} />
|
||||
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAdd ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1"><label className="label">Table Name</label><input className="input" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="my-table" /></div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300 mb-1 cursor-pointer">
|
||||
<input type="checkbox" checked={form.fib} onChange={e => setForm(f => ({ ...f, fib: e.target.checked }))} className="rounded" />
|
||||
FIB
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMutation.mutate()} disabled={!form.name || addMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add Table
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Table</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tables.length === 0 ? <Empty text="No custom routing tables." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">FIB</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{tables.map((t, i) => (
|
||||
<tr key={t['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{t['name'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{t['fib'] === 'true' ? 'Yes' : 'No'}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{t['.id'] && <button onClick={() => { if (confirm(`Remove table "${t['name']}"?`)) removeMutation.mutate(t['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main RoutingTab with sub-tabs ────────────────────────────────────────────
|
||||
export default function RoutingTab({ deviceId }: { deviceId: number }) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<SubTab>('Routes');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Sub-tab bar */}
|
||||
<div className="flex gap-1 border-b border-gray-200 dark:border-slate-700">
|
||||
{SUB_TABS.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveSubTab(tab)}
|
||||
className={clsx(
|
||||
'px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px',
|
||||
activeSubTab === tab
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sub-tab content */}
|
||||
{activeSubTab === 'Routes' && <RoutesSubTab deviceId={deviceId} />}
|
||||
{activeSubTab === 'OSPF' && <OspfSubTab deviceId={deviceId} />}
|
||||
{activeSubTab === 'BGP' && <BgpSubTab deviceId={deviceId} />}
|
||||
{activeSubTab === 'Route Filters' && <RouteFiltersSubTab deviceId={deviceId} />}
|
||||
{activeSubTab === 'Tables' && <TablesSubTab deviceId={deviceId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server,
|
||||
} from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import type { IpAddress, Interface } from '../../types';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
interface Props {
|
||||
deviceId: number;
|
||||
}
|
||||
|
||||
export default function SystemConfigTab({ deviceId }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
// ─── System config (identity, NTP, DNS) ──────────────────────────────────
|
||||
const { data: sysConfig, isLoading: sysLoading } = useQuery({
|
||||
queryKey: ['system-config', deviceId],
|
||||
queryFn: () => devicesApi.getSystemConfig(deviceId).then((r) => r.data),
|
||||
});
|
||||
|
||||
// ─── Clock ────────────────────────────────────────────────────────────────
|
||||
const { data: clockData, isLoading: clockLoading } = useQuery({
|
||||
queryKey: ['device-clock', deviceId],
|
||||
queryFn: () => devicesApi.getClock(deviceId).then((r) => r.data),
|
||||
});
|
||||
|
||||
// ─── Combined edit state ──────────────────────────────────────────────────
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [sysForm, setSysForm] = useState({
|
||||
identity: '',
|
||||
ntp_enabled: true,
|
||||
ntp_primary: '',
|
||||
ntp_secondary: '',
|
||||
dns_servers: '',
|
||||
dns_allow_remote: false,
|
||||
});
|
||||
const [clockForm, setClockForm] = useState({ date: '', time: '', timezone: '' });
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [saveSuccess, setSaveSuccess] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const startEdit = () => {
|
||||
if (!sysConfig) return;
|
||||
setSysForm({
|
||||
identity: sysConfig.identity,
|
||||
ntp_enabled: sysConfig.ntp.enabled !== 'no',
|
||||
ntp_primary: sysConfig.ntp['primary-ntp'] || '',
|
||||
ntp_secondary: sysConfig.ntp['secondary-ntp'] || '',
|
||||
dns_servers: sysConfig.dns['servers'] || '',
|
||||
dns_allow_remote: sysConfig.dns['allow-remote-requests'] === 'yes',
|
||||
});
|
||||
setClockForm({
|
||||
date: clockData?.date || '',
|
||||
time: clockData?.time || '',
|
||||
timezone: clockData?.timezone || '',
|
||||
});
|
||||
setSaveError('');
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
try {
|
||||
await devicesApi.updateSystemConfig(deviceId, sysForm);
|
||||
await devicesApi.setClock(deviceId, clockForm);
|
||||
queryClient.invalidateQueries({ queryKey: ['system-config', deviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['device-clock', deviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['device', deviceId] });
|
||||
setEditing(false);
|
||||
setSaveSuccess('System configuration updated');
|
||||
setTimeout(() => setSaveSuccess(''), 3000);
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setSaveError(msg || 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── IP Addresses ────────────────────────────────────────────────────────
|
||||
const { data: ipAddresses = [], isLoading: ipLoading, refetch: refetchIp } = useQuery({
|
||||
queryKey: ['ip-addresses', deviceId],
|
||||
queryFn: () => devicesApi.getIpAddresses(deviceId).then((r) => r.data as IpAddress[]),
|
||||
});
|
||||
|
||||
const { data: interfaces = [] } = useQuery({
|
||||
queryKey: ['interfaces', deviceId],
|
||||
queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data as Interface[]),
|
||||
});
|
||||
|
||||
const [newIp, setNewIp] = useState({ address: '', interface: '' });
|
||||
const [ipError, setIpError] = useState('');
|
||||
|
||||
const addIpMutation = useMutation({
|
||||
mutationFn: () => devicesApi.addIpAddress(deviceId, newIp),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] });
|
||||
setNewIp({ address: '', interface: '' });
|
||||
setIpError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setIpError(msg || 'Failed to add IP address');
|
||||
},
|
||||
});
|
||||
|
||||
const removeIpMutation = useMutation({
|
||||
mutationFn: (id: string) => devicesApi.removeIpAddress(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] }),
|
||||
});
|
||||
|
||||
// ─── Firmware check / install ─────────────────────────────────────────────
|
||||
const [updateInfo, setUpdateInfo] = useState<Record<string, string> | null>(null);
|
||||
const [updateChecking, setUpdateChecking] = useState(false);
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
const [installSuccess, setInstallSuccess] = useState('');
|
||||
|
||||
const checkUpdateMutation = useMutation({
|
||||
mutationFn: () => devicesApi.checkUpdate(deviceId),
|
||||
onMutate: () => { setUpdateChecking(true); setUpdateError(''); setUpdateInfo(null); setInstallSuccess(''); },
|
||||
onSuccess: (res) => { setUpdateInfo(res.data); setUpdateChecking(false); },
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setUpdateError(msg || 'Failed to check for updates');
|
||||
setUpdateChecking(false);
|
||||
},
|
||||
});
|
||||
|
||||
const installUpdateMutation = useMutation({
|
||||
mutationFn: () => devicesApi.installUpdate(deviceId),
|
||||
onSuccess: () => {
|
||||
setUpdateInfo(null);
|
||||
setInstallSuccess('Update installation initiated. The device will reboot shortly.');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setUpdateError(msg || 'Failed to install update');
|
||||
},
|
||||
});
|
||||
|
||||
// RouterOS 7 uses 'installed-version'; RouterOS 6 uses 'version'
|
||||
const installedVersion = updateInfo?.['installed-version'] || updateInfo?.['version'] || '';
|
||||
const latestVersion = updateInfo?.['latest-version'] || '';
|
||||
const statusSaysUpdate = updateInfo?.['status']?.toLowerCase().includes('new version') ||
|
||||
updateInfo?.['status']?.toLowerCase().includes('available');
|
||||
const hasUpdate = updateInfo && latestVersion && (
|
||||
(installedVersion && latestVersion !== installedVersion) ||
|
||||
(!installedVersion && statusSaysUpdate)
|
||||
);
|
||||
|
||||
const isLoading = sysLoading || clockLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── System Settings (identity, NTP, DNS, clock) ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-500" />
|
||||
System Settings
|
||||
</h3>
|
||||
{canWrite && !editing && (
|
||||
<button
|
||||
onClick={startEdit}
|
||||
disabled={isLoading || !sysConfig}
|
||||
className="btn-secondary text-xs py-1.5"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Loading...
|
||||
</div>
|
||||
) : !sysConfig ? (
|
||||
<div className="text-sm text-gray-400">Could not load system configuration</div>
|
||||
) : editing ? (
|
||||
<div className="space-y-5">
|
||||
{/* Identity */}
|
||||
<div>
|
||||
<label className="label">Device Identity (Hostname)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={sysForm.identity}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, identity: e.target.value }))}
|
||||
placeholder="router"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date / Time / Timezone */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-2">
|
||||
Date & Time
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="label">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={clockForm.date}
|
||||
onChange={(e) => setClockForm((f) => ({ ...f, date: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Time</label>
|
||||
<input
|
||||
type="time"
|
||||
className="input"
|
||||
value={clockForm.time}
|
||||
onChange={(e) => setClockForm((f) => ({ ...f, time: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Timezone</label>
|
||||
<input
|
||||
className="input"
|
||||
value={clockForm.timezone}
|
||||
onChange={(e) => setClockForm((f) => ({ ...f, timezone: e.target.value }))}
|
||||
placeholder="America/New_York"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">IANA name (e.g. UTC, Europe/London)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NTP */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-2">
|
||||
NTP Client
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ntp_enabled"
|
||||
checked={sysForm.ntp_enabled}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, ntp_enabled: e.target.checked }))}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="ntp_enabled" className="text-sm text-gray-700 dark:text-slate-300">
|
||||
NTP enabled
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Primary NTP server</label>
|
||||
<input
|
||||
className="input"
|
||||
value={sysForm.ntp_primary}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, ntp_primary: e.target.value }))}
|
||||
placeholder="0.pool.ntp.org"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Secondary NTP server</label>
|
||||
<input
|
||||
className="input"
|
||||
value={sysForm.ntp_secondary}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, ntp_secondary: e.target.value }))}
|
||||
placeholder="1.pool.ntp.org"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DNS */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-2">
|
||||
DNS
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="label">DNS Servers (comma-separated)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={sysForm.dns_servers}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, dns_servers: e.target.value }))}
|
||||
placeholder="8.8.8.8,8.8.4.4"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dns_remote"
|
||||
checked={sysForm.dns_allow_remote}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, dns_allow_remote: e.target.checked }))}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="dns_remote" className="text-sm text-gray-700 dark:text-slate-300">
|
||||
Allow remote DNS requests (act as DNS server)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{saveError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{saving && <RefreshCw className="w-4 h-4 animate-spin" />}
|
||||
Save Changes
|
||||
</button>
|
||||
<button onClick={() => setEditing(false)} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-y-3 gap-x-8 text-sm">
|
||||
{[
|
||||
['Identity', sysConfig.identity || '—'],
|
||||
['Date', clockData?.date || '—'],
|
||||
['Time', clockData?.time || '—'],
|
||||
['Timezone', clockData?.timezone || '—'],
|
||||
['NTP Enabled', sysConfig.ntp.enabled !== 'no' ? 'Yes' : 'No'],
|
||||
['Primary NTP', sysConfig.ntp['primary-ntp'] || '—'],
|
||||
['Secondary NTP', sysConfig.ntp['secondary-ntp'] || '—'],
|
||||
['DNS Servers', sysConfig.dns.servers || '—'],
|
||||
['Allow Remote DNS', sysConfig.dns['allow-remote-requests'] === 'yes' ? 'Yes' : 'No'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between border-b border-gray-100 dark:border-slate-700 pb-2">
|
||||
<span className="text-gray-500 dark:text-slate-400">{k}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white text-right">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveSuccess && (
|
||||
<div className="flex items-center gap-2 mt-3 text-sm text-green-600">
|
||||
<CheckCircle className="w-4 h-4" /> {saveSuccess}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── IP Addresses ── */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">IP Addresses</h3>
|
||||
<button
|
||||
onClick={() => refetchIp()}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', ipLoading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2 text-left">Address / Prefix</th>
|
||||
<th className="table-header px-4 py-2 text-left">Network</th>
|
||||
<th className="table-header px-4 py-2 text-left">Interface</th>
|
||||
<th className="table-header px-4 py-2 text-left">Status</th>
|
||||
<th className="table-header px-4 py-2 w-12" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{ipLoading ? (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">Loading...</td></tr>
|
||||
) : (ipAddresses as IpAddress[]).length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">No IP addresses</td></tr>
|
||||
) : (
|
||||
(ipAddresses as IpAddress[]).map((ip) => (
|
||||
<tr key={ip['.id']} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2 font-mono text-gray-900 dark:text-white">{ip.address}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{ip.network || '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-slate-300">{ip.interface}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={clsx(
|
||||
'text-xs font-medium',
|
||||
ip.disabled === 'true' ? 'text-gray-400' : 'text-green-600 dark:text-green-400'
|
||||
)}>
|
||||
{ip.dynamic === 'true' ? 'dynamic' : ip.disabled === 'true' ? 'disabled' : 'active'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{canWrite && ip.dynamic !== 'true' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${ip.address} from ${ip.interface}?`)) {
|
||||
removeIpMutation.mutate(ip['.id']);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{canWrite && <div className="p-4 border-t border-gray-100 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="label">Address / Prefix (e.g. 192.168.1.1/24)</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={newIp.address}
|
||||
onChange={(e) => setNewIp((f) => ({ ...f, address: e.target.value }))}
|
||||
placeholder="192.168.1.1/24"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-44">
|
||||
<label className="label">Interface</label>
|
||||
<select
|
||||
className="input"
|
||||
value={newIp.interface}
|
||||
onChange={(e) => setNewIp((f) => ({ ...f, interface: e.target.value }))}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{(interfaces as Interface[]).map((i) => (
|
||||
<option key={i.name} value={i.name}>{i.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addIpMutation.mutate()}
|
||||
disabled={!newIp.address || !newIp.interface || addIpMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{ipError && (
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-red-500">
|
||||
<AlertCircle className="w-4 h-4" /> {ipError}
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* ── Firmware Updates ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-blue-500" />
|
||||
Firmware Updates
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => checkUpdateMutation.mutate()}
|
||||
disabled={updateChecking}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', updateChecking && 'animate-spin')} />
|
||||
{updateChecking ? 'Checking...' : 'Check for Updates'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{updateError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500 mb-3">
|
||||
<AlertCircle className="w-4 h-4" /> {updateError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installSuccess && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400 mb-3">
|
||||
<CheckCircle className="w-4 h-4" /> {installSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateInfo && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{[
|
||||
['Status', updateInfo['status'] || '—'],
|
||||
['Installed Version', installedVersion || '—'],
|
||||
['Latest Version', latestVersion || '—'],
|
||||
['Channel', updateInfo['channel'] || '—'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between border-b border-gray-100 dark:border-slate-700 pb-2">
|
||||
<span className="text-gray-500 dark:text-slate-400">{k}</span>
|
||||
<span className={clsx(
|
||||
'font-medium text-right',
|
||||
k === 'Status' && hasUpdate ? 'text-orange-500' : 'text-gray-900 dark:text-white'
|
||||
)}>
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasUpdate ? (
|
||||
<div className="flex items-center gap-3 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-orange-500 flex-shrink-0" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium text-orange-700 dark:text-orange-400">
|
||||
Update available: {latestVersion}
|
||||
</p>
|
||||
<p className="text-orange-600 dark:text-orange-500 text-xs mt-0.5">
|
||||
The device will reboot after installing.
|
||||
</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Install RouterOS ${latestVersion} and reboot the device?`)) {
|
||||
installUpdateMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={installUpdateMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm flex-shrink-0"
|
||||
>
|
||||
{installUpdateMutation.isPending
|
||||
? <RefreshCw className="w-4 h-4 animate-spin" />
|
||||
: <Download className="w-4 h-4" />}
|
||||
Install & Reboot
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
RouterOS is up to date
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!updateInfo && !updateChecking && !installSuccess && (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">
|
||||
Click "Check for Updates" to query the MikroTik update server.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Play, Loader2, CheckCircle, XCircle, Wifi, RotateCcw } from 'lucide-react';
|
||||
import { deviceToolsApi, devicesApi } from '../../services/api';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function RunButton({ loading, onClick, label = 'Run' }: { loading: boolean; onClick: () => void; label?: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className="btn-primary flex items-center gap-2 shrink-0"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{loading ? 'Running…' : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBox({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-300">
|
||||
<XCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultTable({ headers, rows }: { headers: string[]; rows: (string | null)[][] }) {
|
||||
if (!rows.length) return <p className="text-sm text-gray-400 dark:text-slate-500">No results.</p>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
{headers.map((h) => (
|
||||
<th key={h} className="px-3 py-1.5 text-left text-gray-500 dark:text-slate-400 font-medium">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50 table-zebra">
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-slate-700/20">
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} className="px-3 py-1.5 text-gray-700 dark:text-slate-300">{cell ?? '—'}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Ping ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function PingTool({ deviceId, interfaces }: { deviceId: number; interfaces: string[] }) {
|
||||
const [address, setAddress] = useState('');
|
||||
const [count, setCount] = useState('4');
|
||||
const [iface, setIface] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<Record<string, string>[] | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!address) return;
|
||||
setLoading(true); setError(''); setResults(null);
|
||||
try {
|
||||
const { data } = await deviceToolsApi.ping(deviceId, {
|
||||
address,
|
||||
count: parseInt(count) || 4,
|
||||
interface: iface || undefined,
|
||||
});
|
||||
setResults(data);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'Ping failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Split results into per-packet rows and summary (last row has packet-loss)
|
||||
const packets = results?.filter((r) => r['seq'] !== undefined) ?? [];
|
||||
const summary = results?.find((r) => r['sent'] !== undefined);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
className="input flex-1 min-w-48"
|
||||
placeholder="IP address or hostname"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && run()}
|
||||
/>
|
||||
<input
|
||||
className="input w-20"
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={count}
|
||||
onChange={(e) => setCount(e.target.value)}
|
||||
title="Packet count"
|
||||
/>
|
||||
{interfaces.length > 0 && (
|
||||
<select className="input w-44" value={iface} onChange={(e) => setIface(e.target.value)}>
|
||||
<option value="">Any interface</option>
|
||||
{interfaces.map((i) => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<RunButton loading={loading} onClick={run} />
|
||||
</div>
|
||||
|
||||
{error && <ErrorBox message={error} />}
|
||||
|
||||
{results && (
|
||||
<div className="space-y-3">
|
||||
<ResultTable
|
||||
headers={['Seq', 'Host', 'Time', 'TTL', 'Size', 'Status']}
|
||||
rows={packets.map((r) => [
|
||||
r['seq'] ?? null,
|
||||
r['host'] ?? null,
|
||||
r['time'] ? `${r['time']}ms` : null,
|
||||
r['ttl'] ?? null,
|
||||
r['size'] ? `${r['size']}B` : null,
|
||||
r['status'] || (r['time'] ? 'OK' : 'timeout'),
|
||||
])}
|
||||
/>
|
||||
{summary && (
|
||||
<div className="flex flex-wrap gap-4 p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg text-xs font-mono text-gray-600 dark:text-slate-400">
|
||||
<span>Sent: <b className="text-gray-900 dark:text-white">{summary['sent']}</b></span>
|
||||
<span>Received: <b className="text-gray-900 dark:text-white">{summary['received']}</b></span>
|
||||
<span>Loss: <b className={clsx(
|
||||
summary['packet-loss'] === '0%' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
)}>{summary['packet-loss']}</b></span>
|
||||
{summary['avg-rtt'] && <span>Avg RTT: <b className="text-gray-900 dark:text-white">{summary['avg-rtt']}ms</b></span>}
|
||||
{summary['min-rtt'] && <span>Min: <b className="text-gray-900 dark:text-white">{summary['min-rtt']}ms</b></span>}
|
||||
{summary['max-rtt'] && <span>Max: <b className="text-gray-900 dark:text-white">{summary['max-rtt']}ms</b></span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Traceroute ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TracerouteTool({ deviceId }: { deviceId: number }) {
|
||||
const [address, setAddress] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<Record<string, string>[] | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!address) return;
|
||||
setLoading(true); setError(''); setResults(null);
|
||||
try {
|
||||
const { data } = await deviceToolsApi.traceroute(deviceId, { address });
|
||||
setResults(data);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'Traceroute failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="IP address or hostname"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && run()}
|
||||
/>
|
||||
<RunButton loading={loading} onClick={run} />
|
||||
</div>
|
||||
{loading && (
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">Tracing route — this may take up to 60 seconds…</p>
|
||||
)}
|
||||
{error && <ErrorBox message={error} />}
|
||||
{results && (
|
||||
<ResultTable
|
||||
headers={['Hop', 'Address', 'Status', 'Loss', 'Sent', 'Last', 'Avg', 'Best', 'Worst']}
|
||||
rows={results.map((r, i) => [
|
||||
String(i + 1),
|
||||
r['address'] || r['host'] || '* * *',
|
||||
r['status'] || '',
|
||||
r['loss'] ?? null,
|
||||
r['sent'] ?? null,
|
||||
r['last'] ? `${r['last']}ms` : null,
|
||||
r['avg'] ? `${r['avg']}ms` : null,
|
||||
r['best'] ? `${r['best']}ms` : null,
|
||||
r['worst'] ? `${r['worst']}ms` : null,
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── IP Scan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function IpScanTool({ deviceId, interfaces }: { deviceId: number; interfaces: string[] }) {
|
||||
const [addressRange, setAddressRange] = useState('');
|
||||
const [iface, setIface] = useState(interfaces[0] || '');
|
||||
const [rdns, setRdns] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<Record<string, string>[] | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!addressRange || !iface) return;
|
||||
setLoading(true); setError(''); setResults(null);
|
||||
try {
|
||||
const { data } = await deviceToolsApi.ipScan(deviceId, { addressRange, interface: iface, rdns });
|
||||
setResults(data);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'IP scan failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
className="input flex-1 min-w-48"
|
||||
placeholder="Address range (e.g. 192.168.1.0/24)"
|
||||
value={addressRange}
|
||||
onChange={(e) => setAddressRange(e.target.value)}
|
||||
/>
|
||||
<select className="input w-44" value={iface} onChange={(e) => setIface(e.target.value)}>
|
||||
<option value="">Select interface…</option>
|
||||
{interfaces.map((i) => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rdns}
|
||||
onChange={(e) => setRdns(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 dark:border-slate-600 text-blue-600 cursor-pointer"
|
||||
/>
|
||||
Reverse DNS
|
||||
</label>
|
||||
<RunButton loading={loading} onClick={run} label="Scan" />
|
||||
</div>
|
||||
{loading && (
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">
|
||||
Scanning{rdns ? ' + resolving hostnames' : ''} — results appear when scan completes…
|
||||
</p>
|
||||
)}
|
||||
{error && <ErrorBox message={error} />}
|
||||
{results && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">{results.length} host{results.length !== 1 ? 's' : ''} found</p>
|
||||
<ResultTable
|
||||
headers={rdns ? ['IP Address', 'Hostname', 'MAC Address'] : ['IP Address', 'MAC Address']}
|
||||
rows={results.map((r) =>
|
||||
rdns
|
||||
? [r['address'] ?? null, r['hostname'] || '—', r['mac-address'] ?? null]
|
||||
: [r['address'] ?? null, r['mac-address'] ?? null]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Wake-on-LAN ──────────────────────────────────────────────────────────────
|
||||
|
||||
function WolTool({ deviceId, interfaces }: { deviceId: number; interfaces: string[] }) {
|
||||
const [mac, setMac] = useState('');
|
||||
const [iface, setIface] = useState(interfaces[0] || '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!mac || !iface) return;
|
||||
setLoading(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const { data } = await deviceToolsApi.wol(deviceId, { mac, interface: iface });
|
||||
setSuccess(data.message || 'Magic packet sent successfully');
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'WoL failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
className="input flex-1 min-w-48"
|
||||
placeholder="MAC address (e.g. AA:BB:CC:DD:EE:FF)"
|
||||
value={mac}
|
||||
onChange={(e) => setMac(e.target.value)}
|
||||
/>
|
||||
<select className="input w-44" value={iface} onChange={(e) => setIface(e.target.value)}>
|
||||
<option value="">Select interface…</option>
|
||||
{interfaces.map((i) => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
<RunButton loading={loading} onClick={run} label="Send Packet" />
|
||||
</div>
|
||||
{error && <ErrorBox message={error} />}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg text-sm text-green-700 dark:text-green-300">
|
||||
<CheckCircle className="w-4 h-4 shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Reboot ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function RebootSection({ deviceId }: { deviceId: number }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!confirm('Are you sure you want to reboot this device? It will be temporarily unreachable.')) return;
|
||||
setLoading(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const { data } = await devicesApi.reboot(deviceId);
|
||||
setSuccess(data.message || 'Reboot command sent.');
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'Reboot failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<RotateCcw className="w-4 h-4 text-orange-500 shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Reboot Device</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Sends a reboot command to the device. It will be briefly unreachable while restarting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={run}
|
||||
disabled={loading}
|
||||
className="btn-secondary flex items-center gap-2 shrink-0 text-orange-600 dark:text-orange-400 border-orange-200 dark:border-orange-800 hover:bg-orange-50 dark:hover:bg-orange-900/20"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RotateCcw className="w-4 h-4" />}
|
||||
{loading ? 'Rebooting…' : 'Reboot'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="mt-3"><ErrorBox message={error} /></div>}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 mt-3 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg text-sm text-green-700 dark:text-green-300">
|
||||
<CheckCircle className="w-4 h-4 shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ToolCard({ title, icon: Icon, children }: { title: string; icon: React.FC<{ className?: string }>; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Icon className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ToolsTab({ deviceId }: { deviceId: number }) {
|
||||
const canWrite = useCanWrite();
|
||||
const { data: ifaceData = [] } = useQuery({
|
||||
queryKey: ['interfaces', deviceId],
|
||||
queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data),
|
||||
});
|
||||
|
||||
const interfaceNames = ifaceData.map((i: { name: string }) => i.name).filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{canWrite && <RebootSection deviceId={deviceId} />}
|
||||
|
||||
<ToolCard title="Ping" icon={({ className }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
|
||||
<path d="M5.636 5.636l2.122 2.122M16.243 16.243l2.121 2.121M5.636 18.364l2.122-2.122M16.243 7.757l2.121-2.121" />
|
||||
</svg>
|
||||
)}>
|
||||
<PingTool deviceId={deviceId} interfaces={interfaceNames} />
|
||||
</ToolCard>
|
||||
|
||||
<ToolCard title="Traceroute" icon={({ className }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||
</svg>
|
||||
)}>
|
||||
<TracerouteTool deviceId={deviceId} />
|
||||
</ToolCard>
|
||||
|
||||
<ToolCard title="IP Scan" icon={({ className }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8M12 17v4" />
|
||||
<circle cx="12" cy="10" r="3" /><path d="M6 10h.01M18 10h.01" />
|
||||
</svg>
|
||||
)}>
|
||||
<IpScanTool deviceId={deviceId} interfaces={interfaceNames} />
|
||||
</ToolCard>
|
||||
|
||||
{canWrite && (
|
||||
<ToolCard title="Wake-on-LAN" icon={Wifi}>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 mb-3">
|
||||
Sends a WoL magic packet from this MikroTik device to wake a sleeping host on the network.
|
||||
</p>
|
||||
<WolTool deviceId={deviceId} interfaces={interfaceNames} />
|
||||
</ToolCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, AlertCircle, RefreshCw, Pencil, Check, X, Network } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import type { Vlan, SwitchPort } from '../../types';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
interface PortForm {
|
||||
tagged_ports: string;
|
||||
untagged_ports: string;
|
||||
}
|
||||
|
||||
export default function VlansTab({ deviceId, onGoToPorts }: { deviceId: number; onGoToPorts?: (bridgeName: string) => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data: vlans = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['vlans', deviceId],
|
||||
queryFn: () => devicesApi.getVlans(deviceId).then((r) => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: portsData } = useQuery({
|
||||
queryKey: ['ports', deviceId],
|
||||
queryFn: () => devicesApi.getPorts(deviceId).then((r) => r.data),
|
||||
});
|
||||
const ports: SwitchPort[] = portsData?.ports ?? [];
|
||||
|
||||
// Find bridges where vlan-filtering is not enabled (stored as 'yes' from RouterOS or 'true' after toggle)
|
||||
const bridgesWithoutFiltering = ports.filter(
|
||||
(p) => p.type === 'bridge' && p.config_json?.['vlan-filtering'] !== 'yes' && p.config_json?.['vlan-filtering'] !== 'true'
|
||||
);
|
||||
|
||||
// ── Add form ──
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ vlan_id: '', bridge: '', tagged_ports: '', untagged_ports: '' });
|
||||
const [addError, setAddError] = useState('');
|
||||
|
||||
// ── Edit form ──
|
||||
const [editingVlan, setEditingVlan] = useState<Vlan | null>(null);
|
||||
const [editForm, setEditForm] = useState<PortForm>({ tagged_ports: '', untagged_ports: '' });
|
||||
const [editError, setEditError] = useState('');
|
||||
|
||||
// ── Delete error ──
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
|
||||
const bridgeOptions = [...new Set((vlans as Vlan[]).map((v) => v.bridge).filter(Boolean))];
|
||||
|
||||
// ── Mutations ──
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const tagged = addForm.tagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const untagged = addForm.untagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return devicesApi.addVlan(deviceId, {
|
||||
bridge: addForm.bridge,
|
||||
vlan_id: parseInt(addForm.vlan_id, 10),
|
||||
tagged_ports: tagged,
|
||||
untagged_ports: untagged,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
|
||||
setAddForm({ vlan_id: '', bridge: bridgeOptions[0] || '', tagged_ports: '', untagged_ports: '' });
|
||||
setShowAdd(false);
|
||||
setAddError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setAddError(msg || 'Failed to add VLAN');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!editingVlan) throw new Error('No VLAN selected');
|
||||
const tagged = editForm.tagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const untagged = editForm.untagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return devicesApi.updateVlan(deviceId, editingVlan.id, {
|
||||
tagged_ports: tagged,
|
||||
untagged_ports: untagged,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
|
||||
setEditingVlan(null);
|
||||
setEditError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setEditError(msg || 'Failed to update VLAN');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (vlanDbId: number) => devicesApi.deleteVlan(deviceId, vlanDbId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
|
||||
setDeleteError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setDeleteError(msg || 'Failed to delete VLAN');
|
||||
},
|
||||
});
|
||||
|
||||
const openEdit = (vlan: Vlan) => {
|
||||
setEditingVlan(vlan);
|
||||
setEditError('');
|
||||
setShowAdd(false);
|
||||
setEditForm({
|
||||
tagged_ports: (vlan.tagged_ports ?? []).join(', '),
|
||||
untagged_ports: (vlan.untagged_ports ?? []).join(', '),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* VLAN Filtering warning */}
|
||||
{bridgesWithoutFiltering.length > 0 && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 px-4 py-3">
|
||||
<Network className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-sm text-amber-800 dark:text-amber-300">
|
||||
<span className="font-semibold">VLAN Filtering is not enabled</span> on the Bridge interface
|
||||
{bridgesWithoutFiltering.length > 1
|
||||
? ` (${bridgesWithoutFiltering.map((b) => b.name).join(', ')})`
|
||||
: ` (${bridgesWithoutFiltering[0].name})`}.
|
||||
{' '}VLAN functionality is disabled — tagging and untagging rules will not be enforced.{' '}
|
||||
{onGoToPorts && (
|
||||
<button
|
||||
onClick={() => onGoToPorts(bridgesWithoutFiltering[0].name)}
|
||||
className="inline-flex items-center gap-1 font-semibold underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-200 transition-colors"
|
||||
>
|
||||
Enable VLAN Filtering on the Bridge interface →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{deleteError && (
|
||||
<span className="text-sm text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />{deleteError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAdd(!showAdd);
|
||||
setEditingVlan(null);
|
||||
setAddError('');
|
||||
setAddForm({ vlan_id: '', bridge: bridgeOptions[0] || '', tagged_ports: '', untagged_ports: '' });
|
||||
}}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add VLAN
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add VLAN form */}
|
||||
{canWrite && showAdd && (
|
||||
<div className="card p-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">New Bridge VLAN</h4>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
<label className="label">VLAN ID (1–4094)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
value={addForm.vlan_id}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, vlan_id: e.target.value }))}
|
||||
min={1} max={4094}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Bridge</label>
|
||||
{bridgeOptions.length > 0 ? (
|
||||
<select
|
||||
className="input"
|
||||
value={addForm.bridge}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, bridge: e.target.value }))}
|
||||
>
|
||||
{bridgeOptions.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={addForm.bridge}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, bridge: e.target.value }))}
|
||||
placeholder="bridge1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Tagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={addForm.tagged_ports}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, tagged_ports: e.target.value }))}
|
||||
placeholder="ether1, ether2"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Untagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={addForm.untagged_ports}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, untagged_ports: e.target.value }))}
|
||||
placeholder="ether3"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated</p>
|
||||
</div>
|
||||
</div>
|
||||
{addError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500">
|
||||
<AlertCircle className="w-4 h-4" /> {addError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => addMutation.mutate()}
|
||||
disabled={!addForm.vlan_id || !addForm.bridge || addMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||
Add VLAN
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit VLAN panel */}
|
||||
{canWrite && editingVlan && (
|
||||
<div className="card p-4 space-y-3 border-blue-300 dark:border-blue-600 border-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Edit VLAN{' '}
|
||||
<span className="font-mono text-blue-600 dark:text-blue-400">{editingVlan.vlan_id}</span>
|
||||
{editingVlan.bridge && (
|
||||
<span className="text-gray-400 dark:text-slate-500 font-normal"> on {editingVlan.bridge}</span>
|
||||
)}
|
||||
</h4>
|
||||
<button onClick={() => setEditingVlan(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label">Tagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={editForm.tagged_ports}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, tagged_ports: e.target.value }))}
|
||||
placeholder="ether1, ether2"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated interface names</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Untagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={editForm.untagged_ports}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, untagged_ports: e.target.value }))}
|
||||
placeholder="ether3"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated interface names</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500">
|
||||
<AlertCircle className="w-4 h-4" /> {editError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => updateMutation.mutate()}
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
{updateMutation.isPending
|
||||
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Check className="w-3.5 h-3.5" />}
|
||||
Save Changes
|
||||
</button>
|
||||
<button onClick={() => setEditingVlan(null)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VLANs table */}
|
||||
<div className="card overflow-hidden">
|
||||
{(vlans as Vlan[]).length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 dark:text-slate-500">
|
||||
No VLANs configured on this device, or device not synced yet.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">VLAN ID</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Bridge</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Tagged Ports</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Untagged Ports</th>
|
||||
{canWrite && <th className="table-header px-4 py-2.5 w-16" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{(vlans as Vlan[]).map((vlan) => (
|
||||
<tr
|
||||
key={vlan.id}
|
||||
className={clsx(
|
||||
'hover:bg-gray-50 dark:hover:bg-slate-700/30',
|
||||
editingVlan?.id === vlan.id && 'bg-blue-50 dark:bg-blue-900/10'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2.5 font-mono font-bold text-blue-600 dark:text-blue-400">
|
||||
{vlan.vlan_id}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{vlan.name || `VLAN ${vlan.vlan_id}`}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">
|
||||
{vlan.bridge || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(vlan.tagged_ports ?? []).map((p) => (
|
||||
<span key={p} className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 text-xs rounded font-mono">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{!(vlan.tagged_ports?.length) && '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(vlan.untagged_ports ?? []).map((p) => (
|
||||
<span key={p} className="px-1.5 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 text-xs rounded font-mono">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{!(vlan.untagged_ports?.length) && '—'}
|
||||
</div>
|
||||
</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => openEdit(vlan)}
|
||||
className={clsx(
|
||||
'p-1 rounded transition-colors',
|
||||
editingVlan?.id === vlan.id
|
||||
? 'text-blue-500 bg-blue-100 dark:bg-blue-900/30'
|
||||
: 'text-gray-400 hover:text-blue-500 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
title="Edit VLAN"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Remove VLAN ${vlan.vlan_id} from ${vlan.bridge}?`)) {
|
||||
deleteMutation.mutate(vlan.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title="Delete VLAN"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { X, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
prefill?: { name?: string; ip_address?: string };
|
||||
}
|
||||
|
||||
export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
name: prefill?.name || '',
|
||||
ip_address: prefill?.ip_address || '',
|
||||
api_port: '8728',
|
||||
api_username: 'admin',
|
||||
api_password: '',
|
||||
ssh_port: '22',
|
||||
ssh_username: '',
|
||||
ssh_password: '',
|
||||
device_type: 'router',
|
||||
notes: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name || !form.ip_address || !form.api_username || !form.api_password) {
|
||||
setError('Name, IP address, username, and password are required');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await devicesApi.create({
|
||||
...form,
|
||||
api_port: parseInt(form.api_port),
|
||||
ssh_port: parseInt(form.ssh_port),
|
||||
device_type: form.device_type as import('../../types').DeviceType,
|
||||
});
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Failed to add device');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Add Device</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{/* Basic info */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="label">Device Name *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
placeholder="e.g. Core Switch"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">IP Address *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.ip_address}
|
||||
onChange={(e) => set('ip_address', e.target.value)}
|
||||
placeholder="192.168.1.1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Device Type</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.device_type}
|
||||
onChange={(e) => set('device_type', e.target.value)}
|
||||
>
|
||||
<option value="router">Router</option>
|
||||
<option value="switch">Switch</option>
|
||||
<option value="wireless_ap">Wireless AP</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API credentials */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">
|
||||
RouterOS API Credentials
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Username *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.api_username}
|
||||
onChange={(e) => set('api_username', e.target.value)}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">API Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.api_port}
|
||||
onChange={(e) => set('api_port', e.target.value)}
|
||||
placeholder="8728"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">Password *</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.api_password}
|
||||
onChange={(e) => set('api_password', e.target.value)}
|
||||
placeholder="RouterOS API password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSH credentials (optional) */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider select-none">
|
||||
SSH Credentials (optional, for backup/restore)
|
||||
</summary>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">SSH Username</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.ssh_username}
|
||||
onChange={(e) => set('ssh_username', e.target.value)}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">SSH Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.ssh_port}
|
||||
onChange={(e) => set('ssh_port', e.target.value)}
|
||||
placeholder="22"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">SSH Password</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.ssh_password}
|
||||
onChange={(e) => set('ssh_password', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div>
|
||||
<label className="label">Notes (optional)</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
placeholder="Any notes about this device..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<Loader2 className="w-4 h-4 text-blue-500 animate-spin flex-shrink-0" />
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
Testing connection and collecting device data...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="btn-primary flex items-center gap-2">
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
)}
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
import TopBar from './TopBar';
|
||||
|
||||
export default function AppLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-slate-900 overflow-hidden">
|
||||
{/* Mobile backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sidebar mobileOpen={sidebarOpen} onMobileClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<TopBar onMenuClick={() => setSidebarOpen((o) => !o)} />
|
||||
<main className="flex-1 overflow-auto p-3 sm:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Router, Wifi, AlertTriangle, X } from 'lucide-react';
|
||||
import { searchApi } from '../../services/api';
|
||||
import { format } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type SearchDevice = {
|
||||
id: number; name: string; ip_address: string;
|
||||
model?: string; device_type: string; status: string;
|
||||
};
|
||||
type SearchClient = {
|
||||
mac_address: string; hostname?: string; ip_address?: string;
|
||||
device_id: number; device_name?: string; active: boolean;
|
||||
};
|
||||
type SearchEvent = {
|
||||
id: number; message: string; severity: string;
|
||||
event_time: string; topic?: string; device_name?: string;
|
||||
};
|
||||
type SearchResults = { devices: SearchDevice[]; clients: SearchClient[]; events: SearchEvent[] };
|
||||
|
||||
const SEVERITY_ICON_CLS: Record<string, string> = {
|
||||
error: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
critical: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
warning: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400',
|
||||
info: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
|
||||
};
|
||||
|
||||
export default function GlobalSearch() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Debounce the query input
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQuery(query), 280);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
// Fetch when debounced query is long enough
|
||||
useEffect(() => {
|
||||
if (debouncedQuery.length < 2) { setResults(null); return; }
|
||||
setLoading(true);
|
||||
searchApi.search(debouncedQuery)
|
||||
.then((r) => { setResults(r.data); setHighlighted(0); })
|
||||
.catch(() => setResults(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [debouncedQuery]);
|
||||
|
||||
// Ctrl+K / Cmd+K global shortcut
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
// Flat list for keyboard navigation index tracking
|
||||
const deviceCount = results?.devices.length ?? 0;
|
||||
const clientCount = results?.clients.length ?? 0;
|
||||
const eventCount = results?.events.length ?? 0;
|
||||
const totalCount = deviceCount + clientCount + eventCount;
|
||||
|
||||
const goToResult = useCallback((kind: 'device' | 'client' | 'event', id: string | number) => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
setResults(null);
|
||||
if (kind === 'device') navigate(`/devices/${id}`);
|
||||
else if (kind === 'client') navigate('/clients');
|
||||
else navigate('/events');
|
||||
}, [navigate]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!open || totalCount === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlighted((h) => (h + 1) % totalCount);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlighted((h) => (h - 1 + totalCount) % totalCount);
|
||||
} else if (e.key === 'Enter' && results) {
|
||||
e.preventDefault();
|
||||
if (highlighted < deviceCount) {
|
||||
goToResult('device', results.devices[highlighted].id);
|
||||
} else if (highlighted < deviceCount + clientCount) {
|
||||
goToResult('client', results.clients[highlighted - deviceCount].mac_address);
|
||||
} else {
|
||||
goToResult('event', results.events[highlighted - deviceCount - clientCount].id);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
const hasResults = totalCount > 0;
|
||||
const showDropdown = open && query.length >= 2;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex-1 max-w-lg">
|
||||
{/* Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-slate-500 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search devices, clients, events… (Ctrl+K)"
|
||||
className="w-full pl-9 pr-8 py-1.5 text-sm bg-gray-100 dark:bg-slate-700/80 border border-transparent focus:border-blue-500 focus:bg-white dark:focus:bg-slate-800 rounded-lg outline-none transition-colors text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-slate-500"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => { setQuery(''); setResults(null); inputRef.current?.focus(); }}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1.5 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl shadow-2xl z-50 overflow-hidden max-h-[500px] flex flex-col">
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{loading && (
|
||||
<div className="px-4 py-6 text-sm text-center text-gray-400 dark:text-slate-500">
|
||||
Searching…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !hasResults && (
|
||||
<div className="px-4 py-8 text-sm text-center text-gray-400 dark:text-slate-500">
|
||||
No results for <span className="font-medium text-gray-600 dark:text-slate-300">"{query}"</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && hasResults && results && (
|
||||
<>
|
||||
{/* ── Devices ── */}
|
||||
{results.devices.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pt-3 pb-1.5 text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
Devices
|
||||
</div>
|
||||
{results.devices.map((d, i) => (
|
||||
<button
|
||||
key={d.id}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
|
||||
highlighted === i
|
||||
? 'bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50'
|
||||
)}
|
||||
onMouseEnter={() => setHighlighted(i)}
|
||||
onClick={() => goToResult('device', d.id)}
|
||||
>
|
||||
<div className={clsx(
|
||||
'w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||
d.status === 'online'
|
||||
? 'bg-green-100 dark:bg-green-900/30'
|
||||
: 'bg-gray-100 dark:bg-slate-700'
|
||||
)}>
|
||||
<Router className={clsx(
|
||||
'w-3.5 h-3.5',
|
||||
d.status === 'online' ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-slate-500'
|
||||
)} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{d.name}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 font-mono">
|
||||
{d.ip_address}{d.model ? ` · ${d.model}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'text-xs px-1.5 py-0.5 rounded font-medium flex-shrink-0',
|
||||
d.status === 'online'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-slate-700 dark:text-slate-400'
|
||||
)}>
|
||||
{d.status}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Clients ── */}
|
||||
{results.clients.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pt-3 pb-1.5 text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
Clients
|
||||
</div>
|
||||
{results.clients.map((c, i) => (
|
||||
<button
|
||||
key={c.mac_address}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
|
||||
highlighted === deviceCount + i
|
||||
? 'bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50'
|
||||
)}
|
||||
onMouseEnter={() => setHighlighted(deviceCount + i)}
|
||||
onClick={() => goToResult('client', c.mac_address)}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<Wifi className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{c.hostname || c.mac_address}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 font-mono">
|
||||
{c.hostname ? `${c.mac_address} · ` : ''}
|
||||
{c.ip_address ? `${c.ip_address} · ` : ''}
|
||||
{c.device_name || ''}
|
||||
</div>
|
||||
</div>
|
||||
{!c.active && (
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500 flex-shrink-0">inactive</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Events ── */}
|
||||
{results.events.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pt-3 pb-1.5 text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
Events
|
||||
</div>
|
||||
{results.events.map((ev, i) => (
|
||||
<button
|
||||
key={ev.id}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
|
||||
highlighted === deviceCount + clientCount + i
|
||||
? 'bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50'
|
||||
)}
|
||||
onMouseEnter={() => setHighlighted(deviceCount + clientCount + i)}
|
||||
onClick={() => goToResult('event', ev.id)}
|
||||
>
|
||||
<div className={clsx(
|
||||
'w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||
SEVERITY_ICON_CLS[ev.severity] || SEVERITY_ICON_CLS.info
|
||||
)}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{ev.message}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">
|
||||
{ev.device_name ? `${ev.device_name} · ` : ''}
|
||||
{format(new Date(ev.event_time), 'MMM d, HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'text-xs px-1.5 py-0.5 rounded font-medium flex-shrink-0',
|
||||
SEVERITY_ICON_CLS[ev.severity] || SEVERITY_ICON_CLS.info
|
||||
)}>
|
||||
{ev.severity}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer keyboard hints */}
|
||||
{hasResults && (
|
||||
<div className="flex-shrink-0 px-3 py-2 border-t border-gray-100 dark:border-slate-700 flex items-center gap-4 text-xs text-gray-400 dark:text-slate-500">
|
||||
<span><kbd className="px-1 py-0.5 bg-gray-100 dark:bg-slate-700 rounded text-xs font-sans">↑↓</kbd> navigate</span>
|
||||
<span><kbd className="px-1 py-0.5 bg-gray-100 dark:bg-slate-700 rounded text-xs font-sans">↵</kbd> open</span>
|
||||
<span><kbd className="px-1 py-0.5 bg-gray-100 dark:bg-slate-700 rounded text-xs font-sans">Esc</kbd> close</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive,
|
||||
Settings, Network, ChevronLeft, ChevronRight, Layers, ChevronDown, SlidersHorizontal, X, Wifi,
|
||||
Server, Globe, Clock, Shield,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { APP_VERSION } from '../../version';
|
||||
import { devicesApi } from '../../services/api';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/devices', icon: Router, label: 'Devices' },
|
||||
{ to: '/clients', icon: Users, label: 'Clients' },
|
||||
{ to: '/events', icon: Bell, label: 'Events' },
|
||||
{ to: '/topology', icon: GitBranch, label: 'Topology' },
|
||||
{ to: '/backups', icon: HardDrive, label: 'Backups' },
|
||||
];
|
||||
|
||||
const switchSubItems = [
|
||||
{ to: '/switches', icon: Layers, label: 'Overview' },
|
||||
{ to: '/switches/settings', icon: SlidersHorizontal, label: 'Settings' },
|
||||
];
|
||||
|
||||
const routerSubItems = [
|
||||
{ to: '/routers', icon: Router, label: 'Overview' },
|
||||
{ to: '/routers/settings', icon: SlidersHorizontal, label: 'Settings' },
|
||||
];
|
||||
|
||||
const wirelessSubItems = [
|
||||
{ to: '/wireless', icon: Wifi, label: 'Overview' },
|
||||
{ to: '/wireless/clients', icon: Users, label: 'Clients' },
|
||||
{ to: '/wireless/settings', icon: SlidersHorizontal, label: 'Settings' },
|
||||
];
|
||||
|
||||
const networkServicesSubItems = [
|
||||
{ to: '/network-services', icon: Network, label: 'Overview' },
|
||||
{ to: '/network-services/dhcp', icon: Server, label: 'DHCP' },
|
||||
{ to: '/network-services/dns', icon: Globe, label: 'DNS' },
|
||||
{ to: '/network-services/ntp', icon: Clock, label: 'NTP' },
|
||||
{ to: '/network-services/wireguard', icon: Shield, label: 'WireGuard' },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
mobileOpen: boolean;
|
||||
onMobileClose: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const hasSwitches = devices.some(d => d.device_type === 'switch');
|
||||
const hasRouters = devices.some(d => d.device_type === 'router');
|
||||
const hasWireless = devices.some(d => d.device_type === 'wireless_ap');
|
||||
|
||||
const switchesActive = location.pathname.startsWith('/switches');
|
||||
const [switchesOpen, setSwitchesOpen] = useState(switchesActive);
|
||||
const effectiveSwitchesOpen = switchesOpen || switchesActive;
|
||||
|
||||
const routersActive = location.pathname.startsWith('/routers');
|
||||
const [routersOpen, setRoutersOpen] = useState(routersActive);
|
||||
const effectiveRoutersOpen = routersOpen || routersActive;
|
||||
|
||||
const wirelessActive = location.pathname.startsWith('/wireless');
|
||||
const [wirelessOpen, setWirelessOpen] = useState(wirelessActive);
|
||||
const effectiveWirelessOpen = wirelessOpen || wirelessActive;
|
||||
|
||||
const networkServicesActive = location.pathname.startsWith('/network-services');
|
||||
const [networkServicesOpen, setNetworkServicesOpen] = useState(networkServicesActive);
|
||||
const effectiveNetworkServicesOpen = networkServicesOpen || networkServicesActive;
|
||||
|
||||
// On mobile the sidebar is always expanded (never icon-only)
|
||||
const isCollapsed = collapsed;
|
||||
|
||||
const handleNavClick = () => {
|
||||
// Close mobile drawer when navigating
|
||||
onMobileClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
// Base styles
|
||||
'flex-shrink-0 bg-slate-100 dark:bg-slate-900 border-r border-gray-200 dark:border-slate-700 flex flex-col transition-all duration-200',
|
||||
// Mobile: fixed drawer that slides in/out
|
||||
'fixed inset-y-0 left-0 z-50',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
// Desktop: static in flex layout, normal collapse behaviour
|
||||
'md:relative md:translate-x-0',
|
||||
isCollapsed ? 'md:w-14' : 'md:w-56',
|
||||
// Mobile always full width
|
||||
'w-64',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center border-b border-gray-200 dark:border-slate-700 py-5',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-4 gap-3' : 'gap-3 px-4'
|
||||
)}
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Network className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
{/* Always show text on mobile; respect collapsed on desktop */}
|
||||
<div className={clsx(isCollapsed && 'md:hidden')}>
|
||||
<div className="text-sm font-bold text-gray-900 dark:text-white leading-none">Mikrotik</div>
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400 mt-0.5 leading-none">Manager</div>
|
||||
<div className="text-[10px] text-gray-400 dark:text-slate-500 mt-1 leading-none">{APP_VERSION}</div>
|
||||
</div>
|
||||
{/* Mobile close button */}
|
||||
<button
|
||||
onClick={onMobileClose}
|
||||
className="ml-auto p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-slate-300 md:hidden"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 py-3 space-y-0.5 overflow-y-auto">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
title={isCollapsed ? label : undefined}
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-3 py-2.5 gap-3' : 'gap-3 px-3 py-2.5',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className={clsx(isCollapsed && 'md:hidden')}>{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* Switches group */}
|
||||
{hasSwitches && (
|
||||
isCollapsed ? (
|
||||
<NavLink
|
||||
to="/switches"
|
||||
title="Switches"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
isActive || switchesActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Layers className="w-4 h-4 flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setSwitchesOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
switchesActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Layers className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Switches</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveSwitchesOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
{effectiveSwitchesOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{switchSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Routers group */}
|
||||
{hasRouters && (
|
||||
isCollapsed ? (
|
||||
<NavLink
|
||||
to="/routers"
|
||||
title="Routers"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
isActive || routersActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Router className="w-4 h-4 flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setRoutersOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
routersActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Router className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Routers</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveRoutersOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
{effectiveRoutersOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{routerSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Wireless group */}
|
||||
{hasWireless && (
|
||||
isCollapsed ? (
|
||||
<NavLink
|
||||
to="/wireless"
|
||||
title="Wireless"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
isActive || wirelessActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Wifi className="w-4 h-4 flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setWirelessOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
wirelessActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Wifi className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Wireless</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveWirelessOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
{effectiveWirelessOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{wirelessSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{/* Network Services group — always shown */}
|
||||
{isCollapsed ? (
|
||||
<NavLink
|
||||
to="/network-services"
|
||||
title="Network Services"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
isActive || networkServicesActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Network className="w-4 h-4 flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setNetworkServicesOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
networkServicesActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Network className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Network Services</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveNetworkServicesOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
{effectiveNetworkServicesOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{networkServicesSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Settings + collapse toggle */}
|
||||
<div className="px-2 py-3 border-t border-gray-200 dark:border-slate-700 space-y-0.5">
|
||||
<NavLink
|
||||
to="/settings"
|
||||
title={isCollapsed ? 'Settings' : undefined}
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-3 py-2.5 gap-3' : 'gap-3 px-3 py-2.5',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||
<span className={clsx(isCollapsed && 'md:hidden')}>Settings</span>
|
||||
</NavLink>
|
||||
|
||||
{/* Collapse toggle — desktop only */}
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
className={clsx(
|
||||
'hidden md:flex w-full items-center rounded-lg text-sm font-medium transition-colors duration-150 text-gray-500 dark:text-slate-500 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-700 dark:hover:text-slate-300',
|
||||
isCollapsed ? 'justify-center px-2 py-2.5' : 'gap-3 px-3 py-2.5'
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<><ChevronLeft className="w-4 h-4 flex-shrink-0" />Collapse</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Sun, Moon, LogOut, User, Menu } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
|
||||
interface TopBarProps {
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
export default function TopBar({ onMenuClick }: TopBarProps) {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { theme, toggleTheme } = useThemeStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-14 flex-shrink-0 flex items-center gap-2 sm:gap-4 px-3 sm:px-6 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
|
||||
{/* Hamburger — mobile only */}
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="md:hidden p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors flex-shrink-0"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<GlobalSearch />
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0 ml-auto">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<Moon className="w-4 h-4" />
|
||||
) : (
|
||||
<Sun className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* User info — hide label on very small screens */}
|
||||
<div className="flex items-center gap-2 px-2 sm:px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-slate-700">
|
||||
<User className="w-3.5 h-3.5 text-gray-500 dark:text-slate-400 flex-shrink-0" />
|
||||
<span className="hidden sm:inline text-sm font-medium text-gray-700 dark:text-slate-200">
|
||||
{user?.username}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
export function useCanWrite(): boolean {
|
||||
const user = useAuthStore((state) => state.user);
|
||||
return user?.role !== 'viewer';
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
let globalSocket: Socket | null = null;
|
||||
|
||||
export function getSocket(): Socket | null {
|
||||
return globalSocket;
|
||||
}
|
||||
|
||||
export function useSocket(
|
||||
events: Record<string, (data: unknown) => void>
|
||||
): void {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const eventsRef = useRef(events);
|
||||
eventsRef.current = events;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
if (!globalSocket) {
|
||||
globalSocket = io('/', {
|
||||
path: '/socket.io',
|
||||
transports: ['websocket'],
|
||||
reconnection: true,
|
||||
reconnectionDelay: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
const socket = globalSocket;
|
||||
const handlers: Array<[string, (d: unknown) => void]> = [];
|
||||
|
||||
for (const [event, handler] of Object.entries(eventsRef.current)) {
|
||||
const wrapped = (data: unknown) => handler(data);
|
||||
socket.on(event, wrapped);
|
||||
handlers.push([event, wrapped]);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const [event, handler] of handlers) {
|
||||
socket.off(event, handler);
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated]);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-bg: theme('colors.white');
|
||||
--color-surface: theme('colors.gray.50');
|
||||
--color-border: theme('colors.gray.200');
|
||||
--color-text: theme('colors.gray.900');
|
||||
--color-text-muted: theme('colors.gray.500');
|
||||
--color-sidebar-bg: theme('colors.slate.100');
|
||||
--color-sidebar-text: theme('colors.slate.700');
|
||||
--color-sidebar-active: theme('colors.blue.600');
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-bg: #0f172a;
|
||||
--color-surface: #1e293b;
|
||||
--color-border: #334155;
|
||||
--color-text: #f1f5f9;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-sidebar-bg: #0f172a;
|
||||
--color-sidebar-text: #cbd5e1;
|
||||
--color-sidebar-active: #3b82f6;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-100;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-gray-200 dark:border-slate-700;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply w-1.5 h-1.5;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 dark:bg-slate-600 rounded-full;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl shadow-sm;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium
|
||||
transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-100 hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600
|
||||
text-gray-700 dark:text-slate-200 px-4 py-2 rounded-lg text-sm font-medium
|
||||
transition-colors duration-150;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg text-sm font-medium
|
||||
transition-colors duration-150;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-3 py-2 bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600
|
||||
rounded-lg text-sm text-gray-900 dark:text-slate-100 placeholder-gray-400
|
||||
dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
focus:border-transparent;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@apply text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
@apply bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400;
|
||||
}
|
||||
.status-offline {
|
||||
@apply bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400;
|
||||
}
|
||||
.status-unknown {
|
||||
@apply bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400;
|
||||
}
|
||||
|
||||
.severity-error {
|
||||
@apply bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400;
|
||||
}
|
||||
.severity-warning {
|
||||
@apply bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400;
|
||||
}
|
||||
.severity-info {
|
||||
@apply bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400;
|
||||
}
|
||||
|
||||
/* Zebra striping — uses :where() so explicit bg classes on <tr> always win */
|
||||
:where(.table-zebra > tr:nth-child(even)) {
|
||||
@apply bg-gray-50 dark:bg-slate-700/30;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
// Initialize theme from storage immediately
|
||||
const storedTheme = (() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('mikrotik-theme');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.state?.theme;
|
||||
}
|
||||
} catch {}
|
||||
return 'light';
|
||||
})();
|
||||
if (storedTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { HardDrive, Plus, Download, RotateCcw, Trash2, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { backupsApi, devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import { format } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function formatBytes(bytes?: number): string {
|
||||
if (!bytes) return '—';
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
export default function BackupsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const [selectedDevice, setSelectedDevice] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [restoreConfirm, setRestoreConfirm] = useState<number | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const { data: backups = [], isLoading } = useQuery({
|
||||
queryKey: ['backups'],
|
||||
queryFn: () => backupsApi.list().then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then((r) => r.data),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => backupsApi.create(parseInt(selectedDevice), notes || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
||||
setShowCreateForm(false);
|
||||
setSelectedDevice('');
|
||||
setNotes('');
|
||||
setError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Backup failed');
|
||||
},
|
||||
});
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (id: number) => backupsApi.restore(id),
|
||||
onSuccess: () => setRestoreConfirm(null),
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Restore failed');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => backupsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['backups'] });
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
const downloadBackup = async (id: number, filename: string) => {
|
||||
const res = await backupsApi.download(id);
|
||||
const url = URL.createObjectURL(res.data as Blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Backups
|
||||
{backups.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-slate-400">
|
||||
({backups.length} total)
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Backup
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create backup form */}
|
||||
{showCreateForm && (
|
||||
<div className="card p-5 border-blue-200 dark:border-blue-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-4">New Backup</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Device</label>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedDevice}
|
||||
onChange={(e) => setSelectedDevice(e.target.value)}
|
||||
>
|
||||
<option value="">Select a device...</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name} ({d.ip_address})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Notes (optional)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Pre-upgrade backup..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<button
|
||||
onClick={() => { setShowCreateForm(false); setError(''); }}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!selectedDevice || createMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<HardDrive className="w-4 h-4" />
|
||||
)}
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Backup'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-2">
|
||||
Backup uses SSH export. Make sure SSH credentials are configured for this device.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48 text-gray-400">Loading...</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="card p-12 flex flex-col items-center gap-4 text-center">
|
||||
<HardDrive className="w-16 h-16 text-gray-300 dark:text-slate-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 dark:text-slate-300">No backups yet</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
Create a backup to save your device configurations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Device</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Filename</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Size</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Created</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Notes</th>
|
||||
<th className="table-header px-4 py-2.5 text-left w-32">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{backups.map((backup) => (
|
||||
<tr key={backup.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{backup.device_name || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{backup.filename}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
||||
{formatBytes(backup.size_bytes)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
||||
{format(new Date(backup.created_at), 'MMM d, yyyy HH:mm')}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500 italic">
|
||||
{backup.notes || ''}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => downloadBackup(backup.id, backup.filename)}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{canWrite && (restoreConfirm === backup.id ? (
|
||||
<div className="flex gap-1 text-xs">
|
||||
<button
|
||||
onClick={() => restoreMutation.mutate(backup.id)}
|
||||
disabled={restoreMutation.isPending}
|
||||
className="text-orange-500 hover:text-orange-600 font-medium"
|
||||
>
|
||||
{restoreMutation.isPending ? '...' : 'Restore?'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setRestoreConfirm(backup.id)}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-orange-500 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-colors"
|
||||
title="Restore"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{canWrite && (deleteConfirm === backup.id ? (
|
||||
<div className="flex gap-1 text-xs">
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(backup.id)}
|
||||
className="text-red-500 hover:text-red-600 font-medium"
|
||||
>
|
||||
Delete?
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(backup.id)}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft, Wifi, Network, Router, Layers, Activity,
|
||||
Power, Save, Clock, ChevronRight, AlertCircle, Pencil, X,
|
||||
} from 'lucide-react';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
BarChart, Bar, Cell, ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { format, formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import { clientsApi } from '../services/api';
|
||||
import type { ClientDetail } from '../services/api';
|
||||
import type { SignalPoint } from '../services/api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function signalColor(dbm: number | null): string {
|
||||
if (dbm === null) return 'text-gray-400 dark:text-slate-500';
|
||||
if (dbm >= -55) return 'text-green-600 dark:text-green-400';
|
||||
if (dbm >= -65) return 'text-lime-600 dark:text-lime-400';
|
||||
if (dbm >= -75) return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-red-500 dark:text-red-400';
|
||||
}
|
||||
|
||||
function signalLabel(dbm: number | null): string {
|
||||
if (dbm === null) return '—';
|
||||
if (dbm >= -55) return 'Excellent';
|
||||
if (dbm >= -65) return 'Good';
|
||||
if (dbm >= -75) return 'Fair';
|
||||
return 'Poor';
|
||||
}
|
||||
|
||||
function deviceIcon(type?: string) {
|
||||
if (type === 'wireless_ap') return Wifi;
|
||||
if (type === 'switch') return Layers;
|
||||
return Router;
|
||||
}
|
||||
|
||||
// ─── Connectivity Timeline ────────────────────────────────────────────────────
|
||||
|
||||
function ConnectivityTimeline({ mac }: { mac: string }) {
|
||||
const [range, setRange] = useState<'2h' | '24h' | '7d'>('24h');
|
||||
|
||||
const { data: points = [], isLoading } = useQuery({
|
||||
queryKey: ['client-presence', mac, range],
|
||||
queryFn: () => clientsApi.getPresence(mac, range).then(r => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const ranges = [
|
||||
{ value: '2h', label: '2h' },
|
||||
{ value: '24h', label: '24h' },
|
||||
{ value: '7d', label: '7d' },
|
||||
] as const;
|
||||
|
||||
const onlineCount = points.filter(p => p.online > 0).length;
|
||||
const uptimePct = points.length > 0 ? Math.round((onlineCount / points.length) * 100) : null;
|
||||
|
||||
return (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-white">Connectivity</h2>
|
||||
{uptimePct !== null && (
|
||||
<span className={clsx(
|
||||
'text-xs font-medium px-1.5 py-0.5 rounded',
|
||||
uptimePct >= 90
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: uptimePct >= 70
|
||||
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||
)}>
|
||||
{uptimePct}% uptime
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => setRange(r.value)}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded font-medium transition-colors',
|
||||
range === r.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-8 bg-gray-100 dark:bg-slate-700 rounded animate-pulse" />
|
||||
) : points.length === 0 ? (
|
||||
<div className="h-8 flex items-center justify-center text-xs text-gray-400 dark:text-slate-500 bg-gray-50 dark:bg-slate-800 rounded">
|
||||
No presence data yet — data accumulates as the client is polled
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex h-8 rounded overflow-hidden gap-px">
|
||||
{points.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
title={`${format(parseISO(p.time), 'MMM d HH:mm')} — ${p.online ? 'Online' : 'Offline'}`}
|
||||
className={clsx(
|
||||
'flex-1 transition-colors',
|
||||
p.online ? 'bg-green-500 dark:bg-green-600' : 'bg-gray-200 dark:bg-slate-700'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-gray-400 dark:text-slate-500">
|
||||
<span>{points.length > 0 ? format(parseISO(points[0].time), range === '7d' ? 'MMM d' : 'HH:mm') : ''}</span>
|
||||
<span>now</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-gray-500 dark:text-slate-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-sm bg-green-500 inline-block" />
|
||||
Online
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-sm bg-gray-200 dark:bg-slate-700 inline-block" />
|
||||
Offline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Connection Diagram ───────────────────────────────────────────────────────
|
||||
|
||||
function ConnectionDiagram({ client }: { client: ClientDetail }) {
|
||||
const ClientIcon = client.client_type === 'wireless' ? Wifi : Network;
|
||||
const DeviceIcon = deviceIcon(client.device_type);
|
||||
const UpstreamIcon = client.upstream_device_type ? deviceIcon(client.upstream_device_type) : Router;
|
||||
|
||||
const clientName = client.custom_name || client.hostname || client.mac_address;
|
||||
|
||||
return (
|
||||
<div className="card p-4 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
Connection Path
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Client node */}
|
||||
<div className="flex flex-col items-center gap-1 min-w-[80px]">
|
||||
<div className={clsx(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center',
|
||||
client.active
|
||||
? 'bg-green-100 dark:bg-green-900/30'
|
||||
: 'bg-gray-100 dark:bg-slate-700'
|
||||
)}>
|
||||
<ClientIcon className={clsx(
|
||||
'w-5 h-5',
|
||||
client.active ? 'text-green-600 dark:text-green-400' : 'text-gray-400'
|
||||
)} />
|
||||
</div>
|
||||
<span className="text-[11px] text-center text-gray-700 dark:text-slate-300 font-medium max-w-[90px] truncate">
|
||||
{clientName}
|
||||
</span>
|
||||
{client.ip_address && (
|
||||
<span className="text-[10px] font-mono text-gray-400 dark:text-slate-500">
|
||||
{client.ip_address}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 dark:text-slate-600 flex-shrink-0" />
|
||||
|
||||
{/* Connected device (AP/switch) */}
|
||||
<div className="flex flex-col items-center gap-1 min-w-[80px]">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<DeviceIcon className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<span className="text-[11px] text-center text-gray-700 dark:text-slate-300 font-medium max-w-[90px] truncate">
|
||||
{client.device_name || 'Unknown'}
|
||||
</span>
|
||||
{client.ssid && (
|
||||
<span className="text-[10px] text-blue-500 dark:text-blue-400 font-medium">
|
||||
{client.ssid}
|
||||
</span>
|
||||
)}
|
||||
{client.interface_name && !client.ssid && (
|
||||
<span className="text-[10px] font-mono text-gray-400 dark:text-slate-500">
|
||||
{client.interface_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upstream device (if known) */}
|
||||
{client.upstream_device_name && (
|
||||
<>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 dark:text-slate-600 flex-shrink-0" />
|
||||
<div className="flex flex-col items-center gap-1 min-w-[80px]">
|
||||
<div className="w-10 h-10 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<UpstreamIcon className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<span className="text-[11px] text-center text-gray-700 dark:text-slate-300 font-medium max-w-[90px] truncate">
|
||||
{client.upstream_device_name}
|
||||
</span>
|
||||
{client.upstream_device_ip && (
|
||||
<span className="text-[10px] font-mono text-gray-400 dark:text-slate-500">
|
||||
{client.upstream_device_ip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Client Details Card ──────────────────────────────────────────────────────
|
||||
|
||||
function ClientDetailsCard({ client, canWrite }: { client: ClientDetail; canWrite: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [notes, setNotes] = useState(client.comment || '');
|
||||
const [notesChanged, setNotesChanged] = useState(false);
|
||||
const [wolStatus, setWolStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameValue, setNameValue] = useState(client.custom_name || '');
|
||||
|
||||
const nameMutation = useMutation({
|
||||
mutationFn: () => clientsApi.updateHostname(client.mac_address, nameValue),
|
||||
onSuccess: () => {
|
||||
// Invalidate everywhere names are shown
|
||||
qc.invalidateQueries({ queryKey: ['client-detail', client.mac_address] });
|
||||
qc.invalidateQueries({ queryKey: ['clients'] });
|
||||
qc.invalidateQueries({ queryKey: ['wireless-clients-page'] });
|
||||
setEditingName(false);
|
||||
},
|
||||
});
|
||||
|
||||
const notesMutation = useMutation({
|
||||
mutationFn: () => clientsApi.updateNotes(client.mac_address, notes),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['client-detail', client.mac_address] });
|
||||
setNotesChanged(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleWol = async () => {
|
||||
setWolStatus('sending');
|
||||
try {
|
||||
await clientsApi.wol(client.mac_address);
|
||||
setWolStatus('sent');
|
||||
setTimeout(() => setWolStatus('idle'), 3000);
|
||||
} catch {
|
||||
setWolStatus('error');
|
||||
setTimeout(() => setWolStatus('idle'), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const DetailRow = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||
<div className="flex items-start justify-between gap-4 py-2 border-b border-gray-100 dark:border-slate-700/50 last:border-0">
|
||||
<span className="text-xs text-gray-500 dark:text-slate-400 shrink-0 pt-0.5">{label}</span>
|
||||
<div className="text-xs text-gray-800 dark:text-slate-200 text-right">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card p-4 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-white">Client Details</h2>
|
||||
|
||||
<div className="space-y-0">
|
||||
{/* Editable name row */}
|
||||
<div className="flex items-start justify-between gap-4 py-2 border-b border-gray-100 dark:border-slate-700/50">
|
||||
<span className="text-xs text-gray-500 dark:text-slate-400 shrink-0 pt-0.5">Name</span>
|
||||
<div className="flex-1 flex items-center justify-end gap-1 min-w-0">
|
||||
{editingName ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
className="flex-1 min-w-0 rounded border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs text-gray-800 dark:text-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={client.hostname || 'e.g. Office Printer'}
|
||||
value={nameValue}
|
||||
onChange={e => setNameValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') nameMutation.mutate(); if (e.key === 'Escape') setEditingName(false); }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => nameMutation.mutate()}
|
||||
disabled={nameMutation.isPending}
|
||||
className="p-1 rounded text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30"
|
||||
title="Save"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingName(false); setNameValue(client.custom_name || ''); }}
|
||||
className="p-1 rounded text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-700"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs text-gray-800 dark:text-slate-200 truncate">
|
||||
{client.custom_name || client.hostname || (
|
||||
<span className="italic text-gray-400 dark:text-slate-500">No name</span>
|
||||
)}
|
||||
{client.custom_name && (
|
||||
<span className="ml-1.5 text-[10px] text-blue-500">custom</span>
|
||||
)}
|
||||
</span>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => setEditingName(true)}
|
||||
className="p-1 rounded text-gray-300 dark:text-slate-600 hover:text-gray-500 dark:hover:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 flex-shrink-0"
|
||||
title="Edit name"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{nameMutation.isError && (
|
||||
<p className="text-[11px] text-red-500 pb-1">Failed to save name.</p>
|
||||
)}
|
||||
|
||||
<DetailRow label="Status">
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[11px] font-medium',
|
||||
client.active
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400'
|
||||
)}>
|
||||
<span className={clsx(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
client.active ? 'bg-green-500' : 'bg-gray-400'
|
||||
)} />
|
||||
{client.active ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow label="IP Address">
|
||||
<span className="font-mono">{client.ip_address || '—'}</span>
|
||||
</DetailRow>
|
||||
|
||||
<DetailRow label="MAC Address">
|
||||
<span className="font-mono">{client.mac_address}</span>
|
||||
</DetailRow>
|
||||
|
||||
{client.vendor && (
|
||||
<DetailRow label="Vendor">{client.vendor}</DetailRow>
|
||||
)}
|
||||
|
||||
<DetailRow label="Type">
|
||||
<span className={clsx(
|
||||
'capitalize',
|
||||
client.client_type === 'wireless' ? 'text-purple-600 dark:text-purple-400' : 'text-blue-600 dark:text-blue-400'
|
||||
)}>
|
||||
{client.client_type || '—'}
|
||||
</span>
|
||||
</DetailRow>
|
||||
|
||||
{client.signal_strength != null && (
|
||||
<DetailRow label="Signal">
|
||||
<span className={clsx('font-medium', signalColor(client.signal_strength))}>
|
||||
{client.signal_strength} dBm
|
||||
<span className="ml-1 font-normal opacity-75">({signalLabel(client.signal_strength)})</span>
|
||||
</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
{client.vlan_id != null && (
|
||||
<DetailRow label="VLAN">
|
||||
<span className="font-mono">
|
||||
{client.vlan_id}
|
||||
{client.vlan_name && (
|
||||
<span className="ml-1.5 font-sans text-gray-500 dark:text-slate-400">({client.vlan_name})</span>
|
||||
)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
|
||||
<DetailRow label="Interface">
|
||||
<span className="font-mono">{client.interface_name || '—'}</span>
|
||||
</DetailRow>
|
||||
|
||||
{client.ssid && (
|
||||
<DetailRow label="SSID">{client.ssid}</DetailRow>
|
||||
)}
|
||||
|
||||
<DetailRow label="Last Seen">
|
||||
{client.last_seen
|
||||
? formatDistanceToNow(new Date(client.last_seen), { addSuffix: true })
|
||||
: '—'}
|
||||
</DetailRow>
|
||||
</div>
|
||||
|
||||
{/* Wake-on-LAN */}
|
||||
{canWrite && (
|
||||
<div className="pt-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-slate-300">Wake on LAN</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleWol}
|
||||
disabled={wolStatus === 'sending'}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
|
||||
wolStatus === 'sent'
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: wolStatus === 'error'
|
||||
? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400'
|
||||
: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-900/50'
|
||||
)}
|
||||
>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
{wolStatus === 'sending' ? 'Sending…' : wolStatus === 'sent' ? 'Packet sent!' : wolStatus === 'error' ? 'Failed to send' : 'Send magic packet'}
|
||||
</button>
|
||||
<p className="text-[10px] text-gray-400 dark:text-slate-500 mt-1">
|
||||
Sends a UDP broadcast magic packet to wake this client.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div className="pt-1">
|
||||
<label className="text-xs font-medium text-gray-700 dark:text-slate-300 block mb-1.5">Notes</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
readOnly={!canWrite}
|
||||
className={clsx(
|
||||
'w-full rounded-lg border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs text-gray-800 dark:text-slate-200 px-3 py-2 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder-gray-300 dark:placeholder-slate-600',
|
||||
!canWrite && 'opacity-60 cursor-not-allowed'
|
||||
)}
|
||||
placeholder={canWrite ? 'Add notes about this client…' : 'No notes'}
|
||||
value={notes}
|
||||
onChange={e => { if (canWrite) { setNotes(e.target.value); setNotesChanged(true); } }}
|
||||
/>
|
||||
{canWrite && notesChanged && (
|
||||
<button
|
||||
onClick={() => notesMutation.mutate()}
|
||||
disabled={notesMutation.isPending}
|
||||
className="mt-1.5 flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{notesMutation.isPending ? 'Saving…' : 'Save notes'}
|
||||
</button>
|
||||
)}
|
||||
{notesMutation.isError && (
|
||||
<p className="mt-1 text-[11px] text-red-500">Failed to save notes.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Traffic Graph ────────────────────────────────────────────────────────────
|
||||
|
||||
function TrafficCard({ mac }: { mac: string }) {
|
||||
const [range, setRange] = useState<'2h' | '24h' | '7d'>('24h');
|
||||
|
||||
const { data: points = [], isLoading } = useQuery({
|
||||
queryKey: ['client-traffic', mac, range],
|
||||
queryFn: () => clientsApi.getTraffic(mac, range).then(r => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const ranges = [
|
||||
{ value: '2h', label: '2h' },
|
||||
{ value: '24h', label: '24h' },
|
||||
{ value: '7d', label: '7d' },
|
||||
] as const;
|
||||
|
||||
const chartData = points.map(p => ({
|
||||
time: p.time,
|
||||
Upload: p.tx_bytes,
|
||||
Download: p.rx_bytes,
|
||||
label: format(parseISO(p.time), range === '7d' ? 'MMM d HH:mm' : 'HH:mm'),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
Wireless Usage
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => setRange(r.value)}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded font-medium transition-colors',
|
||||
range === r.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-48 bg-gray-100 dark:bg-slate-700/40 rounded animate-pulse" />
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-48 flex flex-col items-center justify-center gap-2 text-gray-400 dark:text-slate-500 text-xs bg-gray-50 dark:bg-slate-800/50 rounded">
|
||||
<Activity className="w-6 h-6 opacity-30" />
|
||||
No traffic data yet for this range
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 10, fill: 'currentColor' }}
|
||||
className="text-gray-400 dark:text-slate-500"
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatBytes(v)}
|
||||
tick={{ fontSize: 10, fill: 'currentColor' }}
|
||||
className="text-gray-400 dark:text-slate-500"
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [formatBytes(value), '']}
|
||||
contentStyle={{
|
||||
fontSize: '11px',
|
||||
backgroundColor: 'var(--tooltip-bg, #1e293b)',
|
||||
borderColor: 'var(--tooltip-border, #334155)',
|
||||
color: 'var(--tooltip-color, #e2e8f0)',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Upload"
|
||||
stroke="#3b82f6"
|
||||
fill="#3b82f620"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="Download"
|
||||
stroke="#8b5cf6"
|
||||
fill="#8b5cf620"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Signal Strength Graph ────────────────────────────────────────────────────
|
||||
|
||||
function qualityColor(dbm: number): string {
|
||||
if (dbm >= -55) return '#22c55e'; // green-500
|
||||
if (dbm >= -65) return '#84cc16'; // lime-500
|
||||
if (dbm >= -75) return '#eab308'; // yellow-500
|
||||
return '#ef4444'; // red-500
|
||||
}
|
||||
|
||||
function SignalCard({ mac }: { mac: string }) {
|
||||
const [range, setRange] = useState<'2h' | '24h' | '7d'>('24h');
|
||||
|
||||
const { data: raw = [], isLoading } = useQuery({
|
||||
queryKey: ['client-signal', mac, range],
|
||||
queryFn: () => clientsApi.getSignal(mac, range).then(r => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const ranges = [
|
||||
{ value: '2h', label: '2h' },
|
||||
{ value: '24h', label: '24h' },
|
||||
{ value: '7d', label: '7d' },
|
||||
] as const;
|
||||
|
||||
// Bars must grow upward from 0, but dBm values are all negative.
|
||||
// Offset each value so 0 = worst possible signal (-100 dBm), higher = stronger.
|
||||
// Y-axis tickFormatter converts back to real dBm for display.
|
||||
const BASELINE = -100;
|
||||
const chartData = raw.map((p: SignalPoint) => ({
|
||||
...p,
|
||||
label: format(parseISO(p.time), range === '7d' ? 'MMM d HH:mm' : 'HH:mm'),
|
||||
bar_value: p.signal_strength - BASELINE, // always positive
|
||||
}));
|
||||
|
||||
// Reference line Y values are also offset
|
||||
const refExcellent = -55 - BASELINE; // 45
|
||||
const refGood = -65 - BASELINE; // 35
|
||||
const refFair = -75 - BASELINE; // 25
|
||||
|
||||
// Domain: 0 to just above the highest reading
|
||||
const values = chartData.map(p => p.bar_value);
|
||||
const yDomainMax = values.length ? Math.min(BASELINE * -1, Math.max(...values) + 3) : 80;
|
||||
|
||||
return (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
Signal Strength
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => setRange(r.value)}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded font-medium transition-colors',
|
||||
range === r.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-48 bg-gray-100 dark:bg-slate-700/40 rounded animate-pulse" />
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="h-48 flex flex-col items-center justify-center gap-2 text-gray-400 dark:text-slate-500 text-xs bg-gray-50 dark:bg-slate-800/50 rounded">
|
||||
<Activity className="w-6 h-6 opacity-30" />
|
||||
No signal data yet for this range
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} className="opacity-20" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 10, fill: 'currentColor' }}
|
||||
className="text-gray-400 dark:text-slate-500"
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, yDomainMax]}
|
||||
tickFormatter={(v) => `${v + BASELINE}`}
|
||||
tick={{ fontSize: 10, fill: 'currentColor' }}
|
||||
className="text-gray-400 dark:text-slate-500"
|
||||
width={46}
|
||||
unit=" dBm"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(_v: number, _n: string, props: { payload?: { signal_strength: number } }) =>
|
||||
[`${props.payload?.signal_strength ?? ''} dBm`, 'Signal']
|
||||
}
|
||||
contentStyle={{
|
||||
fontSize: '11px',
|
||||
backgroundColor: 'var(--tooltip-bg, #1e293b)',
|
||||
borderColor: 'var(--tooltip-border, #334155)',
|
||||
color: 'var(--tooltip-color, #e2e8f0)',
|
||||
}}
|
||||
/>
|
||||
{/* Quality threshold reference lines (offset to bar scale) */}
|
||||
<ReferenceLine y={refExcellent} stroke="#22c55e" strokeDasharray="3 3" strokeOpacity={0.6}
|
||||
label={{ value: 'Excellent', position: 'insideTopRight', fontSize: 9, fill: '#22c55e' }} />
|
||||
<ReferenceLine y={refGood} stroke="#84cc16" strokeDasharray="3 3" strokeOpacity={0.6}
|
||||
label={{ value: 'Good', position: 'insideTopRight', fontSize: 9, fill: '#84cc16' }} />
|
||||
<ReferenceLine y={refFair} stroke="#eab308" strokeDasharray="3 3" strokeOpacity={0.6}
|
||||
label={{ value: 'Fair', position: 'insideTopRight', fontSize: 9, fill: '#eab308' }} />
|
||||
<Bar dataKey="bar_value" name="Signal" radius={[2, 2, 0, 0]}>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={qualityColor(entry.signal_strength)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex items-center gap-4 text-[10px] text-gray-500 dark:text-slate-400">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-green-500 inline-block" />≥ −55 dBm Excellent</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-lime-500 inline-block" />≥ −65 Good</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-yellow-500 inline-block" />≥ −75 Fair</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-red-500 inline-block" />Poor</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ClientDetailPage() {
|
||||
const { mac } = useParams<{ mac: string }>();
|
||||
const navigate = useNavigate();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data: client, isLoading, isError } = useQuery({
|
||||
queryKey: ['client-detail', mac],
|
||||
queryFn: () => clientsApi.get(mac!).then(r => r.data),
|
||||
enabled: !!mac,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-gray-400">
|
||||
Loading client…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !client) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-3 text-gray-400">
|
||||
<AlertCircle className="w-8 h-8" />
|
||||
<p>Client not found.</p>
|
||||
<button onClick={() => navigate(-1)} className="btn-secondary text-sm">Go back</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = client.custom_name || client.hostname || client.mac_address;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Breadcrumb / back */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-slate-400">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-1 hover:text-gray-700 dark:hover:text-slate-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back
|
||||
</button>
|
||||
<span>/</span>
|
||||
<Link to="/clients" className="hover:text-gray-700 dark:hover:text-slate-200 transition-colors">
|
||||
Clients
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-gray-700 dark:text-slate-200 font-medium truncate">{displayName}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0',
|
||||
client.active
|
||||
? 'bg-green-100 dark:bg-green-900/30'
|
||||
: 'bg-gray-100 dark:bg-slate-700'
|
||||
)}>
|
||||
{client.client_type === 'wireless'
|
||||
? <Wifi className={clsx('w-5 h-5', client.active ? 'text-green-600 dark:text-green-400' : 'text-gray-400')} />
|
||||
: <Network className={clsx('w-5 h-5', client.active ? 'text-green-600 dark:text-green-400' : 'text-gray-400')} />
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{displayName}</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 font-mono">{client.mac_address}</p>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'ml-auto px-2.5 py-1 rounded-full text-xs font-semibold',
|
||||
client.active
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400'
|
||||
)}>
|
||||
{client.active ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connectivity timeline */}
|
||||
<ConnectivityTimeline mac={mac!} />
|
||||
|
||||
{/* Connection diagram */}
|
||||
<ConnectionDiagram client={client} />
|
||||
|
||||
{/* Details on left; Traffic + Signal stacked on right */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-start">
|
||||
<ClientDetailsCard client={client} canWrite={canWrite} />
|
||||
{client.client_type === 'wireless' && (
|
||||
<div className="space-y-4">
|
||||
<TrafficCard mac={mac!} />
|
||||
<SignalCard mac={mac!} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
||||
import { clientsApi } from '../services/api';
|
||||
import type { Client } from '../types';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import { useSocket } from '../hooks/useSocket';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
|
||||
|
||||
function ClientModal({
|
||||
client,
|
||||
onClose,
|
||||
}: {
|
||||
client: Client;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState(client.custom_name || '');
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => clientsApi.updateHostname(client.mac_address, name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Edit Client</h2>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm text-gray-500 dark:text-slate-400 mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span>MAC</span>
|
||||
<span className="font-mono text-gray-800 dark:text-white">{client.mac_address}</span>
|
||||
</div>
|
||||
{client.vendor && (
|
||||
<div className="flex justify-between">
|
||||
<span>Vendor</span>
|
||||
<span className="text-gray-800 dark:text-white">{client.vendor}</span>
|
||||
</div>
|
||||
)}
|
||||
{client.ip_address && (
|
||||
<div className="flex justify-between">
|
||||
<span>IP</span>
|
||||
<span className="font-mono text-gray-800 dark:text-white">{client.ip_address}</span>
|
||||
</div>
|
||||
)}
|
||||
{client.interface_name && (
|
||||
<div className="flex justify-between">
|
||||
<span>Port</span>
|
||||
<span className="font-mono text-gray-800 dark:text-white">{client.interface_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{client.hostname && (
|
||||
<div className="flex justify-between">
|
||||
<span>Discovered Hostname</span>
|
||||
<span className="font-mono text-gray-800 dark:text-white">{client.hostname}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">
|
||||
Custom Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full mb-1"
|
||||
placeholder={client.hostname || 'e.g. Office Printer'}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') mutation.mutate(); }}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mb-4">
|
||||
Overrides the auto-discovered hostname. Persists even when the client is offline.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onClose} className="btn-secondary">Cancel</button>
|
||||
<button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{mutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{mutation.isError && (
|
||||
<p className="mt-2 text-xs text-red-500">Failed to save name.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const canWrite = useCanWrite();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
||||
const [sortCol, setSortCol] = useState<string>('last_seen');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const toggleSort = (col: string) => {
|
||||
if (sortCol === col) setSortDir((d) => d === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortCol(col); setSortDir('asc'); }
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['clients', { search, showAll, page }],
|
||||
queryFn: () =>
|
||||
clientsApi
|
||||
.list({ search: search || undefined, active: showAll ? undefined : true, limit: PAGE_SIZE, offset: page * PAGE_SIZE })
|
||||
.then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const [purgeResult, setPurgeResult] = useState('');
|
||||
const purgeMutation = useMutation({
|
||||
mutationFn: () => clientsApi.purgeStale(),
|
||||
onSuccess: (r) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['clients'] });
|
||||
setPurgeResult(r.data.message);
|
||||
setTimeout(() => setPurgeResult(''), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
useSocket({
|
||||
'clients:updated': () => queryClient.invalidateQueries({ queryKey: ['clients'] }),
|
||||
});
|
||||
|
||||
const rawClients = data?.clients ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const clients = useMemo(() => {
|
||||
const toIpNum = (ip: string) =>
|
||||
(ip || '').split('.').reduce((n, o) => n * 256 + parseInt(o || '0', 10), 0);
|
||||
|
||||
return [...rawClients].sort((a, b) => {
|
||||
let av: string | number, bv: string | number;
|
||||
switch (sortCol) {
|
||||
case 'hostname': av = a.custom_name || a.hostname || ''; bv = b.custom_name || b.hostname || ''; break;
|
||||
case 'vendor': av = a.vendor || ''; bv = b.vendor || ''; break;
|
||||
case 'ip_address': av = toIpNum(a.ip_address || ''); bv = toIpNum(b.ip_address || ''); break;
|
||||
case 'client_type': av = a.client_type || ''; bv = b.client_type || ''; break;
|
||||
case 'interface_name': av = a.interface_name || ''; bv = b.interface_name || ''; break;
|
||||
case 'vlan_id': av = a.vlan_id ?? -1; bv = b.vlan_id ?? -1; break;
|
||||
case 'device_name': av = a.device_name || ''; bv = b.device_name || ''; break;
|
||||
case 'last_seen': av = a.last_seen || ''; bv = b.last_seen || ''; break;
|
||||
default: av = ''; bv = '';
|
||||
}
|
||||
if (av === bv) return 0;
|
||||
const cmp = av < bv ? -1 : 1;
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [rawClients, sortCol, sortDir]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{editingClient && (
|
||||
<ClientModal client={editingClient} onClose={() => setEditingClient(null)} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Clients
|
||||
{total > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-slate-400">
|
||||
({total} {showAll ? 'total' : 'online'})
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<div className="relative w-full sm:flex-1 sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-9"
|
||||
placeholder="Search hostname, MAC, IP, or vendor…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowAll((s) => !s); setPage(0); }}
|
||||
className={clsx(
|
||||
'px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
showAll
|
||||
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border border-amber-300 dark:border-amber-700'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300'
|
||||
)}
|
||||
>
|
||||
{showAll ? 'Active Only' : 'Show Inactive'}
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Purge all inactive clients older than the configured retention period?')) {
|
||||
purgeMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={purgeMutation.isPending}
|
||||
className="btn-secondary flex items-center gap-1.5 text-sm"
|
||||
title="Delete inactive client records older than the retention period (set in Settings)"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Purge Stale
|
||||
</button>
|
||||
)}
|
||||
{purgeResult && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">{purgeResult}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48 text-gray-400">Loading...</div>
|
||||
) : clients.length === 0 ? (
|
||||
<div className="card p-12 flex flex-col items-center gap-3 text-center">
|
||||
<Users className="w-12 h-12 text-gray-300 dark:text-slate-600" />
|
||||
<p className="text-gray-500 dark:text-slate-400">
|
||||
{showAll ? 'No clients found' : 'No active clients detected'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
{([
|
||||
{ col: 'hostname', label: 'Host / Vendor / MAC', align: 'left' },
|
||||
{ col: 'ip_address', label: 'IP Address', align: 'left' },
|
||||
{ col: 'client_type', label: 'Type', align: 'left' },
|
||||
{ col: 'interface_name', label: 'Port', align: 'left' },
|
||||
{ col: 'vlan_id', label: 'VLAN', align: 'left' },
|
||||
{ col: 'device_name', label: 'Device', align: 'left' },
|
||||
{ col: 'last_seen', label: 'Last Seen', align: 'left' },
|
||||
] as { col: string; label: string; align: 'left' | 'right' }[]).map(({ col, label, align }) => (
|
||||
<th
|
||||
key={col}
|
||||
className={clsx('table-header px-4 py-2.5 cursor-pointer select-none whitespace-nowrap', `text-${align}`)}
|
||||
onClick={() => toggleSort(col)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
{sortCol === col ? (
|
||||
sortDir === 'asc'
|
||||
? <ChevronUp className="w-3 h-3 text-blue-500" />
|
||||
: <ChevronDown className="w-3 h-3 text-blue-500" />
|
||||
) : (
|
||||
<ChevronsUpDown className="w-3 h-3 text-gray-300 dark:text-slate-600" />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
{showAll && (
|
||||
<th className="table-header px-4 py-2.5 text-center">Active</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{clients.map((client) => (
|
||||
<tr
|
||||
key={client.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-slate-700/30"
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => navigate(`/clients/${encodeURIComponent(client.mac_address)}`)}
|
||||
className="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left"
|
||||
>
|
||||
{client.custom_name || client.hostname || (
|
||||
<span className="text-gray-400 dark:text-slate-500 italic">No hostname</span>
|
||||
)}
|
||||
</button>
|
||||
{client.custom_name && (
|
||||
<span className="text-xs text-blue-500 dark:text-blue-400 ml-1">custom</span>
|
||||
)}
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setEditingClient(client); }}
|
||||
className="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700"
|
||||
title="Edit name"
|
||||
>
|
||||
<Pencil className="w-3 h-3 text-gray-300 dark:text-slate-600 hover:text-gray-500 dark:hover:text-slate-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{client.vendor && (
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">{client.vendor}</div>
|
||||
)}
|
||||
<div className="text-xs font-mono text-gray-400 dark:text-slate-500">
|
||||
{client.mac_address}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-600 dark:text-slate-400">
|
||||
{client.ip_address || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 text-xs font-medium',
|
||||
client.client_type === 'wireless'
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
)}
|
||||
>
|
||||
{client.client_type === 'wireless' ? (
|
||||
<Wifi className="w-3 h-3" />
|
||||
) : (
|
||||
<Network className="w-3 h-3" />
|
||||
)}
|
||||
{client.client_type}
|
||||
{client.signal_strength != null && (
|
||||
<span className="text-gray-400 ml-1">({client.signal_strength} dBm)</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{client.interface_name || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
{client.vlan_id != null
|
||||
? <span className="px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded font-mono">{client.vlan_id}</span>
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
{client.device_name || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500">
|
||||
{client.last_seen
|
||||
? formatDistanceToNow(new Date(client.last_seen), { addSuffix: true })
|
||||
: '—'}
|
||||
</td>
|
||||
{showAll && (
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<span
|
||||
className={clsx(
|
||||
'w-2 h-2 rounded-full inline-block',
|
||||
client.active ? 'bg-green-500' : 'bg-gray-300 dark:bg-slate-600'
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-slate-400">
|
||||
Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="btn-secondary py-1"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={(page + 1) * PAGE_SIZE >= total}
|
||||
className="btn-secondary py-1"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, Legend,
|
||||
} from 'recharts';
|
||||
import { Router, Users, AlertTriangle, Wifi, Activity, TrendingUp, MapPin, ArrowUpCircle, X, Signal } from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { metricsApi, eventsApi, devicesApi, clientsApi } from '../services/api';
|
||||
import { useSocket } from '../hooks/useSocket';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import type { Device, DeviceEvent } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const ALL_SEVERITIES = ['error', 'warning', 'info'] as const;
|
||||
|
||||
// ─── 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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
// Group devices by exact lat/lng so co-located devices share one marker
|
||||
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;
|
||||
|
||||
// Worst-status color: offline > unknown > online
|
||||
const hasOffline = groupDevices.some((d) => d.status === 'offline');
|
||||
const hasUnknown = groupDevices.some((d) => d.status !== 'online' && d.status !== 'offline');
|
||||
const color = hasOffline ? '#ef4444' : hasUnknown ? '#94a3b8' : '#10b981';
|
||||
|
||||
// Popup: list every device with its status
|
||||
const address = groupDevices[0].location_address
|
||||
? `<br/><span style="color:#64748b;font-size:11px">${groupDevices[0].location_address}</span>`
|
||||
: '';
|
||||
const deviceRows = groupDevices
|
||||
.map((d) => {
|
||||
const c = d.status === 'online' ? '#10b981' : d.status === 'offline' ? '#ef4444' : '#94a3b8';
|
||||
return `<b>${d.name}</b> <span style="color:${c};font-size:11px;font-weight:600">● ${d.status}</span>`;
|
||||
})
|
||||
.join('<br/>');
|
||||
const popupHtml = `${deviceRows}${address}`;
|
||||
|
||||
// Tooltip on hover: quick device list with emoji indicators
|
||||
const tooltipHtml = groupDevices
|
||||
.map((d) => {
|
||||
const dot = d.status === 'online' ? '🟢' : d.status === 'offline' ? '🔴' : '⚪';
|
||||
return `${dot} ${d.name}`;
|
||||
})
|
||||
.join('<br/>');
|
||||
|
||||
let marker: L.Layer;
|
||||
if (count === 1) {
|
||||
marker = L.circleMarker([lat, lng], {
|
||||
radius: 9,
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.9,
|
||||
})
|
||||
.bindPopup(popupHtml)
|
||||
.bindTooltip(tooltipHtml, { direction: 'top', offset: [0, -10] });
|
||||
} else {
|
||||
const size = count > 9 ? 32 : 28;
|
||||
const half = size / 2;
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="background:${color};color:#fff;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 1px 4px rgba(0,0,0,0.35);cursor:pointer">${count}</div>`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [half, half],
|
||||
popupAnchor: [0, -half],
|
||||
tooltipAnchor: [0, -half],
|
||||
});
|
||||
marker = L.marker([lat, lng], { icon })
|
||||
.bindPopup(popupHtml)
|
||||
.bindTooltip(tooltipHtml, { direction: 'top' });
|
||||
}
|
||||
|
||||
marker.addTo(map);
|
||||
markers.push(marker);
|
||||
});
|
||||
|
||||
if (markers.length === 1) {
|
||||
const first = mapped[0];
|
||||
map.setView([Number(first.location_lat), Number(first.location_lng)], 13);
|
||||
} else {
|
||||
map.fitBounds(L.featureGroup(markers).getBounds(), { padding: [40, 40] });
|
||||
}
|
||||
|
||||
return () => { map.remove(); };
|
||||
}, [mapped]);
|
||||
|
||||
if (mapped.length === 0) {
|
||||
return (
|
||||
<div className="h-[300px] flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm text-center px-4">
|
||||
No device locations configured — add addresses via Device › Overview › Physical Details
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div ref={containerRef} style={{ height: 300, width: '100%', borderRadius: 8 }} />;
|
||||
}
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
color = 'blue',
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
color?: string;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const colors: Record<string, string> = {
|
||||
blue: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
|
||||
green: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
|
||||
red: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
yellow: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx('card p-5', onClick && 'cursor-pointer hover:shadow-md transition-shadow')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={clsx('p-3 rounded-xl', colors[color])}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{value}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-slate-400">{label}</div>
|
||||
{sub && <div className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">{sub}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: string }) {
|
||||
const classes: Record<string, string> = {
|
||||
error: 'severity-error',
|
||||
critical: 'severity-error',
|
||||
warning: 'severity-warning',
|
||||
info: 'severity-info',
|
||||
};
|
||||
return (
|
||||
<span className={clsx('px-2 py-0.5 rounded text-xs font-medium', classes[severity] || classes.info)}>
|
||||
{severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 [severities, setSeverities] = useState<Set<string>>(loadSeverities);
|
||||
|
||||
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 severityParam =
|
||||
severities.size === 0 || severities.size === ALL_SEVERITIES.length
|
||||
? undefined
|
||||
: [...severities].join(',');
|
||||
|
||||
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', '24h'],
|
||||
queryFn: () => metricsApi.clientsOverTime('24h').then((r) => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
// Convert ISO timestamps to numeric ms for a proper continuous time axis
|
||||
const clientsOverTime = clientsOverTimeRaw.map((p) => ({
|
||||
...p,
|
||||
ts: new Date(p.time).getTime(),
|
||||
}));
|
||||
|
||||
const { data: topClients = [] } = useQuery({
|
||||
queryKey: ['top-clients'],
|
||||
queryFn: () => metricsApi.topClients(8).then((r) => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
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 { data: eventsData } = 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 [dismissedFirmwareIds, setDismissedFirmwareIds] = useState<number[]>([]);
|
||||
const devicesWithUpdates = devices.filter(
|
||||
(d) => d.firmware_update_available && !dismissedFirmwareIds.includes(d.id)
|
||||
);
|
||||
|
||||
// Device type distribution for pie chart
|
||||
const deviceTypeLabel: Record<string, string> = {
|
||||
router: 'Router', switch: 'Switch', wireless_ap: 'Wireless AP', other: 'Other',
|
||||
};
|
||||
const deviceTypes = devices.reduce((acc: Record<string, number>, d) => {
|
||||
const type = d.device_type || 'other';
|
||||
acc[type] = (acc[type] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const pieData = Object.entries(deviceTypes).map(([key, value]) => ({ name: deviceTypeLabel[key] ?? key, value }));
|
||||
|
||||
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`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
||||
|
||||
{/* Firmware update banner */}
|
||||
{devicesWithUpdates.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-700/50 dark:bg-amber-900/20 px-4 py-3 flex items-start gap-3">
|
||||
<ArrowUpCircle className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 text-sm text-amber-800 dark:text-amber-300">
|
||||
<span className="font-semibold">Firmware update{devicesWithUpdates.length > 1 ? 's' : ''} available</span>
|
||||
{' — '}
|
||||
{devicesWithUpdates.map((d, i) => (
|
||||
<span key={d.id}>
|
||||
{i > 0 && ', '}
|
||||
<button
|
||||
onClick={() => navigate(`/devices/${d.id}`)}
|
||||
className="underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-200 font-medium"
|
||||
>
|
||||
{d.name}
|
||||
{d.latest_ros_version && (
|
||||
<span className="font-normal"> ({d.latest_ros_version})</span>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDismissedFirmwareIds((ids) => [...ids, ...devicesWithUpdates.map((d) => d.id)])}
|
||||
className="p-0.5 rounded hover:bg-amber-200 dark:hover:bg-amber-800/50 text-amber-500 flex-shrink-0"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
<StatCard
|
||||
icon={Router}
|
||||
label="Total Devices"
|
||||
value={summary?.devices.total ?? '—'}
|
||||
sub={`${summary?.devices.online ?? 0} online, ${summary?.devices.offline ?? 0} offline`}
|
||||
color="blue"
|
||||
onClick={() => navigate('/devices')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Wifi}
|
||||
label="Active Clients"
|
||||
value={summary?.clients.active ?? '—'}
|
||||
sub={`${summary?.clients.total ?? 0} total known`}
|
||||
color="green"
|
||||
onClick={() => navigate('/clients')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Signal}
|
||||
label="Wireless Clients"
|
||||
value={wirelessClientsData?.total ?? '—'}
|
||||
color="blue"
|
||||
onClick={() => navigate('/wireless/clients')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Alerts (24h)"
|
||||
value={summary?.alerts.critical ?? '—'}
|
||||
sub={`${summary?.alerts.warning ?? 0} warnings`}
|
||||
color={summary?.alerts.critical ? 'red' : 'yellow'}
|
||||
onClick={() => navigate('/events')}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Devices Online"
|
||||
value={summary?.devices.online ?? '—'}
|
||||
sub={`of ${summary?.devices.total ?? 0} total`}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Devices Offline"
|
||||
value={summary?.devices.offline ?? '—'}
|
||||
sub={`of ${summary?.devices.total ?? 0} total`}
|
||||
color={summary?.devices.offline ? 'red' : 'green'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Clients over time */}
|
||||
<div className="card p-5 lg:col-span-2">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingUp className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Connected Clients (24h)
|
||||
</h2>
|
||||
</div>
|
||||
{clientsOverTime.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={clientsOverTime} margin={{ top: 5, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="ts"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={[Date.now() - 24 * 60 * 60 * 1000, Date.now()]}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickCount={7}
|
||||
tickFormatter={(t) => format(new Date(t), 'HH:mm')}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
|
||||
<Tooltip
|
||||
formatter={(v: number) => [v, 'Clients']}
|
||||
labelFormatter={(t) => format(new Date(t as number), 'MMM d, HH:mm')}
|
||||
contentStyle={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[220px] flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No data yet — metrics will appear after devices are polled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device type pie */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Router className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Device Types</h2>
|
||||
</div>
|
||||
{pieData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={3}
|
||||
dataKey="value"
|
||||
>
|
||||
{pieData.map((_entry, index) => (
|
||||
<Cell key={index} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend formatter={(v) => <span className="text-xs capitalize">{v}</span>} />
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[220px] flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No devices added yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device locations map */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Device Locations</h2>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500">
|
||||
{devices.filter((d) => d.location_lat != null && d.location_lng != null).length} of {devices.length} devices mapped
|
||||
</span>
|
||||
</div>
|
||||
<DeviceLocationsMap devices={devices} />
|
||||
</div>
|
||||
|
||||
{/* Bottom row: Top clients + Recent events */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top clients */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Top Clients</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/clients')}
|
||||
className="text-xs text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
{topClients.length > 0 ? (() => {
|
||||
const top5 = topClients.slice(0, 5);
|
||||
const maxBytes = top5[0]?.total_bytes ?? 1;
|
||||
const barColors = ['#3b82f6', '#6366f1', '#8b5cf6', '#a78bfa', '#c4b5fd'];
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{top5.map((client, i) => {
|
||||
const pct = Math.max(4, (client.total_bytes / maxBytes) * 100);
|
||||
const label = client.hostname || client.mac_address || 'Unknown';
|
||||
return (
|
||||
<div
|
||||
key={client.mac_address}
|
||||
className="flex items-center gap-2 cursor-pointer group"
|
||||
onClick={() => navigate(`/clients/${encodeURIComponent(client.mac_address)}`)}
|
||||
>
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500 w-3 flex-shrink-0 text-right">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||
<span className="text-xs font-medium text-gray-700 dark:text-slate-300 truncate group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||
{label.length > 22 ? label.slice(0, 22) + '…' : label}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-slate-400 flex-shrink-0 font-mono">
|
||||
{formatBytes(client.total_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-gray-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: barColors[i] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})() : (
|
||||
<div className="h-[120px] flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No active clients
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent events */}
|
||||
<div className="card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-blue-500" />
|
||||
<button
|
||||
onClick={() => navigate('/events')}
|
||||
className="text-sm font-semibold text-gray-900 dark:text-white hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||
>
|
||||
Recent Events
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
{([
|
||||
{ key: 'error', label: 'Error', color: 'text-red-500' },
|
||||
{ key: 'warning', label: 'Warning', color: 'text-yellow-500' },
|
||||
{ key: 'info', label: 'Info', color: 'text-blue-500' },
|
||||
] as const).map(({ key, label, color }) => (
|
||||
<label key={key} className="flex items-center gap-1 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={severities.has(key)}
|
||||
onChange={() => toggleSeverity(key)}
|
||||
className="w-3 h-3 rounded cursor-pointer"
|
||||
/>
|
||||
<span className={clsx('text-xs font-medium', color)}>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
onClick={() => navigate('/events')}
|
||||
className="text-xs text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{(eventsData?.events ?? []).length > 0 ? (
|
||||
eventsData!.events.map((ev: DeviceEvent) => (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="flex items-start gap-3 py-1.5 border-b border-gray-100 dark:border-slate-700 last:border-0"
|
||||
>
|
||||
<SeverityBadge severity={ev.severity} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs text-gray-700 dark:text-slate-300 truncate">
|
||||
{ev.message}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">
|
||||
{ev.device_name} · {format(new Date(ev.event_time), 'MMM d, HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[120px] text-gray-400 dark:text-slate-500 text-sm">
|
||||
No recent events
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft, RefreshCw, Activity, Cpu, MemoryStick, Clock, ExternalLink, TerminalSquare,
|
||||
} from 'lucide-react';
|
||||
import { devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import SwitchPortDiagram from '../components/ports/SwitchPortDiagram';
|
||||
import TerminalModal from '../components/TerminalModal';
|
||||
import DeviceLocationSection from '../components/device-detail/DeviceLocationSection';
|
||||
import VlansTab from '../components/device-detail/VlansTab';
|
||||
import RoutingTab from '../components/device-detail/RoutingTab';
|
||||
import FirewallTab from '../components/device-detail/FirewallTab';
|
||||
import SystemConfigTab from '../components/device-detail/SystemConfigTab';
|
||||
import HardwareTab from '../components/device-detail/HardwareTab';
|
||||
import ToolsTab from '../components/device-detail/ToolsTab';
|
||||
import RadiosTab from '../components/device-detail/RadiosTab';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type TabKey = 'overview' | 'ports' | 'vlans' | 'routing' | 'firewall' | 'config' | 'hardware' | 'tools' | 'radios';
|
||||
|
||||
function formatUptime(raw: string): string {
|
||||
if (!raw) return '—';
|
||||
const weeks = parseInt(raw.match(/(\d+)w/)?.[1] ?? '0', 10);
|
||||
const days = parseInt(raw.match(/(\d+)d/)?.[1] ?? '0', 10);
|
||||
const hours = parseInt(raw.match(/(\d+)h/)?.[1] ?? '0', 10);
|
||||
const minutes = parseInt(raw.match(/(\d+)m/)?.[1] ?? '0', 10);
|
||||
const seconds = parseInt(raw.match(/(\d+)s/)?.[1] ?? '0', 10);
|
||||
|
||||
const parts: string[] = [];
|
||||
if (weeks) parts.push(`${weeks} ${weeks === 1 ? 'Week' : 'Weeks'}`);
|
||||
if (days) parts.push(`${days} ${days === 1 ? 'Day' : 'Days'}`);
|
||||
if (hours) parts.push(`${hours} ${hours === 1 ? 'Hour' : 'Hours'}`);
|
||||
if (minutes) parts.push(`${minutes} ${minutes === 1 ? 'Minute' : 'Minutes'}`);
|
||||
|
||||
// Only show seconds when nothing larger is present (uptime < 1 minute)
|
||||
if (parts.length === 0) parts.push(`${seconds} ${seconds === 1 ? 'Second' : 'Seconds'}`);
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
export default function DeviceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('overview');
|
||||
const [autoOpenBridge, setAutoOpenBridge] = useState<string | null>(null);
|
||||
const [showTerminal, setShowTerminal] = useState(false);
|
||||
|
||||
const deviceId = parseInt(id!);
|
||||
|
||||
const { data: device, isLoading } = useQuery({
|
||||
queryKey: ['device', deviceId],
|
||||
queryFn: () => devicesApi.get(deviceId).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: resources } = useQuery({
|
||||
queryKey: ['device-resources-live', deviceId],
|
||||
queryFn: () => devicesApi.getResources(deviceId).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
enabled: device?.status === 'online',
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => devicesApi.sync(deviceId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['device', deviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interfaces', deviceId] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3">
|
||||
<p className="text-gray-500">Device not found</p>
|
||||
<button onClick={() => navigate('/devices')} className="btn-secondary">
|
||||
Back to Devices
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isWirelessAP = device.device_type === 'wireless_ap';
|
||||
|
||||
const tabs: { key: TabKey; label: string }[] = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'ports', label: 'Ports' },
|
||||
{ key: 'vlans', label: 'VLANs' },
|
||||
{ key: 'routing', label: 'Routing' },
|
||||
{ key: 'firewall', label: 'Firewall' },
|
||||
{ key: 'config', label: 'Config' },
|
||||
{ key: 'hardware', label: 'Hardware' },
|
||||
...(isWirelessAP ? [{ key: 'radios' as TabKey, label: 'Radios' }] : []),
|
||||
{ key: 'tools', label: 'Tools' },
|
||||
];
|
||||
|
||||
const cpuLoad = parseInt(resources?.['cpu-load'] || '0', 10);
|
||||
const memTotal = parseInt(resources?.['total-memory'] || '0', 10);
|
||||
const memFree = parseInt(resources?.['free-memory'] || '0', 10);
|
||||
const memUsed = memTotal - memFree;
|
||||
const memPercent = memTotal > 0 ? Math.round((memUsed / memTotal) * 100) : 0;
|
||||
const formatMB = (bytes: number) => `${Math.round(bytes / 1024 / 1024)} MB`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/devices')}
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors flex-shrink-0"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">{device.name}</h1>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium flex-shrink-0',
|
||||
device.status === 'online' && 'status-online',
|
||||
device.status === 'offline' && 'status-offline',
|
||||
device.status === 'unknown' && 'status-unknown'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
device.status === 'online' && 'bg-green-500 animate-pulse',
|
||||
device.status === 'offline' && 'bg-red-500',
|
||||
device.status === 'unknown' && 'bg-gray-400'
|
||||
)}
|
||||
/>
|
||||
{device.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 font-mono truncate">
|
||||
{device.ip_address}:{device.api_port}
|
||||
{device.model && ` · ${device.model}`}
|
||||
{device.ros_version && ` · ROS ${device.ros_version}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap sm:ml-auto sm:flex-shrink-0">
|
||||
<button
|
||||
onClick={() => window.open(`http://${device.ip_address}/`, '_blank', 'noopener,noreferrer')}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
title="Open device web interface"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Web Admin</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTerminal(true)}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
title="Open SSH terminal"
|
||||
>
|
||||
<TerminalSquare className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Terminal</span>
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', syncMutation.isPending && 'animate-spin')} />
|
||||
<span className="hidden sm:inline">Sync</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-200 dark:border-slate-700 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={clsx(
|
||||
'px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px whitespace-nowrap',
|
||||
activeTab === tab.key
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-4">
|
||||
{/* Resource stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cpu className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400">CPU Load</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{resources ? `${cpuLoad}%` : '—'}
|
||||
</div>
|
||||
{resources && (
|
||||
<div className="mt-2 h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full rounded-full transition-all',
|
||||
cpuLoad > 80 ? 'bg-red-500' : cpuLoad > 50 ? 'bg-yellow-500' : 'bg-green-500'
|
||||
)}
|
||||
style={{ width: `${cpuLoad}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MemoryStick className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400">Memory</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{resources ? `${memPercent}%` : '—'}
|
||||
</div>
|
||||
{resources && (
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mt-1">
|
||||
{formatMB(memUsed)} / {formatMB(memTotal)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400">Uptime</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900 dark:text-white truncate">
|
||||
{resources ? formatUptime(resources['uptime'] || '') : '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400">Version</span>
|
||||
</div>
|
||||
<div className="text-sm font-bold text-gray-900 dark:text-white">
|
||||
{device.ros_version || '—'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mt-1">
|
||||
{device.firmware_version || ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System details */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
|
||||
System Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
{[
|
||||
['Name', device.name],
|
||||
['IP Address', device.ip_address],
|
||||
['Model', device.model || '—'],
|
||||
['Serial Number', device.serial_number || '—'],
|
||||
['RouterOS Version', device.ros_version || '—'],
|
||||
['Firmware', device.firmware_version || '—'],
|
||||
['Type', device.device_type],
|
||||
['API Port', String(device.api_port)],
|
||||
['Added', new Date(device.created_at).toLocaleDateString()],
|
||||
['Last Seen', device.last_seen ? new Date(device.last_seen).toLocaleString() : '—'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between py-1 border-b border-gray-100 dark:border-slate-700">
|
||||
<span className="text-gray-500 dark:text-slate-400">{k}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white text-right">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Physical location, rack & notes */}
|
||||
<DeviceLocationSection device={device} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'ports' && (
|
||||
<SwitchPortDiagram
|
||||
deviceId={deviceId}
|
||||
autoOpenBridge={autoOpenBridge ?? undefined}
|
||||
onBridgeOpened={() => setAutoOpenBridge(null)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'vlans' && (
|
||||
<VlansTab
|
||||
deviceId={deviceId}
|
||||
onGoToPorts={(bridgeName) => { setAutoOpenBridge(bridgeName); setActiveTab('ports'); }}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'routing' && <RoutingTab deviceId={deviceId} />}
|
||||
{activeTab === 'firewall' && <FirewallTab deviceId={deviceId} deviceType={device?.device_type} />}
|
||||
{activeTab === 'config' && <SystemConfigTab deviceId={deviceId} />}
|
||||
{activeTab === 'hardware' && <HardwareTab deviceId={deviceId} />}
|
||||
{activeTab === 'radios' && <RadiosTab deviceId={deviceId} deviceStatus={device.status} />}
|
||||
{activeTab === 'tools' && <ToolsTab deviceId={deviceId} />}
|
||||
|
||||
{showTerminal && (
|
||||
<TerminalModal
|
||||
deviceId={deviceId}
|
||||
deviceName={device.name}
|
||||
onClose={() => setShowTerminal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, RefreshCw, Router, Wifi, Trash2, ChevronRight, Search, Radar, ArrowUpCircle } from 'lucide-react';
|
||||
import { devicesApi, topologyApi } from '../services/api';
|
||||
import type { Device } from '../types';
|
||||
import type { DiscoveredDevice } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import clsx from 'clsx';
|
||||
import AddDeviceModal from '../components/devices/AddDeviceModal';
|
||||
|
||||
function StatusDot({ status }: { status: Device['status'] }) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
status === 'online' && 'status-online',
|
||||
status === 'offline' && 'status-offline',
|
||||
status === 'unknown' && 'status-unknown'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
status === 'online' && 'bg-green-500',
|
||||
status === 'offline' && 'bg-red-500',
|
||||
status === 'unknown' && 'bg-gray-400'
|
||||
)}
|
||||
/>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceTypeIcon({ type }: { type: Device['device_type'] }) {
|
||||
if (type === 'wireless_ap') return <Wifi className="w-5 h-5 text-blue-500" />;
|
||||
return <Router className="w-5 h-5 text-blue-500" />;
|
||||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [addPrefill, setAddPrefill] = useState<{ name?: string; ip_address?: string } | undefined>();
|
||||
const [search, setSearch] = useState('');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
const { data: devices = [], isLoading } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: discovered = [] } = useQuery({
|
||||
queryKey: ['devices-discovered'],
|
||||
queryFn: () => devicesApi.discovered().then((r) => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: (id: number) => devicesApi.sync(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['devices'] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => devicesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
||||
setDeleteConfirm(null);
|
||||
},
|
||||
});
|
||||
|
||||
const filtered = devices.filter(
|
||||
(d) =>
|
||||
!search ||
|
||||
d.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
d.ip_address.includes(search) ||
|
||||
d.model?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Devices
|
||||
{devices.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-slate-400">
|
||||
({devices.filter((d) => d.status === 'online').length}/{devices.length} online)
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
await topologyApi.discover();
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['devices-discovered'] });
|
||||
setSearching(false);
|
||||
}, 8000);
|
||||
} catch {
|
||||
setSearching(false);
|
||||
}
|
||||
}}
|
||||
disabled={searching}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', searching && 'animate-spin')} />
|
||||
{searching ? 'Searching…' : 'Search for Devices'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setAddPrefill(undefined); setShowAddModal(true); }}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-9"
|
||||
placeholder="Search devices..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Device list */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48 text-gray-400">Loading...</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="card p-12 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-16 h-16 bg-gray-100 dark:bg-slate-700 rounded-full flex items-center justify-center">
|
||||
<Router className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-700 dark:text-slate-300 font-medium">
|
||||
{search ? 'No devices match your search' : 'No devices added yet'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
{!search && 'Click "Add Device" to connect your first Mikrotik device'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-3">Device</th>
|
||||
<th className="table-header px-4 py-3">IP Address</th>
|
||||
<th className="table-header px-4 py-3">Model</th>
|
||||
<th className="table-header px-4 py-3">ROS Version</th>
|
||||
<th className="table-header px-4 py-3">Status</th>
|
||||
<th className="table-header px-4 py-3">Last Seen</th>
|
||||
<th className="table-header px-4 py-3 w-28">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{filtered.map((device) => (
|
||||
<tr
|
||||
key={device.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer transition-colors"
|
||||
onClick={() => navigate(`/devices/${device.id}`)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<DeviceTypeIcon type={device.device_type} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{device.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">
|
||||
{{ router: 'Router', switch: 'Switch', wireless_ap: 'Wireless AP', other: 'Other' }[device.device_type] ?? device.device_type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-mono text-gray-600 dark:text-slate-400">
|
||||
{device.ip_address}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-slate-400">
|
||||
{device.model || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-slate-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{device.ros_version || '—'}
|
||||
{device.firmware_update_available && (
|
||||
<span
|
||||
title={device.latest_ros_version ? `Update available: ${device.latest_ros_version}` : 'Firmware update available'}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<ArrowUpCircle className="w-3.5 h-3.5 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusDot status={device.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400 dark:text-slate-500">
|
||||
{device.last_seen
|
||||
? new Date(device.last_seen).toLocaleString()
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{canWrite && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => syncMutation.mutate(device.id)}
|
||||
disabled={syncMutation.isPending}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
title="Sync now"
|
||||
>
|
||||
<RefreshCw
|
||||
className={clsx('w-3.5 h-3.5', syncMutation.isPending && 'animate-spin')}
|
||||
/>
|
||||
</button>
|
||||
{deleteConfirm === device.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(device.id)}
|
||||
className="text-xs text-red-500 hover:text-red-600 font-medium px-1"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 px-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(device.id)}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
title="Delete device"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 dark:text-slate-600" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discovered (unmanaged) MikroTik neighbors */}
|
||||
{discovered.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radar className="w-4 h-4 text-amber-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-300">
|
||||
Discovered Devices
|
||||
</h2>
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500">
|
||||
— Mikrotik neighbors seen by your managed devices, not yet added to management
|
||||
</span>
|
||||
</div>
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Identity</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP Address</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">MAC Address</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Seen By</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Last Seen</th>
|
||||
<th className="table-header px-4 py-2.5 w-36" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{(discovered as DiscoveredDevice[]).map((d, i) => (
|
||||
<tr key={d.mac_address || d.address || i} className="hover:bg-amber-50/50 dark:hover:bg-amber-900/10">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{d.identity || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-600 dark:text-slate-400">
|
||||
{d.address ? (
|
||||
d.address
|
||||
) : (
|
||||
<span className="text-amber-500 dark:text-amber-400 italic text-xs font-sans">
|
||||
Not detected — enter manually
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-500">
|
||||
{d.mac_address || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400 text-xs">
|
||||
{d.seen_by}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 dark:text-slate-500 text-xs">
|
||||
{new Date(d.discovered_at).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setAddPrefill({ name: d.identity || '', ip_address: d.address });
|
||||
setShowAddModal(true);
|
||||
}}
|
||||
className="btn-primary text-xs py-1 px-3 flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add to Manager
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddModal && (
|
||||
<AddDeviceModal
|
||||
prefill={addPrefill}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowAddModal(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['devices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['devices-discovered'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Bell, Search, Trash2, AlertCircle, Info, AlertTriangle } from 'lucide-react';
|
||||
import { eventsApi, devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import { useSocket } from '../hooks/useSocket';
|
||||
import { format } from 'date-fns';
|
||||
import type { EventSeverity } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
const severityIcon = (severity: EventSeverity) => {
|
||||
if (severity === 'error' || severity === 'critical') return AlertCircle;
|
||||
if (severity === 'warning') return AlertTriangle;
|
||||
return Info;
|
||||
};
|
||||
|
||||
const severityColor = (severity: EventSeverity): string => {
|
||||
if (severity === 'error' || severity === 'critical') return 'text-red-500';
|
||||
if (severity === 'warning') return 'text-yellow-500';
|
||||
return 'text-blue-500';
|
||||
};
|
||||
|
||||
const ALL_SEVERITIES = ['error', 'warning', 'info'] as const;
|
||||
|
||||
export default function EventsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const [search, setSearch] = useState('');
|
||||
const [severities, setSeverities] = useState<Set<string>>(new Set(ALL_SEVERITIES));
|
||||
const [topic, setTopic] = useState('');
|
||||
const [deviceId, setDeviceId] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
const toggleSeverity = (s: string) => {
|
||||
setSeverities((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(s)) next.delete(s); else next.add(s);
|
||||
return next;
|
||||
});
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
// No filter when all or none selected (show everything); filter when partially selected
|
||||
const severityParam =
|
||||
severities.size === 0 || severities.size === ALL_SEVERITIES.length
|
||||
? undefined
|
||||
: [...severities].join(',');
|
||||
|
||||
const { data: eventsData, isLoading } = useQuery({
|
||||
queryKey: ['events', { search, severityParam, topic, deviceId, page }],
|
||||
queryFn: () =>
|
||||
eventsApi
|
||||
.list({
|
||||
search: search || undefined,
|
||||
severity: severityParam,
|
||||
topic: topic || undefined,
|
||||
deviceId: deviceId ? parseInt(deviceId) : undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then((r) => r.data),
|
||||
});
|
||||
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: () => eventsApi.clear(deviceId ? parseInt(deviceId) : undefined),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['events'] }); setPage(0); },
|
||||
});
|
||||
|
||||
useSocket({
|
||||
'events:updated': () => queryClient.invalidateQueries({ queryKey: ['events'] }),
|
||||
});
|
||||
|
||||
const events = eventsData?.events ?? [];
|
||||
const total = eventsData?.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Events
|
||||
{total > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-slate-400">
|
||||
({total.toLocaleString()})
|
||||
</span>
|
||||
)}
|
||||
{eventsData?.criticalCount ? (
|
||||
<span className="ml-2 px-2 py-0.5 bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 text-xs rounded-full font-medium">
|
||||
{eventsData.criticalCount} critical (24h)
|
||||
</span>
|
||||
) : null}
|
||||
</h1>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Clear all events? This cannot be undone.')) clearMutation.mutate();
|
||||
}}
|
||||
className="btn-secondary flex items-center gap-2 text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative w-full sm:flex-1 sm:min-w-48">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-9"
|
||||
placeholder="Search messages or topics…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full sm:w-36"
|
||||
placeholder="Filter topic…"
|
||||
value={topic}
|
||||
onChange={(e) => { setTopic(e.target.value); setPage(0); }}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 px-3 py-1.5 border border-gray-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800">
|
||||
{([
|
||||
{ key: 'error', label: 'Error', color: 'text-red-500' },
|
||||
{ key: 'warning', label: 'Warning', color: 'text-yellow-500' },
|
||||
{ key: 'info', label: 'Info', color: 'text-blue-500' },
|
||||
] as const).map(({ key, label, color }) => (
|
||||
<label key={key} className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={severities.has(key)}
|
||||
onChange={() => toggleSeverity(key)}
|
||||
className="w-3.5 h-3.5 rounded accent-current cursor-pointer"
|
||||
/>
|
||||
<span className={clsx('text-xs font-medium', color)}>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="input w-full sm:w-44"
|
||||
value={deviceId}
|
||||
onChange={(e) => { setDeviceId(e.target.value); setPage(0); }}
|
||||
>
|
||||
<option value="">All devices</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48 text-gray-400">Loading...</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="card p-12 flex flex-col items-center gap-3 text-center">
|
||||
<Bell className="w-12 h-12 text-gray-300 dark:text-slate-600" />
|
||||
<p className="text-gray-500 dark:text-slate-400">No events found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Time</th>
|
||||
<th className="table-header px-4 py-2.5 text-left w-24">Severity</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Device</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Topic</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{events.map((ev) => {
|
||||
const Icon = severityIcon(ev.severity);
|
||||
return (
|
||||
<tr
|
||||
key={ev.id}
|
||||
className={clsx(
|
||||
'hover:bg-gray-50 dark:hover:bg-slate-700/30',
|
||||
(ev.severity === 'error' || ev.severity === 'critical') &&
|
||||
'bg-red-50/30 dark:bg-red-900/10'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2.5 text-xs font-mono text-gray-500 dark:text-slate-400 whitespace-nowrap">
|
||||
{format(new Date(ev.event_time), 'MMM d, HH:mm:ss')}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={clsx('inline-flex items-center gap-1 text-xs font-medium', severityColor(ev.severity))}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{ev.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
{ev.device_name || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs font-mono text-gray-400 dark:text-slate-500">
|
||||
{ev.topic || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-700 dark:text-slate-300">
|
||||
{ev.message}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-slate-400">
|
||||
Showing {(page * PAGE_SIZE + 1).toLocaleString()}–{Math.min((page + 1) * PAGE_SIZE, total).toLocaleString()} of {total.toLocaleString()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="btn-secondary py-1 text-xs"
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={(page + 1) * PAGE_SIZE >= total}
|
||||
className="btn-secondary py-1 text-xs"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Network, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||
import { authApi } from '../services/api';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import CircuitBackground from '../components/CircuitBackground';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const navigate = useNavigate();
|
||||
const { theme, toggleTheme } = useThemeStore();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const { data } = await authApi.login(username, password);
|
||||
setAuth(data.token, data.user);
|
||||
navigate('/dashboard');
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Login failed. Please check your credentials.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex flex-col items-center justify-center p-4 relative overflow-hidden"
|
||||
style={{ backgroundColor: isDark ? '#040c07' : '#e8f2f7' }}
|
||||
>
|
||||
<CircuitBackground theme={theme} />
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`absolute top-4 right-4 p-2 rounded-lg transition-colors z-10 ${
|
||||
isDark
|
||||
? 'text-slate-400 hover:text-slate-200'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{isDark ? '☀️' : '🌙'}
|
||||
</button>
|
||||
|
||||
<div className="w-full max-w-sm relative z-10">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className={`w-14 h-14 bg-blue-600 rounded-2xl flex items-center justify-center mb-4 ${
|
||||
isDark ? 'shadow-lg shadow-blue-900/50' : 'shadow-lg shadow-blue-400/40'
|
||||
}`}>
|
||||
<Network className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<h1 className={`text-2xl font-bold drop-shadow-lg ${isDark ? 'text-white' : 'text-slate-800'}`}>
|
||||
Mikrotik Manager
|
||||
</h1>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-green-400/70' : 'text-cyan-700/80'}`}>
|
||||
Network Management Platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login card */}
|
||||
<div className={`backdrop-blur-sm rounded-xl shadow-2xl p-6 ${
|
||||
isDark
|
||||
? 'bg-slate-900/80 border border-slate-700/60 shadow-black/60'
|
||||
: 'bg-white/75 border border-slate-300/60 shadow-slate-400/30'
|
||||
}`}>
|
||||
<h2 className={`text-lg font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-800'}`}>
|
||||
Sign in to your account
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium mb-1.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}
|
||||
htmlFor="username"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
className={`w-full px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
|
||||
isDark
|
||||
? 'bg-slate-800/80 border border-slate-600 text-white placeholder-slate-500'
|
||||
: 'bg-white/80 border border-slate-300 text-slate-800 placeholder-slate-400'
|
||||
}`}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="admin"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={`block text-sm font-medium mb-1.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}
|
||||
htmlFor="password"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className={`w-full px-3 py-2 pr-10 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors ${
|
||||
isDark
|
||||
? 'bg-slate-800/80 border border-slate-600 text-white placeholder-slate-500'
|
||||
: 'bg-white/80 border border-slate-300 text-slate-800 placeholder-slate-400'
|
||||
}`}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute right-3 top-1/2 -translate-y-1/2 transition-colors ${
|
||||
isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={`flex items-center gap-2 p-3 rounded-lg ${
|
||||
isDark
|
||||
? 'bg-red-900/30 border border-red-700/50'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}>
|
||||
<AlertCircle className={`w-4 h-4 flex-shrink-0 ${isDark ? 'text-red-400' : 'text-red-500'}`} />
|
||||
<p className={`text-sm ${isDark ? 'text-red-300' : 'text-red-600'}`}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username || !password}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="inline-block w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className={`text-center text-xs mt-6 ${isDark ? 'text-slate-600' : 'text-slate-500'}`}>
|
||||
Default credentials: admin / admin
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Server, RefreshCw, AlertTriangle, Power, Plus, Pencil, Trash2,
|
||||
ChevronDown, ChevronRight, X, Check,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { networkServicesApi, devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
|
||||
type NS = Record<string, string>;
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function leaseLabel(secs: string): string {
|
||||
const n = parseInt(secs);
|
||||
if (!n || isNaN(n)) return secs || '—';
|
||||
if (n < 3600) return `${Math.round(n / 60)} min`;
|
||||
if (n < 86400) return `${Math.round(n / 3600)} hr`;
|
||||
return `${Math.round(n / 86400)} d`;
|
||||
}
|
||||
|
||||
function StatusBadge({ disabled }: { disabled: boolean }) {
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
disabled
|
||||
? 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400'
|
||||
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
)}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', disabled ? 'bg-gray-400' : 'bg-green-500')} />
|
||||
{disabled ? 'Disabled' : 'Enabled'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Server form modal ────────────────────────────────────────────────────────
|
||||
|
||||
interface ServerFormProps {
|
||||
protocol: 'ipv4' | 'ipv6';
|
||||
existing?: NS;
|
||||
pools: NS[];
|
||||
deviceId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ServerForm({ protocol, existing, pools, deviceId, onClose }: ServerFormProps) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(existing?.['name'] || '');
|
||||
const [iface, setIface] = useState(existing?.['interface'] || '');
|
||||
const [pool, setPool] = useState(existing?.['address-pool'] || '');
|
||||
const [leaseTime, setLeaseTime] = useState(existing?.['lease-time'] || '00:10:00');
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: NS & { protocol: 'ipv4' | 'ipv6' } = {
|
||||
protocol, name, interface: iface, 'address-pool': pool, 'lease-time': leaseTime,
|
||||
};
|
||||
return existing?.['.id']
|
||||
? networkServicesApi.updateDhcpServer(deviceId, existing['.id'], body)
|
||||
: networkServicesApi.addDhcpServer(deviceId, body);
|
||||
},
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }); onClose(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{existing ? 'Edit' : 'Add'} {protocol === 'ipv4' ? 'IPv4' : 'IPv6'} DHCP Server
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Name *</label>
|
||||
<input className="input w-full" value={name} onChange={e => setName(e.target.value)} placeholder="dhcp1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Interface *</label>
|
||||
<input className="input w-full" value={iface} onChange={e => setIface(e.target.value)} placeholder="bridge1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Address Pool</label>
|
||||
{pools.length > 0 ? (
|
||||
<select className="input w-full" value={pool} onChange={e => setPool(e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
{pools.map(p => <option key={p['.id']} value={p['name']}>{p['name']} ({p['ranges'] || p['prefix'] || ''})</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input className="input w-full" value={pool} onChange={e => setPool(e.target.value)} placeholder="pool-name" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Lease Time</label>
|
||||
<input className="input w-full" value={leaseTime} onChange={e => setLeaseTime(e.target.value)} placeholder="00:10:00" />
|
||||
<p className="mt-0.5 text-xs text-gray-400">Format: HH:MM:SS or e.g. 10m, 1h, 1d</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!name || !iface || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{save.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 dark:text-slate-400 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pool form modal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface PoolFormProps {
|
||||
protocol: 'ipv4' | 'ipv6';
|
||||
deviceId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function PoolForm({ protocol, deviceId, onClose }: PoolFormProps) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const [ranges, setRanges] = useState('');
|
||||
const [prefix, setPrefix] = useState('');
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const params: NS & { protocol: 'ipv4' | 'ipv6' } = { protocol, name };
|
||||
if (protocol === 'ipv4') params['ranges'] = ranges;
|
||||
else params['prefix'] = prefix;
|
||||
return networkServicesApi.addDhcpPool(deviceId, params);
|
||||
},
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }); onClose(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Add {protocol === 'ipv4' ? 'IPv4' : 'IPv6'} Address Pool
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Name *</label>
|
||||
<input className="input w-full" value={name} onChange={e => setName(e.target.value)} placeholder="pool1" />
|
||||
</div>
|
||||
{protocol === 'ipv4' ? (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Ranges *</label>
|
||||
<input className="input w-full" value={ranges} onChange={e => setRanges(e.target.value)} placeholder="192.168.1.10-192.168.1.100" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Prefix *</label>
|
||||
<input className="input w-full" value={prefix} onChange={e => setPrefix(e.target.value)} placeholder="2001:db8::/64" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!name || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{save.isPending ? 'Adding…' : 'Add Pool'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Static Lease form modal ──────────────────────────────────────────────────
|
||||
|
||||
interface StaticLeaseFormProps {
|
||||
protocol: 'ipv4' | 'ipv6';
|
||||
servers: NS[];
|
||||
deviceId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function StaticLeaseForm({ protocol, servers, deviceId, onClose }: StaticLeaseFormProps) {
|
||||
const qc = useQueryClient();
|
||||
const [mac, setMac] = useState('');
|
||||
const [address, setAddress] = useState('');
|
||||
const [server, setServer] = useState(servers[0]?.['name'] || '');
|
||||
const [comment, setComment] = useState('');
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: NS & { protocol: 'ipv4' | 'ipv6' } = {
|
||||
protocol, 'mac-address': mac, address, comment,
|
||||
...(protocol === 'ipv4' ? { server } : { server }),
|
||||
};
|
||||
return networkServicesApi.addStaticLease(deviceId, body);
|
||||
},
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-leases', deviceId, protocol] }); onClose(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Add Static Lease</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">MAC Address *</label>
|
||||
<input className="input w-full font-mono" value={mac} onChange={e => setMac(e.target.value)} placeholder="AA:BB:CC:DD:EE:FF" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">IP Address *</label>
|
||||
<input className="input w-full font-mono" value={address} onChange={e => setAddress(e.target.value)} placeholder={protocol === 'ipv4' ? '192.168.1.50' : '2001:db8::50'} />
|
||||
</div>
|
||||
{servers.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">DHCP Server</label>
|
||||
<select className="input w-full" value={server} onChange={e => setServer(e.target.value)}>
|
||||
{servers.map(s => <option key={s['.id']} value={s['name']}>{s['name']}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Comment</label>
|
||||
<input className="input w-full" value={comment} onChange={e => setComment(e.target.value)} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!mac || !address || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{save.isPending ? 'Adding…' : 'Add Lease'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section component ────────────────────────────────────────────────────────
|
||||
|
||||
interface SectionProps {
|
||||
color: string;
|
||||
title: string;
|
||||
count: number;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Section({ color, title, count, open, onToggle, action, children }: SectionProps) {
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center border-b border-gray-200 dark:border-slate-700">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 flex-1 px-5 py-3 hover:bg-gray-50 dark:hover:bg-slate-800/40 transition-colors text-left"
|
||||
>
|
||||
{open ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
|
||||
<Server className={clsx('w-4 h-4', color)} />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">{title}</h2>
|
||||
<span className="ml-2 text-xs text-gray-400 dark:text-slate-500">{count}</span>
|
||||
</button>
|
||||
{action && <div className="px-3">{action}</div>}
|
||||
</div>
|
||||
{open && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function NetworkServicesDHCPPage() {
|
||||
const canWrite = useCanWrite();
|
||||
const qc = useQueryClient();
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | ''>('');
|
||||
const [v4Open, setV4Open] = useState(true);
|
||||
const [v6Open, setV6Open] = useState(true);
|
||||
const [poolsV4Open, setPoolsV4Open] = useState(false);
|
||||
const [poolsV6Open, setPoolsV6Open] = useState(false);
|
||||
const [leasesOpen, setLeasesOpen] = useState(false);
|
||||
const [leasesV6Open, setLeasesV6Open] = useState(false);
|
||||
|
||||
// Modals
|
||||
const [serverForm, setServerForm] = useState<{ protocol: 'ipv4' | 'ipv6'; existing?: NS } | null>(null);
|
||||
const [poolForm, setPoolForm] = useState<'ipv4' | 'ipv6' | null>(null);
|
||||
const [leaseForm, setLeaseForm] = useState<'ipv4' | 'ipv6' | null>(null);
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const deviceId = typeof selectedDeviceId === 'number' ? selectedDeviceId : 0;
|
||||
const selectedDevice = devices.find(d => d.id === deviceId);
|
||||
|
||||
const { data: dhcp, isLoading, refetch, isFetching, error } = useQuery({
|
||||
queryKey: ['ns-dhcp', deviceId],
|
||||
queryFn: () => networkServicesApi.getDhcp(deviceId).then(r => r.data),
|
||||
enabled: deviceId > 0,
|
||||
});
|
||||
|
||||
const { data: leasesV4 = [] } = useQuery({
|
||||
queryKey: ['ns-leases', deviceId, 'ipv4'],
|
||||
queryFn: () => networkServicesApi.getLeases(deviceId, 'ipv4').then(r => r.data),
|
||||
enabled: deviceId > 0 && leasesOpen,
|
||||
});
|
||||
|
||||
const { data: leasesV6 = [] } = useQuery({
|
||||
queryKey: ['ns-leases', deviceId, 'ipv6'],
|
||||
queryFn: () => networkServicesApi.getLeases(deviceId, 'ipv6').then(r => r.data),
|
||||
enabled: deviceId > 0 && leasesV6Open,
|
||||
});
|
||||
|
||||
const deleteServer = useMutation({
|
||||
mutationFn: ({ id, protocol }: { id: string; protocol: 'ipv4' | 'ipv6' }) =>
|
||||
networkServicesApi.deleteDhcpServer(deviceId, id, protocol),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }),
|
||||
});
|
||||
|
||||
const toggleServer = useMutation({
|
||||
mutationFn: ({ id, disabled, protocol }: { id: string; disabled: boolean; protocol: 'ipv4' | 'ipv6' }) =>
|
||||
networkServicesApi.toggleDhcpServer(deviceId, id, disabled, protocol),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }),
|
||||
});
|
||||
|
||||
const deletePool = useMutation({
|
||||
mutationFn: ({ id, protocol }: { id: string; protocol: 'ipv4' | 'ipv6' }) =>
|
||||
networkServicesApi.deleteDhcpPool(deviceId, id, protocol),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-dhcp', deviceId] }),
|
||||
});
|
||||
|
||||
const deleteLease = useMutation({
|
||||
mutationFn: ({ id, protocol }: { id: string; protocol: 'ipv4' | 'ipv6' }) =>
|
||||
networkServicesApi.deleteStaticLease(deviceId, id, protocol),
|
||||
onSuccess: (_d, vars) => qc.invalidateQueries({ queryKey: ['ns-leases', deviceId, vars.protocol] }),
|
||||
});
|
||||
|
||||
function ServerTable({ servers, protocol }: { servers: NS[]; protocol: 'ipv4' | 'ipv6' }) {
|
||||
if (servers.length === 0) {
|
||||
return <div className="px-5 py-6 text-sm text-gray-400 dark:text-slate-500 text-center">No servers configured.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Name</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Interface</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Pool</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Lease Time</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Status</th>
|
||||
{canWrite && <th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{servers.map((s, i) => {
|
||||
const disabled = s['disabled'] === 'true';
|
||||
return (
|
||||
<tr key={s['.id'] || i} className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40', i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{s['name'] || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{s['interface'] || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{s['address-pool'] || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{leaseLabel(s['lease-time'] || '')}</td>
|
||||
<td className="px-4 py-3"><StatusBadge disabled={disabled} /></td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => toggleServer.mutate({ id: s['.id'], disabled: !disabled, protocol })} title={disabled ? 'Enable' : 'Disable'}
|
||||
className={clsx('p-1.5 rounded-lg transition-colors', disabled ? 'text-gray-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20' : 'text-green-600 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20')}>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setServerForm({ protocol, existing: s })} title="Edit"
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => { if (confirm(`Delete DHCP server "${s['name']}"?`)) deleteServer.mutate({ id: s['.id'], protocol }); }} title="Delete"
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PoolTable({ pools, protocol }: { pools: NS[]; protocol: 'ipv4' | 'ipv6' }) {
|
||||
if (pools.length === 0) return <div className="px-5 py-6 text-sm text-gray-400 dark:text-slate-500 text-center">No pools configured.</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Name</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">{protocol === 'ipv4' ? 'Ranges' : 'Prefix'}</th>
|
||||
{canWrite && <th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pools.map((p, i) => (
|
||||
<tr key={p['.id'] || i} className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40', i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{p['name']}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-slate-300">{p['ranges'] || p['prefix'] || '—'}</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button onClick={() => { if (confirm(`Delete pool "${p['name']}"?`)) deletePool.mutate({ id: p['.id'], protocol }); }}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LeaseTable({ leases, protocol }: { leases: NS[]; protocol: 'ipv4' | 'ipv6' }) {
|
||||
if (leases.length === 0) return <div className="px-5 py-6 text-sm text-gray-400 dark:text-slate-500 text-center">No leases.</div>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">MAC / DUID</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Address</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Hostname</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Type</th>
|
||||
{canWrite && <th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leases.map((l, i) => {
|
||||
const isStatic = l['dynamic'] !== 'true';
|
||||
return (
|
||||
<tr key={l['.id'] || i} className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40', i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-700 dark:text-slate-300">{l['mac-address'] || l['duid'] || '—'}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-700 dark:text-slate-300">{l['address'] || '—'}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-300">{l['host-name'] || l['comment'] || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={clsx('text-xs px-2 py-0.5 rounded-full font-medium', isStatic ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-500')}>
|
||||
{isStatic ? 'Static' : 'Dynamic'}
|
||||
</span>
|
||||
</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-3 text-right">
|
||||
{isStatic && (
|
||||
<button onClick={() => deleteLease.mutate({ id: l['.id'], protocol })}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Modals */}
|
||||
{serverForm && (
|
||||
<ServerForm
|
||||
protocol={serverForm.protocol}
|
||||
existing={serverForm.existing}
|
||||
pools={serverForm.protocol === 'ipv4' ? (dhcp?.pools_v4 || []) : (dhcp?.pools_v6 || [])}
|
||||
deviceId={deviceId}
|
||||
onClose={() => setServerForm(null)}
|
||||
/>
|
||||
)}
|
||||
{poolForm && <PoolForm protocol={poolForm} deviceId={deviceId} onClose={() => setPoolForm(null)} />}
|
||||
{leaseForm && (
|
||||
<StaticLeaseForm
|
||||
protocol={leaseForm}
|
||||
servers={leaseForm === 'ipv4' ? (dhcp?.ipv4 || []) : (dhcp?.ipv6 || [])}
|
||||
deviceId={deviceId}
|
||||
onClose={() => setLeaseForm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">DHCP Server</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">Manage DHCP servers, address pools, and leases</p>
|
||||
</div>
|
||||
{deviceId > 0 && (
|
||||
<button onClick={() => refetch()} disabled={isFetching}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device selector */}
|
||||
<div className="card p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-2">Select Device</label>
|
||||
<select className="input w-full max-w-xs" value={selectedDeviceId}
|
||||
onChange={e => setSelectedDeviceId(e.target.value === '' ? '' : parseInt(e.target.value))}>
|
||||
<option value="">— Choose a device —</option>
|
||||
{devices.map(d => (
|
||||
<option key={d.id} value={d.id}>{d.name} ({d.ip_address}){d.status !== 'online' ? ' — offline' : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedDevice && selectedDevice.status !== 'online' && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />This device is currently offline. Connection may fail.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deviceId === 0 && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Select a device above.</div>}
|
||||
{deviceId > 0 && isLoading && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Loading…</div>}
|
||||
{deviceId > 0 && error && (
|
||||
<div className="card p-4 flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />Failed: {(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dhcp && (
|
||||
<>
|
||||
{/* IPv4 Servers */}
|
||||
<Section color="text-blue-500" title="IPv4 DHCP Servers" count={dhcp.ipv4.length}
|
||||
open={v4Open} onToggle={() => setV4Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setServerForm({ protocol: 'ipv4' })} className="flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Add</button> : undefined}>
|
||||
<ServerTable servers={dhcp.ipv4} protocol="ipv4" />
|
||||
</Section>
|
||||
|
||||
{/* IPv4 Pools */}
|
||||
<Section color="text-cyan-500" title="IPv4 Address Pools" count={dhcp.pools_v4.length}
|
||||
open={poolsV4Open} onToggle={() => setPoolsV4Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setPoolForm('ipv4')} className="flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Add</button> : undefined}>
|
||||
<PoolTable pools={dhcp.pools_v4} protocol="ipv4" />
|
||||
</Section>
|
||||
|
||||
{/* IPv4 Leases */}
|
||||
<Section color="text-sky-500" title="IPv4 Leases" count={leasesV4.length}
|
||||
open={leasesOpen} onToggle={() => setLeasesOpen(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setLeaseForm('ipv4')} className="flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Static</button> : undefined}>
|
||||
<LeaseTable leases={leasesV4} protocol="ipv4" />
|
||||
</Section>
|
||||
|
||||
{/* IPv6 Servers */}
|
||||
<Section color="text-indigo-500" title="IPv6 DHCP Servers" count={dhcp.ipv6.length}
|
||||
open={v6Open} onToggle={() => setV6Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setServerForm({ protocol: 'ipv6' })} className="flex items-center gap-1 px-2.5 py-1 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Add</button> : undefined}>
|
||||
<ServerTable servers={dhcp.ipv6} protocol="ipv6" />
|
||||
</Section>
|
||||
|
||||
{/* IPv6 Pools */}
|
||||
<Section color="text-violet-500" title="IPv6 Address Pools" count={dhcp.pools_v6.length}
|
||||
open={poolsV6Open} onToggle={() => setPoolsV6Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setPoolForm('ipv6')} className="flex items-center gap-1 px-2.5 py-1 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Add</button> : undefined}>
|
||||
<PoolTable pools={dhcp.pools_v6} protocol="ipv6" />
|
||||
</Section>
|
||||
|
||||
{/* IPv6 Leases */}
|
||||
<Section color="text-purple-500" title="IPv6 Bindings" count={leasesV6.length}
|
||||
open={leasesV6Open} onToggle={() => setLeasesV6Open(o => !o)}
|
||||
action={canWrite ? <button onClick={() => setLeaseForm('ipv6')} className="flex items-center gap-1 px-2.5 py-1 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded-lg transition-colors"><Plus className="w-3 h-3" />Static</button> : undefined}>
|
||||
<LeaseTable leases={leasesV6} protocol="ipv6" />
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Globe, RefreshCw, AlertTriangle, Save, Trash2, Plus, Pencil, X, Check, Eraser,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { networkServicesApi, devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
|
||||
type NS = Record<string, string>;
|
||||
|
||||
// ─── DNS record types ─────────────────────────────────────────────────────────
|
||||
|
||||
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'PTR', 'TXT', 'SRV'] as const;
|
||||
type RecordType = typeof RECORD_TYPES[number];
|
||||
|
||||
// ─── Static record form modal ─────────────────────────────────────────────────
|
||||
|
||||
interface RecordFormProps {
|
||||
deviceId: number;
|
||||
existing?: NS;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function RecordForm({ deviceId, existing, onClose }: RecordFormProps) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(existing?.['name'] || '');
|
||||
const [type, setType] = useState<RecordType>((existing?.['type'] as RecordType) || 'A');
|
||||
const [address, setAddress] = useState(existing?.['address'] || '');
|
||||
const [cname, setCname] = useState(existing?.['cname'] || '');
|
||||
const [text, setText] = useState(existing?.['text'] || '');
|
||||
const [ttl, setTtl] = useState(existing?.['ttl'] || '');
|
||||
const [disabled, setDisabled] = useState(existing?.['disabled'] === 'true');
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: NS = { name, type };
|
||||
if (type === 'A' || type === 'AAAA') body['address'] = address;
|
||||
else if (type === 'CNAME') body['cname'] = cname;
|
||||
else if (type === 'TXT') body['text'] = text;
|
||||
else body['address'] = address; // MX, NS, PTR, SRV share address field
|
||||
if (ttl) body['ttl'] = ttl;
|
||||
body['disabled'] = disabled ? 'yes' : 'no';
|
||||
|
||||
return existing?.['.id']
|
||||
? networkServicesApi.updateDnsStatic(deviceId, existing['.id'], body)
|
||||
: networkServicesApi.addDnsStatic(deviceId, body);
|
||||
},
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-dns', deviceId] }); onClose(); },
|
||||
});
|
||||
|
||||
const valueLabel = type === 'CNAME' ? 'CNAME Target' : type === 'TXT' ? 'Text' : 'Address / Target';
|
||||
const valuePlaceholder =
|
||||
type === 'A' ? '192.168.1.10' :
|
||||
type === 'AAAA' ? '2001:db8::1' :
|
||||
type === 'CNAME' ? 'alias.example.com' :
|
||||
type === 'TXT' ? '"v=spf1 include:example.com ~all"' :
|
||||
type === 'MX' || type === 'NS' ? 'mail.example.com' :
|
||||
type === 'PTR' ? 'hostname.example.com' :
|
||||
'_sip._tcp.example.com';
|
||||
|
||||
const valueField = type === 'TXT'
|
||||
? <textarea className="input w-full h-20 resize-y" value={text} onChange={e => setText(e.target.value)} placeholder={valuePlaceholder} />
|
||||
: type === 'CNAME'
|
||||
? <input className="input w-full" value={cname} onChange={e => setCname(e.target.value)} placeholder={valuePlaceholder} />
|
||||
: <input className="input w-full" value={address} onChange={e => setAddress(e.target.value)} placeholder={valuePlaceholder} />;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{existing ? 'Edit' : 'Add'} DNS Record
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Name *</label>
|
||||
<input className="input w-full" value={name} onChange={e => setName(e.target.value)} placeholder="hostname.local" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Type</label>
|
||||
<select className="input w-full" value={type} onChange={e => setType(e.target.value as RecordType)}>
|
||||
{RECORD_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">{valueLabel} *</label>
|
||||
{valueField}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">TTL</label>
|
||||
<input className="input w-full" value={ttl} onChange={e => setTtl(e.target.value)} placeholder="e.g. 5m (blank = default)" />
|
||||
</div>
|
||||
<div className="flex items-end gap-2 pb-0.5">
|
||||
<button role="switch" aria-checked={disabled}
|
||||
onClick={() => setDisabled(v => !v)}
|
||||
className={clsx('relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors mb-0.5', disabled ? 'bg-gray-300 dark:bg-slate-600' : 'bg-blue-600')}>
|
||||
<span className={clsx('pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform', disabled ? 'translate-x-0' : 'translate-x-4')} />
|
||||
</button>
|
||||
<span className="text-xs text-gray-600 dark:text-slate-300">Enabled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button onClick={() => save.mutate()} disabled={!name || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors">
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{save.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function NetworkServicesDNSPage() {
|
||||
const canWrite = useCanWrite();
|
||||
const qc = useQueryClient();
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | ''>('');
|
||||
|
||||
// Settings form state
|
||||
const [serversInput, setServersInput] = useState('');
|
||||
const [allowRemote, setAllowRemote] = useState(false);
|
||||
const [maxUdpSize, setMaxUdpSize] = useState('');
|
||||
const [cacheSize, setCacheSize] = useState('');
|
||||
const [cacheMaxTtl, setCacheMaxTtl] = useState('');
|
||||
const [settingsDirty, setSettingsDirty] = useState(false);
|
||||
|
||||
const [recordForm, setRecordForm] = useState<NS | null | 'new'>(null);
|
||||
const [flushMsg, setFlushMsg] = useState('');
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const deviceId = typeof selectedDeviceId === 'number' ? selectedDeviceId : 0;
|
||||
const selectedDevice = devices.find(d => d.id === deviceId);
|
||||
|
||||
const { data: dns, isLoading, refetch, isFetching, error } = useQuery({
|
||||
queryKey: ['ns-dns', deviceId],
|
||||
queryFn: () => networkServicesApi.getDns(deviceId).then(r => r.data),
|
||||
enabled: deviceId > 0,
|
||||
});
|
||||
|
||||
// Sync settings form
|
||||
useEffect(() => {
|
||||
if (dns?.settings) {
|
||||
setServersInput(dns.settings['servers'] || '');
|
||||
setAllowRemote(dns.settings['allow-remote-requests'] === 'yes');
|
||||
setMaxUdpSize(dns.settings['max-udp-packet-size'] || '');
|
||||
setCacheSize(dns.settings['cache-size'] || '');
|
||||
setCacheMaxTtl(dns.settings['cache-max-ttl'] || '');
|
||||
setSettingsDirty(false);
|
||||
}
|
||||
}, [dns?.settings]);
|
||||
|
||||
// Conflict detection
|
||||
const { data: overview = [] } = useQuery({
|
||||
queryKey: ['network-services-overview'],
|
||||
queryFn: () => networkServicesApi.overview().then(r => r.data as NS[]),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const conflictDevices = overview.filter(d => {
|
||||
if (parseInt(d['id']) === deviceId) return false;
|
||||
return (d['dns'] as unknown as { allow_remote: boolean } | null)?.allow_remote;
|
||||
});
|
||||
|
||||
const saveSettings = useMutation({
|
||||
mutationFn: () => networkServicesApi.setDns(deviceId, {
|
||||
servers: serversInput, allow_remote_requests: allowRemote,
|
||||
max_udp_packet_size: maxUdpSize, cache_size: cacheSize, cache_max_ttl: cacheMaxTtl,
|
||||
}),
|
||||
onSuccess: () => { setSettingsDirty(false); qc.invalidateQueries({ queryKey: ['ns-dns', deviceId] }); qc.invalidateQueries({ queryKey: ['network-services-overview'] }); },
|
||||
});
|
||||
|
||||
const flushCache = useMutation({
|
||||
mutationFn: () => networkServicesApi.flushDns(deviceId),
|
||||
onSuccess: () => { setFlushMsg('Cache flushed'); setTimeout(() => setFlushMsg(''), 3000); },
|
||||
});
|
||||
|
||||
const deleteRecord = useMutation({
|
||||
mutationFn: (id: string) => networkServicesApi.deleteDnsStatic(deviceId, id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-dns', deviceId] }),
|
||||
});
|
||||
|
||||
const statics = dns?.statics || [];
|
||||
|
||||
function mark() { setSettingsDirty(true); }
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Record form modal */}
|
||||
{recordForm && (
|
||||
<RecordForm
|
||||
deviceId={deviceId}
|
||||
existing={recordForm === 'new' ? undefined : recordForm as NS}
|
||||
onClose={() => setRecordForm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">DNS Server</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">DNS resolver settings and static records</p>
|
||||
</div>
|
||||
{deviceId > 0 && (
|
||||
<button onClick={() => refetch()} disabled={isFetching}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device selector */}
|
||||
<div className="card p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-2">Select Device</label>
|
||||
<select className="input w-full max-w-xs" value={selectedDeviceId}
|
||||
onChange={e => setSelectedDeviceId(e.target.value === '' ? '' : parseInt(e.target.value))}>
|
||||
<option value="">— Choose a device —</option>
|
||||
{devices.map(d => <option key={d.id} value={d.id}>{d.name} ({d.ip_address}){d.status !== 'online' ? ' — offline' : ''}</option>)}
|
||||
</select>
|
||||
{selectedDevice?.status !== 'online' && deviceId > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />This device is currently offline.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{conflictDevices.length > 0 && deviceId > 0 && allowRemote && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 text-sm text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>DNS remote requests also enabled on: <strong>{conflictDevices.map(d => d['name']).join(', ')}</strong>. Multiple DNS servers may cause resolution issues.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deviceId === 0 && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Select a device above.</div>}
|
||||
{deviceId > 0 && isLoading && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Loading…</div>}
|
||||
{deviceId > 0 && error && (
|
||||
<div className="card p-4 flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />Failed: {(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dns && (
|
||||
<>
|
||||
{/* ─── Settings ────────────────────────────────────────────────────── */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">DNS Settings</h2>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => flushCache.mutate()}
|
||||
disabled={flushCache.isPending}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium text-gray-600 dark:text-slate-300 border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Eraser className="w-3 h-3" />
|
||||
{flushCache.isPending ? 'Flushing…' : 'Flush Cache'}
|
||||
</button>
|
||||
)}
|
||||
{flushMsg && <span className="text-xs text-green-600 dark:text-green-400">{flushMsg}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">Upstream DNS Servers</label>
|
||||
<input type="text" className="input w-full max-w-sm" value={serversInput}
|
||||
onChange={e => { setServersInput(e.target.value); mark(); }} placeholder="8.8.8.8,1.1.1.1" disabled={!canWrite} />
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-slate-500">Comma-separated upstream resolver addresses.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">Max UDP Packet Size</label>
|
||||
<input type="text" className="input w-full" value={maxUdpSize}
|
||||
onChange={e => { setMaxUdpSize(e.target.value); mark(); }} placeholder="4096" disabled={!canWrite} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">Cache Max TTL</label>
|
||||
<input type="text" className="input w-full" value={cacheMaxTtl}
|
||||
onChange={e => { setCacheMaxTtl(e.target.value); mark(); }} placeholder="e.g. 1d" disabled={!canWrite} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<button role="switch" aria-checked={allowRemote}
|
||||
onClick={() => { if (canWrite) { setAllowRemote(v => !v); mark(); } }}
|
||||
disabled={!canWrite}
|
||||
className={clsx('relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors mt-0.5',
|
||||
allowRemote ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600', !canWrite && 'opacity-50 cursor-not-allowed')}>
|
||||
<span className={clsx('pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform', allowRemote ? 'translate-x-4' : 'translate-x-0')} />
|
||||
</button>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-slate-200">Allow Remote Requests</div>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">Allow other devices to use this router as a DNS resolver.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only stats */}
|
||||
{(dns.settings['cache-used'] || dns.settings['cache-size'] || dns.settings['dynamic-servers']) && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 pt-3 border-t border-gray-100 dark:border-slate-800">
|
||||
{dns.settings['cache-size'] && <div><div className="text-xs text-gray-400 mb-0.5">Cache Size</div><div className="text-sm font-medium text-gray-900 dark:text-white">{dns.settings['cache-size']} KiB</div></div>}
|
||||
{dns.settings['cache-used'] && <div><div className="text-xs text-gray-400 mb-0.5">Cache Used</div><div className="text-sm font-medium text-gray-900 dark:text-white">{dns.settings['cache-used']} KiB</div></div>}
|
||||
{dns.settings['dynamic-servers'] && <div><div className="text-xs text-gray-400 mb-0.5">Dynamic Servers</div><div className="text-sm font-medium text-gray-900 dark:text-white">{dns.settings['dynamic-servers']}</div></div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && settingsDirty && (
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors">
|
||||
<Save className="w-3.5 h-3.5" />{saveSettings.isPending ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
const s = dns.settings;
|
||||
setServersInput(s['servers'] || ''); setAllowRemote(s['allow-remote-requests'] === 'yes');
|
||||
setMaxUdpSize(s['max-udp-packet-size'] || ''); setCacheSize(s['cache-size'] || '');
|
||||
setCacheMaxTtl(s['cache-max-ttl'] || ''); setSettingsDirty(false);
|
||||
}} className="text-sm text-gray-500 hover:text-gray-700">Discard</button>
|
||||
{saveSettings.isError && <span className="text-xs text-red-500">{(saveSettings.error as Error).message}</span>}
|
||||
</div>
|
||||
)}
|
||||
{canWrite && saveSettings.isSuccess && !settingsDirty && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">Saved</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Static Records ───────────────────────────────────────────────── */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-green-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Static DNS Records</h2>
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500 ml-1">{statics.length}</span>
|
||||
{canWrite && (
|
||||
<button onClick={() => setRecordForm('new')}
|
||||
className="ml-auto flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors">
|
||||
<Plus className="w-3 h-3" />Add Record
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{statics.length === 0 ? (
|
||||
<div className="px-5 py-6 text-sm text-gray-400 dark:text-slate-500 text-center">No static records configured.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Name</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Type</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Value</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">TTL</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Status</th>
|
||||
{canWrite && <th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statics.map((r, i) => {
|
||||
const isDisabled = r['disabled'] === 'true';
|
||||
const value = r['address'] || r['cname'] || r['text'] || '—';
|
||||
return (
|
||||
<tr key={r['.id'] || i} className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40', i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{r['name'] || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 font-mono font-medium">
|
||||
{r['type'] || 'A'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-slate-300 max-w-xs truncate">{value}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-slate-400 text-xs">{r['ttl'] || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
isDisabled ? 'bg-gray-100 dark:bg-slate-700 text-gray-500' : 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400')}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', isDisabled ? 'bg-gray-400' : 'bg-green-500')} />
|
||||
{isDisabled ? 'Disabled' : 'Active'}
|
||||
</span>
|
||||
</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => setRecordForm(r)} className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"><Pencil className="w-3.5 h-3.5" /></button>
|
||||
<button onClick={() => { if (confirm(`Delete record "${r['name']}"?`)) deleteRecord.mutate(r['.id']); }}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"><Trash2 className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Clock, RefreshCw, AlertTriangle, Save } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { networkServicesApi, devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
role="switch" aria-checked={checked}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
className={clsx(
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors',
|
||||
checked ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<span className={clsx('pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform', checked ? 'translate-x-4' : 'translate-x-0')} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({ label, description, children }: { label: string; description?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="pt-0.5">{children}</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-slate-200">{label}</div>
|
||||
{description && <p className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkServicesNTPPage() {
|
||||
const canWrite = useCanWrite();
|
||||
const qc = useQueryClient();
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | ''>('');
|
||||
|
||||
// Server form
|
||||
const [serverEnabled, setServerEnabled] = useState(false);
|
||||
const [serverBroadcast, setServerBroadcast] = useState(false);
|
||||
const [serverManycast, setServerManycast] = useState(false);
|
||||
|
||||
// Client form
|
||||
const [clientEnabled, setClientEnabled] = useState(false);
|
||||
const [clientMode, setClientMode] = useState('unicast');
|
||||
const [clientServers, setClientServers] = useState('');
|
||||
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const deviceId = typeof selectedDeviceId === 'number' ? selectedDeviceId : 0;
|
||||
const selectedDevice = devices.find(d => d.id === deviceId);
|
||||
|
||||
const { data: ntp, isLoading, refetch, isFetching, error } = useQuery({
|
||||
queryKey: ['ns-ntp', deviceId],
|
||||
queryFn: () => networkServicesApi.getNtp(deviceId).then(r => r.data),
|
||||
enabled: deviceId > 0,
|
||||
});
|
||||
|
||||
// Conflict detection
|
||||
const { data: overview = [] } = useQuery({
|
||||
queryKey: ['network-services-overview'],
|
||||
queryFn: () => networkServicesApi.overview().then(r => r.data as Record<string, unknown>[]),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const conflictDevices = overview.filter(d => {
|
||||
if ((d.id as number) === deviceId) return false;
|
||||
const n = d.ntp as { server_enabled: boolean } | null;
|
||||
return n?.server_enabled;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (ntp) {
|
||||
setServerEnabled(ntp.server?.['enabled'] === 'yes');
|
||||
setServerBroadcast(ntp.server?.['broadcast'] === 'yes');
|
||||
setServerManycast(ntp.server?.['manycast'] === 'yes');
|
||||
setClientEnabled(ntp.client?.['enabled'] === 'yes');
|
||||
setClientMode(ntp.client?.['mode'] || 'unicast');
|
||||
setClientServers(ntp.client?.['servers'] || '');
|
||||
setDirty(false);
|
||||
}
|
||||
}, [ntp]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => networkServicesApi.setNtp(deviceId, {
|
||||
server_enabled: serverEnabled, server_broadcast: serverBroadcast, server_manycast: serverManycast,
|
||||
client_enabled: clientEnabled, client_mode: clientMode, client_servers: clientServers,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setDirty(false);
|
||||
qc.invalidateQueries({ queryKey: ['ns-ntp', deviceId] });
|
||||
qc.invalidateQueries({ queryKey: ['network-services-overview'] });
|
||||
},
|
||||
});
|
||||
|
||||
function mark() { setDirty(true); }
|
||||
|
||||
const serverHasData = ntp && Object.keys(ntp.server || {}).length > 0;
|
||||
const clientHasData = ntp && Object.keys(ntp.client || {}).length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">NTP Server</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">Network Time Protocol server and client configuration</p>
|
||||
</div>
|
||||
{deviceId > 0 && (
|
||||
<button onClick={() => refetch()} disabled={isFetching}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />Refresh
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device selector */}
|
||||
<div className="card p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-2">Select Device</label>
|
||||
<select className="input w-full max-w-xs" value={selectedDeviceId}
|
||||
onChange={e => setSelectedDeviceId(e.target.value === '' ? '' : parseInt(e.target.value))}>
|
||||
<option value="">— Choose a device —</option>
|
||||
{devices.map(d => <option key={d.id} value={d.id}>{d.name} ({d.ip_address}){d.status !== 'online' ? ' — offline' : ''}</option>)}
|
||||
</select>
|
||||
{selectedDevice?.status !== 'online' && deviceId > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />This device is currently offline.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conflict warning */}
|
||||
{conflictDevices.length > 0 && deviceId > 0 && serverEnabled && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 text-sm text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>NTP server also enabled on: <strong>{conflictDevices.map(d => d.name as string).join(', ')}</strong>. Multiple NTP servers may cause time drift.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deviceId === 0 && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Select a device above.</div>}
|
||||
{deviceId > 0 && isLoading && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Loading…</div>}
|
||||
{deviceId > 0 && error && (
|
||||
<div className="card p-4 flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />Failed: {(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ntp && (
|
||||
<div className="space-y-4">
|
||||
{/* NTP Server */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">NTP Server</h2>
|
||||
<span className="ml-auto">
|
||||
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
serverEnabled ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400')}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', serverEnabled ? 'bg-green-500' : 'bg-gray-400')} />
|
||||
{serverEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
{!serverHasData ? (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">NTP server settings not available (requires RouterOS 7).</p>
|
||||
) : (
|
||||
<>
|
||||
<SettingRow label="Enable NTP Server" description="Allow other devices on the network to sync their clocks to this device.">
|
||||
<Toggle checked={serverEnabled} onChange={v => { setServerEnabled(v); mark(); }} disabled={!canWrite} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Broadcast Mode" description="Periodically broadcast the current time on the local network segment.">
|
||||
<Toggle checked={serverBroadcast} onChange={v => { setServerBroadcast(v); mark(); }} disabled={!canWrite || !serverEnabled} />
|
||||
</SettingRow>
|
||||
<SettingRow label="Manycast Mode" description="Respond to manycast NTP client requests.">
|
||||
<Toggle checked={serverManycast} onChange={v => { setServerManycast(v); mark(); }} disabled={!canWrite || !serverEnabled} />
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NTP Client */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-indigo-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">NTP Client</h2>
|
||||
<span className="ml-auto">
|
||||
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
clientEnabled ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400')}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', clientEnabled ? 'bg-green-500' : 'bg-gray-400')} />
|
||||
{clientEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
{!clientHasData ? (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">NTP client settings not available (requires RouterOS 7).</p>
|
||||
) : (
|
||||
<>
|
||||
<SettingRow label="Enable NTP Client" description="Sync this device's clock to upstream NTP servers.">
|
||||
<Toggle checked={clientEnabled} onChange={v => { setClientEnabled(v); mark(); }} disabled={!canWrite} />
|
||||
</SettingRow>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">Mode</label>
|
||||
<select className="input w-full max-w-xs" value={clientMode}
|
||||
onChange={e => { setClientMode(e.target.value); mark(); }} disabled={!canWrite || !clientEnabled}>
|
||||
<option value="unicast">Unicast (direct server)</option>
|
||||
<option value="broadcast">Broadcast (listen for broadcasts)</option>
|
||||
<option value="multicast">Multicast</option>
|
||||
<option value="manycast">Manycast (auto-discover)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-slate-500">
|
||||
Unicast is recommended for most setups.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(clientMode === 'unicast' || clientMode === 'manycast') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">NTP Servers</label>
|
||||
<input type="text" className="input w-full max-w-sm" value={clientServers}
|
||||
onChange={e => { setClientServers(e.target.value); mark(); }}
|
||||
placeholder="pool.ntp.org,time.cloudflare.com" disabled={!canWrite || !clientEnabled} />
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-slate-500">Comma-separated list of NTP server addresses.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ntp.client['status'] && (
|
||||
<div className="pt-3 border-t border-gray-100 dark:border-slate-800">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mb-0.5">Sync Status</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white capitalize">{ntp.client['status']}</div>
|
||||
</div>
|
||||
{ntp.client['synced-server'] && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mb-0.5">Synced Server</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{ntp.client['synced-server']}</div>
|
||||
</div>
|
||||
)}
|
||||
{ntp.client['last-adjustment'] && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mb-0.5">Last Adjustment</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{ntp.client['last-adjustment']}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save bar */}
|
||||
{canWrite && dirty && (
|
||||
<div className="card p-4 flex items-center gap-3">
|
||||
<button onClick={() => save.mutate()} disabled={save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors">
|
||||
<Save className="w-3.5 h-3.5" />{save.isPending ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
if (ntp) {
|
||||
setServerEnabled(ntp.server?.['enabled'] === 'yes');
|
||||
setServerBroadcast(ntp.server?.['broadcast'] === 'yes');
|
||||
setServerManycast(ntp.server?.['manycast'] === 'yes');
|
||||
setClientEnabled(ntp.client?.['enabled'] === 'yes');
|
||||
setClientMode(ntp.client?.['mode'] || 'unicast');
|
||||
setClientServers(ntp.client?.['servers'] || '');
|
||||
setDirty(false);
|
||||
}
|
||||
}} className="text-sm text-gray-500 hover:text-gray-700">Discard</button>
|
||||
{save.isError && <span className="text-sm text-red-500">{(save.error as Error).message}</span>}
|
||||
{save.isSuccess && <span className="text-sm text-green-600 dark:text-green-400">Saved</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Server, RefreshCw, CheckCircle, XCircle, MinusCircle,
|
||||
Globe, Clock, Shield, Wifi,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { networkServicesApi } from '../services/api';
|
||||
|
||||
interface DeviceServiceRow {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
dhcp_v4: { total: number; enabled: number } | null;
|
||||
dhcp_v6: { total: number; enabled: number } | null;
|
||||
dns: { allow_remote: boolean; servers: string } | null;
|
||||
ntp: { server_enabled: boolean; client_enabled: boolean } | null;
|
||||
wireguard: { total: number; running: number } | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function ServiceCell({ active, label }: { active: boolean | null; label?: string }) {
|
||||
if (active === null) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-400 dark:text-slate-500">
|
||||
<MinusCircle className="w-3.5 h-3.5" />
|
||||
{label ?? '—'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (active) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
{label ?? 'Active'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-400 dark:text-slate-500">
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
{label ?? 'Inactive'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkServicesOverviewPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['network-services-overview'],
|
||||
queryFn: () => networkServicesApi.overview().then(r => r.data as unknown as DeviceServiceRow[]),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const serviceColumns = [
|
||||
{ key: 'dhcp_v4', label: 'DHCP v4', icon: Server, href: '/network-services/dhcp' },
|
||||
{ key: 'dhcp_v6', label: 'DHCP v6', icon: Server, href: '/network-services/dhcp' },
|
||||
{ key: 'dns', label: 'DNS', icon: Globe, href: '/network-services/dns' },
|
||||
{ key: 'ntp', label: 'NTP', icon: Clock, href: '/network-services/ntp' },
|
||||
{ key: 'wireguard', label: 'WireGuard', icon: Shield, href: '/network-services/wireguard' },
|
||||
] as const;
|
||||
|
||||
// KPI: for each service, how many devices have it active?
|
||||
const kpis = serviceColumns.map((col) => {
|
||||
const active = data.filter((d) => {
|
||||
const svc = d[col.key];
|
||||
if (!svc) return false;
|
||||
if (col.key === 'dhcp_v4' || col.key === 'dhcp_v6') {
|
||||
return (svc as { enabled: number }).enabled > 0;
|
||||
}
|
||||
if (col.key === 'dns') return (svc as { allow_remote: boolean }).allow_remote;
|
||||
if (col.key === 'ntp') return (svc as { server_enabled: boolean }).server_enabled || (svc as { client_enabled: boolean }).client_enabled;
|
||||
if (col.key === 'wireguard') return (svc as { running: number }).running > 0;
|
||||
return false;
|
||||
}).length;
|
||||
return { ...col, active, total: data.length };
|
||||
});
|
||||
|
||||
function renderCell(device: DeviceServiceRow, key: typeof serviceColumns[number]['key']) {
|
||||
const svc = device[key];
|
||||
if (device.error || !svc) {
|
||||
return <ServiceCell active={null} />;
|
||||
}
|
||||
if (key === 'dhcp_v4' || key === 'dhcp_v6') {
|
||||
const s = svc as { total: number; enabled: number };
|
||||
if (s.total === 0) return <ServiceCell active={null} label="None" />;
|
||||
return <ServiceCell active={s.enabled > 0} label={`${s.enabled}/${s.total} active`} />;
|
||||
}
|
||||
if (key === 'dns') {
|
||||
const s = svc as { allow_remote: boolean; servers: string };
|
||||
return <ServiceCell active={s.allow_remote} label={s.allow_remote ? 'Remote on' : 'Local only'} />;
|
||||
}
|
||||
if (key === 'ntp') {
|
||||
const s = svc as { server_enabled: boolean; client_enabled: boolean };
|
||||
const label = s.server_enabled ? 'Server on' : s.client_enabled ? 'Client only' : 'Disabled';
|
||||
return <ServiceCell active={s.server_enabled || s.client_enabled} label={label} />;
|
||||
}
|
||||
if (key === 'wireguard') {
|
||||
const s = svc as { total: number; running: number };
|
||||
if (s.total === 0) return <ServiceCell active={null} label="None" />;
|
||||
return <ServiceCell active={s.running > 0} label={`${s.running}/${s.total} up`} />;
|
||||
}
|
||||
return <ServiceCell active={null} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Network Services</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">
|
||||
Service status across all online devices
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{kpis.map(({ key, label, icon: Icon, active, total, href }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => navigate(href)}
|
||||
className="card p-4 flex flex-col gap-2 text-left hover:shadow-md transition-shadow cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<Icon className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400">{label}</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{active}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">of {total} device{total !== 1 ? 's' : ''}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<Wifi className="w-4 h-4 text-gray-400 dark:text-slate-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Per-Device Service Status</h2>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400 dark:text-slate-500">Loading service status…</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-gray-400 dark:text-slate-500">No online devices found.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Device</th>
|
||||
{serviceColumns.map((col) => (
|
||||
<th key={col.key} className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((device, i) => (
|
||||
<tr
|
||||
key={device.id}
|
||||
className={clsx(
|
||||
'border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40',
|
||||
i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{device.name}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">{device.ip_address}</div>
|
||||
{device.error && (
|
||||
<div className="text-xs text-red-500 mt-0.5">Connection failed</div>
|
||||
)}
|
||||
</td>
|
||||
{serviceColumns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
{renderCell(device, col.key)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Shield, RefreshCw, AlertTriangle, Power, Plus, Pencil, Trash2,
|
||||
ChevronDown, ChevronRight, Key, Copy, Check, X,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { networkServicesApi, devicesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
|
||||
type NS = Record<string, string>;
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function truncateKey(key: string): string {
|
||||
if (!key || key.length <= 16) return key;
|
||||
return `${key.slice(0, 8)}…${key.slice(-6)}`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: string): string {
|
||||
const n = parseInt(bytes);
|
||||
if (!n || isNaN(n)) return '—';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); }}
|
||||
className="p-1 rounded text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Interface form modal ─────────────────────────────────────────────────────
|
||||
|
||||
interface IfaceFormProps {
|
||||
deviceId: number;
|
||||
existing?: NS;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function IfaceForm({ deviceId, existing, onClose }: IfaceFormProps) {
|
||||
const qc = useQueryClient();
|
||||
const [name, setName] = useState(existing?.['name'] || '');
|
||||
const [listenPort, setListenPort] = useState(existing?.['listen-port'] || '13231');
|
||||
const [mtu, setMtu] = useState(existing?.['mtu'] || '1420');
|
||||
const [disabled, setDisabled] = useState(existing?.['disabled'] === 'true');
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: NS = { name, 'listen-port': listenPort, mtu, disabled: disabled ? 'yes' : 'no' };
|
||||
return existing?.['.id']
|
||||
? networkServicesApi.updateWireGuardInterface(deviceId, existing['.id'], body)
|
||||
: networkServicesApi.addWireGuardInterface(deviceId, body);
|
||||
},
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-wireguard', deviceId] }); onClose(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-sm">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{existing ? 'Edit' : 'Add'} WireGuard Interface
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Name *</label>
|
||||
<input className="input w-full" value={name} onChange={e => setName(e.target.value)} placeholder="wireguard1" disabled={!!existing} />
|
||||
{!existing && <p className="mt-0.5 text-xs text-gray-400">RouterOS will auto-generate a key pair.</p>}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Listen Port</label>
|
||||
<input className="input w-full" type="number" value={listenPort} onChange={e => setListenPort(e.target.value)} placeholder="13231" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">MTU</label>
|
||||
<input className="input w-full" type="number" value={mtu} onChange={e => setMtu(e.target.value)} placeholder="1420" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button role="switch" aria-checked={!disabled} onClick={() => setDisabled(v => !v)}
|
||||
className={clsx('relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors', !disabled ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600')}>
|
||||
<span className={clsx('pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform', !disabled ? 'translate-x-4' : 'translate-x-0')} />
|
||||
</button>
|
||||
<span className="text-sm text-gray-700 dark:text-slate-200">Enabled</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button onClick={() => save.mutate()} disabled={!name || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors">
|
||||
<Check className="w-3.5 h-3.5" />{save.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Peer form modal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface PeerFormProps {
|
||||
deviceId: number;
|
||||
ifaceName: string;
|
||||
existing?: NS;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function PeerForm({ deviceId, ifaceName, existing, onClose }: PeerFormProps) {
|
||||
const qc = useQueryClient();
|
||||
const [pubKey, setPubKey] = useState(existing?.['public-key'] || '');
|
||||
const [allowedAddr, setAllowedAddr] = useState(existing?.['allowed-address'] || '');
|
||||
const [endpointAddr, setEndpointAddr] = useState(existing?.['endpoint-address'] || '');
|
||||
const [endpointPort, setEndpointPort] = useState(existing?.['endpoint-port'] || '');
|
||||
const [keepalive, setKeepalive] = useState(existing?.['persistent-keepalive'] || '');
|
||||
const [presharedKey, setPresharedKey] = useState('');
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: NS = {
|
||||
interface: ifaceName,
|
||||
'public-key': pubKey,
|
||||
'allowed-address': allowedAddr,
|
||||
};
|
||||
if (endpointAddr) body['endpoint-address'] = endpointAddr;
|
||||
if (endpointPort) body['endpoint-port'] = endpointPort;
|
||||
if (keepalive) body['persistent-keepalive'] = keepalive;
|
||||
if (presharedKey) body['preshared-key'] = presharedKey;
|
||||
|
||||
return existing?.['.id']
|
||||
? networkServicesApi.updateWireGuardPeer(deviceId, existing['.id'], body)
|
||||
: networkServicesApi.addWireGuardPeer(deviceId, body);
|
||||
},
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ns-wireguard', deviceId] }); onClose(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-lg">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{existing ? 'Edit' : 'Add'} Peer — {ifaceName}
|
||||
</h3>
|
||||
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"><X className="w-4 h-4" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Peer Public Key *</label>
|
||||
<input className="input w-full font-mono text-xs" value={pubKey} onChange={e => setPubKey(e.target.value)} placeholder="base64-encoded WireGuard public key" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Allowed Addresses *</label>
|
||||
<input className="input w-full font-mono" value={allowedAddr} onChange={e => setAllowedAddr(e.target.value)} placeholder="10.0.0.2/32 or 0.0.0.0/0" />
|
||||
<p className="mt-0.5 text-xs text-gray-400">Comma-separated CIDRs this peer is allowed to use.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Endpoint Address</label>
|
||||
<input className="input w-full" value={endpointAddr} onChange={e => setEndpointAddr(e.target.value)} placeholder="peer.example.com or IP" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Endpoint Port</label>
|
||||
<input className="input w-full" type="number" value={endpointPort} onChange={e => setEndpointPort(e.target.value)} placeholder="51820" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Persistent Keepalive (s)</label>
|
||||
<input className="input w-full" type="number" value={keepalive} onChange={e => setKeepalive(e.target.value)} placeholder="25" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 dark:text-slate-300 mb-1">Preshared Key</label>
|
||||
<input className="input w-full font-mono text-xs" value={presharedKey} onChange={e => setPresharedKey(e.target.value)} placeholder="Optional" type="password" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 pb-4 flex items-center gap-3">
|
||||
<button onClick={() => save.mutate()} disabled={!pubKey || !allowedAddr || save.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors">
|
||||
<Check className="w-3.5 h-3.5" />{save.isPending ? 'Saving…' : 'Save Peer'}
|
||||
</button>
|
||||
<button onClick={onClose} className="text-sm text-gray-500 hover:text-gray-700">Cancel</button>
|
||||
{save.isError && <span className="text-xs text-red-500">{(save.error as Error).message}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function NetworkServicesWireGuardPage() {
|
||||
const canWrite = useCanWrite();
|
||||
const qc = useQueryClient();
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | ''>('');
|
||||
const [expandedIface, setExpandedIface] = useState<string | null>(null);
|
||||
const [ifaceForm, setIfaceForm] = useState<NS | 'new' | null>(null);
|
||||
const [peerForm, setPeerForm] = useState<{ ifaceName: string; existing?: NS } | null>(null);
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const deviceId = typeof selectedDeviceId === 'number' ? selectedDeviceId : 0;
|
||||
const selectedDevice = devices.find(d => d.id === deviceId);
|
||||
|
||||
const { data: wg, isLoading, refetch, isFetching, error } = useQuery({
|
||||
queryKey: ['ns-wireguard', deviceId],
|
||||
queryFn: () => networkServicesApi.getWireGuard(deviceId).then(r => r.data),
|
||||
enabled: deviceId > 0,
|
||||
});
|
||||
|
||||
const toggleIface = useMutation({
|
||||
mutationFn: ({ interfaceId, disabled }: { interfaceId: string; disabled: boolean }) =>
|
||||
networkServicesApi.toggleWireGuard(deviceId, interfaceId, disabled),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-wireguard', deviceId] }),
|
||||
});
|
||||
|
||||
const deleteIface = useMutation({
|
||||
mutationFn: (id: string) => networkServicesApi.deleteWireGuardInterface(deviceId, id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-wireguard', deviceId] }),
|
||||
});
|
||||
|
||||
const deletePeer = useMutation({
|
||||
mutationFn: (id: string) => networkServicesApi.deleteWireGuardPeer(deviceId, id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['ns-wireguard', deviceId] }),
|
||||
});
|
||||
|
||||
const peersForIface = (name: string) => (wg?.peers || []).filter(p => p['interface'] === name);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Modals */}
|
||||
{ifaceForm && (
|
||||
<IfaceForm
|
||||
deviceId={deviceId}
|
||||
existing={ifaceForm === 'new' ? undefined : ifaceForm as NS}
|
||||
onClose={() => setIfaceForm(null)}
|
||||
/>
|
||||
)}
|
||||
{peerForm && (
|
||||
<PeerForm
|
||||
deviceId={deviceId}
|
||||
ifaceName={peerForm.ifaceName}
|
||||
existing={peerForm.existing}
|
||||
onClose={() => setPeerForm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">WireGuard</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">WireGuard VPN interfaces and peers</p>
|
||||
</div>
|
||||
{deviceId > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{canWrite && (
|
||||
<button onClick={() => setIfaceForm('new')}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
<Plus className="w-3.5 h-3.5" />New Interface
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => refetch()} disabled={isFetching}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />Refresh
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device selector */}
|
||||
<div className="card p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-2">Select Device</label>
|
||||
<select className="input w-full max-w-xs" value={selectedDeviceId}
|
||||
onChange={e => setSelectedDeviceId(e.target.value === '' ? '' : parseInt(e.target.value))}>
|
||||
<option value="">— Choose a device —</option>
|
||||
{devices.map(d => <option key={d.id} value={d.id}>{d.name} ({d.ip_address}){d.status !== 'online' ? ' — offline' : ''}</option>)}
|
||||
</select>
|
||||
{selectedDevice?.status !== 'online' && deviceId > 0 && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />This device is currently offline.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deviceId === 0 && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Select a device above.</div>}
|
||||
{deviceId > 0 && isLoading && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Loading…</div>}
|
||||
{deviceId > 0 && error && (
|
||||
<div className="card p-4 flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />Failed: {(error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wg && wg.interfaces.length === 0 && deviceId > 0 && !isLoading && (
|
||||
<div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">
|
||||
No WireGuard interfaces configured.{canWrite && ' Click "New Interface" to add one.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wg && wg.interfaces.map(iface => {
|
||||
const id = iface['.id'] || '';
|
||||
const name = iface['name'] || id;
|
||||
const disabled = iface['disabled'] === 'true';
|
||||
const running = iface['running'] === 'true';
|
||||
const peers = peersForIface(name);
|
||||
const isExpanded = expandedIface === name;
|
||||
|
||||
return (
|
||||
<div key={id || name} className="card overflow-hidden">
|
||||
{/* Interface header */}
|
||||
<div className="flex items-center border-b border-gray-200 dark:border-slate-700">
|
||||
<button onClick={() => setExpandedIface(isExpanded ? null : name)}
|
||||
className="flex items-center gap-2 flex-1 px-5 py-3 text-left hover:bg-gray-50 dark:hover:bg-slate-800/40 transition-colors">
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
|
||||
<Shield className="w-4 h-4 text-indigo-500 flex-shrink-0" />
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-slate-200">{name}</span>
|
||||
{iface['listen-port'] && <span className="text-xs text-gray-400 dark:text-slate-500">:{iface['listen-port']}</span>}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
running && !disabled ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: disabled ? 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400'
|
||||
: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400')}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', running && !disabled ? 'bg-green-500' : disabled ? 'bg-gray-400' : 'bg-amber-400')} />
|
||||
{disabled ? 'Disabled' : running ? 'Running' : 'Stopped'}
|
||||
</span>
|
||||
{canWrite && id && (
|
||||
<>
|
||||
<button onClick={() => toggleIface.mutate({ interfaceId: id, disabled: !disabled })}
|
||||
title={disabled ? 'Enable' : 'Disable'}
|
||||
className={clsx('p-1.5 rounded-lg transition-colors',
|
||||
disabled ? 'text-gray-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'
|
||||
: 'text-green-600 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20')}>
|
||||
<Power className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => setIfaceForm(iface)} title="Edit"
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => { if (confirm(`Delete WireGuard interface "${name}" and all its peers?`)) deleteIface.mutate(id); }}
|
||||
title="Delete"
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Interface details */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{iface['public-key'] && (
|
||||
<div className="sm:col-span-2">
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mb-1 flex items-center gap-1">
|
||||
<Key className="w-3 h-3" /> Public Key
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-gray-700 dark:text-slate-300 break-all flex-1">{iface['public-key']}</code>
|
||||
<CopyButton text={iface['public-key']} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{iface['mtu'] && (
|
||||
<div><div className="text-xs text-gray-400 dark:text-slate-500 mb-0.5">MTU</div><div className="text-sm text-gray-900 dark:text-white">{iface['mtu']}</div></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Peers */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">
|
||||
Peers ({peers.length})
|
||||
</h3>
|
||||
{canWrite && (
|
||||
<button onClick={() => setPeerForm({ ifaceName: name })}
|
||||
className="flex items-center gap-1 px-2.5 py-1 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded-lg transition-colors">
|
||||
<Plus className="w-3 h-3" />Add Peer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{peers.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">No peers configured.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-slate-700">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Public Key</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Allowed Addresses</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Endpoint</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">TX / RX</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Last Handshake</th>
|
||||
{canWrite && <th className="px-3 py-2 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{peers.map((peer, i) => (
|
||||
<tr key={peer['.id'] || i} className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40', i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs font-mono text-gray-700 dark:text-slate-300">{truncateKey(peer['public-key'] || '')}</code>
|
||||
{peer['public-key'] && <CopyButton text={peer['public-key']} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs font-mono text-gray-600 dark:text-slate-300">{peer['allowed-address'] || '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-600 dark:text-slate-300">
|
||||
{peer['endpoint-address'] ? `${peer['endpoint-address']}:${peer['endpoint-port'] || '?'}` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">
|
||||
{(peer['tx'] || peer['rx']) ? `${formatBytes(peer['tx'] || '0')} / ${formatBytes(peer['rx'] || '0')}` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">
|
||||
{peer['last-handshake'] || '—'}
|
||||
</td>
|
||||
{canWrite && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => setPeerForm({ ifaceName: name, existing: peer })}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => { if (confirm('Delete this peer?')) deletePeer.mutate(peer['.id']); }}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
Radio, RefreshCw, CheckCircle, XCircle, AlertCircle, Check,
|
||||
HelpCircle, Shield, Eye, EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { routersApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface SnmpForm {
|
||||
enabled: boolean;
|
||||
community_name: string;
|
||||
version: 'v1' | 'v2c' | 'v3';
|
||||
contact: string;
|
||||
location: string;
|
||||
trap_target: string;
|
||||
auth_protocol: string;
|
||||
auth_password: string;
|
||||
priv_protocol: string;
|
||||
priv_password: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SNMP: SnmpForm = {
|
||||
enabled: true,
|
||||
community_name: 'public',
|
||||
version: 'v2c',
|
||||
contact: '',
|
||||
location: '',
|
||||
trap_target: '',
|
||||
auth_protocol: 'MD5',
|
||||
auth_password: '',
|
||||
priv_protocol: 'none',
|
||||
priv_password: '',
|
||||
};
|
||||
|
||||
function PasswordInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={show ? 'text' : 'password'}
|
||||
className="input pr-9"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
onClick={() => setShow(s => !s)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RouterSettingsPage() {
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
// ── LLDP state ──────────────────────────────────────────────────────────────
|
||||
const [lldpApplyResult, setLldpApplyResult] = useState<{ applied: number; total: number } | null>(null);
|
||||
const [lldpApplyError, setLldpApplyError] = useState('');
|
||||
|
||||
const { data: lldpStatuses = [], isLoading: lldpLoading, refetch: refetchLldp, isFetching: lldpFetching } =
|
||||
useQuery({ queryKey: ['routers-lldp'], queryFn: () => routersApi.getLldpStatus().then(r => r.data) });
|
||||
|
||||
const setLldpMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => routersApi.setLldp(enabled),
|
||||
onSuccess: (res) => { setLldpApplyResult({ applied: res.data.applied, total: res.data.total }); setLldpApplyError(''); refetchLldp(); },
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setLldpApplyError(msg || 'Failed to apply LLDP settings');
|
||||
},
|
||||
});
|
||||
|
||||
const allEnabled = lldpStatuses.length > 0 && lldpStatuses.every(s => s.enabled === true);
|
||||
const allDisabled = lldpStatuses.length > 0 && lldpStatuses.every(s => s.enabled === false);
|
||||
|
||||
// ── SNMP state ──────────────────────────────────────────────────────────────
|
||||
const [snmpForm, setSnmpForm] = useState<SnmpForm>(DEFAULT_SNMP);
|
||||
const [snmpApplyResult, setSnmpApplyResult] = useState<{ applied: number; total: number } | null>(null);
|
||||
const [snmpApplyError, setSnmpApplyError] = useState('');
|
||||
const [snmpFormEdited, setSnmpFormEdited] = useState(false);
|
||||
|
||||
const sf = (patch: Partial<SnmpForm>) => { setSnmpForm(f => ({ ...f, ...patch })); setSnmpFormEdited(true); };
|
||||
|
||||
const { data: snmpStatuses = [], isLoading: snmpLoading, refetch: refetchSnmp, isFetching: snmpFetching } =
|
||||
useQuery({
|
||||
queryKey: ['routers-snmp'],
|
||||
queryFn: () => routersApi.getSnmpStatus().then(r => {
|
||||
const first = r.data.find(s => s.enabled != null && !s.error);
|
||||
if (first && !snmpFormEdited) {
|
||||
setSnmpForm({
|
||||
enabled: first.enabled ?? true,
|
||||
community_name: first.community_name ?? 'public',
|
||||
version: (first.version as 'v1' | 'v2c' | 'v3') ?? 'v2c',
|
||||
contact: first.contact ?? '',
|
||||
location: first.location ?? '',
|
||||
trap_target: first.trap_target ?? '',
|
||||
auth_protocol: first.auth_protocol ?? 'MD5',
|
||||
auth_password: '',
|
||||
priv_protocol: first.priv_protocol ?? 'none',
|
||||
priv_password: '',
|
||||
});
|
||||
}
|
||||
return r.data;
|
||||
}),
|
||||
});
|
||||
|
||||
const setSnmpMutation = useMutation({
|
||||
mutationFn: () => routersApi.setSnmp(snmpForm),
|
||||
onSuccess: (res) => {
|
||||
setSnmpApplyResult({ applied: res.data.applied, total: res.data.total });
|
||||
setSnmpApplyError('');
|
||||
refetchSnmp();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setSnmpApplyError(msg || 'Failed to apply SNMP settings');
|
||||
},
|
||||
});
|
||||
|
||||
const isV3 = snmpForm.version === 'v3';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Router Settings</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Network-wide configuration applied to all managed routers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── LLDP Card ──────────────────────────────────────────────────────────── */}
|
||||
<div className="card p-6 space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 bg-blue-50 dark:bg-blue-900/20 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Radio className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">
|
||||
Link Layer Discovery Protocol (LLDP)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
||||
LLDP lets your routers announce themselves to neighboring devices and collect
|
||||
neighbor information. Enabling LLDP improves the accuracy of the network topology map.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!lldpLoading && lldpStatuses.length > 0 && (
|
||||
<div className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
|
||||
allEnabled ? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400' :
|
||||
allDisabled ? 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400' :
|
||||
'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400'
|
||||
)}>
|
||||
{allEnabled ? <CheckCircle className="w-4 h-4" /> : allDisabled ? <XCircle className="w-4 h-4" /> : <HelpCircle className="w-4 h-4" />}
|
||||
{allEnabled ? 'LLDP is enabled on all online routers' :
|
||||
allDisabled ? 'LLDP is disabled on all online routers' :
|
||||
'LLDP state is mixed across routers'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lldpLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking LLDP status on all routers…
|
||||
</div>
|
||||
) : lldpStatuses.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">
|
||||
No online routers found. Routers must be online to check or change LLDP settings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Router</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">LLDP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Protocols</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{lldpStatuses.map(r => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{r.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{r.ip_address}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{r.error ? (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> Error</span>
|
||||
) : r.enabled === null ? (
|
||||
<span className="text-xs text-gray-400">Unknown</span>
|
||||
) : r.enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400"><CheckCircle className="w-3.5 h-3.5" /> Enabled</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400"><XCircle className="w-3.5 h-3.5" /> Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{r.protocol && r.protocol !== 'unknown' ? r.protocol : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lldpApplyResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="w-4 h-4" /> Applied to {lldpApplyResult.applied} of {lldpApplyResult.total} routers
|
||||
</div>
|
||||
)}
|
||||
{lldpApplyError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{lldpApplyError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button onClick={() => refetchLldp()} disabled={lldpFetching} className="btn-secondary flex items-center gap-1.5 text-sm">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', lldpFetching && 'animate-spin')} /> Refresh Status
|
||||
</button>
|
||||
<button
|
||||
disabled={setLldpMutation.isPending || lldpLoading || lldpStatuses.length === 0}
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
onClick={() => { setLldpApplyResult(null); setLldpMutation.mutate(true); }}
|
||||
>
|
||||
{setLldpMutation.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
|
||||
Enable LLDP on All
|
||||
</button>
|
||||
<button
|
||||
disabled={setLldpMutation.isPending || lldpLoading || lldpStatuses.length === 0}
|
||||
className="btn-secondary flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
onClick={() => { setLldpApplyResult(null); setLldpMutation.mutate(false); }}
|
||||
>
|
||||
{setLldpMutation.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <XCircle className="w-3.5 h-3.5" />}
|
||||
Disable LLDP on All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── SNMP Card ───────────────────────────────────────────────────────────── */}
|
||||
<div className="card p-6 space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 bg-purple-50 dark:bg-purple-900/20 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Shield className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">
|
||||
Simple Network Management Protocol (SNMP)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
||||
Configure SNMP on all managed routers. Settings are applied network-wide.
|
||||
SNMPv3 provides authentication and optional encryption for secure monitoring.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset disabled={!canWrite} className="space-y-4 disabled:opacity-60">
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Enable SNMP</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Allow SNMP polling and trap generation on all routers
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sf({ enabled: !snmpForm.enabled })}
|
||||
className={clsx(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
snmpForm.enabled ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600'
|
||||
)}
|
||||
>
|
||||
<span className={clsx(
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
snmpForm.enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div>
|
||||
<label className="label">SNMP Version</label>
|
||||
<div className="flex rounded-lg border border-gray-300 dark:border-slate-600 overflow-hidden w-fit">
|
||||
{(['v1', 'v2c', 'v3'] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => sf({ version: v })}
|
||||
className={clsx(
|
||||
'px-5 py-2 text-sm font-medium transition-colors',
|
||||
snmpForm.version === v
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 text-gray-600 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{v.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-1.5">
|
||||
{snmpForm.version === 'v1' && 'SNMPv1 — community string, no encryption. Legacy use only.'}
|
||||
{snmpForm.version === 'v2c' && 'SNMPv2c — community string, supports 64-bit counters. Recommended for read-only monitoring.'}
|
||||
{snmpForm.version === 'v3' && 'SNMPv3 — username-based with authentication and optional encryption. Most secure.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Community / Username */}
|
||||
<div>
|
||||
<label className="label">{isV3 ? 'Username' : 'Community Name'}</label>
|
||||
<input
|
||||
className="input max-w-xs"
|
||||
value={snmpForm.community_name}
|
||||
onChange={e => sf({ community_name: e.target.value })}
|
||||
placeholder={isV3 ? 'snmpv3user' : 'public'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Global info */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Contact</label>
|
||||
<input className="input" value={snmpForm.contact} onChange={e => sf({ contact: e.target.value })} placeholder="admin@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Location</label>
|
||||
<input className="input" value={snmpForm.location} onChange={e => sf({ location: e.target.value })} placeholder="Server Room A" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Trap Destination (optional)</label>
|
||||
<input className="input max-w-xs" value={snmpForm.trap_target} onChange={e => sf({ trap_target: e.target.value })} placeholder="192.168.1.100" />
|
||||
</div>
|
||||
|
||||
{/* SNMPv3 section */}
|
||||
{isV3 && (
|
||||
<div className="border border-purple-200 dark:border-purple-800/50 rounded-lg p-4 space-y-4 bg-purple-50/30 dark:bg-purple-900/10">
|
||||
<p className="text-xs font-semibold text-purple-700 dark:text-purple-400 uppercase tracking-wide">
|
||||
SNMPv3 Security
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Authentication Protocol</label>
|
||||
<select className="input" value={snmpForm.auth_protocol} onChange={e => sf({ auth_protocol: e.target.value })}>
|
||||
<option value="MD5">MD5</option>
|
||||
<option value="SHA1">SHA1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Authentication Password</label>
|
||||
<PasswordInput value={snmpForm.auth_password} onChange={v => sf({ auth_password: v })} placeholder="Leave blank to keep current" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Privacy (Encryption) Protocol</label>
|
||||
<select className="input" value={snmpForm.priv_protocol} onChange={e => sf({ priv_protocol: e.target.value })}>
|
||||
<option value="none">None (auth only)</option>
|
||||
<option value="DES">DES</option>
|
||||
<option value="AES">AES</option>
|
||||
</select>
|
||||
</div>
|
||||
{snmpForm.priv_protocol !== 'none' && (
|
||||
<div>
|
||||
<label className="label">Privacy Password</label>
|
||||
<PasswordInput value={snmpForm.priv_password} onChange={v => sf({ priv_password: v })} placeholder="Leave blank to keep current" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">
|
||||
Security level:{' '}
|
||||
<span className="font-medium text-purple-700 dark:text-purple-300">
|
||||
{snmpForm.priv_protocol !== 'none' ? 'authPriv (auth + encryption)' : 'authNoPriv (auth only)'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Per-router SNMP status table */}
|
||||
{snmpLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking SNMP status on all routers…
|
||||
</div>
|
||||
) : snmpStatuses.length > 0 && (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Router</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">SNMP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Version</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Community / User</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{snmpStatuses.map(r => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{r.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{r.ip_address}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{r.error ? (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> Error</span>
|
||||
) : r.enabled === null ? (
|
||||
<span className="text-xs text-gray-400">Unknown</span>
|
||||
) : r.enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400"><CheckCircle className="w-3.5 h-3.5" /> Enabled</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400"><XCircle className="w-3.5 h-3.5" /> Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{r.version ? (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
|
||||
r.version === 'v3' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300'
|
||||
)}>
|
||||
{r.version.toUpperCase()}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{r.community_name ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snmpApplyResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="w-4 h-4" /> Applied to {snmpApplyResult.applied} of {snmpApplyResult.total} routers
|
||||
</div>
|
||||
)}
|
||||
{snmpApplyError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{snmpApplyError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button onClick={() => { setSnmpApplyResult(null); refetchSnmp(); }} disabled={snmpFetching} className="btn-secondary flex items-center gap-1.5 text-sm">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', snmpFetching && 'animate-spin')} /> Refresh Status
|
||||
</button>
|
||||
<button
|
||||
disabled={setSnmpMutation.isPending || snmpStatuses.length === 0}
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
onClick={() => { setSnmpApplyResult(null); setSnmpApplyError(''); setSnmpMutation.mutate(); }}
|
||||
>
|
||||
{setSnmpMutation.isPending
|
||||
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Applying…</>
|
||||
: <><Check className="w-3.5 h-3.5" /> Apply to All Routers</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Router, CheckCircle, XCircle, HelpCircle, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
import { routersApi } from '../services/api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface RouterDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
model?: string;
|
||||
status: string;
|
||||
last_seen?: string;
|
||||
ros_version?: string;
|
||||
firmware_version?: string;
|
||||
rack_name?: string;
|
||||
rack_slot?: string;
|
||||
ifaces_up: number;
|
||||
ifaces_down: number;
|
||||
ifaces_disabled: number;
|
||||
ifaces_total: number;
|
||||
}
|
||||
|
||||
function timeAgo(ts: string | undefined): string {
|
||||
if (!ts) return 'Never';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (diff < 60_000) return 'Just now';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
status === 'online' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' :
|
||||
status === 'offline' ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' :
|
||||
'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400'
|
||||
)}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full',
|
||||
status === 'online' ? 'bg-green-500' : status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||
)} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoutersPage() {
|
||||
const { data: routers = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routers'],
|
||||
queryFn: () => routersApi.overview().then(r => r.data as unknown as RouterDevice[]),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const online = routers.filter(r => r.status === 'online').length;
|
||||
const offline = routers.filter(r => r.status === 'offline').length;
|
||||
const unknown = routers.filter(r => r.status !== 'online' && r.status !== 'offline').length;
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Total Routers', value: routers.length, icon: Router, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
||||
{ label: 'Online', value: online, icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-900/20' },
|
||||
{ label: 'Offline', value: offline, icon: XCircle, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-900/20' },
|
||||
{ label: 'Unknown', value: unknown, icon: HelpCircle, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-900/20' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center h-64 text-gray-400"><RefreshCw className="w-5 h-5 animate-spin mr-2" />Loading routers…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Routers</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Overview of all managed router devices
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
<Link to="/devices" className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<ExternalLink className="w-3.5 h-3.5" /> Manage Devices
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{kpis.map(({ label, value, icon: Icon, color, bg }) => (
|
||||
<div key={label} className="card p-5 flex items-center gap-4">
|
||||
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', bg)}>
|
||||
<Icon className={clsx('w-5 h-5', color)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{routers.length === 0 && (
|
||||
<div className="card p-12 text-center">
|
||||
<Router className="w-12 h-12 text-gray-300 dark:text-slate-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 dark:text-slate-400 font-medium">No routers found</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
Add devices with type <span className="font-mono">router</span> to see them here.
|
||||
</p>
|
||||
<Link to="/devices" className="btn-primary inline-flex items-center gap-2 mt-4 text-sm">
|
||||
<ExternalLink className="w-4 h-4" /> Manage Devices
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Router cards grid */}
|
||||
{routers.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{routers.map(router => {
|
||||
const ifacePct = router.ifaces_total > 0
|
||||
? Math.round((router.ifaces_up / router.ifaces_total) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div key={router.id} className="card p-5 flex flex-col gap-4">
|
||||
{/* Card header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">{router.name}</h3>
|
||||
<p className="text-xs font-mono text-gray-500 dark:text-slate-400 mt-0.5">{router.ip_address}</p>
|
||||
</div>
|
||||
<StatusBadge status={router.status} />
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
{router.model && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">Model</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium truncate">{router.model}</span>
|
||||
</>
|
||||
)}
|
||||
{router.ros_version && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">RouterOS</span>
|
||||
<span className="text-gray-900 dark:text-white font-mono">{router.ros_version}</span>
|
||||
</>
|
||||
)}
|
||||
{router.rack_name && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">Rack</span>
|
||||
<span className="text-gray-900 dark:text-white">{router.rack_name}{router.rack_slot ? ` / ${router.rack_slot}` : ''}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-500 dark:text-slate-400">Last seen</span>
|
||||
<span className="text-gray-900 dark:text-white">{timeAgo(router.last_seen)}</span>
|
||||
</div>
|
||||
|
||||
{/* Interface utilisation */}
|
||||
{router.ifaces_total > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500 dark:text-slate-400">Interfaces</span>
|
||||
<span className="text-gray-700 dark:text-slate-300">
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">{router.ifaces_up}</span>
|
||||
{' / '}
|
||||
{router.ifaces_total} up
|
||||
{router.ifaces_down > 0 && <span className="text-red-500 ml-1">· {router.ifaces_down} down</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${ifacePct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<Link
|
||||
to={`/devices/${router.id}`}
|
||||
className="btn-secondary text-xs py-1.5 text-center"
|
||||
>
|
||||
View Device →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Layers, CheckCircle, XCircle, HelpCircle, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
import { switchesApi } from '../services/api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface SwitchDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
model?: string;
|
||||
status: string;
|
||||
last_seen?: string;
|
||||
ros_version?: string;
|
||||
rack_name?: string;
|
||||
rack_slot?: string;
|
||||
ports_up: number;
|
||||
ports_down: number;
|
||||
ports_disabled: number;
|
||||
ports_total: number;
|
||||
}
|
||||
|
||||
function timeAgo(ts: string | undefined): string {
|
||||
if (!ts) return 'Never';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (diff < 60_000) return 'Just now';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
status === 'online' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' :
|
||||
status === 'offline' ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' :
|
||||
'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400'
|
||||
)}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full',
|
||||
status === 'online' ? 'bg-green-500' : status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||
)} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SwitchesOverviewPage() {
|
||||
const { data: switches = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['switches'],
|
||||
queryFn: () => switchesApi.list().then(r => r.data as unknown as SwitchDevice[]),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const online = switches.filter(s => s.status === 'online').length;
|
||||
const offline = switches.filter(s => s.status === 'offline').length;
|
||||
const unknown = switches.filter(s => s.status !== 'online' && s.status !== 'offline').length;
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Total Switches', value: switches.length, icon: Layers, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
||||
{ label: 'Online', value: online, icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-900/20' },
|
||||
{ label: 'Offline', value: offline, icon: XCircle, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-900/20' },
|
||||
{ label: 'Unknown', value: unknown, icon: HelpCircle, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-900/20' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center h-64 text-gray-400"><RefreshCw className="w-5 h-5 animate-spin mr-2" />Loading switches…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Switches</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Overview of all managed switch devices
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
<Link to="/devices" className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<ExternalLink className="w-3.5 h-3.5" /> Manage Devices
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{kpis.map(({ label, value, icon: Icon, color, bg }) => (
|
||||
<div key={label} className="card p-5 flex items-center gap-4">
|
||||
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', bg)}>
|
||||
<Icon className={clsx('w-5 h-5', color)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* No switches */}
|
||||
{switches.length === 0 && (
|
||||
<div className="card p-12 text-center">
|
||||
<Layers className="w-12 h-12 text-gray-300 dark:text-slate-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 dark:text-slate-400 font-medium">No switches found</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
Add devices with type <span className="font-mono">switch</span> to see them here.
|
||||
</p>
|
||||
<Link to="/devices" className="btn-primary inline-flex items-center gap-2 mt-4 text-sm">
|
||||
<ExternalLink className="w-4 h-4" /> Manage Devices
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Switch cards grid */}
|
||||
{switches.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{switches.map(sw => {
|
||||
const portPct = sw.ports_total > 0 ? Math.round((sw.ports_up / sw.ports_total) * 100) : 0;
|
||||
return (
|
||||
<div key={sw.id} className="card p-5 flex flex-col gap-4">
|
||||
{/* Card header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">{sw.name}</h3>
|
||||
<p className="text-xs font-mono text-gray-500 dark:text-slate-400 mt-0.5">{sw.ip_address}</p>
|
||||
</div>
|
||||
<StatusBadge status={sw.status} />
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
{sw.model && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">Model</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium truncate">{sw.model}</span>
|
||||
</>
|
||||
)}
|
||||
{sw.ros_version && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">RouterOS</span>
|
||||
<span className="text-gray-900 dark:text-white font-mono">{sw.ros_version}</span>
|
||||
</>
|
||||
)}
|
||||
{sw.rack_name && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">Rack</span>
|
||||
<span className="text-gray-900 dark:text-white">{sw.rack_name}{sw.rack_slot ? ` / ${sw.rack_slot}` : ''}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-500 dark:text-slate-400">Last seen</span>
|
||||
<span className="text-gray-900 dark:text-white">{timeAgo(sw.last_seen)}</span>
|
||||
</div>
|
||||
|
||||
{/* Port utilisation */}
|
||||
{sw.ports_total > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500 dark:text-slate-400">Ports</span>
|
||||
<span className="text-gray-700 dark:text-slate-300">
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">{sw.ports_up}</span>
|
||||
{' / '}
|
||||
{sw.ports_total} up
|
||||
{sw.ports_down > 0 && <span className="text-red-500 ml-1">· {sw.ports_down} down</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${portPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<Link
|
||||
to={`/devices/${sw.id}`}
|
||||
className="btn-secondary text-xs py-1.5 text-center"
|
||||
>
|
||||
View Device →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
Radio, RefreshCw, CheckCircle, XCircle, AlertCircle, Check,
|
||||
HelpCircle, Shield, Eye, EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { switchesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ─── SNMP form state ──────────────────────────────────────────────────────────
|
||||
interface SnmpForm {
|
||||
enabled: boolean;
|
||||
community_name: string;
|
||||
version: 'v1' | 'v2c' | 'v3';
|
||||
contact: string;
|
||||
location: string;
|
||||
trap_target: string;
|
||||
auth_protocol: string;
|
||||
auth_password: string;
|
||||
priv_protocol: string;
|
||||
priv_password: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SNMP: SnmpForm = {
|
||||
enabled: true,
|
||||
community_name: 'public',
|
||||
version: 'v2c',
|
||||
contact: '',
|
||||
location: '',
|
||||
trap_target: '',
|
||||
auth_protocol: 'MD5',
|
||||
auth_password: '',
|
||||
priv_protocol: 'none',
|
||||
priv_password: '',
|
||||
};
|
||||
|
||||
function PasswordInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={show ? 'text' : 'password'}
|
||||
className="input pr-9"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
onClick={() => setShow(s => !s)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
export default function SwitchesSettingsPage() {
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
// ── LLDP state ────────────────────────────────────────────────────────────
|
||||
const [lldpApplyResult, setLldpApplyResult] = useState<{ applied: number; total: number } | null>(null);
|
||||
const [lldpApplyError, setLldpApplyError] = useState('');
|
||||
|
||||
const { data: lldpStatuses = [], isLoading: lldpLoading, refetch: refetchLldp, isFetching: lldpFetching } =
|
||||
useQuery({ queryKey: ['switches-lldp'], queryFn: () => switchesApi.getLldpStatus().then(r => r.data) });
|
||||
|
||||
const setLldpMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => switchesApi.setLldp(enabled),
|
||||
onSuccess: (res) => { setLldpApplyResult({ applied: res.data.applied, total: res.data.total }); setLldpApplyError(''); refetchLldp(); },
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setLldpApplyError(msg || 'Failed to apply LLDP settings');
|
||||
},
|
||||
});
|
||||
|
||||
const allEnabled = lldpStatuses.length > 0 && lldpStatuses.every(s => s.enabled === true);
|
||||
const allDisabled = lldpStatuses.length > 0 && lldpStatuses.every(s => s.enabled === false);
|
||||
|
||||
// ── SNMP state ────────────────────────────────────────────────────────────
|
||||
const [snmpForm, setSnmpForm] = useState<SnmpForm>(DEFAULT_SNMP);
|
||||
const [snmpApplyResult, setSnmpApplyResult] = useState<{ applied: number; total: number } | null>(null);
|
||||
const [snmpApplyError, setSnmpApplyError] = useState('');
|
||||
const [snmpFormEdited, setSnmpFormEdited] = useState(false);
|
||||
|
||||
const sf = (patch: Partial<SnmpForm>) => { setSnmpForm(f => ({ ...f, ...patch })); setSnmpFormEdited(true); };
|
||||
|
||||
const { data: snmpStatuses = [], isLoading: snmpLoading, refetch: refetchSnmp, isFetching: snmpFetching } =
|
||||
useQuery({
|
||||
queryKey: ['switches-snmp'],
|
||||
queryFn: () => switchesApi.getSnmpStatus().then(r => {
|
||||
// Pre-populate form from first successful result (if form not yet manually edited)
|
||||
const first = r.data.find(s => s.enabled != null && !s.error);
|
||||
if (first && !snmpFormEdited) {
|
||||
setSnmpForm({
|
||||
enabled: first.enabled ?? true,
|
||||
community_name: first.community_name ?? 'public',
|
||||
version: (first.version as 'v1' | 'v2c' | 'v3') ?? 'v2c',
|
||||
contact: first.contact ?? '',
|
||||
location: first.location ?? '',
|
||||
trap_target: first.trap_target ?? '',
|
||||
auth_protocol: first.auth_protocol ?? 'MD5',
|
||||
auth_password: '',
|
||||
priv_protocol: first.priv_protocol ?? 'none',
|
||||
priv_password: '',
|
||||
});
|
||||
}
|
||||
return r.data;
|
||||
}),
|
||||
});
|
||||
|
||||
const setSnmpMutation = useMutation({
|
||||
mutationFn: () => switchesApi.setSnmp(snmpForm),
|
||||
onSuccess: (res) => {
|
||||
setSnmpApplyResult({ applied: res.data.applied, total: res.data.total });
|
||||
setSnmpApplyError('');
|
||||
refetchSnmp();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setSnmpApplyError(msg || 'Failed to apply SNMP settings');
|
||||
},
|
||||
});
|
||||
|
||||
const isV3 = snmpForm.version === 'v3';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Switch Settings</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Network-wide configuration applied to all managed switches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── LLDP Card ────────────────────────────────────────────────────────── */}
|
||||
<div className="card p-6 space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 bg-blue-50 dark:bg-blue-900/20 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Radio className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">
|
||||
Link Layer Discovery Protocol (LLDP)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
||||
LLDP lets your switches announce themselves to neighboring devices and collect
|
||||
neighbor information. Enabling LLDP improves the accuracy of the network topology map.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!lldpLoading && lldpStatuses.length > 0 && (
|
||||
<div className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
|
||||
allEnabled ? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400' :
|
||||
allDisabled ? 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400' :
|
||||
'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400'
|
||||
)}>
|
||||
{allEnabled ? <CheckCircle className="w-4 h-4" /> : allDisabled ? <XCircle className="w-4 h-4" /> : <HelpCircle className="w-4 h-4" />}
|
||||
{allEnabled ? 'LLDP is enabled on all online switches' :
|
||||
allDisabled ? 'LLDP is disabled on all online switches' :
|
||||
'LLDP state is mixed across switches'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lldpLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking LLDP status on all switches…
|
||||
</div>
|
||||
) : lldpStatuses.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">
|
||||
No online switches found. Switches must be online to check or change LLDP settings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Switch</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">LLDP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Protocols</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{lldpStatuses.map(sw => (
|
||||
<tr key={sw.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{sw.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{sw.ip_address}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{sw.error ? (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> Error</span>
|
||||
) : sw.enabled === null ? (
|
||||
<span className="text-xs text-gray-400">Unknown</span>
|
||||
) : sw.enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400"><CheckCircle className="w-3.5 h-3.5" /> Enabled</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400"><XCircle className="w-3.5 h-3.5" /> Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{sw.protocol && sw.protocol !== 'unknown' ? sw.protocol : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lldpApplyResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="w-4 h-4" /> Applied to {lldpApplyResult.applied} of {lldpApplyResult.total} switches
|
||||
</div>
|
||||
)}
|
||||
{lldpApplyError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{lldpApplyError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button onClick={() => refetchLldp()} disabled={lldpFetching} className="btn-secondary flex items-center gap-1.5 text-sm">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', lldpFetching && 'animate-spin')} /> Refresh Status
|
||||
</button>
|
||||
<button
|
||||
disabled={setLldpMutation.isPending || lldpLoading || lldpStatuses.length === 0}
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
onClick={() => { setLldpApplyResult(null); setLldpMutation.mutate(true); }}
|
||||
>
|
||||
{setLldpMutation.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
|
||||
Enable LLDP on All
|
||||
</button>
|
||||
<button
|
||||
disabled={setLldpMutation.isPending || lldpLoading || lldpStatuses.length === 0}
|
||||
className="btn-secondary flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
onClick={() => { setLldpApplyResult(null); setLldpMutation.mutate(false); }}
|
||||
>
|
||||
{setLldpMutation.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <XCircle className="w-3.5 h-3.5" />}
|
||||
Disable LLDP on All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── SNMP Card ─────────────────────────────────────────────────────────── */}
|
||||
<div className="card p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 bg-purple-50 dark:bg-purple-900/20 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Shield className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">
|
||||
Simple Network Management Protocol (SNMP)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
||||
Configure SNMP on all managed switches. Settings are applied network-wide.
|
||||
SNMPv3 provides authentication and optional encryption for secure monitoring.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration form */}
|
||||
<fieldset disabled={!canWrite} className="space-y-4 disabled:opacity-60">
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Enable SNMP</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Allow SNMP polling and trap generation on all switches
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sf({ enabled: !snmpForm.enabled })}
|
||||
className={clsx(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
snmpForm.enabled ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600'
|
||||
)}
|
||||
>
|
||||
<span className={clsx(
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
snmpForm.enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div>
|
||||
<label className="label">SNMP Version</label>
|
||||
<div className="flex rounded-lg border border-gray-300 dark:border-slate-600 overflow-hidden w-fit">
|
||||
{(['v1', 'v2c', 'v3'] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => sf({ version: v })}
|
||||
className={clsx(
|
||||
'px-5 py-2 text-sm font-medium transition-colors',
|
||||
snmpForm.version === v
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 text-gray-600 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{v.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-1.5">
|
||||
{snmpForm.version === 'v1' && 'SNMPv1 — community string, no encryption. Legacy use only.'}
|
||||
{snmpForm.version === 'v2c' && 'SNMPv2c — community string, supports 64-bit counters. Recommended for read-only monitoring.'}
|
||||
{snmpForm.version === 'v3' && 'SNMPv3 — username-based with authentication and optional encryption. Most secure.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Community / Username */}
|
||||
<div>
|
||||
<label className="label">{isV3 ? 'Username' : 'Community Name'}</label>
|
||||
<input
|
||||
className="input max-w-xs"
|
||||
value={snmpForm.community_name}
|
||||
onChange={e => sf({ community_name: e.target.value })}
|
||||
placeholder={isV3 ? 'snmpv3user' : 'public'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Global info */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Contact</label>
|
||||
<input
|
||||
className="input"
|
||||
value={snmpForm.contact}
|
||||
onChange={e => sf({ contact: e.target.value })}
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Location</label>
|
||||
<input
|
||||
className="input"
|
||||
value={snmpForm.location}
|
||||
onChange={e => sf({ location: e.target.value })}
|
||||
placeholder="Server Room A"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Trap Destination (optional)</label>
|
||||
<input
|
||||
className="input max-w-xs"
|
||||
value={snmpForm.trap_target}
|
||||
onChange={e => sf({ trap_target: e.target.value })}
|
||||
placeholder="192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SNMPv3 section */}
|
||||
{isV3 && (
|
||||
<div className="border border-purple-200 dark:border-purple-800/50 rounded-lg p-4 space-y-4 bg-purple-50/30 dark:bg-purple-900/10">
|
||||
<p className="text-xs font-semibold text-purple-700 dark:text-purple-400 uppercase tracking-wide">
|
||||
SNMPv3 Security
|
||||
</p>
|
||||
|
||||
{/* Auth */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Authentication Protocol</label>
|
||||
<select
|
||||
className="input"
|
||||
value={snmpForm.auth_protocol}
|
||||
onChange={e => sf({ auth_protocol: e.target.value })}
|
||||
>
|
||||
<option value="MD5">MD5</option>
|
||||
<option value="SHA1">SHA1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Authentication Password</label>
|
||||
<PasswordInput
|
||||
value={snmpForm.auth_password}
|
||||
onChange={v => sf({ auth_password: v })}
|
||||
placeholder="Leave blank to keep current"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Privacy (Encryption) Protocol</label>
|
||||
<select
|
||||
className="input"
|
||||
value={snmpForm.priv_protocol}
|
||||
onChange={e => sf({ priv_protocol: e.target.value })}
|
||||
>
|
||||
<option value="none">None (auth only)</option>
|
||||
<option value="DES">DES</option>
|
||||
<option value="AES">AES</option>
|
||||
</select>
|
||||
</div>
|
||||
{snmpForm.priv_protocol !== 'none' && (
|
||||
<div>
|
||||
<label className="label">Privacy Password</label>
|
||||
<PasswordInput
|
||||
value={snmpForm.priv_password}
|
||||
onChange={v => sf({ priv_password: v })}
|
||||
placeholder="Leave blank to keep current"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">
|
||||
Security level:{' '}
|
||||
<span className="font-medium text-purple-700 dark:text-purple-300">
|
||||
{snmpForm.priv_protocol !== 'none' ? 'authPriv (auth + encryption)' : 'authNoPriv (auth only)'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Per-switch SNMP status table */}
|
||||
{snmpLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking SNMP status on all switches…
|
||||
</div>
|
||||
) : snmpStatuses.length > 0 && (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Switch</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">SNMP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Version</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Community / User</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{snmpStatuses.map(sw => (
|
||||
<tr key={sw.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{sw.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{sw.ip_address}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{sw.error ? (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> Error</span>
|
||||
) : sw.enabled === null ? (
|
||||
<span className="text-xs text-gray-400">Unknown</span>
|
||||
) : sw.enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400"><CheckCircle className="w-3.5 h-3.5" /> Enabled</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400"><XCircle className="w-3.5 h-3.5" /> Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{sw.version ? (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
|
||||
sw.version === 'v3' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300'
|
||||
)}>
|
||||
{sw.version.toUpperCase()}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{sw.community_name ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snmpApplyResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="w-4 h-4" /> Applied to {snmpApplyResult.applied} of {snmpApplyResult.total} switches
|
||||
</div>
|
||||
)}
|
||||
{snmpApplyError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{snmpApplyError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
onClick={() => { setSnmpApplyResult(null); refetchSnmp(); }}
|
||||
disabled={snmpFetching}
|
||||
className="btn-secondary flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', snmpFetching && 'animate-spin')} /> Refresh Status
|
||||
</button>
|
||||
<button
|
||||
disabled={setSnmpMutation.isPending || snmpStatuses.length === 0}
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
onClick={() => { setSnmpApplyResult(null); setSnmpApplyError(''); setSnmpMutation.mutate(); }}
|
||||
>
|
||||
{setSnmpMutation.isPending
|
||||
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Applying…</>
|
||||
: <><Check className="w-3.5 h-3.5" /> Apply to All Switches</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
ReactFlow,
|
||||
MiniMap,
|
||||
Controls,
|
||||
Background,
|
||||
Handle,
|
||||
Position,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
type Node,
|
||||
type Edge,
|
||||
type Connection,
|
||||
BackgroundVariant,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { RefreshCw, GitBranch } from 'lucide-react';
|
||||
import { topologyApi } from '../services/api';
|
||||
import type { TopologyDevice, TopologyLink, ExternalTopologyNode } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// helper to derive device class from LLDP capabilities
|
||||
function capsLabel(caps: string | undefined): string {
|
||||
if (!caps) return '';
|
||||
const c = caps.toLowerCase();
|
||||
if (c.includes('bridge')) return 'Switch';
|
||||
if (c.includes('router')) return 'Router';
|
||||
if (c.includes('wlan-ap')) return 'AP';
|
||||
if (c.includes('telephone')) return 'Phone';
|
||||
return '';
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
online: '#22c55e',
|
||||
offline: '#ef4444',
|
||||
unknown: '#94a3b8',
|
||||
};
|
||||
|
||||
const deviceIcon: Record<string, string> = {
|
||||
router: '⇌',
|
||||
switch: '⊞',
|
||||
wireless_ap: '⊙',
|
||||
other: '◈',
|
||||
};
|
||||
|
||||
const handleStyle = { opacity: 0, width: 8, height: 8 };
|
||||
|
||||
function DeviceNode({ data }: { data: Record<string, unknown> }) {
|
||||
const device = data as unknown as TopologyDevice & { isRootBridge: boolean };
|
||||
return (
|
||||
<div
|
||||
className={clsx('card px-3 py-2 min-w-[140px] text-xs shadow-md')}
|
||||
style={{
|
||||
borderColor: device.isRootBridge ? '#f59e0b' : statusColor[device.status],
|
||||
borderWidth: device.isRootBridge ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="target" position={Position.Left} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="source" position={Position.Right} isConnectable={false} style={handleStyle} />
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-base leading-none">{deviceIcon[device.device_type] || '◈'}</span>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ background: statusColor[device.status] }}
|
||||
/>
|
||||
<span className="font-semibold truncate text-gray-900 dark:text-white">
|
||||
{device.name}
|
||||
</span>
|
||||
{device.isRootBridge && (
|
||||
<span title="Root Bridge" className="ml-auto text-amber-500 text-xs font-bold">★</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-mono text-gray-400 dark:text-slate-500">{device.ip_address}</div>
|
||||
{device.model && (
|
||||
<div className="text-gray-400 dark:text-slate-500 truncate">{device.model}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExternalNode({ data }: { data: Record<string, unknown> }) {
|
||||
const node = data as unknown as ExternalTopologyNode;
|
||||
|
||||
// Shared segment synthetic node
|
||||
if (node.caps === 'segment') {
|
||||
return (
|
||||
<div
|
||||
className="px-3 py-2 min-w-[140px] text-xs rounded-lg bg-amber-50 dark:bg-amber-900/20 shadow-sm"
|
||||
style={{ border: '2px dashed #f59e0b' }}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="target" position={Position.Left} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="source" position={Position.Right} isConnectable={false} style={handleStyle} />
|
||||
<div className="font-semibold text-amber-700 dark:text-amber-400 mb-0.5">⊕ Shared Segment</div>
|
||||
{node.platform && (
|
||||
<div className="text-amber-600 dark:text-amber-500">{node.platform}</div>
|
||||
)}
|
||||
<div className="text-amber-400 dark:text-amber-600 mt-0.5 text-[10px]">
|
||||
Unmanaged L2 switch/hub
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cl = capsLabel(node.caps);
|
||||
return (
|
||||
<div
|
||||
className="px-3 py-2 min-w-[130px] text-xs rounded-lg bg-gray-100 dark:bg-slate-700/60 shadow-sm"
|
||||
style={{ border: '1.5px dashed #94a3b8' }}
|
||||
>
|
||||
<Handle type="target" position={Position.Top} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="target" position={Position.Left} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="source" position={Position.Bottom} isConnectable={false} style={handleStyle} />
|
||||
<Handle type="source" position={Position.Right} isConnectable={false} style={handleStyle} />
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-base leading-none text-gray-400">◌</span>
|
||||
<span className="font-semibold truncate text-gray-600 dark:text-slate-300">
|
||||
{node.name}{cl ? ` (${cl})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{node.address && (
|
||||
<div className="font-mono text-gray-400 dark:text-slate-500">{node.address}</div>
|
||||
)}
|
||||
{node.platform && (
|
||||
<div className="text-gray-400 dark:text-slate-500 truncate">{node.platform}</div>
|
||||
)}
|
||||
<div className="text-gray-300 dark:text-slate-600 mt-0.5">External</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { deviceNode: DeviceNode, externalNode: ExternalNode };
|
||||
|
||||
const NODE_W = 160;
|
||||
const NODE_H = 80;
|
||||
const H_GAP = 60;
|
||||
const V_GAP = 90;
|
||||
|
||||
function buildGraph(
|
||||
devices: TopologyDevice[],
|
||||
externalNodes: ExternalTopologyNode[],
|
||||
links: TopologyLink[]
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
|
||||
// ── Shared-segment detection ────────────────────────────────────────────────
|
||||
// LLDP is 802.1AB point-to-point — always a direct link.
|
||||
// CDP and MNDP are L2 multicast and flood across unmanaged switches, so a port
|
||||
// seeing multiple non-LLDP neighbors means they're all on a shared segment,
|
||||
// not individually wired to this device.
|
||||
|
||||
const lldpLinks = links.filter((l) => l.link_type === 'lldp');
|
||||
const nonLldpLinks = links.filter((l) => l.link_type !== 'lldp' && !!l.from_device_id);
|
||||
|
||||
// Group non-LLDP links by (from_device_id, from_interface)
|
||||
const portGroupMap = new Map<string, TopologyLink[]>();
|
||||
for (const link of nonLldpLinks) {
|
||||
const pk = `${link.from_device_id}::${link.from_interface ?? ''}`;
|
||||
if (!portGroupMap.has(pk)) portGroupMap.set(pk, []);
|
||||
portGroupMap.get(pk)!.push(link);
|
||||
}
|
||||
|
||||
// Port groups with ≥2 neighbors → shared segment; 1 neighbor → keep as direct
|
||||
const sharedPortKeys = [...portGroupMap.keys()].filter((pk) => portGroupMap.get(pk)!.length >= 2);
|
||||
const soloNonLldp = [...portGroupMap.values()].filter((g) => g.length < 2).flat();
|
||||
|
||||
// Stable key to identify a neighbor across links
|
||||
const nKey = (l: TopologyLink) =>
|
||||
l.to_device_id ? `d:${l.to_device_id}` :
|
||||
l.neighbor_mac ? `m:${l.neighbor_mac.toLowerCase()}` :
|
||||
l.neighbor_address ? `a:${l.neighbor_address}` :
|
||||
`i:${l.neighbor_identity ?? ''}`;
|
||||
|
||||
// Union-find: merge port groups that see any common neighbor (same physical segment)
|
||||
const ufParent = new Map<string, string>(sharedPortKeys.map((k) => [k, k]));
|
||||
const ufFind = (k: string): string => {
|
||||
if (ufParent.get(k) !== k) ufParent.set(k, ufFind(ufParent.get(k)!));
|
||||
return ufParent.get(k)!;
|
||||
};
|
||||
const ufUnion = (a: string, b: string) => ufParent.set(ufFind(a), ufFind(b));
|
||||
|
||||
const pkNeighborSets = new Map<string, Set<string>>();
|
||||
for (const pk of sharedPortKeys) {
|
||||
pkNeighborSets.set(pk, new Set(portGroupMap.get(pk)!.map(nKey)));
|
||||
}
|
||||
for (let i = 0; i < sharedPortKeys.length; i++) {
|
||||
for (let j = i + 1; j < sharedPortKeys.length; j++) {
|
||||
const setA = pkNeighborSets.get(sharedPortKeys[i])!;
|
||||
for (const n of pkNeighborSets.get(sharedPortKeys[j])!) {
|
||||
if (setA.has(n)) { ufUnion(sharedPortKeys[i], sharedPortKeys[j]); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group port keys by segment root
|
||||
const segGroups = new Map<string, string[]>();
|
||||
for (const pk of sharedPortKeys) {
|
||||
const root = ufFind(pk);
|
||||
if (!segGroups.has(root)) segGroups.set(root, []);
|
||||
segGroups.get(root)!.push(pk);
|
||||
}
|
||||
|
||||
// IDs of all links absorbed into segments (not rendered as direct edges)
|
||||
const absorbedIds = new Set<number>(
|
||||
sharedPortKeys.flatMap((pk) => portGroupMap.get(pk)!.map((l) => l.id))
|
||||
);
|
||||
|
||||
// Build synthetic segment nodes and their connections
|
||||
interface SegConn { src: string; dst: string; port: string; }
|
||||
const segNodes: ExternalTopologyNode[] = [];
|
||||
const segConns: SegConn[] = [];
|
||||
|
||||
for (const [root, pks] of segGroups) {
|
||||
const segId = `seg-${root.replace(/[^a-z0-9]/gi, '')}`;
|
||||
|
||||
const srcDevPorts = new Map<string, string>(); // devId → local port
|
||||
const allDevIds = new Set<string>();
|
||||
const extNKeys = new Set<string>();
|
||||
|
||||
for (const pk of pks) {
|
||||
const colonIdx = pk.indexOf('::');
|
||||
const devId = pk.slice(0, colonIdx);
|
||||
const port = pk.slice(colonIdx + 2);
|
||||
srcDevPorts.set(devId, port);
|
||||
allDevIds.add(devId);
|
||||
for (const link of portGroupMap.get(pk)!) {
|
||||
if (link.to_device_id) allDevIds.add(String(link.to_device_id));
|
||||
else extNKeys.add(nKey(link));
|
||||
}
|
||||
}
|
||||
|
||||
segNodes.push({
|
||||
id: segId,
|
||||
name: 'Shared Segment',
|
||||
address: '',
|
||||
platform: `${allDevIds.size} managed devices`,
|
||||
mac: '',
|
||||
caps: 'segment', // sentinel for the ExternalNode renderer
|
||||
});
|
||||
|
||||
// Connect each source device to the segment node
|
||||
for (const [devId, port] of srcDevPorts) {
|
||||
segConns.push({ src: devId, dst: segId, port });
|
||||
}
|
||||
// Connect unmanaged external neighbors to the segment node
|
||||
for (const nk of extNKeys) {
|
||||
const ext = externalNodes.find((e) =>
|
||||
`m:${(e.mac || '').toLowerCase()}` === nk ||
|
||||
`a:${e.address}` === nk ||
|
||||
`i:${e.name}` === nk
|
||||
);
|
||||
if (ext) segConns.push({ src: ext.id, dst: segId, port: '' });
|
||||
}
|
||||
}
|
||||
|
||||
// Active direct links = LLDP + solo non-LLDP (single neighbor on that port)
|
||||
const activeLinks = [...lldpLinks, ...soloNonLldp];
|
||||
const allExtNodes = [...externalNodes, ...segNodes];
|
||||
|
||||
// ── Adjacency ───────────────────────────────────────────────────────────────
|
||||
const adj = new Map<string, Set<string>>();
|
||||
for (const d of devices) adj.set(String(d.id), new Set());
|
||||
for (const e of allExtNodes) adj.set(e.id, new Set());
|
||||
|
||||
const linkToExtId = new Map<number, string>();
|
||||
for (const link of activeLinks) {
|
||||
if (!link.from_device_id) continue;
|
||||
const src = String(link.from_device_id);
|
||||
let dst: string | null = null;
|
||||
if (link.to_device_id) {
|
||||
dst = String(link.to_device_id);
|
||||
} else {
|
||||
const ext = allExtNodes.find(
|
||||
(e) => (link.neighbor_address && e.address === link.neighbor_address) ||
|
||||
(link.neighbor_mac && e.mac === link.neighbor_mac) ||
|
||||
(!link.neighbor_address && !link.neighbor_mac && link.neighbor_identity && e.name === link.neighbor_identity)
|
||||
);
|
||||
if (ext) { dst = ext.id; linkToExtId.set(link.id, ext.id); }
|
||||
}
|
||||
if (dst && adj.has(src) && adj.has(dst)) {
|
||||
adj.get(src)!.add(dst); adj.get(dst)!.add(src);
|
||||
}
|
||||
}
|
||||
// Segment connections into adjacency
|
||||
for (const { src, dst } of segConns) {
|
||||
if (adj.has(src) && adj.has(dst)) {
|
||||
adj.get(src)!.add(dst); adj.get(dst)!.add(src);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Root / BFS levels ───────────────────────────────────────────────────────
|
||||
const hasStp = links.some((l) => l.stp_role);
|
||||
let rootId: string;
|
||||
if (hasStp) {
|
||||
const devicesWithRootPort = new Set(
|
||||
links.filter((l) => l.stp_role === 'root').map((l) => String(l.from_device_id))
|
||||
);
|
||||
const rootDevice = devices.find((d) => !devicesWithRootPort.has(String(d.id)));
|
||||
rootId = rootDevice ? String(rootDevice.id) : devices[0] ? String(devices[0].id) : '';
|
||||
} else {
|
||||
rootId = devices.reduce((best, d) =>
|
||||
(adj.get(String(d.id))?.size || 0) > (adj.get(best)?.size || 0) ? String(d.id) : best,
|
||||
devices[0] ? String(devices[0].id) : ''
|
||||
);
|
||||
}
|
||||
|
||||
const levels = new Map<string, number>();
|
||||
if (rootId) {
|
||||
const queue: string[] = [rootId];
|
||||
levels.set(rootId, 0);
|
||||
while (queue.length) {
|
||||
const curr = queue.shift()!;
|
||||
for (const neighbor of adj.get(curr) || []) {
|
||||
if (!levels.has(neighbor)) {
|
||||
levels.set(neighbor, levels.get(curr)! + 1);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxLevel = levels.size > 0 ? Math.max(...levels.values()) : 0;
|
||||
const allIds = [...devices.map((d) => String(d.id)), ...allExtNodes.map((e) => e.id)];
|
||||
for (const id of allIds) if (!levels.has(id)) levels.set(id, maxLevel + 1);
|
||||
|
||||
// ── Positioning ─────────────────────────────────────────────────────────────
|
||||
const byLevel = new Map<number, string[]>();
|
||||
for (const [id, lvl] of levels) {
|
||||
if (!byLevel.has(lvl)) byLevel.set(lvl, []);
|
||||
byLevel.get(lvl)!.push(id);
|
||||
}
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
for (const [lvl, ids] of byLevel) {
|
||||
const rowW = ids.length * NODE_W + (ids.length - 1) * H_GAP;
|
||||
ids.forEach((id, i) => {
|
||||
positions.set(id, { x: -rowW / 2 + i * (NODE_W + H_GAP), y: lvl * (NODE_H + V_GAP) });
|
||||
});
|
||||
}
|
||||
|
||||
// ── React Flow nodes ─────────────────────────────────────────────────────────
|
||||
const nodes: Node[] = [
|
||||
...devices.map((d) => ({
|
||||
id: String(d.id),
|
||||
type: 'deviceNode',
|
||||
position: positions.get(String(d.id)) || { x: 0, y: 0 },
|
||||
data: { ...d, isRootBridge: String(d.id) === rootId && hasStp } as unknown as Record<string, unknown>,
|
||||
})),
|
||||
...allExtNodes.map((e) => ({
|
||||
id: e.id,
|
||||
type: 'externalNode',
|
||||
position: positions.get(e.id) || { x: 0, y: (maxLevel + 1) * (NODE_H + V_GAP) },
|
||||
data: e as unknown as Record<string, unknown>,
|
||||
})),
|
||||
];
|
||||
|
||||
// ── React Flow edges ─────────────────────────────────────────────────────────
|
||||
const seen = new Set<string>();
|
||||
const edges: Edge[] = [];
|
||||
|
||||
// Direct links (LLDP + solo non-LLDP)
|
||||
for (const link of activeLinks) {
|
||||
if (!link.from_device_id) continue;
|
||||
const src = String(link.from_device_id);
|
||||
const dst = link.to_device_id ? String(link.to_device_id) : (linkToExtId.get(link.id) ?? null);
|
||||
if (!dst) continue;
|
||||
|
||||
const edgeKey = [src, dst].sort().join('--');
|
||||
if (seen.has(edgeKey)) continue;
|
||||
seen.add(edgeKey);
|
||||
|
||||
let stroke = '#94a3b8';
|
||||
let strokeDasharray: string | undefined;
|
||||
let animated = false;
|
||||
if (link.stp_role === 'root') { stroke = '#3b82f6'; animated = true; }
|
||||
else if (link.stp_role === 'designated') { stroke = '#22c55e'; }
|
||||
else if (link.stp_role === 'alternate' || link.stp_role === 'backup') {
|
||||
stroke = '#ef4444'; strokeDasharray = '6,3';
|
||||
}
|
||||
|
||||
const stpLabel = link.stp_role ? ` [${link.stp_role}]` : '';
|
||||
const portLabel = link.from_interface
|
||||
? (link.to_interface ? `${link.from_interface} ↔ ${link.to_interface}` : link.from_interface)
|
||||
: '';
|
||||
|
||||
edges.push({
|
||||
id: `edge-${link.id}`,
|
||||
source: src,
|
||||
target: dst,
|
||||
label: (portLabel + stpLabel) || undefined,
|
||||
labelStyle: { fontSize: 9, fill: '#94a3b8' },
|
||||
style: { stroke, strokeWidth: 2, strokeDasharray },
|
||||
animated,
|
||||
});
|
||||
}
|
||||
|
||||
// Shared-segment connection edges
|
||||
for (const { src, dst, port } of segConns) {
|
||||
const edgeKey = [src, dst].sort().join('--');
|
||||
if (seen.has(edgeKey)) continue;
|
||||
seen.add(edgeKey);
|
||||
edges.push({
|
||||
id: `segedge-${src}-${dst}`,
|
||||
source: src,
|
||||
target: dst,
|
||||
label: port || undefined,
|
||||
labelStyle: { fontSize: 9, fill: '#b45309' },
|
||||
style: { stroke: '#f59e0b', strokeWidth: 2, strokeDasharray: '5,3' },
|
||||
animated: false,
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export default function TopologyPage() {
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['topology'],
|
||||
queryFn: () => topologyApi.get().then((r) => r.data),
|
||||
});
|
||||
|
||||
const discoverMutation = useMutation({
|
||||
mutationFn: () => topologyApi.discover(),
|
||||
onSuccess: () => setTimeout(() => refetch(), 3000),
|
||||
});
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const { nodes: n, edges: e } = buildGraph(
|
||||
(data.devices as TopologyDevice[]) || [],
|
||||
(data.externalNodes as ExternalTopologyNode[]) || [],
|
||||
(data.links as TopologyLink[]) || []
|
||||
);
|
||||
setNodes(n);
|
||||
setEdges(e);
|
||||
}, [data, setNodes, setEdges]);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => setEdges((eds) => addEdge(connection, eds)),
|
||||
[setEdges]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96 text-gray-400">Loading topology...</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasData = (data?.devices?.length ?? 0) > 0;
|
||||
const hasStp = (data?.links as TopologyLink[])?.some((l) => l.stp_role);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Network Topology</h1>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => discoverMutation.mutate()}
|
||||
disabled={discoverMutation.isPending}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', discoverMutation.isPending && 'animate-spin')} />
|
||||
Discover
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hasData ? (
|
||||
<div className="card p-16 flex flex-col items-center gap-4 text-center">
|
||||
<GitBranch className="w-16 h-16 text-gray-300 dark:text-slate-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-700 dark:text-slate-300">No topology data yet</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
Add devices and click "Discover" to map your network topology via LLDP neighbors.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => discoverMutation.mutate()}
|
||||
disabled={discoverMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', discoverMutation.isPending && 'animate-spin')} />
|
||||
Start Discovery
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="card" style={{ height: 'min(600px, 60vh)' }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
minZoom={0.2}
|
||||
maxZoom={2}
|
||||
className="dark:bg-slate-800"
|
||||
>
|
||||
<Controls className="dark:bg-slate-700 dark:text-white" />
|
||||
<MiniMap
|
||||
className="dark:bg-slate-700"
|
||||
nodeColor={(n) => {
|
||||
if (n.type === 'externalNode') return '#94a3b8';
|
||||
const d = n.data as unknown as TopologyDevice;
|
||||
return statusColor[d.status] || '#94a3b8';
|
||||
}}
|
||||
/>
|
||||
<Background variant={BackgroundVariant.Dots} color="#94a3b8" gap={20} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="card px-4 py-3 flex flex-wrap gap-4 text-xs text-gray-500 dark:text-slate-400">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-0.5 bg-gray-400" />
|
||||
<span>Direct link (LLDP)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div style={{ width: 16, borderTop: '2px dashed #f59e0b' }} />
|
||||
<span>Shared segment (CDP/MNDP)</span>
|
||||
</div>
|
||||
{hasStp && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-0.5 bg-blue-500" />
|
||||
<span>Root port (uplink)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-0.5 bg-green-500" />
|
||||
<span>Designated (active)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div style={{ width: 16, borderTop: '2px dashed #ef4444' }} />
|
||||
<span>Alternate/Backup (blocked)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-amber-500 font-bold">★</span>
|
||||
<span>Root Bridge</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-4 rounded border-dashed border border-gray-400" />
|
||||
<span>External (unmanaged)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-4 h-4 rounded border-dashed border-amber-400" />
|
||||
<span>Shared segment node</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Link table */}
|
||||
{(data?.links?.length ?? 0) > 0 && (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Discovered Links ({(data!.links as TopologyLink[]).length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">From Device</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Local Port</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Remote Port</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Neighbor</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Neighbor IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Protocol</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">STP Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{(data!.links as TopologyLink[]).map((link) => (
|
||||
<tr key={link.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{link.from_device_name || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{link.from_interface || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{link.to_interface || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-700 dark:text-slate-300">
|
||||
{link.to_device_name || link.neighbor_identity || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{link.neighbor_address || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs">
|
||||
{link.link_type ? (
|
||||
<span className={clsx(
|
||||
'px-1.5 py-0.5 rounded font-medium uppercase',
|
||||
link.link_type === 'lldp' && 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
link.link_type === 'cdp' && 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
link.link_type === 'mndp' && 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
!['lldp','cdp','mndp'].includes(link.link_type) && 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-slate-300',
|
||||
)}>
|
||||
{link.link_type}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs">
|
||||
{link.stp_role ? (
|
||||
<span className={clsx(
|
||||
'px-1.5 py-0.5 rounded font-medium',
|
||||
link.stp_role === 'root' && 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
link.stp_role === 'designated' && 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
(link.stp_role === 'alternate' || link.stp_role === 'backup') && 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
)}>
|
||||
{link.stp_role}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RefreshCw, Wifi } from 'lucide-react';
|
||||
import { clientsApi } from '../services/api';
|
||||
import { useSocket } from '../hooks/useSocket';
|
||||
import type { Client } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function signalLabel(dbm: number | null): string {
|
||||
if (dbm === null) return '—';
|
||||
if (dbm >= -55) return 'Excellent';
|
||||
if (dbm >= -65) return 'Good';
|
||||
if (dbm >= -75) return 'Fair';
|
||||
return 'Poor';
|
||||
}
|
||||
|
||||
function signalColor(dbm: number | null): string {
|
||||
if (dbm === null) return 'text-gray-400 dark:text-slate-500';
|
||||
if (dbm >= -55) return 'text-green-600 dark:text-green-400';
|
||||
if (dbm >= -65) return 'text-lime-600 dark:text-lime-400';
|
||||
if (dbm >= -75) return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-red-500 dark:text-red-400';
|
||||
}
|
||||
|
||||
export default function WirelessClientsPage() {
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['wireless-clients-page'],
|
||||
queryFn: () =>
|
||||
clientsApi.list({ active: true, client_type: 'wireless', limit: 200 }).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
useSocket({
|
||||
'clients:updated': () => qc.invalidateQueries({ queryKey: ['wireless-clients-page'] }),
|
||||
});
|
||||
|
||||
const clients: Client[] = data?.clients ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Wireless Clients</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Active wireless clients across all access points
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', isLoading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
<RefreshCw className="w-5 h-5 animate-spin inline mr-2" />Loading…
|
||||
</div>
|
||||
) : clients.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
<Wifi className="w-6 h-6 mx-auto mb-2 opacity-40" />
|
||||
No active wireless clients
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">IP Address</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">MAC Address</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">SSID</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Signal</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Access Point</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
|
||||
{clients.map((client, i) => {
|
||||
const c = client as Client & { device_name?: string; ssid?: string };
|
||||
const name = c.custom_name || c.hostname || c.mac_address;
|
||||
const dbm = c.signal_strength ?? null;
|
||||
return (
|
||||
<tr
|
||||
key={c.mac_address}
|
||||
className={clsx(
|
||||
'hover:bg-gray-50 dark:hover:bg-slate-700/30 transition-colors',
|
||||
i % 2 === 1 && 'bg-gray-50/50 dark:bg-slate-800/20'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<button
|
||||
onClick={() => navigate(`/clients/${encodeURIComponent(c.mac_address)}`)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline text-left"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-slate-400">
|
||||
{c.ip_address || <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-slate-400">
|
||||
{c.mac_address}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-400">
|
||||
{c.ssid || <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{dbm !== null ? (
|
||||
<span className={clsx('font-medium', signalColor(dbm))}>
|
||||
{dbm} dBm
|
||||
<span className="ml-1.5 font-normal text-xs opacity-75">
|
||||
({signalLabel(dbm)})
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-slate-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-400">
|
||||
{c.device_name || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 text-right">
|
||||
{clients.length} client{clients.length !== 1 ? 's' : ''} · refreshes every 30 s
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Wifi, CheckCircle, XCircle, HelpCircle, Users, RefreshCw, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { wirelessApi } from '../services/api';
|
||||
import type { WirelessAP } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function timeAgo(ts: string | undefined): string {
|
||||
if (!ts) return 'Never';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (diff < 60_000) return 'Just now';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
status === 'online' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' :
|
||||
status === 'offline' ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' :
|
||||
'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400'
|
||||
)}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full',
|
||||
status === 'online' ? 'bg-green-500' : status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||
)} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WirelessPage() {
|
||||
const { data: aps = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['wireless-aps'],
|
||||
queryFn: () => wirelessApi.list().then(r => r.data as WirelessAP[]),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const online = aps.filter(a => a.status === 'online').length;
|
||||
const offline = aps.filter(a => a.status === 'offline').length;
|
||||
const unknown = aps.filter(a => a.status !== 'online' && a.status !== 'offline').length;
|
||||
const totalClients = aps.reduce((s, a) => s + (Number(a.client_count) || 0), 0);
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Total APs', value: aps.length, icon: Wifi, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
||||
{ label: 'Online', value: online, icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-900/20' },
|
||||
{ label: 'Offline', value: offline, icon: XCircle, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-900/20' },
|
||||
{ label: 'Unknown', value: unknown, icon: HelpCircle, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-900/20' },
|
||||
{ label: 'Wireless Clients', value: totalClients, icon: Users, color: 'text-purple-600 dark:text-purple-400', bg: 'bg-purple-50 dark:bg-purple-900/20' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-gray-400">
|
||||
<RefreshCw className="w-5 h-5 animate-spin mr-2" />Loading wireless APs…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Wireless</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Overview of all managed wireless access points
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{kpis.map(({ label, value, icon: Icon, color, bg }) => (
|
||||
<div key={label} className="card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={clsx('p-2 rounded-lg', bg)}>
|
||||
<Icon className={clsx('w-4 h-4', color)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{value}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* AP table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Access Points</h2>
|
||||
</div>
|
||||
|
||||
{aps.length === 0 ? (
|
||||
<div className="p-10 text-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No wireless APs found — add a device with type <strong>Wireless AP</strong> in Devices.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Status</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Model</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">IP Address</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-500 dark:text-slate-400">Radios</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-500 dark:text-slate-400">Active SSIDs</th>
|
||||
<th className="px-4 py-3 text-center font-medium text-gray-500 dark:text-slate-400">Clients</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">ROS Version</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Last Seen</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
|
||||
{aps.map((ap) => (
|
||||
<tr key={ap.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
to={`/devices/${ap.id}`}
|
||||
className="font-medium text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{ap.name}
|
||||
</Link>
|
||||
{ap.rack_name && (
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">
|
||||
{ap.rack_name}{ap.rack_slot ? ` / ${ap.rack_slot}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={ap.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-slate-300">
|
||||
{ap.model || <span className="text-gray-400 dark:text-slate-500">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-slate-400">
|
||||
{ap.ip_address}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="font-medium text-gray-800 dark:text-slate-200">
|
||||
{Number(ap.radio_count) || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="font-medium text-gray-800 dark:text-slate-200">
|
||||
{Number(ap.ssid_count) || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
Number(ap.client_count) > 0
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400'
|
||||
: 'bg-gray-50 dark:bg-slate-700/50 text-gray-500 dark:text-slate-400'
|
||||
)}>
|
||||
<Users className="w-3 h-3" />
|
||||
{Number(ap.client_count) || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-500 dark:text-slate-400 font-mono">
|
||||
{ap.ros_version || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-gray-400 dark:text-slate-500">
|
||||
{timeAgo(ap.last_seen)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
to={`/devices/${ap.id}`}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
title="Open device detail"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,590 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import type {
|
||||
Device,
|
||||
Interface,
|
||||
Vlan,
|
||||
Client,
|
||||
DeviceEvent,
|
||||
Backup,
|
||||
MetricsSummary,
|
||||
TimeSeriesPoint,
|
||||
TrafficPoint,
|
||||
ResourcePoint,
|
||||
SwitchPort,
|
||||
SystemConfig,
|
||||
IpAddress,
|
||||
PortVlanConfig,
|
||||
PortMonitorData,
|
||||
} from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = useAuthStore.getState().token;
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(r) => r,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
useAuthStore.getState().logout();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post<{ token: string; user: import('../types').User }>(
|
||||
'/auth/login',
|
||||
{ username, password }
|
||||
),
|
||||
me: () => api.get('/auth/me'),
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
api.put('/auth/password', { currentPassword, newPassword }),
|
||||
};
|
||||
|
||||
// ─── Devices ──────────────────────────────────────────────────────────────────
|
||||
export interface DiscoveredDevice {
|
||||
identity: string;
|
||||
address: string;
|
||||
mac_address: string;
|
||||
platform: string;
|
||||
discovered_at: string;
|
||||
seen_by: string;
|
||||
}
|
||||
|
||||
export const devicesApi = {
|
||||
list: () => api.get<Device[]>('/devices'),
|
||||
discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'),
|
||||
get: (id: number) => api.get<Device>(`/devices/${id}`),
|
||||
create: (data: Partial<Device> & { api_password: string }) =>
|
||||
api.post<Device>('/devices', data),
|
||||
update: (id: number, data: Partial<Device> & { api_password?: string }) =>
|
||||
api.put<Device>(`/devices/${id}`, data),
|
||||
delete: (id: number) => api.delete(`/devices/${id}`),
|
||||
sync: (id: number) => api.post(`/devices/${id}/sync`, undefined, { timeout: 60_000 }),
|
||||
test: (id: number) => api.post<{ success: boolean; identity?: string; error?: string }>(`/devices/${id}/test`),
|
||||
getInterfaces: (id: number) => api.get<Interface[]>(`/devices/${id}/interfaces`),
|
||||
updateInterface: (id: number, name: string, data: Partial<Interface>) =>
|
||||
api.put(`/devices/${id}/interfaces/${encodeURIComponent(name)}`, data),
|
||||
getVlans: (id: number) => api.get<Vlan[]>(`/devices/${id}/vlans`),
|
||||
getPorts: (id: number) =>
|
||||
api.get<{ ports: SwitchPort[]; vlans: Vlan[] }>(`/devices/${id}/ports`),
|
||||
getRouting: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/routing`),
|
||||
getFirewall: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/firewall`),
|
||||
addFirewallRule: (id: number, data: Record<string, unknown>) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/firewall`, data),
|
||||
updateFirewallRule: (id: number, ruleId: string, data: Record<string, unknown>) =>
|
||||
api.put<Record<string, string>[]>(`/devices/${id}/firewall/${encodeURIComponent(ruleId)}`, data),
|
||||
deleteFirewallRule: (id: number, ruleId: string) =>
|
||||
api.delete(`/devices/${id}/firewall/${encodeURIComponent(ruleId)}`),
|
||||
getResources: (id: number) => api.get<Record<string, string>>(`/devices/${id}/resources`),
|
||||
configurePortVlan: (id: number, name: string, data: PortVlanConfig) =>
|
||||
api.put(`/devices/${id}/ports/${encodeURIComponent(name)}/vlan`, data),
|
||||
getSystemConfig: (id: number) => api.get<SystemConfig>(`/devices/${id}/system-config`),
|
||||
updateSystemConfig: (id: number, data: Partial<{
|
||||
identity: string;
|
||||
ntp_enabled: boolean;
|
||||
ntp_primary: string;
|
||||
ntp_secondary: string;
|
||||
dns_servers: string;
|
||||
dns_allow_remote: boolean;
|
||||
}>) => api.put(`/devices/${id}/system-config`, data),
|
||||
getIpAddresses: (id: number) => api.get<IpAddress[]>(`/devices/${id}/ip-addresses`),
|
||||
addIpAddress: (id: number, data: { address: string; interface: string }) =>
|
||||
api.post<IpAddress[]>(`/devices/${id}/ip-addresses`, data),
|
||||
removeIpAddress: (id: number, addrId: string) =>
|
||||
api.delete(`/devices/${id}/ip-addresses/${encodeURIComponent(addrId)}`),
|
||||
checkUpdate: (id: number) =>
|
||||
api.post<Record<string, string>>(`/devices/${id}/check-update`),
|
||||
getHardware: (id: number) =>
|
||||
api.get<{ health: Record<string, string>[]; disks: Record<string, string>[] }>(`/devices/${id}/hardware`),
|
||||
installUpdate: (id: number) => api.post(`/devices/${id}/install-update`),
|
||||
getClock: (id: number) => api.get<{ date: string; time: string; timezone: string }>(`/devices/${id}/clock`),
|
||||
setClock: (id: number, data: { date?: string; time?: string; timezone?: string }) =>
|
||||
api.put(`/devices/${id}/clock`, data),
|
||||
addRoute: (id: number, data: { dst_address: string; gateway: string; distance?: number; comment?: string }) =>
|
||||
api.post(`/devices/${id}/routing`, data),
|
||||
deleteRoute: (id: number, routeId: string) =>
|
||||
api.delete(`/devices/${id}/routing/${encodeURIComponent(routeId)}`),
|
||||
addVlan: (id: number, data: { bridge: string; vlan_id: number; tagged_ports?: string[]; untagged_ports?: string[] }) =>
|
||||
api.post(`/devices/${id}/vlans`, data),
|
||||
updateVlan: (id: number, vlanDbId: number, data: { tagged_ports: string[]; untagged_ports: string[] }) =>
|
||||
api.put(`/devices/${id}/vlans/${vlanDbId}`, data),
|
||||
deleteVlan: (id: number, vlanDbId: number) => api.delete(`/devices/${id}/vlans/${vlanDbId}`),
|
||||
reboot: (id: number) => api.post<{ message: string }>(`/devices/${id}/reboot`),
|
||||
getPortMonitor: (id: number, name: string) =>
|
||||
api.get<PortMonitorData>(`/devices/${id}/ports/${encodeURIComponent(name)}/monitor`),
|
||||
createBond: (id: number, data: {
|
||||
name: string; mode: string; slaves: string[];
|
||||
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
|
||||
}) => api.post(`/devices/${id}/bonds`, data),
|
||||
updateBond: (id: number, bondName: string, data: {
|
||||
mode: string; slaves: string[];
|
||||
lacp_rate?: string; transmit_hash_policy?: string; mtu?: number; min_links?: number;
|
||||
}) => api.put(`/devices/${id}/bonds/${encodeURIComponent(bondName)}`, data),
|
||||
deleteBond: (id: number, bondName: string) =>
|
||||
api.delete(`/devices/${id}/bonds/${encodeURIComponent(bondName)}`),
|
||||
setBridgeVlanFiltering: (id: number, bridgeName: string, enabled: boolean) =>
|
||||
api.put(`/devices/${id}/bridge/${encodeURIComponent(bridgeName)}/vlan-filtering`, { enabled }),
|
||||
getNat: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/nat`),
|
||||
addNatRule: (id: number, data: Record<string, unknown>) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/nat`, data),
|
||||
updateNatRule: (id: number, ruleId: string, data: Record<string, unknown>) =>
|
||||
api.put<Record<string, string>[]>(`/devices/${id}/nat/${encodeURIComponent(ruleId)}`, data),
|
||||
deleteNatRule: (id: number, ruleId: string) =>
|
||||
api.delete(`/devices/${id}/nat/${encodeURIComponent(ruleId)}`),
|
||||
patchLocation: (id: number, data: {
|
||||
location_address?: string | null;
|
||||
location_lat?: number | null;
|
||||
location_lng?: number | null;
|
||||
rack_name?: string | null;
|
||||
rack_slot?: string | null;
|
||||
notes?: string | null;
|
||||
}) => api.patch<Device>(`/devices/${id}/location`, data),
|
||||
};
|
||||
|
||||
// ─── Clients ──────────────────────────────────────────────────────────────────
|
||||
export interface ClientDetail extends Client {
|
||||
device_type?: string;
|
||||
ssid?: string;
|
||||
vlan_name?: string;
|
||||
upstream_device_id?: number;
|
||||
upstream_interface?: string;
|
||||
upstream_device_name?: string;
|
||||
upstream_device_type?: string;
|
||||
upstream_device_ip?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface PresencePoint { time: string; online: number; }
|
||||
export interface TrafficPoint2 { time: string; tx_bytes: number; rx_bytes: number; }
|
||||
export interface SignalPoint { time: string; signal_strength: number; }
|
||||
|
||||
export const clientsApi = {
|
||||
list: (params?: {
|
||||
deviceId?: number;
|
||||
active?: boolean;
|
||||
search?: string;
|
||||
client_type?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) =>
|
||||
api.get<{ clients: Client[]; total: number }>('/clients', { params }),
|
||||
get: (mac: string) => api.get<ClientDetail>(`/clients/${mac}`),
|
||||
getPresence: (mac: string, range = '24h') =>
|
||||
api.get<PresencePoint[]>(`/clients/${mac}/presence`, { params: { range } }),
|
||||
getTraffic: (mac: string, range = '24h') =>
|
||||
api.get<TrafficPoint2[]>(`/clients/${mac}/traffic`, { params: { range } }),
|
||||
getSignal: (mac: string, range = '24h') =>
|
||||
api.get<SignalPoint[]>(`/clients/${mac}/signal`, { params: { range } }),
|
||||
wol: (mac: string) => api.post<{ success: boolean; message: string }>(`/clients/${mac}/wol`),
|
||||
updateNotes: (mac: string, notes: string) =>
|
||||
api.put<Client>(`/clients/${mac}/notes`, { notes }),
|
||||
updateHostname: (mac: string, hostname: string) =>
|
||||
api.put<Client>(`/clients/${mac}/hostname`, { hostname }),
|
||||
purgeStale: () => api.post<{ message: string; count: number }>('/clients/purge'),
|
||||
};
|
||||
|
||||
// ─── Device Network Tools ─────────────────────────────────────────────────────
|
||||
// Long timeout (120s) since traceroute/ip-scan can run for a while
|
||||
const TOOL_TIMEOUT = 120_000;
|
||||
export const deviceToolsApi = {
|
||||
ping: (id: number, body: { address: string; count?: number; interface?: string }) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/tools/ping`, body, { timeout: TOOL_TIMEOUT }),
|
||||
traceroute: (id: number, body: { address: string }) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/tools/traceroute`, body, { timeout: TOOL_TIMEOUT }),
|
||||
ipScan: (id: number, body: { addressRange: string; interface: string; rdns?: boolean }) =>
|
||||
api.post<Record<string, string>[]>(`/devices/${id}/tools/ip-scan`, body, { timeout: TOOL_TIMEOUT }),
|
||||
wol: (id: number, body: { mac: string; interface: string }) =>
|
||||
api.post<{ success: boolean; message: string }>(`/devices/${id}/tools/wol`, body, { timeout: TOOL_TIMEOUT }),
|
||||
};
|
||||
|
||||
// ─── Events ───────────────────────────────────────────────────────────────────
|
||||
export const eventsApi = {
|
||||
list: (params?: {
|
||||
deviceId?: number;
|
||||
severity?: string;
|
||||
topic?: string;
|
||||
search?: string;
|
||||
since?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) =>
|
||||
api.get<{ events: DeviceEvent[]; total: number; criticalCount: number }>('/events', { params }),
|
||||
clear: (deviceId?: number) => api.delete('/events', { params: deviceId ? { deviceId } : {} }),
|
||||
};
|
||||
|
||||
// ─── Backups ──────────────────────────────────────────────────────────────────
|
||||
export const backupsApi = {
|
||||
list: (deviceId?: number) =>
|
||||
api.get<Backup[]>('/backups', { params: deviceId ? { deviceId } : {} }),
|
||||
create: (deviceId: number, notes?: string) =>
|
||||
api.post<Backup>('/backups', { deviceId, notes }),
|
||||
download: (id: number) =>
|
||||
api.get(`/backups/${id}/download`, { responseType: 'blob' }),
|
||||
restore: (id: number) => api.post(`/backups/${id}/restore`),
|
||||
delete: (id: number) => api.delete(`/backups/${id}`),
|
||||
};
|
||||
|
||||
// ─── Metrics ─────────────────────────────────────────────────────────────────
|
||||
export const metricsApi = {
|
||||
summary: () => api.get<MetricsSummary>('/metrics/summary'),
|
||||
clientsOverTime: (range = '24h') =>
|
||||
api.get<TimeSeriesPoint[]>('/metrics/clients-over-time', { params: { range } }),
|
||||
topClients: (limit = 10) =>
|
||||
api.get<(Client & { total_bytes: number })[]>('/metrics/top-clients', { params: { limit } }),
|
||||
interfaceTraffic: (deviceId: number, iface: string, range = '1h') =>
|
||||
api.get<TrafficPoint[]>(`/metrics/interface/${deviceId}/${encodeURIComponent(iface)}`, {
|
||||
params: { range },
|
||||
}),
|
||||
interfacePackets: (deviceId: number, iface: string, range = '1h') =>
|
||||
api.get<TrafficPoint[]>(`/metrics/interface/${deviceId}/${encodeURIComponent(iface)}/packets`, {
|
||||
params: { range },
|
||||
}),
|
||||
deviceResources: (deviceId: number, range = '24h') =>
|
||||
api.get<ResourcePoint[]>(`/metrics/device/${deviceId}/resources`, { params: { range } }),
|
||||
};
|
||||
|
||||
// ─── Topology ────────────────────────────────────────────────────────────────
|
||||
export const topologyApi = {
|
||||
get: () =>
|
||||
api.get<{
|
||||
devices: Device[];
|
||||
links: import('../types').TopologyLink[];
|
||||
externalNodes: import('../types').ExternalTopologyNode[];
|
||||
}>('/topology'),
|
||||
discover: () => api.post('/topology/discover'),
|
||||
};
|
||||
|
||||
// ─── Search ──────────────────────────────────────────────────────────────────
|
||||
export const searchApi = {
|
||||
search: (q: string) =>
|
||||
api.get<{
|
||||
devices: {
|
||||
id: number; name: string; ip_address: string;
|
||||
model?: string; device_type: string; status: string;
|
||||
}[];
|
||||
clients: {
|
||||
mac_address: string; hostname?: string; ip_address?: string;
|
||||
device_id: number; device_name?: string; active: boolean;
|
||||
}[];
|
||||
events: {
|
||||
id: number; message: string; severity: string;
|
||||
event_time: string; topic?: string; device_name?: string;
|
||||
}[];
|
||||
}>('/search', { params: { q } }),
|
||||
};
|
||||
|
||||
// ─── Routers ──────────────────────────────────────────────────────────────────
|
||||
export const routersApi = {
|
||||
overview: () => api.get<Record<string, unknown>[]>('/devices/routers/overview'),
|
||||
getLldpStatus: () => api.get<{
|
||||
id: number; name: string; ip_address: string;
|
||||
enabled: boolean | null; protocol: string | null; error?: string;
|
||||
}[]>('/routers/lldp'),
|
||||
setLldp: (enabled: boolean) => api.put<{
|
||||
applied: number; total: number;
|
||||
results: { id: number; name: string; success: boolean; error?: string }[];
|
||||
}>('/routers/lldp', { enabled }),
|
||||
getSnmpStatus: () => api.get<{
|
||||
id: number; name: string; ip_address: string;
|
||||
enabled: boolean | null; community_name?: string; version?: string;
|
||||
auth_protocol?: string; priv_protocol?: string;
|
||||
contact?: string; location?: string; trap_target?: string;
|
||||
error?: string;
|
||||
}[]>('/routers/snmp'),
|
||||
setSnmp: (config: {
|
||||
enabled: boolean; community_name: string; version: 'v1' | 'v2c' | 'v3';
|
||||
contact?: string; location?: string; trap_target?: string;
|
||||
auth_protocol?: string; auth_password?: string;
|
||||
priv_protocol?: string; priv_password?: string;
|
||||
}) => api.put<{
|
||||
applied: number; total: number;
|
||||
results: { id: number; name: string; success: boolean; error?: string }[];
|
||||
}>('/routers/snmp', config),
|
||||
};
|
||||
|
||||
// ─── Routing Protocols (per-device) ───────────────────────────────────────────
|
||||
Object.assign(devicesApi, {
|
||||
getOspf: (id: number) => api.get<Record<string, unknown>>(`/devices/${id}/routing/ospf`),
|
||||
addOspfInstance: (id: number, data: Record<string, unknown>) =>
|
||||
api.post(`/devices/${id}/routing/ospf/instance`, data),
|
||||
removeOspfInstance: (id: number, itemId: string) =>
|
||||
api.delete(`/devices/${id}/routing/ospf/instance/${encodeURIComponent(itemId)}`),
|
||||
addOspfArea: (id: number, data: Record<string, unknown>) =>
|
||||
api.post(`/devices/${id}/routing/ospf/area`, data),
|
||||
removeOspfArea: (id: number, itemId: string) =>
|
||||
api.delete(`/devices/${id}/routing/ospf/area/${encodeURIComponent(itemId)}`),
|
||||
getBgp: (id: number) => api.get<Record<string, unknown>>(`/devices/${id}/routing/bgp`),
|
||||
addBgpConnection: (id: number, data: Record<string, unknown>) =>
|
||||
api.post(`/devices/${id}/routing/bgp/connection`, data),
|
||||
removeBgpConnection: (id: number, itemId: string) =>
|
||||
api.delete(`/devices/${id}/routing/bgp/connection/${encodeURIComponent(itemId)}`),
|
||||
getRoutingTables: (id: number) =>
|
||||
api.get<Record<string, string>[]>(`/devices/${id}/routing/tables`),
|
||||
addRoutingTable: (id: number, data: { name: string; fib?: boolean }) =>
|
||||
api.post(`/devices/${id}/routing/tables`, data),
|
||||
removeRoutingTable: (id: number, itemId: string) =>
|
||||
api.delete(`/devices/${id}/routing/tables/${encodeURIComponent(itemId)}`),
|
||||
getRouteFilters: (id: number) =>
|
||||
api.get<Record<string, unknown>>(`/devices/${id}/routing/filters`),
|
||||
addFilterRule: (id: number, data: Record<string, unknown>) =>
|
||||
api.post(`/devices/${id}/routing/filters/rule`, data),
|
||||
updateFilterRule: (id: number, itemId: string, data: Record<string, unknown>) =>
|
||||
api.put(`/devices/${id}/routing/filters/rule/${encodeURIComponent(itemId)}`, data),
|
||||
removeFilterRule: (id: number, itemId: string) =>
|
||||
api.delete(`/devices/${id}/routing/filters/rule/${encodeURIComponent(itemId)}`),
|
||||
getRouterIds: (id: number) =>
|
||||
api.get<Record<string, string>[]>(`/devices/${id}/routing/router-id`),
|
||||
});
|
||||
|
||||
// ─── Switches ─────────────────────────────────────────────────────────────────
|
||||
export const switchesApi = {
|
||||
list: () => api.get<Record<string, unknown>[]>('/switches'),
|
||||
getLldpStatus: () => api.get<{
|
||||
id: number; name: string; ip_address: string;
|
||||
enabled: boolean | null; protocol: string | null; error?: string;
|
||||
}[]>('/switches/lldp'),
|
||||
setLldp: (enabled: boolean) => api.put<{
|
||||
applied: number; total: number;
|
||||
results: { id: number; name: string; success: boolean; error?: string }[];
|
||||
}>('/switches/lldp', { enabled }),
|
||||
getSnmpStatus: () => api.get<{
|
||||
id: number; name: string; ip_address: string;
|
||||
enabled: boolean | null; community_name?: string; version?: string;
|
||||
auth_protocol?: string; priv_protocol?: string;
|
||||
contact?: string; location?: string; trap_target?: string;
|
||||
error?: string;
|
||||
}[]>('/switches/snmp'),
|
||||
setSnmp: (config: {
|
||||
enabled: boolean; community_name: string; version: 'v1' | 'v2c' | 'v3';
|
||||
contact?: string; location?: string; trap_target?: string;
|
||||
auth_protocol?: string; auth_password?: string;
|
||||
priv_protocol?: string; priv_password?: string;
|
||||
}) => api.put<{
|
||||
applied: number; total: number;
|
||||
results: { id: number; name: string; success: boolean; error?: string }[];
|
||||
}>('/switches/snmp', config),
|
||||
};
|
||||
|
||||
// ─── Alerts ──────────────────────────────────────────────────────────────────
|
||||
export interface AlertRule {
|
||||
event_type: string;
|
||||
enabled: boolean;
|
||||
threshold: number | null;
|
||||
cooldown_min: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AlertChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'email' | 'slack' | 'discord' | 'telegram';
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AlertHistoryEntry {
|
||||
id: number;
|
||||
event_type: string;
|
||||
device_id: number | null;
|
||||
device_name: string | null;
|
||||
message: string;
|
||||
channels_notified: string[];
|
||||
sent_at: string;
|
||||
}
|
||||
|
||||
export const alertsApi = {
|
||||
getRules: () => api.get<AlertRule[]>('/alerts/rules'),
|
||||
updateRule: (type: string, data: Partial<Pick<AlertRule, 'enabled' | 'threshold' | 'cooldown_min'>>) =>
|
||||
api.put<AlertRule>(`/alerts/rules/${encodeURIComponent(type)}`, data),
|
||||
getChannels: () => api.get<AlertChannel[]>('/alerts/channels'),
|
||||
createChannel: (data: { name: string; type: AlertChannel['type']; enabled?: boolean; config?: Record<string, unknown> }) =>
|
||||
api.post<AlertChannel>('/alerts/channels', data),
|
||||
updateChannel: (id: number, data: Partial<Pick<AlertChannel, 'name' | 'enabled' | 'config'>>) =>
|
||||
api.put<AlertChannel>(`/alerts/channels/${id}`, data),
|
||||
deleteChannel: (id: number) => api.delete(`/alerts/channels/${id}`),
|
||||
testChannel: (id: number) => api.post<{ message: string }>(`/alerts/channels/${id}/test`),
|
||||
getHistory: (limit = 50) => api.get<AlertHistoryEntry[]>('/alerts/history', { params: { limit } }),
|
||||
};
|
||||
|
||||
// ─── Settings ────────────────────────────────────────────────────────────────
|
||||
export const settingsApi = {
|
||||
get: () => api.get<Record<string, unknown>>('/settings'),
|
||||
update: (data: Record<string, unknown>) => api.put('/settings', data),
|
||||
getUsers: () => api.get('/settings/users'),
|
||||
createUser: (data: { username: string; password: string; role?: string }) =>
|
||||
api.post('/settings/users', data),
|
||||
updateUser: (id: number, data: { role?: string; password?: string }) =>
|
||||
api.put(`/settings/users/${id}`, data),
|
||||
deleteUser: (id: number) => api.delete(`/settings/users/${id}`),
|
||||
};
|
||||
|
||||
export interface CertInfo {
|
||||
exists: boolean;
|
||||
subject?: string;
|
||||
subject_full?: string;
|
||||
issuer?: string;
|
||||
issuer_full?: string;
|
||||
serial_number?: string;
|
||||
valid_from?: string;
|
||||
valid_to?: string;
|
||||
days_remaining?: number;
|
||||
is_self_signed?: boolean;
|
||||
san?: string | null;
|
||||
}
|
||||
|
||||
export const certApi = {
|
||||
get: () => api.get<CertInfo>('/cert'),
|
||||
regenerate: () => api.post<CertInfo & { message: string }>('/cert/regenerate'),
|
||||
upload: (certificate: string, private_key: string) =>
|
||||
api.post<CertInfo & { message: string }>('/cert/upload', { certificate, private_key }),
|
||||
};
|
||||
|
||||
// ─── Wireless ─────────────────────────────────────────────────────────────────
|
||||
export const wirelessApi = {
|
||||
// Section overview
|
||||
list: () => api.get('/wireless'),
|
||||
|
||||
// Multi-AP SSID deployment
|
||||
bulkCreateInterfaces: (data: { apIds: number[] } & Record<string, unknown>) =>
|
||||
api.post('/wireless/ssid/bulk', data),
|
||||
|
||||
// Per-AP: wireless interfaces (SSIDs)
|
||||
getInterfaces: (apId: number) => api.get(`/wireless/${apId}/interfaces`),
|
||||
createInterface: (apId: number, data: Record<string, unknown>) =>
|
||||
api.post(`/wireless/${apId}/interfaces`, data),
|
||||
updateInterface: (apId: number, name: string, data: Record<string, unknown>) =>
|
||||
api.put(`/wireless/${apId}/interfaces/${encodeURIComponent(name)}`, data),
|
||||
deleteInterface: (apId: number, name: string) =>
|
||||
api.delete(`/wireless/${apId}/interfaces/${encodeURIComponent(name)}`),
|
||||
|
||||
// Per-AP: security profiles
|
||||
getSecurityProfiles: (apId: number) => api.get(`/wireless/${apId}/security-profiles`),
|
||||
createSecurityProfile: (apId: number, data: Record<string, unknown>) =>
|
||||
api.post(`/wireless/${apId}/security-profiles`, data),
|
||||
updateSecurityProfile: (apId: number, name: string, data: Record<string, unknown>) =>
|
||||
api.put(`/wireless/${apId}/security-profiles/${encodeURIComponent(name)}`, data),
|
||||
deleteSecurityProfile: (apId: number, name: string) =>
|
||||
api.delete(`/wireless/${apId}/security-profiles/${encodeURIComponent(name)}`),
|
||||
|
||||
// Hardware radio capabilities (wifi package)
|
||||
getRadios: (apId: number) => api.get(`/wireless/${apId}/radios`),
|
||||
|
||||
// Bridge list + port memberships
|
||||
getBridges: (apId: number) => api.get(`/wireless/${apId}/bridges`),
|
||||
|
||||
// Radio monitoring
|
||||
getRegistrationTable: (apId: number) => api.get(`/wireless/${apId}/registration-table`),
|
||||
getMonitor: (apId: number, iface: string) =>
|
||||
api.get(`/wireless/${apId}/monitor/${encodeURIComponent(iface)}`),
|
||||
scan: (apId: number, iface: string) =>
|
||||
api.get(`/wireless/${apId}/scan/${encodeURIComponent(iface)}`),
|
||||
|
||||
// Cached wireless data (for device detail Radios tab)
|
||||
getCachedInterfaces: (deviceId: number) => api.get(`/devices/${deviceId}/wireless`),
|
||||
getMetrics: (deviceId: number, iface?: string, range?: string) =>
|
||||
api.get(`/devices/${deviceId}/wireless/metrics`, { params: { iface, range } }),
|
||||
|
||||
// Spectral scan
|
||||
runSpectralScan: (apId: number, iface: string) =>
|
||||
api.post(`/wireless/${apId}/spectral-scan/${encodeURIComponent(iface)}`),
|
||||
getSpectralHistory: (apId: number, iface: string, limit = 5) =>
|
||||
api.get(`/wireless/${apId}/spectral-history/${encodeURIComponent(iface)}`, { params: { limit } }),
|
||||
|
||||
// AP scan
|
||||
runAPScan: (apId: number) => api.post(`/wireless/${apId}/ap-scan`),
|
||||
getAPScanHistory: (apId: number, limit = 5) =>
|
||||
api.get(`/wireless/${apId}/ap-scan-history`, { params: { limit } }),
|
||||
};
|
||||
|
||||
// ─── Network Services ─────────────────────────────────────────────────────────
|
||||
|
||||
type NS = Record<string, string>;
|
||||
|
||||
export const networkServicesApi = {
|
||||
// Overview
|
||||
overview: () =>
|
||||
api.get<Record<string, unknown>[]>('/network-services/overview', { timeout: 60_000 }),
|
||||
|
||||
// ── DHCP ──────────────────────────────────────────────────────────────────
|
||||
getDhcp: (deviceId: number) =>
|
||||
api.get<{ ipv4: NS[]; ipv6: NS[]; pools_v4: NS[]; pools_v6: NS[] }>(
|
||||
'/network-services/dhcp', { params: { deviceId } }
|
||||
),
|
||||
addDhcpServer: (deviceId: number, body: NS & { protocol: 'ipv4' | 'ipv6' }) =>
|
||||
api.post<NS[]>('/network-services/dhcp/server', body, { params: { deviceId } }),
|
||||
updateDhcpServer: (deviceId: number, id: string, body: NS & { protocol: 'ipv4' | 'ipv6' }) =>
|
||||
api.put<NS[]>(`/network-services/dhcp/server/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
|
||||
deleteDhcpServer: (deviceId: number, id: string, protocol: 'ipv4' | 'ipv6') =>
|
||||
api.delete(`/network-services/dhcp/server/${encodeURIComponent(id)}`, { params: { deviceId, protocol } }),
|
||||
toggleDhcpServer: (deviceId: number, serverId: string, disabled: boolean, protocol: 'ipv4' | 'ipv6') =>
|
||||
api.put('/network-services/dhcp/server', { serverId, disabled, protocol }, { params: { deviceId } }),
|
||||
addDhcpPool: (deviceId: number, body: NS & { protocol: 'ipv4' | 'ipv6' }) =>
|
||||
api.post<NS[]>('/network-services/dhcp/pool', body, { params: { deviceId } }),
|
||||
deleteDhcpPool: (deviceId: number, id: string, protocol: 'ipv4' | 'ipv6') =>
|
||||
api.delete(`/network-services/dhcp/pool/${encodeURIComponent(id)}`, { params: { deviceId, protocol } }),
|
||||
getLeases: (deviceId: number, protocol: 'ipv4' | 'ipv6') =>
|
||||
api.get<NS[]>('/network-services/dhcp/leases', { params: { deviceId, protocol } }),
|
||||
addStaticLease: (deviceId: number, body: NS & { protocol: 'ipv4' | 'ipv6' }) =>
|
||||
api.post('/network-services/dhcp/static-lease', body, { params: { deviceId } }),
|
||||
deleteStaticLease: (deviceId: number, id: string, protocol: 'ipv4' | 'ipv6') =>
|
||||
api.delete(`/network-services/dhcp/static-lease/${encodeURIComponent(id)}`, { params: { deviceId, protocol } }),
|
||||
|
||||
// ── DNS ───────────────────────────────────────────────────────────────────
|
||||
getDns: (deviceId: number) =>
|
||||
api.get<{ settings: NS; statics: NS[] }>('/network-services/dns', { params: { deviceId } }),
|
||||
setDns: (deviceId: number, settings: {
|
||||
servers?: string; allow_remote_requests?: boolean;
|
||||
max_udp_packet_size?: string; cache_size?: string; cache_max_ttl?: string;
|
||||
}) =>
|
||||
api.put<NS>('/network-services/dns', settings, { params: { deviceId } }),
|
||||
flushDns: (deviceId: number) =>
|
||||
api.post('/network-services/dns/flush', {}, { params: { deviceId } }),
|
||||
addDnsStatic: (deviceId: number, body: NS) =>
|
||||
api.post<NS[]>('/network-services/dns/static', body, { params: { deviceId } }),
|
||||
updateDnsStatic: (deviceId: number, id: string, body: NS) =>
|
||||
api.put<NS[]>(`/network-services/dns/static/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
|
||||
deleteDnsStatic: (deviceId: number, id: string) =>
|
||||
api.delete(`/network-services/dns/static/${encodeURIComponent(id)}`, { params: { deviceId } }),
|
||||
|
||||
// ── NTP ───────────────────────────────────────────────────────────────────
|
||||
getNtp: (deviceId: number) =>
|
||||
api.get<{ server: NS; client: NS }>('/network-services/ntp', { params: { deviceId } }),
|
||||
setNtp: (deviceId: number, settings: {
|
||||
server_enabled?: boolean; server_broadcast?: boolean; server_manycast?: boolean;
|
||||
client_enabled?: boolean; client_mode?: string; client_servers?: string;
|
||||
}) =>
|
||||
api.put<{ server: NS; client: NS }>('/network-services/ntp', settings, { params: { deviceId } }),
|
||||
|
||||
// ── WireGuard ─────────────────────────────────────────────────────────────
|
||||
getWireGuard: (deviceId: number) =>
|
||||
api.get<{ interfaces: NS[]; peers: NS[] }>('/network-services/wireguard', { params: { deviceId } }),
|
||||
addWireGuardInterface: (deviceId: number, body: NS) =>
|
||||
api.post<NS[]>('/network-services/wireguard', body, { params: { deviceId } }),
|
||||
updateWireGuardInterface: (deviceId: number, id: string, body: NS) =>
|
||||
api.put<NS[]>(`/network-services/wireguard/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
|
||||
deleteWireGuardInterface: (deviceId: number, id: string) =>
|
||||
api.delete(`/network-services/wireguard/${encodeURIComponent(id)}`, { params: { deviceId } }),
|
||||
toggleWireGuard: (deviceId: number, interfaceId: string, disabled: boolean) =>
|
||||
api.put('/network-services/wireguard/toggle', { interfaceId, disabled }, { params: { deviceId } }),
|
||||
addWireGuardPeer: (deviceId: number, body: NS) =>
|
||||
api.post<NS[]>('/network-services/wireguard/peer', body, { params: { deviceId } }),
|
||||
updateWireGuardPeer: (deviceId: number, id: string, body: NS) =>
|
||||
api.put<NS[]>(`/network-services/wireguard/peer/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
|
||||
deleteWireGuardPeer: (deviceId: number, id: string) =>
|
||||
api.delete(`/network-services/wireguard/peer/${encodeURIComponent(id)}`, { params: { deviceId } }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { User } from '../types';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
setAuth: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
setAuth: (token, user) => set({ token, user, isAuthenticated: true }),
|
||||
logout: () => set({ token: null, user: null, isAuthenticated: false }),
|
||||
}),
|
||||
{
|
||||
name: 'mikrotik-auth',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
theme: 'light',
|
||||
setTheme: (theme) => {
|
||||
set({ theme });
|
||||
applyTheme(theme);
|
||||
},
|
||||
toggleTheme: () => {
|
||||
const next = get().theme === 'light' ? 'dark' : 'light';
|
||||
set({ theme: next });
|
||||
applyTheme(next);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'mikrotik-theme',
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) applyTheme(state.theme);
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
function applyTheme(theme: Theme): void {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
export type UserRole = 'admin' | 'operator' | 'viewer';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface IpAddress {
|
||||
'.id': string;
|
||||
address: string;
|
||||
interface: string;
|
||||
network?: string;
|
||||
disabled?: string;
|
||||
invalid?: string;
|
||||
dynamic?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
identity: string;
|
||||
ntp: {
|
||||
enabled?: string;
|
||||
'primary-ntp'?: string;
|
||||
'secondary-ntp'?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
dns: {
|
||||
servers?: string;
|
||||
'allow-remote-requests'?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PortVlanConfig {
|
||||
pvid?: number;
|
||||
tagged_vlans?: number[];
|
||||
untagged_vlans?: number[];
|
||||
}
|
||||
|
||||
export type DeviceStatus = 'online' | 'offline' | 'unknown';
|
||||
export type DeviceType = 'router' | 'switch' | 'wireless_ap' | 'other';
|
||||
|
||||
export interface Device {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
api_port: number;
|
||||
api_username: string;
|
||||
ssh_port?: number;
|
||||
ssh_username?: string;
|
||||
model?: string;
|
||||
serial_number?: string;
|
||||
firmware_version?: string;
|
||||
ros_version?: string;
|
||||
latest_ros_version?: string;
|
||||
firmware_update_available?: boolean;
|
||||
device_type: DeviceType;
|
||||
status: DeviceStatus;
|
||||
last_seen?: string;
|
||||
notes?: string;
|
||||
location_address?: string;
|
||||
location_lat?: number;
|
||||
location_lng?: number;
|
||||
rack_name?: string;
|
||||
rack_slot?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
id: number;
|
||||
device_id: number;
|
||||
name: string;
|
||||
type?: string;
|
||||
mac_address?: string;
|
||||
mtu?: number;
|
||||
running: boolean;
|
||||
disabled: boolean;
|
||||
comment?: string;
|
||||
speed?: string;
|
||||
full_duplex?: boolean;
|
||||
config_json?: Record<string, string>;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Vlan {
|
||||
id: number;
|
||||
device_id: number;
|
||||
vlan_id: number;
|
||||
name?: string;
|
||||
bridge?: string;
|
||||
tagged_ports?: string[];
|
||||
untagged_ports?: string[];
|
||||
}
|
||||
|
||||
export interface BridgeVlanEntry {
|
||||
device_id: number;
|
||||
bridge: string;
|
||||
port: string;
|
||||
pvid?: number;
|
||||
vlan_ids?: string[];
|
||||
tagged: boolean;
|
||||
}
|
||||
|
||||
export interface SwitchPort extends Interface {
|
||||
bridgeInfo?: BridgeVlanEntry | null;
|
||||
}
|
||||
|
||||
export interface PortMonitorData {
|
||||
// Link state
|
||||
status?: string;
|
||||
'auto-negotiation'?: string;
|
||||
rate?: string;
|
||||
'full-duplex'?: string;
|
||||
'tx-flow-control'?: string;
|
||||
'rx-flow-control'?: string;
|
||||
'fec-mode'?: string;
|
||||
// SFP
|
||||
'sfp-module-present'?: string;
|
||||
'sfp-type'?: string;
|
||||
'sfp-connector-type'?: string;
|
||||
'sfp-vendor-name'?: string;
|
||||
'sfp-vendor-part-number'?: string;
|
||||
'sfp-vendor-revision'?: string;
|
||||
'sfp-vendor-serial'?: string;
|
||||
'sfp-manufacturing-date'?: string;
|
||||
'sfp-wavelength'?: string;
|
||||
'sfp-temperature'?: string;
|
||||
'sfp-supply-voltage'?: string;
|
||||
'sfp-tx-bias-current'?: string;
|
||||
'sfp-tx-power'?: string;
|
||||
'sfp-rx-power'?: string;
|
||||
'sfp-link-length-50um'?: string;
|
||||
'sfp-link-length-62um'?: string;
|
||||
'sfp-link-length-copper'?: string;
|
||||
'sfp-link-length-multimode'?: string;
|
||||
'sfp-link-length-singlemode'?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export type EventSeverity = 'info' | 'warning' | 'error' | 'critical';
|
||||
|
||||
export interface DeviceEvent {
|
||||
id: number;
|
||||
device_id?: number;
|
||||
device_name?: string;
|
||||
event_time: string;
|
||||
severity: EventSeverity;
|
||||
topic?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: number;
|
||||
device_id: number;
|
||||
device_name?: string;
|
||||
mac_address: string;
|
||||
custom_name?: string;
|
||||
hostname?: string;
|
||||
vendor?: string;
|
||||
ip_address?: string;
|
||||
interface_name?: string;
|
||||
vlan_id?: number;
|
||||
tx_bytes: number;
|
||||
rx_bytes: number;
|
||||
signal_strength?: number;
|
||||
client_type: 'wired' | 'wireless';
|
||||
active: boolean;
|
||||
last_seen?: string;
|
||||
}
|
||||
|
||||
export interface Backup {
|
||||
id: number;
|
||||
device_id: number;
|
||||
device_name?: string;
|
||||
filename: string;
|
||||
size_bytes?: number;
|
||||
backup_type: 'manual' | 'scheduled';
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TopologyDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
model?: string;
|
||||
device_type: DeviceType;
|
||||
status: DeviceStatus;
|
||||
ros_version?: string;
|
||||
}
|
||||
|
||||
export interface TopologyLink {
|
||||
id: number;
|
||||
from_device_id?: number;
|
||||
from_interface?: string;
|
||||
to_device_id?: number;
|
||||
to_interface?: string;
|
||||
neighbor_address?: string;
|
||||
neighbor_identity?: string;
|
||||
neighbor_platform?: string;
|
||||
neighbor_mac?: string;
|
||||
stp_role?: string;
|
||||
stp_state?: string;
|
||||
bridge_name?: string;
|
||||
neighbor_caps?: string;
|
||||
link_type?: string;
|
||||
discovered_by?: string;
|
||||
from_device_name?: string;
|
||||
to_device_name?: string;
|
||||
discovered_at: string;
|
||||
}
|
||||
|
||||
export interface ExternalTopologyNode {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
platform: string;
|
||||
mac: string;
|
||||
caps?: string;
|
||||
}
|
||||
|
||||
export interface MetricsSummary {
|
||||
devices: { total: number; online: number; offline: number };
|
||||
clients: { total: number; active: number };
|
||||
alerts: { critical: number; warning: number };
|
||||
}
|
||||
|
||||
export interface TimeSeriesPoint {
|
||||
time: string;
|
||||
value: number;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
export interface TrafficPoint {
|
||||
time: string;
|
||||
rx: number;
|
||||
tx: number;
|
||||
}
|
||||
|
||||
export interface ResourcePoint {
|
||||
time: string;
|
||||
cpu_load?: number;
|
||||
memory_used?: number;
|
||||
memory_total?: number;
|
||||
}
|
||||
|
||||
// ─── Wireless ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WirelessInterface {
|
||||
id: number;
|
||||
device_id: number;
|
||||
name: string;
|
||||
ssid?: string;
|
||||
mode?: string;
|
||||
band?: string;
|
||||
frequency?: number;
|
||||
channel_width?: string;
|
||||
tx_power?: number;
|
||||
tx_power_mode?: string;
|
||||
antenna_gain?: number;
|
||||
country?: string;
|
||||
installation?: string;
|
||||
disabled: boolean;
|
||||
running: boolean;
|
||||
mac_address?: string;
|
||||
security_profile?: string;
|
||||
noise_floor?: number;
|
||||
registered_clients: number;
|
||||
config_json?: Record<string, string>;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface WirelessSecurityProfile {
|
||||
id?: number;
|
||||
'.id'?: string;
|
||||
device_id?: number;
|
||||
name: string;
|
||||
mode: string;
|
||||
authentication_types?: string[];
|
||||
'authentication-types'?: string;
|
||||
unicast_ciphers?: string[];
|
||||
'unicast-ciphers'?: string;
|
||||
group_ciphers?: string[];
|
||||
'group-ciphers'?: string;
|
||||
management_protection?: string;
|
||||
'management-protection'?: string;
|
||||
config_json?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface WirelessAP {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
model?: string;
|
||||
status: string;
|
||||
last_seen?: string;
|
||||
ros_version?: string;
|
||||
rack_name?: string;
|
||||
rack_slot?: string;
|
||||
radio_count: number;
|
||||
client_count: number;
|
||||
ssid_count: number;
|
||||
}
|
||||
|
||||
export interface WirelessMetricPoint {
|
||||
time: string;
|
||||
interface: string;
|
||||
ssid?: string;
|
||||
registered_clients?: number;
|
||||
noise_floor?: number;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = 'v0.10.0 Beta';
|
||||
@@ -0,0 +1,43 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
sidebar: {
|
||||
light: '#f1f5f9',
|
||||
dark: '#0f172a',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'fade-in': 'fadeIn 0.2s ease-out',
|
||||
'slide-in': 'slideIn 0.2s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideIn: {
|
||||
'0%': { transform: 'translateX(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateX(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3001',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom', 'react-router-dom'],
|
||||
charts: ['recharts'],
|
||||
flow: ['@xyflow/react'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user