Initial commit baby!

This commit is contained in:
Rich Teslow
2026-04-13 18:15:41 -07:00
parent 10e6227931
commit 6b0fc9fdf1
99 changed files with 34064 additions and 3 deletions
+73
View File
@@ -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 }}
/>
);
}
+322
View File
@@ -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: '&copy; <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 &amp; 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 &amp; 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 &amp; 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>
);
}
+428
View File
@@ -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>
);
}
+67
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
import { useAuthStore } from '../store/authStore';
export function useCanWrite(): boolean {
const user = useAuthStore((state) => state.user);
return user?.role !== 'viewer';
}
+45
View File
@@ -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]);
}
+108
View File
@@ -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;
}
}
+38
View File
@@ -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>
);
+278
View File
@@ -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>
);
}
+808
View File
@@ -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>
);
}
+399
View File
@@ -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>
);
}
+603
View File
@@ -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: '&copy; <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>&nbsp;<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>
);
}
+328
View File
@@ -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>
);
}
+356
View File
@@ -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>
);
}
+248
View File
@@ -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>
);
}
+176
View File
@@ -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>
);
}
+478
View File
@@ -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>
);
}
+197
View File
@@ -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
+194
View File
@@ -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>
);
}
+523
View File
@@ -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>
);
}
+635
View File
@@ -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>
);
}
+143
View File
@@ -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>
);
}
+197
View File
@@ -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
+590
View File
@@ -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;
+31
View File
@@ -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,
}),
}
)
);
+42
View File
@@ -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');
}
}
+314
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = 'v0.10.0 Beta';