Initial commit baby!
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Point { x: number; y: number }
|
||||
interface Seg { from: Point; to: Point }
|
||||
interface Trace { segs: Seg[]; bright: boolean }
|
||||
interface Pulse {
|
||||
trace: Trace;
|
||||
segIdx: number;
|
||||
t: number; // [0,1] within current segment
|
||||
speed: number; // segments per second
|
||||
hue: number; // HSL hue
|
||||
trail: Point[];
|
||||
}
|
||||
|
||||
interface ThemePalette {
|
||||
bg: string;
|
||||
traceBright: string;
|
||||
traceDim: string;
|
||||
padBright: string;
|
||||
padDim: string;
|
||||
viaStroke: string;
|
||||
viaFill: string;
|
||||
pulseHues: number[];
|
||||
trailLightness: number; // % lightness for trail hsla
|
||||
glowLightness: number; // % lightness for glow center
|
||||
coreLightness: number; // % lightness for core dot
|
||||
coreSaturation: number; // % saturation for core dot
|
||||
}
|
||||
|
||||
// ─── Theme palettes ───────────────────────────────────────────────────────────
|
||||
|
||||
const DARK_PALETTE: ThemePalette = {
|
||||
bg: '#040c07',
|
||||
traceBright: '#112b1a',
|
||||
traceDim: '#0a1f10',
|
||||
padBright: '#183322',
|
||||
padDim: '#0f2219',
|
||||
viaStroke: '#1a3d25',
|
||||
viaFill: '#0d2218',
|
||||
pulseHues: [140, 150, 155, 160, 165, 170, 175, 180, 185],
|
||||
trailLightness: 65,
|
||||
glowLightness: 80,
|
||||
coreLightness: 96,
|
||||
coreSaturation: 60,
|
||||
};
|
||||
|
||||
const LIGHT_PALETTE: ThemePalette = {
|
||||
bg: '#e8f2f7',
|
||||
traceBright: '#7aafc8',
|
||||
traceDim: '#9dc4d8',
|
||||
padBright: '#689fbc',
|
||||
padDim: '#88b8cc',
|
||||
viaStroke: '#5590ac',
|
||||
viaFill: '#c2dce8',
|
||||
pulseHues: [185, 190, 195, 200, 205, 210, 215, 220, 170],
|
||||
trailLightness: 38,
|
||||
glowLightness: 28,
|
||||
coreLightness: 22,
|
||||
coreSaturation: 75,
|
||||
};
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const CELL = 56;
|
||||
const TRAIL = 28;
|
||||
const PULSE_COUNT = 22;
|
||||
const DX = [1, 0, -1, 0];
|
||||
const DY = [0, 1, 0, -1];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function pt(c: number, r: number): Point {
|
||||
return { x: c * CELL, y: r * CELL };
|
||||
}
|
||||
|
||||
function buildTraces(w: number, h: number): Trace[] {
|
||||
const cols = Math.floor(w / CELL) + 2;
|
||||
const rows = Math.floor(h / CELL) + 2;
|
||||
const count = 40 + Math.floor((w * h) / 40000);
|
||||
const traces: Trace[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
let c = Math.floor(Math.random() * cols);
|
||||
let r = Math.floor(Math.random() * rows);
|
||||
let dir = Math.floor(Math.random() * 4);
|
||||
const bright = Math.random() < 0.35;
|
||||
const maxLen = bright
|
||||
? 6 + Math.floor(Math.random() * 14)
|
||||
: 3 + Math.floor(Math.random() * 8);
|
||||
const segs: Seg[] = [];
|
||||
|
||||
for (let step = 0; step < maxLen; step++) {
|
||||
const nc = c + DX[dir];
|
||||
const nr = r + DY[dir];
|
||||
if (nc < 0 || nc >= cols || nr < 0 || nr >= rows) break;
|
||||
segs.push({ from: pt(c, r), to: pt(nc, nr) });
|
||||
c = nc;
|
||||
r = nr;
|
||||
if (Math.random() < 0.28) {
|
||||
dir = (dir + (Math.random() < 0.5 ? 1 : 3)) % 4;
|
||||
}
|
||||
if (step >= 2 && Math.random() < 0.06) break;
|
||||
}
|
||||
|
||||
if (segs.length >= 2) traces.push({ segs, bright });
|
||||
}
|
||||
|
||||
return traces;
|
||||
}
|
||||
|
||||
function buildPulses(traces: Trace[], palette: ThemePalette): Pulse[] {
|
||||
const candidates = traces.filter((t) => t.segs.length >= 3 && t.bright);
|
||||
const pool = candidates.length > 0 ? candidates : traces.filter((t) => t.segs.length >= 3);
|
||||
const pulses: Pulse[] = [];
|
||||
|
||||
for (let i = 0; i < PULSE_COUNT; i++) {
|
||||
const trace = pool[Math.floor(Math.random() * pool.length)];
|
||||
if (!trace) continue;
|
||||
pulses.push({
|
||||
trace,
|
||||
segIdx: Math.floor(Math.random() * trace.segs.length),
|
||||
t: Math.random(),
|
||||
speed: 0.45 + Math.random() * 0.85,
|
||||
hue: palette.pulseHues[Math.floor(Math.random() * palette.pulseHues.length)],
|
||||
trail: [],
|
||||
});
|
||||
}
|
||||
return pulses;
|
||||
}
|
||||
|
||||
function lerp(a: Point, b: Point, t: number): Point {
|
||||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
theme?: 'dark' | 'light';
|
||||
}
|
||||
|
||||
export default function CircuitBackground({ theme = 'dark' }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef<{ traces: Trace[]; pulses: Pulse[]; raf: number }>({
|
||||
traces: [], pulses: [], raf: 0,
|
||||
});
|
||||
|
||||
const palette = theme === 'light' ? LIGHT_PALETTE : DARK_PALETTE;
|
||||
|
||||
const init = useCallback((w: number, h: number) => {
|
||||
const traces = buildTraces(w, h);
|
||||
const pulses = buildPulses(traces, palette);
|
||||
stateRef.current.traces = traces;
|
||||
stateRef.current.pulses = pulses;
|
||||
}, [palette]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
if (!ctx) return;
|
||||
|
||||
let lastTime = performance.now();
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
init(canvas.width, canvas.height);
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// ── Draw helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function drawTraces(traces: Trace[]) {
|
||||
for (const trace of traces) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = trace.bright ? palette.traceBright : palette.traceDim;
|
||||
ctx.lineWidth = trace.bright ? 1.5 : 1;
|
||||
ctx.lineCap = 'square';
|
||||
ctx.moveTo(trace.segs[0].from.x, trace.segs[0].from.y);
|
||||
for (const seg of trace.segs) {
|
||||
ctx.lineTo(seg.to.x, seg.to.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
const padCol = trace.bright ? palette.padBright : palette.padDim;
|
||||
for (const seg of trace.segs) {
|
||||
ctx.fillStyle = padCol;
|
||||
ctx.beginPath();
|
||||
ctx.arc(seg.to.x, seg.to.y, 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (trace.bright && Math.random() < 0.4) {
|
||||
const last = trace.segs[trace.segs.length - 1].to;
|
||||
ctx.strokeStyle = palette.viaStroke;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.arc(last.x, last.y, 5, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = palette.viaFill;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawPulse(pulse: Pulse) {
|
||||
const len = pulse.trail.length;
|
||||
if (len < 2) return;
|
||||
|
||||
const { trailLightness, glowLightness, coreLightness, coreSaturation } = palette;
|
||||
|
||||
for (let i = 1; i < len; i++) {
|
||||
const alpha = Math.pow(i / len, 1.6) * 0.75;
|
||||
const width = (i / len) * 2.5;
|
||||
ctx.strokeStyle = `hsla(${pulse.hue}, 90%, ${trailLightness}%, ${alpha})`;
|
||||
ctx.lineWidth = width;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pulse.trail[i - 1].x, pulse.trail[i - 1].y);
|
||||
ctx.lineTo(pulse.trail[i].x, pulse.trail[i].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
const head = pulse.trail[len - 1];
|
||||
const glow = ctx.createRadialGradient(head.x, head.y, 0, head.x, head.y, 16);
|
||||
glow.addColorStop(0, `hsla(${pulse.hue}, 90%, ${glowLightness}%, 0.7)`);
|
||||
glow.addColorStop(0.35, `hsla(${pulse.hue}, 90%, ${trailLightness}%, 0.25)`);
|
||||
glow.addColorStop(1, `hsla(${pulse.hue}, 90%, ${trailLightness}%, 0)`);
|
||||
ctx.fillStyle = glow;
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, 16, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = `hsl(${pulse.hue}, ${coreSaturation}%, ${coreLightness}%)`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, 2.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── Animation loop ────────────────────────────────────────────────────────
|
||||
|
||||
const animate = (now: number) => {
|
||||
const dt = Math.min((now - lastTime) / 1000, 0.05);
|
||||
lastTime = now;
|
||||
|
||||
const { traces, pulses } = stateRef.current;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.fillStyle = palette.bg;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
drawTraces(traces);
|
||||
|
||||
for (const pulse of pulses) {
|
||||
pulse.t += pulse.speed * dt;
|
||||
while (pulse.t >= 1) {
|
||||
pulse.t -= 1;
|
||||
pulse.segIdx = (pulse.segIdx + 1) % pulse.trace.segs.length;
|
||||
}
|
||||
|
||||
const seg = pulse.trace.segs[pulse.segIdx];
|
||||
const pos = lerp(seg.from, seg.to, pulse.t);
|
||||
pulse.trail.push(pos);
|
||||
if (pulse.trail.length > TRAIL) pulse.trail.shift();
|
||||
|
||||
drawPulse(pulse);
|
||||
}
|
||||
|
||||
stateRef.current.raf = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
stateRef.current.raf = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize);
|
||||
cancelAnimationFrame(stateRef.current.raf);
|
||||
};
|
||||
}, [init, palette]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { X, Maximize2, Minimize2, TerminalSquare, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
const MIN_W = 480;
|
||||
const MIN_H = 300;
|
||||
const DEFAULT_W = 900;
|
||||
const DEFAULT_H = 560;
|
||||
|
||||
interface Props {
|
||||
deviceId: number;
|
||||
deviceName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ConnState = 'connecting' | 'ready' | 'error' | 'closed';
|
||||
|
||||
const RESIZE_HANDLES = [
|
||||
{ dir: 'n', cursor: 'ns-resize', style: { top: 0, left: 6, right: 6, height: 6 } },
|
||||
{ dir: 'ne', cursor: 'nesw-resize', style: { top: 0, right: 0, width: 10, height: 10 } },
|
||||
{ dir: 'e', cursor: 'ew-resize', style: { top: 6, right: 0, bottom: 6, width: 6 } },
|
||||
{ dir: 'se', cursor: 'nwse-resize', style: { bottom: 0, right: 0, width: 10, height: 10 } },
|
||||
{ dir: 's', cursor: 'ns-resize', style: { bottom: 0, left: 6, right: 6, height: 6 } },
|
||||
{ dir: 'sw', cursor: 'nesw-resize', style: { bottom: 0, left: 0, width: 10, height: 10 } },
|
||||
{ dir: 'w', cursor: 'ew-resize', style: { top: 6, left: 0, bottom: 6, width: 6 } },
|
||||
{ dir: 'nw', cursor: 'nwse-resize', style: { top: 0, left: 0, width: 10, height: 10 } },
|
||||
] as const;
|
||||
|
||||
export default function TerminalModal({ deviceId, deviceName, onClose }: Props) {
|
||||
const termRef = useRef<HTMLDivElement>(null);
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
||||
const [connState, setConnState] = useState<ConnState>('connecting');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
const [pos, setPos] = useState(() => ({
|
||||
x: Math.max(0, (window.innerWidth - DEFAULT_W) / 2),
|
||||
y: Math.max(0, (window.innerHeight - DEFAULT_H) / 2),
|
||||
}));
|
||||
const [size, setSize] = useState({ w: DEFAULT_W, h: DEFAULT_H });
|
||||
const savedGeometry = useRef({ pos: { x: 0, y: 0 }, size: { w: DEFAULT_W, h: DEFAULT_H } });
|
||||
|
||||
const token = useAuthStore((s) => s.token);
|
||||
|
||||
// ─── SSH connection ────────────────────────────────────────────────────────
|
||||
const connect = useCallback(() => {
|
||||
socketRef.current?.disconnect();
|
||||
xtermRef.current?.clear();
|
||||
setConnState('connecting');
|
||||
setErrorMsg('');
|
||||
|
||||
const socket = io('/terminal', {
|
||||
path: '/socket.io',
|
||||
auth: { token },
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
const fit = fitRef.current;
|
||||
fit?.fit();
|
||||
socket.emit('start', {
|
||||
deviceId,
|
||||
cols: xtermRef.current?.cols ?? 80,
|
||||
rows: xtermRef.current?.rows ?? 24,
|
||||
});
|
||||
});
|
||||
socket.on('ready', () => { setConnState('ready'); xtermRef.current?.focus(); });
|
||||
socket.on('data', (d: string) => { xtermRef.current?.write(d); });
|
||||
socket.on('close', () => { setConnState('closed'); xtermRef.current?.write('\r\n\x1b[33m[Session closed]\x1b[0m\r\n'); });
|
||||
socket.on('error', (msg: string)=> { setConnState('error'); setErrorMsg(msg); });
|
||||
socket.on('connect_error',(err) => { setConnState('error'); setErrorMsg(err.message); });
|
||||
socket.on('disconnect', () => { if (connState === 'ready') setConnState('closed'); });
|
||||
}, [deviceId, token]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ─── Terminal init (once) ─────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!termRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", Menlo, Monaco, monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
theme: {
|
||||
background: '#0f172a', foreground: '#e2e8f0', cursor: '#3b82f6',
|
||||
black: '#1e293b', red: '#ef4444', green: '#22c55e', yellow: '#eab308',
|
||||
blue: '#3b82f6', magenta: '#a855f7', cyan: '#06b6d4', white: '#f1f5f9',
|
||||
brightBlack: '#475569', brightRed: '#f87171', brightGreen: '#4ade80',
|
||||
brightYellow: '#fbbf24', brightBlue: '#60a5fa', brightMagenta: '#c084fc',
|
||||
brightCyan: '#22d3ee', brightWhite: '#ffffff',
|
||||
},
|
||||
allowProposedApi: false,
|
||||
});
|
||||
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(termRef.current);
|
||||
fit.fit();
|
||||
|
||||
xtermRef.current = term;
|
||||
fitRef.current = fit;
|
||||
|
||||
const disposable = term.onData((data) => {
|
||||
if (socketRef.current?.connected) socketRef.current.emit('data', data);
|
||||
});
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
fit.fit();
|
||||
if (socketRef.current?.connected) {
|
||||
socketRef.current.emit('resize', { cols: term.cols, rows: term.rows });
|
||||
}
|
||||
});
|
||||
if (termRef.current) ro.observe(termRef.current);
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
ro.disconnect();
|
||||
term.dispose();
|
||||
socketRef.current?.disconnect();
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Re-fit after maximize toggle
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
fitRef.current?.fit();
|
||||
if (socketRef.current?.connected && xtermRef.current) {
|
||||
socketRef.current.emit('resize', { cols: xtermRef.current.cols, rows: xtermRef.current.rows });
|
||||
}
|
||||
}, 50);
|
||||
}, [maximized]);
|
||||
|
||||
// ─── Maximize / restore ───────────────────────────────────────────────────
|
||||
const toggleMaximize = () => {
|
||||
if (!maximized) {
|
||||
savedGeometry.current = { pos: { ...pos }, size: { ...size } };
|
||||
setMaximized(true);
|
||||
} else {
|
||||
setMaximized(false);
|
||||
setPos(savedGeometry.current.pos);
|
||||
setSize(savedGeometry.current.size);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Drag (title bar) ─────────────────────────────────────────────────────
|
||||
const handleTitleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (maximized || e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
const el = windowRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const origX = el.offsetLeft;
|
||||
const origY = el.offsetTop;
|
||||
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
el.style.left = `${Math.max(0, origX + me.clientX - startX)}px`;
|
||||
el.style.top = `${Math.max(0, origY + me.clientY - startY)}px`;
|
||||
};
|
||||
const onUp = () => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
setPos({ x: el.offsetLeft, y: el.offsetTop });
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// ─── Resize (edge / corner handles) ───────────────────────────────────────
|
||||
const handleResizeMouseDown = (e: React.MouseEvent, dir: string) => {
|
||||
if (maximized || e.button !== 0) return;
|
||||
const el = windowRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const origL = el.offsetLeft;
|
||||
const origT = el.offsetTop;
|
||||
const origW = el.offsetWidth;
|
||||
const origH = el.offsetHeight;
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = (me: MouseEvent) => {
|
||||
const dx = me.clientX - startX;
|
||||
const dy = me.clientY - startY;
|
||||
let l = origL, t = origT, w = origW, h = origH;
|
||||
|
||||
if (dir.includes('e')) w = Math.max(MIN_W, origW + dx);
|
||||
if (dir.includes('s')) h = Math.max(MIN_H, origH + dy);
|
||||
if (dir.includes('w')) { w = Math.max(MIN_W, origW - dx); l = origL + origW - w; }
|
||||
if (dir.includes('n')) { h = Math.max(MIN_H, origH - dy); t = origT + origH - h; }
|
||||
|
||||
el.style.left = `${l}px`;
|
||||
el.style.top = `${t}px`;
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
};
|
||||
const onUp = () => {
|
||||
document.body.style.userSelect = '';
|
||||
setPos ({ x: el.offsetLeft, y: el.offsetTop });
|
||||
setSize({ w: el.offsetWidth, h: el.offsetHeight });
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleReconnect = () => { xtermRef.current?.clear(); connect(); };
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
return createPortal(
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 z-[9999] bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Window */}
|
||||
<div
|
||||
ref={windowRef}
|
||||
className="flex flex-col bg-slate-900 shadow-2xl border border-slate-700 overflow-hidden"
|
||||
style={maximized
|
||||
? { position: 'fixed', inset: 0, zIndex: 10000, borderRadius: 0 }
|
||||
: { position: 'fixed', left: pos.x, top: pos.y, width: size.w, height: size.h,
|
||||
zIndex: 10000, borderRadius: '0.75rem', minWidth: MIN_W, minHeight: MIN_H }
|
||||
}
|
||||
>
|
||||
{/* Resize handles */}
|
||||
{!maximized && RESIZE_HANDLES.map(({ dir, cursor, style }) => (
|
||||
<div
|
||||
key={dir}
|
||||
style={{ position: 'absolute', zIndex: 10, cursor, ...style }}
|
||||
onMouseDown={(e) => handleResizeMouseDown(e, dir)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Title bar — drag target */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-slate-800 border-b border-slate-700 select-none shrink-0"
|
||||
style={{ cursor: maximized ? 'default' : 'grab' }}
|
||||
onMouseDown={handleTitleMouseDown}
|
||||
onDoubleClick={toggleMaximize}
|
||||
>
|
||||
<TerminalSquare className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<span className="text-sm font-medium text-slate-200 flex-1 truncate">
|
||||
SSH — {deviceName}
|
||||
</span>
|
||||
|
||||
{connState === 'connecting' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-yellow-400">
|
||||
<RefreshCw className="w-3 h-3 animate-spin" /> Connecting…
|
||||
</span>
|
||||
)}
|
||||
{connState === 'ready' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-green-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse" /> Connected
|
||||
</span>
|
||||
)}
|
||||
{(connState === 'error' || connState === 'closed') && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300 px-2 py-0.5 rounded border border-blue-700 hover:border-blue-500 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Reconnect
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={toggleMaximize}
|
||||
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700 transition-colors"
|
||||
title={maximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
{maximized ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded text-slate-400 hover:text-red-400 hover:bg-slate-700 transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{connState === 'error' && errorMsg && (
|
||||
<div className="flex items-start gap-2 px-4 py-2.5 bg-red-900/40 border-b border-red-800 text-sm text-red-300">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{errorMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal */}
|
||||
<div className="flex-1 min-h-0 p-1 bg-[#0f172a]">
|
||||
<div ref={termRef} className="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
MapPin, Building2, Hash, FileText, Pencil, Check, X, RefreshCw, AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import type { Device } from '../../types';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
interface Props {
|
||||
device: Device;
|
||||
}
|
||||
|
||||
interface LocationForm {
|
||||
location_address: string;
|
||||
rack_name: string;
|
||||
rack_slot: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`,
|
||||
{ headers: { Accept: 'application/json' } }
|
||||
);
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
|
||||
}
|
||||
} catch {
|
||||
// ignore geocoding failures
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapEmbed({ lat, lng, address }: { lat: number | string; lng: number | string; address: string }) {
|
||||
const latN = Number(lat);
|
||||
const lngN = Number(lng);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const map = L.map(containerRef.current, { scrollWheelZoom: false });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
L.circleMarker([latN, lngN], {
|
||||
radius: 9, color: '#fff', weight: 2, fillColor: '#3b82f6', fillOpacity: 0.9,
|
||||
}).bindPopup(`<b>${address}</b>`).addTo(map);
|
||||
map.setView([latN, lngN], 15);
|
||||
return () => { map.remove(); };
|
||||
}, [latN, lngN, address]);
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden h-full flex flex-col">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-gray-200 dark:border-slate-700">
|
||||
<MapPin className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white flex-1 truncate">
|
||||
{address}
|
||||
</h3>
|
||||
<a
|
||||
href={`https://www.openstreetmap.org/?mlat=${latN}&mlon=${lngN}#map=16/${latN}/${lngN}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-500 hover:text-blue-400 whitespace-nowrap"
|
||||
>
|
||||
Open map ↗
|
||||
</a>
|
||||
</div>
|
||||
<div ref={containerRef} style={{ height: 280, isolation: 'isolate' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceLocationSection({ device }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [geocoding, setGeocoding] = useState(false);
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [form, setForm] = useState<LocationForm>({
|
||||
location_address: device.location_address ?? '',
|
||||
rack_name: device.rack_name ?? '',
|
||||
rack_slot: device.rack_slot ?? '',
|
||||
notes: device.notes ?? '',
|
||||
});
|
||||
|
||||
const hasMap = device.location_lat != null && device.location_lng != null
|
||||
&& !isNaN(Number(device.location_lat)) && !isNaN(Number(device.location_lng));
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (f: LocationForm) => {
|
||||
setSaveError('');
|
||||
let lat: number | null = device.location_lat ?? null;
|
||||
let lng: number | null = device.location_lng ?? null;
|
||||
|
||||
// Re-geocode if address changed
|
||||
const addressChanged = f.location_address !== (device.location_address ?? '');
|
||||
if (f.location_address && addressChanged) {
|
||||
setGeocoding(true);
|
||||
const coords = await geocodeAddress(f.location_address);
|
||||
setGeocoding(false);
|
||||
if (coords) {
|
||||
lat = coords.lat;
|
||||
lng = coords.lng;
|
||||
} else {
|
||||
lat = null;
|
||||
lng = null;
|
||||
}
|
||||
}
|
||||
// Clear coords if address was cleared
|
||||
if (!f.location_address) { lat = null; lng = null; }
|
||||
|
||||
return devicesApi.patchLocation(device.id, {
|
||||
location_address: f.location_address || null,
|
||||
location_lat: lat,
|
||||
location_lng: lng,
|
||||
rack_name: f.rack_name || null,
|
||||
rack_slot: f.rack_slot || null,
|
||||
notes: f.notes || null,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['device', device.id] });
|
||||
setEditing(false);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
setGeocoding(false);
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setSaveError(msg || 'Failed to save');
|
||||
},
|
||||
});
|
||||
|
||||
const openEdit = () => {
|
||||
setForm({
|
||||
location_address: device.location_address ?? '',
|
||||
rack_name: device.rack_name ?? '',
|
||||
rack_slot: device.rack_slot ?? '',
|
||||
notes: device.notes ?? '',
|
||||
});
|
||||
setSaveError('');
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEdit = () => { setEditing(false); setSaveError(''); };
|
||||
|
||||
const isPending = mutation.isPending || geocoding;
|
||||
|
||||
return (
|
||||
<div className={`grid gap-4 ${hasMap && !editing ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{/* Info / edit card */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Physical Details</h3>
|
||||
{canWrite && !editing && (
|
||||
<button onClick={openEdit} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<Pencil className="w-3.5 h-3.5" /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<MapPin className="w-3.5 h-3.5 text-blue-500" /> Physical Location
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.location_address}
|
||||
onChange={(e) => setForm((f) => ({ ...f, location_address: e.target.value }))}
|
||||
placeholder="123 Main St, City, State 00000"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Address will be geocoded and shown on the map.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<Building2 className="w-3.5 h-3.5 text-blue-500" /> Rack Name
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.rack_name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, rack_name: e.target.value }))}
|
||||
placeholder="Rack-A1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<Hash className="w-3.5 h-3.5 text-blue-500" /> Rack Slot
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.rack_slot}
|
||||
onChange={(e) => setForm((f) => ({ ...f, rack_slot: e.target.value }))}
|
||||
placeholder="U3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label flex items-center gap-1.5">
|
||||
<FileText className="w-3.5 h-3.5 text-blue-500" /> Notes
|
||||
</label>
|
||||
<textarea
|
||||
className="input resize-none"
|
||||
rows={4}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||
placeholder="Any relevant notes about this device..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{saveError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => mutation.mutate(form)}
|
||||
disabled={isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
{isPending
|
||||
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" />{geocoding ? 'Geocoding…' : 'Saving…'}</>
|
||||
: <><Check className="w-3.5 h-3.5" />Save</>
|
||||
}
|
||||
</button>
|
||||
<button onClick={cancelEdit} disabled={isPending} className="btn-secondary text-sm">
|
||||
<X className="w-3.5 h-3.5 inline mr-1" />Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 text-sm">
|
||||
<InfoRow icon={<MapPin className="w-3.5 h-3.5 text-blue-500" />} label="Location"
|
||||
value={device.location_address}
|
||||
emptyText="No location set" />
|
||||
<InfoRow icon={<Building2 className="w-3.5 h-3.5 text-blue-500" />} label="Rack"
|
||||
value={device.rack_name}
|
||||
emptyText="No rack assigned" />
|
||||
<InfoRow icon={<Hash className="w-3.5 h-3.5 text-blue-500" />} label="Rack Slot"
|
||||
value={device.rack_slot}
|
||||
emptyText="—" />
|
||||
<div className="pt-1 border-t border-gray-100 dark:border-slate-700">
|
||||
<div className="flex items-start gap-2 mt-2">
|
||||
<FileText className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide block mb-1">Notes</span>
|
||||
{device.notes
|
||||
? <p className="text-gray-700 dark:text-slate-300 whitespace-pre-wrap leading-relaxed">{device.notes}</p>
|
||||
: <p className="text-gray-400 dark:text-slate-500 italic">No notes</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Map card — only when we have coordinates and not editing */}
|
||||
{hasMap && !editing && (
|
||||
<MapEmbed
|
||||
lat={device.location_lat!}
|
||||
lng={device.location_lng!}
|
||||
address={device.location_address!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
icon, label, value, emptyText,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
emptyText: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase tracking-wide block mb-0.5">
|
||||
{label}
|
||||
</span>
|
||||
{value
|
||||
? <span className="text-gray-900 dark:text-white">{value}</span>
|
||||
: <span className="text-gray-400 dark:text-slate-500 italic">{emptyText}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,826 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Pencil, AlertCircle, RefreshCw, Check, X, ArrowRightLeft, Shield } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ─── Shared types ─────────────────────────────────────────────────────────────
|
||||
interface FirewallRule extends Record<string, string> {
|
||||
'.id': string;
|
||||
chain: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
interface NatRule extends Record<string, string> {
|
||||
'.id': string;
|
||||
chain: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
// ─── Firewall constants ───────────────────────────────────────────────────────
|
||||
const COMMON_CHAINS = ['forward', 'input', 'output'];
|
||||
const FW_ACTIONS = ['accept', 'drop', 'reject', 'log', 'passthrough', 'jump', 'return'];
|
||||
const PROTOCOLS = ['', 'tcp', 'udp', 'icmp', 'ip', 'gre', 'ospf'];
|
||||
const CONN_STATES = ['new', 'established', 'related', 'invalid'];
|
||||
|
||||
const ACTION_COLOR: Record<string, string> = {
|
||||
accept: 'text-green-600 dark:text-green-400',
|
||||
drop: 'text-red-600 dark:text-red-400',
|
||||
reject: 'text-red-500 dark:text-red-400',
|
||||
log: 'text-yellow-600 dark:text-yellow-400',
|
||||
passthrough: 'text-blue-600 dark:text-blue-400',
|
||||
jump: 'text-purple-600 dark:text-purple-400',
|
||||
return: 'text-indigo-500 dark:text-indigo-400',
|
||||
masquerade: 'text-orange-600 dark:text-orange-400',
|
||||
'src-nat': 'text-blue-600 dark:text-blue-400',
|
||||
'dst-nat': 'text-cyan-600 dark:text-cyan-400',
|
||||
netmap: 'text-teal-600 dark:text-teal-400',
|
||||
redirect: 'text-violet-600 dark:text-violet-400',
|
||||
};
|
||||
|
||||
// ─── NAT constants ────────────────────────────────────────────────────────────
|
||||
const NAT_CHAINS = ['srcnat', 'dstnat'];
|
||||
const NAT_ACTIONS = ['masquerade', 'src-nat', 'dst-nat', 'netmap', 'redirect', 'accept', 'drop', 'return', 'jump', 'passthrough'];
|
||||
|
||||
// ─── Firewall rule form ───────────────────────────────────────────────────────
|
||||
interface RuleForm {
|
||||
chain: string; action: string;
|
||||
src_address: string; dst_address: string;
|
||||
protocol: string; src_port: string; dst_port: string;
|
||||
in_interface: string; out_interface: string;
|
||||
connection_state: string; jump_target: string;
|
||||
comment: string; disabled: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FW_FORM: RuleForm = {
|
||||
chain: 'forward', action: 'accept',
|
||||
src_address: '', dst_address: '', protocol: '',
|
||||
src_port: '', dst_port: '',
|
||||
in_interface: '', out_interface: '',
|
||||
connection_state: '', jump_target: '',
|
||||
comment: '', disabled: false,
|
||||
};
|
||||
|
||||
function ruleToForm(r: FirewallRule): RuleForm {
|
||||
const states = (r['connection-state'] ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
return {
|
||||
chain: r.chain ?? 'forward', action: r.action ?? 'accept',
|
||||
src_address: r['src-address'] ?? '', dst_address: r['dst-address'] ?? '',
|
||||
protocol: r.protocol ?? '',
|
||||
src_port: r['src-port'] ?? '', dst_port: r['dst-port'] ?? '',
|
||||
in_interface: r['in-interface'] ?? '', out_interface: r['out-interface'] ?? '',
|
||||
connection_state: states.join(','), jump_target: r['jump-target'] ?? '',
|
||||
comment: r.comment ?? '', disabled: r.disabled === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
function formToPayload(f: RuleForm): Record<string, unknown> {
|
||||
const p: Record<string, unknown> = { chain: f.chain, action: f.action, disabled: f.disabled ? 'yes' : 'no' };
|
||||
if (f.src_address) p.src_address = f.src_address;
|
||||
if (f.dst_address) p.dst_address = f.dst_address;
|
||||
if (f.protocol) p.protocol = f.protocol;
|
||||
if (f.src_port) p.src_port = f.src_port;
|
||||
if (f.dst_port) p.dst_port = f.dst_port;
|
||||
if (f.in_interface) p.in_interface = f.in_interface;
|
||||
if (f.out_interface) p.out_interface = f.out_interface;
|
||||
if (f.connection_state) p.connection_state = f.connection_state;
|
||||
if (f.jump_target) p.jump_target = f.jump_target;
|
||||
if (f.comment) p.comment = f.comment;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ─── NAT rule form ────────────────────────────────────────────────────────────
|
||||
interface NatForm {
|
||||
chain: string; action: string;
|
||||
src_address: string; dst_address: string;
|
||||
protocol: string; src_port: string; dst_port: string;
|
||||
in_interface: string; out_interface: string;
|
||||
to_addresses: string; to_ports: string;
|
||||
comment: string; disabled: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_NAT_FORM: NatForm = {
|
||||
chain: 'srcnat', action: 'masquerade',
|
||||
src_address: '', dst_address: '', protocol: '',
|
||||
src_port: '', dst_port: '',
|
||||
in_interface: '', out_interface: '',
|
||||
to_addresses: '', to_ports: '',
|
||||
comment: '', disabled: false,
|
||||
};
|
||||
|
||||
function natRuleToForm(r: NatRule): NatForm {
|
||||
return {
|
||||
chain: r.chain ?? 'srcnat', action: r.action ?? 'masquerade',
|
||||
src_address: r['src-address'] ?? '', dst_address: r['dst-address'] ?? '',
|
||||
protocol: r.protocol ?? '',
|
||||
src_port: r['src-port'] ?? '', dst_port: r['dst-port'] ?? '',
|
||||
in_interface: r['in-interface'] ?? '', out_interface: r['out-interface'] ?? '',
|
||||
to_addresses: r['to-addresses'] ?? '', to_ports: r['to-ports'] ?? '',
|
||||
comment: r.comment ?? '', disabled: r.disabled === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
function natFormToPayload(f: NatForm): Record<string, unknown> {
|
||||
const p: Record<string, unknown> = { chain: f.chain, action: f.action, disabled: f.disabled ? 'yes' : 'no' };
|
||||
if (f.src_address) p.src_address = f.src_address;
|
||||
if (f.dst_address) p.dst_address = f.dst_address;
|
||||
if (f.protocol) p.protocol = f.protocol;
|
||||
if (f.src_port) p.src_port = f.src_port;
|
||||
if (f.dst_port) p.dst_port = f.dst_port;
|
||||
if (f.in_interface) p.in_interface = f.in_interface;
|
||||
if (f.out_interface)p.out_interface= f.out_interface;
|
||||
if (f.to_addresses) p.to_addresses = f.to_addresses;
|
||||
if (f.to_ports) p.to_ports = f.to_ports;
|
||||
if (f.comment) p.comment = f.comment;
|
||||
return p;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function errMsg(err: unknown) {
|
||||
return (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Operation failed';
|
||||
}
|
||||
|
||||
function DisabledToggle({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-slate-300">Disabled</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!value)}
|
||||
className={clsx('relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
value ? 'bg-gray-300 dark:bg-slate-600' : 'bg-blue-600')}
|
||||
>
|
||||
<span className={clsx('inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
||||
value ? 'translate-x-1' : 'translate-x-6')} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Firewall Rule Modal ──────────────────────────────────────────────────────
|
||||
function RuleModal({
|
||||
title, form, setForm, onSave, onClose, isPending, error,
|
||||
}: {
|
||||
title: string; form: RuleForm;
|
||||
setForm: React.Dispatch<React.SetStateAction<RuleForm>>;
|
||||
onSave: () => void; onClose: () => void;
|
||||
isPending: boolean; error: string;
|
||||
}) {
|
||||
const set = (key: keyof RuleForm, val: string | boolean) => setForm(f => ({ ...f, [key]: val }));
|
||||
|
||||
const toggleConnState = (state: string) => {
|
||||
const current = form.connection_state ? form.connection_state.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
const next = current.includes(state) ? current.filter(s => s !== state) : [...current, state];
|
||||
set('connection_state', next.join(','));
|
||||
};
|
||||
|
||||
const connStates = form.connection_state ? form.connection_state.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
const hasPorts = form.protocol === 'tcp' || form.protocol === 'udp' || form.protocol === '';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Basic</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Chain</label>
|
||||
<input list="chains-list" className="input" value={form.chain} onChange={e => set('chain', e.target.value)} placeholder="forward" />
|
||||
<datalist id="chains-list">{COMMON_CHAINS.map(c => <option key={c} value={c} />)}</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Action</label>
|
||||
<select className="input" value={form.action} onChange={e => set('action', e.target.value)}>
|
||||
{FW_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{form.action === 'jump' && (
|
||||
<div>
|
||||
<label className="label">Jump Target Chain</label>
|
||||
<input className="input" value={form.jump_target} onChange={e => set('jump_target', e.target.value)} placeholder="custom-chain" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="label">Comment</label>
|
||||
<input className="input" value={form.comment} onChange={e => set('comment', e.target.value)} placeholder="Optional description…" />
|
||||
</div>
|
||||
<DisabledToggle value={form.disabled} onChange={v => set('disabled', v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Address & Port</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">Protocol</label>
|
||||
<select className="input" value={form.protocol} onChange={e => set('protocol', e.target.value)}>
|
||||
<option value="">— any —</option>
|
||||
{PROTOCOLS.filter(Boolean).map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Src Address</label>
|
||||
<input className="input font-mono" value={form.src_address} onChange={e => set('src_address', e.target.value)} placeholder="0.0.0.0/0" /></div>
|
||||
<div><label className="label">Dst Address</label>
|
||||
<input className="input font-mono" value={form.dst_address} onChange={e => set('dst_address', e.target.value)} placeholder="0.0.0.0/0" /></div>
|
||||
</div>
|
||||
{hasPorts && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Src Port</label>
|
||||
<input className="input font-mono" value={form.src_port} onChange={e => set('src_port', e.target.value)} placeholder="e.g. 80 or 1024-2048" /></div>
|
||||
<div><label className="label">Dst Port</label>
|
||||
<input className="input font-mono" value={form.dst_port} onChange={e => set('dst_port', e.target.value)} placeholder="e.g. 443" /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Interface</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">In Interface</label>
|
||||
<input className="input font-mono" value={form.in_interface} onChange={e => set('in_interface', e.target.value)} placeholder="ether1" /></div>
|
||||
<div><label className="label">Out Interface</label>
|
||||
<input className="input font-mono" value={form.out_interface} onChange={e => set('out_interface', e.target.value)} placeholder="ether2" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Connection State</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CONN_STATES.map(s => (
|
||||
<button key={s} type="button" onClick={() => toggleConnState(s)}
|
||||
className={clsx('px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors',
|
||||
connStates.includes(s)
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 border-gray-300 dark:border-slate-600 text-gray-600 dark:text-slate-300 hover:border-blue-400'
|
||||
)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="btn-secondary">Cancel</button>
|
||||
<button onClick={onSave} disabled={isPending} className="btn-primary flex items-center gap-2">
|
||||
{isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} Save Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── NAT Rule Modal ───────────────────────────────────────────────────────────
|
||||
function NatModal({
|
||||
title, form, setForm, onSave, onClose, isPending, error,
|
||||
}: {
|
||||
title: string; form: NatForm;
|
||||
setForm: React.Dispatch<React.SetStateAction<NatForm>>;
|
||||
onSave: () => void; onClose: () => void;
|
||||
isPending: boolean; error: string;
|
||||
}) {
|
||||
const set = (key: keyof NatForm, val: string | boolean) => setForm(f => ({ ...f, [key]: val }));
|
||||
|
||||
const needsTarget = ['src-nat', 'dst-nat', 'netmap'].includes(form.action);
|
||||
const needsPort = ['redirect', 'dst-nat', 'src-nat'].includes(form.action);
|
||||
const hasPorts = form.protocol === 'tcp' || form.protocol === 'udp' || form.protocol === '';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"><X className="w-5 h-5" /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-5">
|
||||
{/* Basic */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Basic</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Chain</label>
|
||||
<select className="input" value={form.chain} onChange={e => set('chain', e.target.value)}>
|
||||
{NAT_CHAINS.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Action</label>
|
||||
<select className="input" value={form.action} onChange={e => set('action', e.target.value)}>
|
||||
{NAT_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* action description */}
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500">
|
||||
{form.action === 'masquerade' && 'Replaces source IP with router\'s outgoing interface IP. Best for dynamic WAN IPs.'}
|
||||
{form.action === 'src-nat' && 'Replaces source IP with a static address (specify in To Addresses).'}
|
||||
{form.action === 'dst-nat' && 'Replaces destination IP/port, used for port forwarding.'}
|
||||
{form.action === 'netmap' && 'One-to-one address translation. Requires To Addresses.'}
|
||||
{form.action === 'redirect' && 'Redirects packets to the router itself (specify To Ports for target port).'}
|
||||
</p>
|
||||
<div>
|
||||
<label className="label">Comment</label>
|
||||
<input className="input" value={form.comment} onChange={e => set('comment', e.target.value)} placeholder="Optional description…" />
|
||||
</div>
|
||||
<DisabledToggle value={form.disabled} onChange={v => set('disabled', v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Match Criteria</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">Protocol</label>
|
||||
<select className="input" value={form.protocol} onChange={e => set('protocol', e.target.value)}>
|
||||
<option value="">— any —</option>
|
||||
{PROTOCOLS.filter(Boolean).map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Src Address</label>
|
||||
<input className="input font-mono" value={form.src_address} onChange={e => set('src_address', e.target.value)} placeholder="0.0.0.0/0" /></div>
|
||||
<div><label className="label">Dst Address</label>
|
||||
<input className="input font-mono" value={form.dst_address} onChange={e => set('dst_address', e.target.value)} placeholder="0.0.0.0/0" /></div>
|
||||
</div>
|
||||
{hasPorts && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Src Port</label>
|
||||
<input className="input font-mono" value={form.src_port} onChange={e => set('src_port', e.target.value)} placeholder="e.g. 80" /></div>
|
||||
<div><label className="label">Dst Port</label>
|
||||
<input className="input font-mono" value={form.dst_port} onChange={e => set('dst_port', e.target.value)} placeholder="e.g. 443" /></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">In Interface</label>
|
||||
<input className="input font-mono" value={form.in_interface} onChange={e => set('in_interface', e.target.value)} placeholder="ether1" /></div>
|
||||
<div><label className="label">Out Interface</label>
|
||||
<input className="input font-mono" value={form.out_interface} onChange={e => set('out_interface', e.target.value)} placeholder="ether2" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translation target */}
|
||||
{(needsTarget || needsPort) && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">Translation Target</h4>
|
||||
<div className="space-y-3">
|
||||
{needsTarget && (
|
||||
<div><label className="label">To Addresses</label>
|
||||
<input className="input font-mono" value={form.to_addresses} onChange={e => set('to_addresses', e.target.value)} placeholder="e.g. 192.168.1.10 or 192.168.1.10-192.168.1.20" /></div>
|
||||
)}
|
||||
{needsPort && (
|
||||
<div><label className="label">To Ports</label>
|
||||
<input className="input font-mono" value={form.to_ports} onChange={e => set('to_ports', e.target.value)} placeholder="e.g. 8080 or 8080-8090" /></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button onClick={onClose} className="btn-secondary">Cancel</button>
|
||||
<button onClick={onSave} disabled={isPending} className="btn-primary flex items-center gap-2">
|
||||
{isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} Save Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── NAT Card (router-only) ───────────────────────────────────────────────────
|
||||
function NatCard({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const [chainFilter, setChainFilter] = useState('all');
|
||||
const [editingRule, setEditingRule] = useState<NatRule | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState<NatForm>(EMPTY_NAT_FORM);
|
||||
const [mutError, setMutError] = useState('');
|
||||
|
||||
const { data: rules = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['nat', deviceId],
|
||||
queryFn: () => devicesApi.getNat(deviceId).then(r => r.data as NatRule[]),
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['nat', deviceId] });
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => devicesApi.addNatRule(deviceId, data),
|
||||
onSuccess: () => { invalidate(); setShowAdd(false); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
devicesApi.updateNatRule(deviceId, id, data),
|
||||
onSuccess: () => { invalidate(); setEditingRule(null); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => devicesApi.deleteNatRule(deviceId, id),
|
||||
onSuccess: () => { invalidate(); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) =>
|
||||
devicesApi.updateNatRule(deviceId, id, { disabled: disabled ? 'yes' : 'no' }),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
|
||||
const openAdd = () => {
|
||||
setForm({ ...EMPTY_NAT_FORM, chain: chainFilter !== 'all' ? chainFilter : 'srcnat' });
|
||||
setMutError('');
|
||||
setShowAdd(true);
|
||||
};
|
||||
|
||||
const openEdit = (rule: NatRule) => {
|
||||
setForm(natRuleToForm(rule));
|
||||
setMutError('');
|
||||
setEditingRule(rule);
|
||||
};
|
||||
|
||||
const allChains = ['all', ...Array.from(new Set(rules.map(r => r.chain).filter(Boolean))).sort()];
|
||||
const filtered = chainFilter === 'all' ? rules : rules.filter(r => r.chain === chainFilter);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<div className="w-7 h-7 bg-orange-50 dark:bg-orange-900/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<ArrowRightLeft className="w-3.5 h-3.5 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Network Address Translation (NAT)</h3>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-slate-800 p-1 rounded-lg">
|
||||
{allChains.map(c => (
|
||||
<button key={c} onClick={() => setChainFilter(c)}
|
||||
className={clsx('px-3 py-1 text-xs font-medium rounded-md transition-colors capitalize',
|
||||
chainFilter === c
|
||||
? 'bg-white dark:bg-slate-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200'
|
||||
)}>
|
||||
{c === 'all' ? `All (${rules.length})` : `${c} (${rules.filter(r => r.chain === c).length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button onClick={openAdd} className="btn-primary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<Plus className="w-3.5 h-3.5" /> Add Rule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mutError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{mutError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-6 text-gray-400"><RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading NAT rules…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="card p-8 text-center text-gray-400 dark:text-slate-500">
|
||||
No NAT rules{chainFilter !== 'all' ? ` in chain "${chainFilter}"` : ''}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-3 py-2.5 text-left w-8">#</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Chain</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Action</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Proto</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Src</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Dst</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Ports</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">To</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Comment</th>
|
||||
{canWrite && <th className="table-header px-3 py-2.5 w-24" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{filtered.map((r, i) => {
|
||||
const disabled = r.disabled === 'true';
|
||||
const srcPort = r['src-port'];
|
||||
const dstPort = r['dst-port'];
|
||||
const portCell = [srcPort && `src:${srcPort}`, dstPort && `dst:${dstPort}`].filter(Boolean).join(' ');
|
||||
const toCell = [
|
||||
r['to-addresses'] && r['to-addresses'],
|
||||
r['to-ports'] && `:${r['to-ports']}`,
|
||||
].filter(Boolean).join('');
|
||||
return (
|
||||
<tr key={r['.id'] ?? i} className={clsx('hover:bg-gray-50 dark:hover:bg-slate-700/30', disabled && 'opacity-40')}>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{i + 1}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700 dark:text-slate-300">{r.chain}</td>
|
||||
<td className={clsx('px-3 py-2 text-xs font-bold uppercase', ACTION_COLOR[r.action] ?? 'text-gray-500')}>
|
||||
{r.action}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">{r.protocol || 'any'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{[r['src-address'], r['in-interface'] && `in:${r['in-interface']}`].filter(Boolean).join(' ') || 'any'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{[r['dst-address'], r['out-interface'] && `out:${r['out-interface']}`].filter(Boolean).join(' ') || 'any'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{portCell || '—'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-orange-600 dark:text-orange-400">{toCell || '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-400 dark:text-slate-500 italic max-w-[120px] truncate">{r.comment || ''}</td>
|
||||
{canWrite && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button
|
||||
title={disabled ? 'Enable' : 'Disable'}
|
||||
onClick={() => toggleMutation.mutate({ id: r['.id'], disabled: !disabled })}
|
||||
className={clsx('p-1 rounded transition-colors', disabled ? 'text-gray-400 hover:text-green-500' : 'text-green-500 hover:text-gray-400')}
|
||||
>
|
||||
<span className={clsx('inline-block w-2 h-2 rounded-full', disabled ? 'bg-gray-400' : 'bg-green-500')} />
|
||||
</button>
|
||||
<button onClick={() => openEdit(r)} className="p-1 rounded text-gray-400 hover:text-blue-500 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Delete this NAT rule?')) deleteMutation.mutate(r['.id']); }}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<NatModal
|
||||
title="Add NAT Rule"
|
||||
form={form} setForm={setForm}
|
||||
onSave={() => addMutation.mutate(natFormToPayload(form))}
|
||||
onClose={() => setShowAdd(false)}
|
||||
isPending={addMutation.isPending}
|
||||
error={mutError}
|
||||
/>
|
||||
)}
|
||||
{editingRule && (
|
||||
<NatModal
|
||||
title={`Edit NAT Rule #${rules.indexOf(editingRule) + 1}`}
|
||||
form={form} setForm={setForm}
|
||||
onSave={() => updateMutation.mutate({ id: editingRule['.id'], data: natFormToPayload(form) })}
|
||||
onClose={() => setEditingRule(null)}
|
||||
isPending={updateMutation.isPending}
|
||||
error={mutError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main FirewallTab ─────────────────────────────────────────────────────────
|
||||
export default function FirewallTab({ deviceId, deviceType }: { deviceId: number; deviceType?: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const [chainFilter, setChainFilter] = useState('all');
|
||||
const [editingRule, setEditingRule] = useState<FirewallRule | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState<RuleForm>(EMPTY_FW_FORM);
|
||||
const [mutError, setMutError] = useState('');
|
||||
|
||||
const { data: rules = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['firewall', deviceId],
|
||||
queryFn: () => devicesApi.getFirewall(deviceId).then(r => r.data as FirewallRule[]),
|
||||
});
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['firewall', deviceId] });
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => devicesApi.addFirewallRule(deviceId, data),
|
||||
onSuccess: () => { invalidate(); setShowAdd(false); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
devicesApi.updateFirewallRule(deviceId, id, data),
|
||||
onSuccess: () => { invalidate(); setEditingRule(null); setMutError(''); },
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => devicesApi.deleteFirewallRule(deviceId, id),
|
||||
onSuccess: () => invalidate(),
|
||||
onError: (err: unknown) => setMutError(errMsg(err)),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) =>
|
||||
devicesApi.updateFirewallRule(deviceId, id, { disabled: disabled ? 'yes' : 'no' }),
|
||||
onSuccess: () => invalidate(),
|
||||
});
|
||||
|
||||
const openAdd = () => {
|
||||
setForm({ ...EMPTY_FW_FORM, chain: chainFilter !== 'all' ? chainFilter : 'forward' });
|
||||
setMutError('');
|
||||
setShowAdd(true);
|
||||
};
|
||||
|
||||
const openEdit = (rule: FirewallRule) => {
|
||||
setForm(ruleToForm(rule));
|
||||
setMutError('');
|
||||
setEditingRule(rule);
|
||||
};
|
||||
|
||||
const allChains = ['all', ...Array.from(new Set(rules.map(r => r.chain).filter(Boolean))).sort()];
|
||||
const filtered = chainFilter === 'all' ? rules : rules.filter(r => r.chain === chainFilter);
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Section heading */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 bg-blue-50 dark:bg-blue-900/20 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Shield className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Firewall Rules</h3>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1 bg-gray-100 dark:bg-slate-800 p-1 rounded-lg flex-wrap">
|
||||
{allChains.map(c => (
|
||||
<button key={c} onClick={() => setChainFilter(c)}
|
||||
className={clsx('px-3 py-1 text-xs font-medium rounded-md transition-colors capitalize',
|
||||
chainFilter === c
|
||||
? 'bg-white dark:bg-slate-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200'
|
||||
)}>
|
||||
{c === 'all' ? `All (${rules.length})` : `${c} (${rules.filter(r => r.chain === c).length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button onClick={openAdd} className="btn-primary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<Plus className="w-3.5 h-3.5" /> Add Rule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mutError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{mutError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="card p-8 text-center text-gray-400 dark:text-slate-500">
|
||||
No firewall rules{chainFilter !== 'all' ? ` in chain "${chainFilter}"` : ''}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-3 py-2.5 text-left w-8">#</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Chain</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Action</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Protocol</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Src</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Dst</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Ports</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">State</th>
|
||||
<th className="table-header px-3 py-2.5 text-left">Comment</th>
|
||||
{canWrite && <th className="table-header px-3 py-2.5 w-24" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{filtered.map((r, i) => {
|
||||
const disabled = r.disabled === 'true';
|
||||
const srcPort = r['src-port'];
|
||||
const dstPort = r['dst-port'];
|
||||
const portCell = [srcPort && `src:${srcPort}`, dstPort && `dst:${dstPort}`].filter(Boolean).join(' ');
|
||||
return (
|
||||
<tr key={r['.id'] ?? i} className={clsx('hover:bg-gray-50 dark:hover:bg-slate-700/30', disabled && 'opacity-40')}>
|
||||
<td className="px-3 py-2 text-gray-400 text-xs">{i + 1}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-700 dark:text-slate-300">{r.chain}</td>
|
||||
<td className={clsx('px-3 py-2 text-xs font-bold uppercase', ACTION_COLOR[r.action] ?? 'text-gray-500')}>
|
||||
{r.action}{r['jump-target'] ? ` → ${r['jump-target']}` : ''}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">{r.protocol || 'any'}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{[r['src-address'], r['in-interface'] && `in:${r['in-interface']}`].filter(Boolean).join(' ') || 'any'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{[r['dst-address'], r['out-interface'] && `out:${r['out-interface']}`].filter(Boolean).join(' ') || 'any'}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{portCell || '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">{r['connection-state'] || '—'}</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-400 dark:text-slate-500 italic max-w-[140px] truncate">{r.comment || ''}</td>
|
||||
{canWrite && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<button
|
||||
title={disabled ? 'Enable' : 'Disable'}
|
||||
onClick={() => toggleMutation.mutate({ id: r['.id'], disabled: !disabled })}
|
||||
className={clsx('p-1 rounded transition-colors', disabled ? 'text-gray-400 hover:text-green-500' : 'text-green-500 hover:text-gray-400')}
|
||||
>
|
||||
<span className={clsx('inline-block w-2 h-2 rounded-full', disabled ? 'bg-gray-400' : 'bg-green-500')} />
|
||||
</button>
|
||||
<button onClick={() => openEdit(r)} className="p-1 rounded text-gray-400 hover:text-blue-500 transition-colors">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm('Delete this firewall rule?')) deleteMutation.mutate(r['.id']); }}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && (
|
||||
<RuleModal
|
||||
title="Add Firewall Rule"
|
||||
form={form} setForm={setForm}
|
||||
onSave={() => addMutation.mutate(formToPayload(form))}
|
||||
onClose={() => setShowAdd(false)}
|
||||
isPending={addMutation.isPending}
|
||||
error={mutError}
|
||||
/>
|
||||
)}
|
||||
{editingRule && (
|
||||
<RuleModal
|
||||
title={`Edit Rule #${rules.indexOf(editingRule) + 1}`}
|
||||
form={form} setForm={setForm}
|
||||
onSave={() => updateMutation.mutate({ id: editingRule['.id'], data: formToPayload(form) })}
|
||||
onClose={() => setEditingRule(null)}
|
||||
isPending={updateMutation.isPending}
|
||||
error={mutError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* NAT card — routers only */}
|
||||
{deviceType === 'router' && (
|
||||
<>
|
||||
<div className="border-t border-gray-200 dark:border-slate-700 pt-2" />
|
||||
<NatCard deviceId={deviceId} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { metricsApi, devicesApi } from '../../services/api';
|
||||
import type { Interface, TrafficPoint, ResourcePoint } from '../../types';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
|
||||
interface Props {
|
||||
deviceId: number;
|
||||
}
|
||||
|
||||
function formatBps(val: number): string {
|
||||
if (val >= 1e9) return `${(val / 1e9).toFixed(2)} GB/s`;
|
||||
if (val >= 1e6) return `${(val / 1e6).toFixed(2)} MB/s`;
|
||||
if (val >= 1e3) return `${(val / 1e3).toFixed(1)} KB/s`;
|
||||
return `${Math.round(val)} B/s`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string, compact: boolean): string {
|
||||
const d = new Date(iso);
|
||||
if (compact) {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleString([], {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const TRAFFIC_RANGES = ['1h', '3h', '6h', '12h', '24h'] as const;
|
||||
const RESOURCE_RANGES = ['6h', '12h', '24h', '7d'] as const;
|
||||
|
||||
export default function GraphsTab({ deviceId }: Props) {
|
||||
const [trafficIface, setTrafficIface] = useState('');
|
||||
const [trafficRange, setTrafficRange] = useState<string>('1h');
|
||||
const [resourceRange, setResourceRange] = useState<string>('24h');
|
||||
|
||||
const { data: interfaces = [] } = useQuery({
|
||||
queryKey: ['interfaces', deviceId],
|
||||
queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data as Interface[]),
|
||||
});
|
||||
|
||||
const { data: rawTraffic = [] } = useQuery({
|
||||
queryKey: ['traffic', deviceId, trafficIface, trafficRange],
|
||||
queryFn: () =>
|
||||
metricsApi.interfaceTraffic(deviceId, trafficIface, trafficRange).then((r) => r.data),
|
||||
enabled: !!trafficIface,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: rawResources = [] } = useQuery({
|
||||
queryKey: ['resources-history', deviceId, resourceRange],
|
||||
queryFn: () =>
|
||||
metricsApi.deviceResources(deviceId, resourceRange).then((r) => r.data as ResourcePoint[]),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const compact = trafficRange === '1h' || trafficRange === '3h';
|
||||
const resourceCompact = resourceRange === '6h' || resourceRange === '12h' || resourceRange === '24h';
|
||||
|
||||
const trafficData = (rawTraffic as TrafficPoint[]).map((p) => ({
|
||||
time: formatTime(p.time, compact),
|
||||
rx: Math.round(p.rx),
|
||||
tx: Math.round(p.tx),
|
||||
}));
|
||||
|
||||
const resourceData = (rawResources as ResourcePoint[]).map((p) => {
|
||||
const memPct =
|
||||
p.memory_total && p.memory_total > 0
|
||||
? Math.round(((p.memory_used || 0) / p.memory_total) * 100)
|
||||
: 0;
|
||||
return {
|
||||
time: formatTime(p.time, resourceCompact),
|
||||
cpu: Math.round(p.cpu_load || 0),
|
||||
mem: memPct,
|
||||
};
|
||||
});
|
||||
|
||||
const RangeButtons = ({
|
||||
ranges,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
ranges: readonly string[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) => (
|
||||
<div className="flex gap-1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => onChange(r)}
|
||||
className={`px-2 py-1 text-xs rounded font-medium transition-colors ${
|
||||
value === r
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Interface Traffic ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Interface Traffic</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="input py-1.5 text-sm"
|
||||
value={trafficIface}
|
||||
onChange={(e) => setTrafficIface(e.target.value)}
|
||||
>
|
||||
<option value="">Select interface...</option>
|
||||
{(interfaces as Interface[])
|
||||
.filter((i) => !i.disabled)
|
||||
.map((i) => (
|
||||
<option key={i.name} value={i.name}>
|
||||
{i.name}
|
||||
{i.speed ? ` (${i.speed})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<RangeButtons ranges={TRAFFIC_RANGES} value={trafficRange} onChange={setTrafficRange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!trafficIface ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
Select an interface to view traffic history
|
||||
</div>
|
||||
) : trafficData.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No traffic data yet for this interface in the selected range
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={trafficData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-gray-200 dark:text-slate-600" opacity={0.5} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tickFormatter={formatBps} tick={{ fontSize: 10 }} width={72} />
|
||||
<Tooltip formatter={(v) => formatBps(v as number)} contentStyle={{ fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="rx" stroke="#3b82f6" name="RX (download)" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="tx" stroke="#10b981" name="TX (upload)" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── CPU & Memory ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">CPU & Memory Usage</h3>
|
||||
<RangeButtons ranges={RESOURCE_RANGES} value={resourceRange} onChange={setResourceRange} />
|
||||
</div>
|
||||
|
||||
{resourceData.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No resource history available yet — data accumulates over time
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={resourceData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-gray-200 dark:text-slate-600" opacity={0.5} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 10 }} />
|
||||
<Tooltip formatter={(v) => `${v}%`} contentStyle={{ fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="cpu" stroke="#f59e0b" name="CPU Load" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="mem" stroke="#8b5cf6" name="Memory Used" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Thermometer, Zap, Wind, Power, Activity, HelpCircle, RefreshCw, HardDrive } from 'lucide-react';
|
||||
import { devicesApi, metricsApi } from '../../services/api';
|
||||
import type { ResourcePoint } from '../../types';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, Legend,
|
||||
} from 'recharts';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface HealthMetric {
|
||||
name: string;
|
||||
value: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
// RouterOS health comes in two formats depending on version:
|
||||
// New (v7+): [{name:'temperature', value:'42', type:'C'}, ...]
|
||||
// Old (v6): [{'cpu-temperature':'42', voltage:'23.9', ...}]
|
||||
function normalize(raw: Record<string, string>[]): HealthMetric[] {
|
||||
if (!raw.length) return [];
|
||||
|
||||
if ('name' in raw[0]) {
|
||||
return raw.map((r) => ({
|
||||
name: r['name'] || '',
|
||||
value: r['value'] || '',
|
||||
unit: r['type'] || '',
|
||||
}));
|
||||
}
|
||||
|
||||
const obj = raw[0];
|
||||
return Object.entries(obj)
|
||||
.filter(([k]) => !k.startsWith('.'))
|
||||
.map(([name, value]) => ({ name, value, unit: inferUnit(name, value) }));
|
||||
}
|
||||
|
||||
function inferUnit(name: string, value: string): string {
|
||||
const n = name.toLowerCase();
|
||||
if (n.includes('temperature')) return 'C';
|
||||
if (n.includes('voltage')) return 'V';
|
||||
if (n.includes('current')) return 'A';
|
||||
if (n.includes('power') && /^\d/.test(value)) return 'W';
|
||||
if (n.includes('fan') && /^\d+$/.test(value)) return 'RPM';
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatName(name: string): string {
|
||||
return name
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.replace(/\bPsu\b/g, 'PSU')
|
||||
.replace(/\bNtp\b/g, 'NTP');
|
||||
}
|
||||
|
||||
// ─── Disk helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Parse sizes in either raw-bytes ("134217728") or human-readable ("7.5GiB", "128MiB") form
|
||||
function parseBytes(val: string): number {
|
||||
if (!val) return 0;
|
||||
const n = parseFloat(val);
|
||||
if (isNaN(n)) return 0;
|
||||
const unit = val.replace(/[\d.\s]/g, '').toUpperCase();
|
||||
const map: Record<string, number> = {
|
||||
'': 1, B: 1,
|
||||
KB: 1e3, KIB: 1024,
|
||||
MB: 1e6, MIB: 1024 ** 2,
|
||||
GB: 1e9, GIB: 1024 ** 3,
|
||||
TB: 1e12, TIB: 1024 ** 4,
|
||||
};
|
||||
return n * (map[unit] ?? 1);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
|
||||
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KiB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function diskUsageColor(pct: number): string {
|
||||
if (pct >= 85) return 'bg-red-500';
|
||||
if (pct >= 70) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
// ─── Category helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function isTemperature(m: HealthMetric) {
|
||||
return m.unit === 'C' || m.name.toLowerCase().includes('temperature');
|
||||
}
|
||||
function isVoltage(m: HealthMetric) {
|
||||
return m.unit === 'V' || m.name.toLowerCase().includes('voltage');
|
||||
}
|
||||
function isFan(m: HealthMetric) {
|
||||
return m.unit === 'RPM' || m.name.toLowerCase().includes('fan');
|
||||
}
|
||||
function isPsu(m: HealthMetric) {
|
||||
const n = m.name.toLowerCase();
|
||||
return n.includes('psu') || n.includes('power-supply');
|
||||
}
|
||||
function isPower(m: HealthMetric) {
|
||||
return m.unit === 'W' || (m.name.toLowerCase().includes('power') && !isPsu(m));
|
||||
}
|
||||
function isCurrent(m: HealthMetric) {
|
||||
return m.unit === 'A' || m.name.toLowerCase().includes('current');
|
||||
}
|
||||
|
||||
// ─── Color helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function tempColor(celsius: number): string {
|
||||
if (celsius >= 70) return 'text-red-500 dark:text-red-400';
|
||||
if (celsius >= 50) return 'text-yellow-500 dark:text-yellow-400';
|
||||
return 'text-green-500 dark:text-green-400';
|
||||
}
|
||||
|
||||
function fanColor(rpm: number): string {
|
||||
return rpm === 0 ? 'text-red-500 dark:text-red-400' : 'text-green-500 dark:text-green-400';
|
||||
}
|
||||
|
||||
function psuColor(state: string): string {
|
||||
const s = state.toLowerCase();
|
||||
if (s === 'ok' || s === 'good') return 'text-green-500 dark:text-green-400';
|
||||
return 'text-red-500 dark:text-red-400';
|
||||
}
|
||||
|
||||
// ─── Card components ──────────────────────────────────────────────────────────
|
||||
|
||||
function Section({ title, icon, children, grid = true }: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
grid?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-blue-500">{icon}</span>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
</div>
|
||||
{grid ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">{children}</div>
|
||||
) : (
|
||||
<div className="space-y-3">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, unit, valueClass }: {
|
||||
label: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
valueClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400 mb-1">{label}</div>
|
||||
<div className={clsx('text-lg font-bold', valueClass || 'text-gray-900 dark:text-white')}>
|
||||
{value}
|
||||
{unit && <span className="text-xs font-normal text-gray-500 dark:text-slate-400 ml-1">{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiskCard({ disk }: { disk: Record<string, string> }) {
|
||||
const totalBytes = parseBytes(disk['total'] || '0');
|
||||
const freeBytes = parseBytes(disk['free'] || '0');
|
||||
const usedBytes = Math.max(0, totalBytes - freeBytes);
|
||||
const pct = totalBytes > 0 ? Math.round((usedBytes / totalBytes) * 100) : 0;
|
||||
|
||||
const label = disk['label'] || formatName(disk['name'] || 'Disk');
|
||||
const type = disk['type'] || '';
|
||||
const model = disk['model'] || '';
|
||||
const serial = disk['serial'] || '';
|
||||
const fs = disk['filesystem'] || disk['fs'] || '';
|
||||
const slot = disk['slot'] || '';
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-slate-700/50 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="font-medium text-sm text-gray-900 dark:text-white">{label}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400 mt-0.5 space-x-2">
|
||||
{type && <span className="uppercase">{type}</span>}
|
||||
{slot && <span>· {slot}</span>}
|
||||
{fs && <span>· {fs}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-bold text-gray-900 dark:text-white">{pct}%</div>
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400">used</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full mb-2">
|
||||
<div
|
||||
className={clsx('h-full rounded-full transition-all', diskUsageColor(pct))}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-gray-500 dark:text-slate-400">
|
||||
<span>{formatBytes(usedBytes)} used</span>
|
||||
<span>{formatBytes(freeBytes)} free / {formatBytes(totalBytes)}</span>
|
||||
</div>
|
||||
|
||||
{(model || serial) && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-slate-600 text-xs text-gray-400 dark:text-slate-500 space-y-0.5">
|
||||
{model && <div>Model: {model}</div>}
|
||||
{serial && <div>S/N: {serial}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toDisplayTemp(celsius: number, unit: 'C' | 'F'): string {
|
||||
if (unit === 'F') return String(Math.round(celsius * 9 / 5 + 32));
|
||||
return String(celsius % 1 === 0 ? celsius : celsius.toFixed(1));
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
const RESOURCE_RANGES = ['6h', '12h', '24h', '7d'] as const;
|
||||
|
||||
function ResourceChart({ deviceId }: { deviceId: number }) {
|
||||
const [range, setRange] = useState<string>('24h');
|
||||
|
||||
const { data: rawResources = [] } = useQuery({
|
||||
queryKey: ['resources-history', deviceId, range],
|
||||
queryFn: () => metricsApi.deviceResources(deviceId, range).then((r) => r.data as ResourcePoint[]),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const compact = range === '6h' || range === '12h' || range === '24h';
|
||||
const resourceData = rawResources.map((p) => {
|
||||
const memPct =
|
||||
p.memory_total && p.memory_total > 0
|
||||
? Math.round(((p.memory_used || 0) / p.memory_total) * 100)
|
||||
: 0;
|
||||
return {
|
||||
time: new Date(p.time).toLocaleTimeString([], {
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
...(compact ? {} : { month: 'short', day: 'numeric' }),
|
||||
}),
|
||||
cpu: Math.round(p.cpu_load || 0),
|
||||
mem: memPct,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">CPU & Memory Usage</h3>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{RESOURCE_RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded font-medium transition-colors',
|
||||
range === r
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-600'
|
||||
)}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resourceData.length === 0 ? (
|
||||
<div className="h-48 flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No resource history available yet — data accumulates over time
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={resourceData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 10 }} />
|
||||
<Tooltip
|
||||
formatter={(v) => `${v}%`}
|
||||
contentStyle={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="cpu" stroke="#f59e0b" name="CPU Load" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="mem" stroke="#8b5cf6" name="Memory Used" dot={false} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HardwareTab({ deviceId }: { deviceId: number }) {
|
||||
const [tempUnit, setTempUnit] = useState<'C' | 'F'>(() =>
|
||||
(localStorage.getItem('hw-temp-unit') as 'C' | 'F') || 'C'
|
||||
);
|
||||
|
||||
const setAndPersistUnit = (unit: 'C' | 'F') => {
|
||||
setTempUnit(unit);
|
||||
localStorage.setItem('hw-temp-unit', unit);
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||
queryKey: ['device-hardware', deviceId],
|
||||
queryFn: () => devicesApi.getHardware(deviceId).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="card p-8 text-center text-gray-500 dark:text-slate-400">
|
||||
Failed to load hardware data.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metrics = normalize(data?.health || []);
|
||||
const disks = data?.disks || [];
|
||||
|
||||
const temperatures = metrics.filter(isTemperature);
|
||||
const voltages = metrics.filter(isVoltage);
|
||||
const fans = metrics.filter(isFan);
|
||||
const psus = metrics.filter(isPsu);
|
||||
const power = metrics.filter(isPower);
|
||||
const current = metrics.filter(isCurrent);
|
||||
const other = metrics.filter(
|
||||
(m) => !isTemperature(m) && !isVoltage(m) && !isFan(m) && !isPsu(m) && !isPower(m) && !isCurrent(m)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{/* Temperature unit toggle */}
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-slate-600 text-xs font-medium">
|
||||
{(['C', 'F'] as const).map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
onClick={() => setAndPersistUnit(unit)}
|
||||
className={clsx(
|
||||
'px-2.5 py-1 transition-colors',
|
||||
tempUnit === unit
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
°{unit}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CPU & Memory chart — full width at top */}
|
||||
<ResourceChart deviceId={deviceId} />
|
||||
|
||||
{/* Sensor & disk sections in a 2-column grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{disks.length > 0 && (
|
||||
<div className="lg:col-span-2">
|
||||
<Section title="Storage" icon={<HardDrive className="w-4 h-4" />} grid={false}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{disks.map((d, i) => <DiskCard key={d['name'] || i} disk={d} />)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{temperatures.length > 0 && (
|
||||
<Section title="Temperature" icon={<Thermometer className="w-4 h-4" />}>
|
||||
{temperatures.map((m) => {
|
||||
const c = parseFloat(m.value);
|
||||
return (
|
||||
<MetricCard
|
||||
key={m.name}
|
||||
label={formatName(m.name)}
|
||||
value={isNaN(c) ? m.value : toDisplayTemp(c, tempUnit)}
|
||||
unit={`°${tempUnit}`}
|
||||
valueClass={isNaN(c) ? undefined : tempColor(c)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{fans.length > 0 && (
|
||||
<Section title="Fans" icon={<Wind className="w-4 h-4" />}>
|
||||
{fans.map((m) => {
|
||||
const rpm = parseInt(m.value, 10);
|
||||
return (
|
||||
<MetricCard
|
||||
key={m.name}
|
||||
label={formatName(m.name)}
|
||||
value={isNaN(rpm) ? m.value : rpm.toLocaleString()}
|
||||
unit={isNaN(rpm) ? undefined : 'RPM'}
|
||||
valueClass={isNaN(rpm) ? undefined : fanColor(rpm)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{psus.length > 0 && (
|
||||
<Section title="Power Supply" icon={<Power className="w-4 h-4" />}>
|
||||
{psus.map((m) => (
|
||||
<MetricCard
|
||||
key={m.name}
|
||||
label={formatName(m.name)}
|
||||
value={m.value.toUpperCase()}
|
||||
valueClass={psuColor(m.value)}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{voltages.length > 0 && (
|
||||
<Section title="Voltage" icon={<Zap className="w-4 h-4" />}>
|
||||
{voltages.map((m) => (
|
||||
<MetricCard key={m.name} label={formatName(m.name)} value={m.value} unit="V" />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(power.length > 0 || current.length > 0) && (
|
||||
<Section title="Power Consumption" icon={<Activity className="w-4 h-4" />}>
|
||||
{power.map((m) => (
|
||||
<MetricCard key={m.name} label={formatName(m.name)} value={m.value} unit="W" />
|
||||
))}
|
||||
{current.map((m) => (
|
||||
<MetricCard key={m.name} label={formatName(m.name)} value={m.value} unit="A" />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{other.length > 0 && (
|
||||
<Section title="Other" icon={<HelpCircle className="w-4 h-4" />}>
|
||||
{other.map((m) => (
|
||||
<MetricCard
|
||||
key={m.name}
|
||||
label={formatName(m.name)}
|
||||
value={m.value}
|
||||
unit={m.unit || undefined}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function InterfacesTab({ deviceId }: { deviceId: number }) {
|
||||
const { data: interfaces = [], isLoading } = useQuery({
|
||||
queryKey: ['interfaces', deviceId],
|
||||
queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading...</div>;
|
||||
if (!interfaces.length) return (
|
||||
<div className="text-center py-8 text-gray-400 dark:text-slate-500">
|
||||
No interfaces found. Sync the device to collect data.
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Type</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Status</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">MAC Address</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">MTU</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Speed</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{interfaces.map((iface) => (
|
||||
<tr key={iface.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-mono font-semibold text-gray-900 dark:text-white">
|
||||
{iface.name}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400 capitalize">
|
||||
{iface.type || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 text-xs font-medium',
|
||||
iface.disabled
|
||||
? 'text-gray-400'
|
||||
: iface.running
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-500 dark:text-red-400'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
iface.disabled ? 'bg-gray-400' : iface.running ? 'bg-green-500' : 'bg-red-500'
|
||||
)}
|
||||
/>
|
||||
{iface.disabled ? 'Disabled' : iface.running ? 'Running' : 'Down'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{iface.mac_address || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
||||
{iface.mtu || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">
|
||||
{iface.speed || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 dark:text-slate-500 italic">
|
||||
{iface.comment || ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,716 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
// ─── Extended devicesApi type (routing protocol methods added at runtime) ──────
|
||||
const dApi = devicesApi as typeof devicesApi & {
|
||||
getOspf: (id: number) => Promise<{ data: Record<string, unknown> }>;
|
||||
getBgp: (id: number) => Promise<{ data: Record<string, unknown> }>;
|
||||
getRoutingTables: (id: number) => Promise<{ data: Record<string, string>[] }>;
|
||||
getRouteFilters: (id: number) => Promise<{ data: Record<string, unknown> }>;
|
||||
getRouterIds: (id: number) => Promise<{ data: Record<string, string>[] }>;
|
||||
addOspfInstance: (id: number, d: Record<string, unknown>) => Promise<unknown>;
|
||||
removeOspfInstance: (id: number, itemId: string) => Promise<unknown>;
|
||||
addOspfArea: (id: number, d: Record<string, unknown>) => Promise<unknown>;
|
||||
removeOspfArea: (id: number, itemId: string) => Promise<unknown>;
|
||||
addBgpConnection: (id: number, d: Record<string, unknown>) => Promise<unknown>;
|
||||
removeBgpConnection: (id: number, itemId: string) => Promise<unknown>;
|
||||
addRoutingTable: (id: number, d: { name: string; fib?: boolean }) => Promise<unknown>;
|
||||
removeRoutingTable: (id: number, itemId: string) => Promise<unknown>;
|
||||
addFilterRule: (id: number, d: Record<string, unknown>) => Promise<unknown>;
|
||||
removeFilterRule: (id: number, itemId: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const SUB_TABS = ['Routes', 'OSPF', 'BGP', 'Route Filters', 'Tables'] as const;
|
||||
type SubTab = (typeof SUB_TABS)[number];
|
||||
|
||||
// ─── Reusable helpers ─────────────────────────────────────────────────────────
|
||||
function errMsg(err: unknown) {
|
||||
return (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Operation failed';
|
||||
}
|
||||
|
||||
function KVTable({ rows }: { rows: Record<string, string>[] }) {
|
||||
if (!rows.length) return <Empty text="No entries." />;
|
||||
const keys = Object.keys(rows[0]).filter(k => k !== '.id');
|
||||
return (
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
{keys.map(k => (
|
||||
<th key={k} className="table-header px-4 py-2.5 text-left">{k}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
{keys.map(k => (
|
||||
<td key={k} className="px-4 py-2.5 font-mono text-xs text-gray-700 dark:text-slate-300">
|
||||
{String(r[k] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ text }: { text: string }) {
|
||||
return <p className="text-center py-8 text-gray-400 dark:text-slate-500">{text}</p>;
|
||||
}
|
||||
|
||||
function SectionHeader({ title, onRefresh, isFetching }: { title: string; onRefresh: () => void; isFetching: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
<button onClick={onRefresh} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Routes sub-tab (original) ────────────────────────────────────────────────
|
||||
function RoutesSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data: routes = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing', deviceId],
|
||||
queryFn: () => devicesApi.getRouting(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ dst_address: '', gateway: '', distance: '1', comment: '' });
|
||||
const [addError, setAddError] = useState('');
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => devicesApi.addRoute(deviceId, {
|
||||
dst_address: form.dst_address,
|
||||
gateway: form.gateway,
|
||||
distance: form.distance ? parseInt(form.distance, 10) : undefined,
|
||||
comment: form.comment || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['routing', deviceId] });
|
||||
setForm({ dst_address: '', gateway: '', distance: '1', comment: '' });
|
||||
setShowAdd(false);
|
||||
setAddError('');
|
||||
},
|
||||
onError: (err: unknown) => setAddError(errMsg(err)),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (routeId: string) => devicesApi.deleteRoute(deviceId, routeId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['routing', deviceId] });
|
||||
setDeleteError('');
|
||||
},
|
||||
onError: (err: unknown) => setDeleteError(errMsg(err)),
|
||||
});
|
||||
|
||||
const isDynamic = (r: Record<string, string>) =>
|
||||
r['dynamic'] === 'true' || (r['flags'] && /[DCBOE]/.test(r['flags']));
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{deleteError && (
|
||||
<span className="text-sm text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />{deleteError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-2 text-sm">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button onClick={() => { setShowAdd(!showAdd); setAddError(''); }} className="btn-primary flex items-center gap-2 text-sm">
|
||||
<Plus className="w-4 h-4" /> Add Route
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canWrite && showAdd && (
|
||||
<div className="card p-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">New Static Route</h4>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div><label className="label">Destination (CIDR)</label>
|
||||
<input className="input font-mono" value={form.dst_address} onChange={e => setForm(f => ({ ...f, dst_address: e.target.value }))} placeholder="0.0.0.0/0" /></div>
|
||||
<div><label className="label">Gateway</label>
|
||||
<input className="input font-mono" value={form.gateway} onChange={e => setForm(f => ({ ...f, gateway: e.target.value }))} placeholder="192.168.1.1" /></div>
|
||||
<div><label className="label">Distance</label>
|
||||
<input type="number" className="input" value={form.distance} onChange={e => setForm(f => ({ ...f, distance: e.target.value }))} min={1} max={255} /></div>
|
||||
<div><label className="label">Comment</label>
|
||||
<input className="input" value={form.comment} onChange={e => setForm(f => ({ ...f, comment: e.target.value }))} placeholder="Optional" /></div>
|
||||
</div>
|
||||
{addError && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{addError}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMutation.mutate()} disabled={!form.dst_address || !form.gateway || addMutation.isPending} className="btn-primary flex items-center gap-2 text-sm">
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add Route
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{routes.length === 0 ? (
|
||||
<Empty text="No routes found." />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Destination</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Gateway</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Interface</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Dist.</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Status</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Comment</th>
|
||||
{canWrite && <th className="table-header px-4 py-2.5 w-10" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{(routes as Record<string, string>[]).map((r, i) => {
|
||||
const active = r['active'] === 'true' || r['flags']?.includes('A');
|
||||
const dynamic = isDynamic(r);
|
||||
const routeId = r['.id'] || '';
|
||||
return (
|
||||
<tr key={routeId || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-mono text-gray-900 dark:text-white">{r['dst-address'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{r['gateway'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{r['interface'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{r['distance'] || '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={clsx('text-xs font-medium', active ? 'text-green-600 dark:text-green-400' : 'text-gray-400')}>
|
||||
{active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
{dynamic && <span className="text-xs text-gray-400 dark:text-slate-500">(dynamic)</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 dark:text-slate-500 text-xs">{r['comment'] || ''}</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-2.5">
|
||||
{!dynamic && routeId && (
|
||||
<button
|
||||
onClick={() => { if (confirm(`Remove route to ${r['dst-address']}?`)) deleteMutation.mutate(routeId); }}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── OSPF sub-tab ─────────────────────────────────────────────────────────────
|
||||
function OspfSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing-ospf', deviceId],
|
||||
queryFn: () => dApi.getOspf(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAddInstance, setShowAddInstance] = useState(false);
|
||||
const [instanceForm, setInstanceForm] = useState({ name: '', 'router-id': '' });
|
||||
const [showAddArea, setShowAddArea] = useState(false);
|
||||
const [areaForm, setAreaForm] = useState({ name: '', area: '0.0.0.0', instance: '' });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const addInstanceMutation = useMutation({
|
||||
mutationFn: () => dApi.addOspfInstance(deviceId, instanceForm),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-ospf', deviceId] }); setShowAddInstance(false); setInstanceForm({ name: '', 'router-id': '' }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeInstanceMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeOspfInstance(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-ospf', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const addAreaMutation = useMutation({
|
||||
mutationFn: () => dApi.addOspfArea(deviceId, areaForm),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-ospf', deviceId] }); setShowAddArea(false); setAreaForm({ name: '', area: '0.0.0.0', instance: '' }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeAreaMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeOspfArea(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-ospf', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading…</div>;
|
||||
|
||||
const instances = (data?.instances as Record<string, string>[]) || [];
|
||||
const areas = (data?.areas as Record<string, string>[]) || [];
|
||||
const interfaces= (data?.interfaces as Record<string, string>[]) || [];
|
||||
const neighbors = (data?.neighbors as Record<string, string>[]) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{error}</div>}
|
||||
|
||||
{/* Instances */}
|
||||
<div>
|
||||
<SectionHeader title="Instances" onRefresh={refetch} isFetching={isFetching} />
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAddInstance ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="label">Name</label><input className="input" value={instanceForm.name} onChange={e => setInstanceForm(f => ({ ...f, name: e.target.value }))} placeholder="default" /></div>
|
||||
<div><label className="label">Router ID</label><input className="input font-mono" value={instanceForm['router-id']} onChange={e => setInstanceForm(f => ({ ...f, 'router-id': e.target.value }))} placeholder="1.2.3.4" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addInstanceMutation.mutate()} disabled={!instanceForm.name || addInstanceMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addInstanceMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add
|
||||
</button>
|
||||
<button onClick={() => setShowAddInstance(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAddInstance(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Instance</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{instances.length === 0 ? <Empty text="No OSPF instances." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Router ID</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">State</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{instances.map((inst, i) => (
|
||||
<tr key={inst['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{inst['name'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{inst['router-id'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{inst['state'] || inst['status'] || '—'}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{inst['.id'] && <button onClick={() => { if (confirm('Remove instance?')) removeInstanceMutation.mutate(inst['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Areas */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Areas</h3>
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAddArea ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div><label className="label">Name</label><input className="input" value={areaForm.name} onChange={e => setAreaForm(f => ({ ...f, name: e.target.value }))} placeholder="backbone" /></div>
|
||||
<div><label className="label">Area ID</label><input className="input font-mono" value={areaForm.area} onChange={e => setAreaForm(f => ({ ...f, area: e.target.value }))} placeholder="0.0.0.0" /></div>
|
||||
<div><label className="label">Instance</label><input className="input" value={areaForm.instance} onChange={e => setAreaForm(f => ({ ...f, instance: e.target.value }))} placeholder="default" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addAreaMutation.mutate()} disabled={!areaForm.name || addAreaMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addAreaMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add
|
||||
</button>
|
||||
<button onClick={() => setShowAddArea(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAddArea(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Area</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{areas.length === 0 ? <Empty text="No OSPF areas." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Area</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Instance</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{areas.map((a, i) => (
|
||||
<tr key={a['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{a['name'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{a['area'] || a['area-id'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{a['instance'] || '—'}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{a['.id'] && <button onClick={() => { if (confirm('Remove area?')) removeAreaMutation.mutate(a['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interfaces (read-only) */}
|
||||
{interfaces.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Interfaces</h3>
|
||||
<KVTable rows={interfaces} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Neighbors (read-only) */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Neighbors</h3>
|
||||
{neighbors.length === 0 ? <Empty text="No OSPF neighbors." /> : <KVTable rows={neighbors} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── BGP sub-tab ──────────────────────────────────────────────────────────────
|
||||
function BgpSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing-bgp', deviceId],
|
||||
queryFn: () => dApi.getBgp(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', 'remote.address': '', 'remote.as': '', 'local.role': 'ebgp' });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => dApi.addBgpConnection(deviceId, form),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-bgp', deviceId] }); setShowAdd(false); setForm({ name: '', 'remote.address': '', 'remote.as': '', 'local.role': 'ebgp' }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeBgpConnection(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-bgp', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading…</div>;
|
||||
|
||||
const connections = (data?.connections as Record<string, string>[]) || [];
|
||||
const sessions = (data?.sessions as Record<string, string>[]) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{error}</div>}
|
||||
|
||||
{/* Connections */}
|
||||
<div>
|
||||
<SectionHeader title="BGP Connections" onRefresh={refetch} isFetching={isFetching} />
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAdd ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div><label className="label">Name</label><input className="input" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="peer-name" /></div>
|
||||
<div><label className="label">Remote Address</label><input className="input font-mono" value={form['remote.address']} onChange={e => setForm(f => ({ ...f, 'remote.address': e.target.value }))} placeholder="10.0.0.1" /></div>
|
||||
<div><label className="label">Remote AS</label><input className="input font-mono" value={form['remote.as']} onChange={e => setForm(f => ({ ...f, 'remote.as': e.target.value }))} placeholder="65000" /></div>
|
||||
<div><label className="label">Role</label>
|
||||
<select className="input" value={form['local.role']} onChange={e => setForm(f => ({ ...f, 'local.role': e.target.value }))}>
|
||||
<option value="ebgp">eBGP</option>
|
||||
<option value="ibgp">iBGP</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMutation.mutate()} disabled={!form.name || !form['remote.address'] || addMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Connection</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{connections.length === 0 ? <Empty text="No BGP connections configured." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Remote Address</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Remote AS</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Role</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{connections.map((c, i) => (
|
||||
<tr key={c['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{c['name'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{c['remote.address'] || c['remote-address'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">{c['remote.as'] || c['remote-as'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{c['local.role'] || c['local-role'] || '—'}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{c['.id'] && <button onClick={() => { if (confirm('Remove BGP connection?')) removeMutation.mutate(c['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sessions (read-only) */}
|
||||
{sessions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">Active Sessions</h3>
|
||||
<KVTable rows={sessions} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Route Filters sub-tab ────────────────────────────────────────────────────
|
||||
function RouteFiltersSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing-filters', deviceId],
|
||||
queryFn: () => dApi.getRouteFilters(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ chain: '', action: 'accept', prefix: '', comment: '' });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => dApi.addFilterRule(deviceId, { ...form, prefix: form.prefix || undefined }),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-filters', deviceId] }); setShowAdd(false); setForm({ chain: '', action: 'accept', prefix: '', comment: '' }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeFilterRule(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-filters', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading…</div>;
|
||||
|
||||
const rules = (data?.rules as Record<string, string>[]) || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{error}</div>}
|
||||
<SectionHeader title="Route Filter Rules" onRefresh={refetch} isFetching={isFetching} />
|
||||
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAdd ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div><label className="label">Chain</label><input className="input" value={form.chain} onChange={e => setForm(f => ({ ...f, chain: e.target.value }))} placeholder="my-filter" /></div>
|
||||
<div><label className="label">Action</label>
|
||||
<select className="input" value={form.action} onChange={e => setForm(f => ({ ...f, action: e.target.value }))}>
|
||||
<option value="accept">accept</option>
|
||||
<option value="reject">reject</option>
|
||||
<option value="discard">discard</option>
|
||||
<option value="return">return</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label className="label">Prefix (optional)</label><input className="input font-mono" value={form.prefix} onChange={e => setForm(f => ({ ...f, prefix: e.target.value }))} placeholder="10.0.0.0/8" /></div>
|
||||
<div><label className="label">Comment</label><input className="input" value={form.comment} onChange={e => setForm(f => ({ ...f, comment: e.target.value }))} placeholder="Optional" /></div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMutation.mutate()} disabled={!form.chain || addMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add Rule
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Rule</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rules.length === 0 ? <Empty text="No route filter rules." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">#</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Chain</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Action</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Prefix</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Comment</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{rules.map((r, i) => (
|
||||
<tr key={r['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 text-gray-400 dark:text-slate-500 text-xs">{i + 1}</td>
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{r['chain'] || '—'}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={clsx('text-xs font-medium', r['action'] === 'accept' ? 'text-green-600 dark:text-green-400' : 'text-red-500')}>
|
||||
{r['action'] || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{r['prefix'] || r['dst-address'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500">{r['comment'] || ''}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{r['.id'] && <button onClick={() => { if (confirm('Remove filter rule?')) removeMutation.mutate(r['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Routing Tables sub-tab ───────────────────────────────────────────────────
|
||||
function TablesSubTab({ deviceId }: { deviceId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data: tables = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routing-tables', deviceId],
|
||||
queryFn: () => dApi.getRoutingTables(deviceId).then(r => r.data),
|
||||
});
|
||||
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', fib: false });
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => dApi.addRoutingTable(deviceId, form),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['routing-tables', deviceId] }); setShowAdd(false); setForm({ name: '', fib: false }); setError(''); },
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (id: string) => dApi.removeRoutingTable(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['routing-tables', deviceId] }),
|
||||
onError: (err: unknown) => setError(errMsg(err)),
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && <div className="flex items-center gap-2 text-sm text-red-500"><AlertCircle className="w-4 h-4" />{error}</div>}
|
||||
<SectionHeader title="Routing Tables" onRefresh={refetch} isFetching={isFetching} />
|
||||
|
||||
{canWrite && (
|
||||
<div className="mb-3">
|
||||
{showAdd ? (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1"><label className="label">Table Name</label><input className="input" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="my-table" /></div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300 mb-1 cursor-pointer">
|
||||
<input type="checkbox" checked={form.fib} onChange={e => setForm(f => ({ ...f, fib: e.target.checked }))} className="rounded" />
|
||||
FIB
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => addMutation.mutate()} disabled={!form.name || addMutation.isPending} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />} Add Table
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowAdd(true)} className="btn-secondary flex items-center gap-1.5 text-sm"><Plus className="w-3.5 h-3.5" /> Add Table</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tables.length === 0 ? <Empty text="No custom routing tables." /> : (
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">FIB</th>
|
||||
{canWrite && <th className="w-10" />}
|
||||
</tr></thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{tables.map((t, i) => (
|
||||
<tr key={t['.id'] || i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{t['name'] || '—'}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400">{t['fib'] === 'true' ? 'Yes' : 'No'}</td>
|
||||
{canWrite && <td className="px-4 py-2.5">
|
||||
{t['.id'] && <button onClick={() => { if (confirm(`Remove table "${t['name']}"?`)) removeMutation.mutate(t['.id']); }} className="p-1 text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>}
|
||||
</td>}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main RoutingTab with sub-tabs ────────────────────────────────────────────
|
||||
export default function RoutingTab({ deviceId }: { deviceId: number }) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<SubTab>('Routes');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Sub-tab bar */}
|
||||
<div className="flex gap-1 border-b border-gray-200 dark:border-slate-700">
|
||||
{SUB_TABS.map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveSubTab(tab)}
|
||||
className={clsx(
|
||||
'px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px',
|
||||
activeSubTab === tab
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sub-tab content */}
|
||||
{activeSubTab === 'Routes' && <RoutesSubTab deviceId={deviceId} />}
|
||||
{activeSubTab === 'OSPF' && <OspfSubTab deviceId={deviceId} />}
|
||||
{activeSubTab === 'BGP' && <BgpSubTab deviceId={deviceId} />}
|
||||
{activeSubTab === 'Route Filters' && <RouteFiltersSubTab deviceId={deviceId} />}
|
||||
{activeSubTab === 'Tables' && <TablesSubTab deviceId={deviceId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
RefreshCw, Plus, Trash2, CheckCircle, AlertCircle, Download, Server,
|
||||
} from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import type { IpAddress, Interface } from '../../types';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
interface Props {
|
||||
deviceId: number;
|
||||
}
|
||||
|
||||
export default function SystemConfigTab({ deviceId }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
// ─── System config (identity, NTP, DNS) ──────────────────────────────────
|
||||
const { data: sysConfig, isLoading: sysLoading } = useQuery({
|
||||
queryKey: ['system-config', deviceId],
|
||||
queryFn: () => devicesApi.getSystemConfig(deviceId).then((r) => r.data),
|
||||
});
|
||||
|
||||
// ─── Clock ────────────────────────────────────────────────────────────────
|
||||
const { data: clockData, isLoading: clockLoading } = useQuery({
|
||||
queryKey: ['device-clock', deviceId],
|
||||
queryFn: () => devicesApi.getClock(deviceId).then((r) => r.data),
|
||||
});
|
||||
|
||||
// ─── Combined edit state ──────────────────────────────────────────────────
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [sysForm, setSysForm] = useState({
|
||||
identity: '',
|
||||
ntp_enabled: true,
|
||||
ntp_primary: '',
|
||||
ntp_secondary: '',
|
||||
dns_servers: '',
|
||||
dns_allow_remote: false,
|
||||
});
|
||||
const [clockForm, setClockForm] = useState({ date: '', time: '', timezone: '' });
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [saveSuccess, setSaveSuccess] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const startEdit = () => {
|
||||
if (!sysConfig) return;
|
||||
setSysForm({
|
||||
identity: sysConfig.identity,
|
||||
ntp_enabled: sysConfig.ntp.enabled !== 'no',
|
||||
ntp_primary: sysConfig.ntp['primary-ntp'] || '',
|
||||
ntp_secondary: sysConfig.ntp['secondary-ntp'] || '',
|
||||
dns_servers: sysConfig.dns['servers'] || '',
|
||||
dns_allow_remote: sysConfig.dns['allow-remote-requests'] === 'yes',
|
||||
});
|
||||
setClockForm({
|
||||
date: clockData?.date || '',
|
||||
time: clockData?.time || '',
|
||||
timezone: clockData?.timezone || '',
|
||||
});
|
||||
setSaveError('');
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
try {
|
||||
await devicesApi.updateSystemConfig(deviceId, sysForm);
|
||||
await devicesApi.setClock(deviceId, clockForm);
|
||||
queryClient.invalidateQueries({ queryKey: ['system-config', deviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['device-clock', deviceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['device', deviceId] });
|
||||
setEditing(false);
|
||||
setSaveSuccess('System configuration updated');
|
||||
setTimeout(() => setSaveSuccess(''), 3000);
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setSaveError(msg || 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── IP Addresses ────────────────────────────────────────────────────────
|
||||
const { data: ipAddresses = [], isLoading: ipLoading, refetch: refetchIp } = useQuery({
|
||||
queryKey: ['ip-addresses', deviceId],
|
||||
queryFn: () => devicesApi.getIpAddresses(deviceId).then((r) => r.data as IpAddress[]),
|
||||
});
|
||||
|
||||
const { data: interfaces = [] } = useQuery({
|
||||
queryKey: ['interfaces', deviceId],
|
||||
queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data as Interface[]),
|
||||
});
|
||||
|
||||
const [newIp, setNewIp] = useState({ address: '', interface: '' });
|
||||
const [ipError, setIpError] = useState('');
|
||||
|
||||
const addIpMutation = useMutation({
|
||||
mutationFn: () => devicesApi.addIpAddress(deviceId, newIp),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] });
|
||||
setNewIp({ address: '', interface: '' });
|
||||
setIpError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setIpError(msg || 'Failed to add IP address');
|
||||
},
|
||||
});
|
||||
|
||||
const removeIpMutation = useMutation({
|
||||
mutationFn: (id: string) => devicesApi.removeIpAddress(deviceId, id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ip-addresses', deviceId] }),
|
||||
});
|
||||
|
||||
// ─── Firmware check / install ─────────────────────────────────────────────
|
||||
const [updateInfo, setUpdateInfo] = useState<Record<string, string> | null>(null);
|
||||
const [updateChecking, setUpdateChecking] = useState(false);
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
const [installSuccess, setInstallSuccess] = useState('');
|
||||
|
||||
const checkUpdateMutation = useMutation({
|
||||
mutationFn: () => devicesApi.checkUpdate(deviceId),
|
||||
onMutate: () => { setUpdateChecking(true); setUpdateError(''); setUpdateInfo(null); setInstallSuccess(''); },
|
||||
onSuccess: (res) => { setUpdateInfo(res.data); setUpdateChecking(false); },
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setUpdateError(msg || 'Failed to check for updates');
|
||||
setUpdateChecking(false);
|
||||
},
|
||||
});
|
||||
|
||||
const installUpdateMutation = useMutation({
|
||||
mutationFn: () => devicesApi.installUpdate(deviceId),
|
||||
onSuccess: () => {
|
||||
setUpdateInfo(null);
|
||||
setInstallSuccess('Update installation initiated. The device will reboot shortly.');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setUpdateError(msg || 'Failed to install update');
|
||||
},
|
||||
});
|
||||
|
||||
// RouterOS 7 uses 'installed-version'; RouterOS 6 uses 'version'
|
||||
const installedVersion = updateInfo?.['installed-version'] || updateInfo?.['version'] || '';
|
||||
const latestVersion = updateInfo?.['latest-version'] || '';
|
||||
const statusSaysUpdate = updateInfo?.['status']?.toLowerCase().includes('new version') ||
|
||||
updateInfo?.['status']?.toLowerCase().includes('available');
|
||||
const hasUpdate = updateInfo && latestVersion && (
|
||||
(installedVersion && latestVersion !== installedVersion) ||
|
||||
(!installedVersion && statusSaysUpdate)
|
||||
);
|
||||
|
||||
const isLoading = sysLoading || clockLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── System Settings (identity, NTP, DNS, clock) ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-500" />
|
||||
System Settings
|
||||
</h3>
|
||||
{canWrite && !editing && (
|
||||
<button
|
||||
onClick={startEdit}
|
||||
disabled={isLoading || !sysConfig}
|
||||
className="btn-secondary text-xs py-1.5"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Loading...
|
||||
</div>
|
||||
) : !sysConfig ? (
|
||||
<div className="text-sm text-gray-400">Could not load system configuration</div>
|
||||
) : editing ? (
|
||||
<div className="space-y-5">
|
||||
{/* Identity */}
|
||||
<div>
|
||||
<label className="label">Device Identity (Hostname)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={sysForm.identity}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, identity: e.target.value }))}
|
||||
placeholder="router"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date / Time / Timezone */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-2">
|
||||
Date & Time
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="label">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={clockForm.date}
|
||||
onChange={(e) => setClockForm((f) => ({ ...f, date: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Time</label>
|
||||
<input
|
||||
type="time"
|
||||
className="input"
|
||||
value={clockForm.time}
|
||||
onChange={(e) => setClockForm((f) => ({ ...f, time: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Timezone</label>
|
||||
<input
|
||||
className="input"
|
||||
value={clockForm.timezone}
|
||||
onChange={(e) => setClockForm((f) => ({ ...f, timezone: e.target.value }))}
|
||||
placeholder="America/New_York"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">IANA name (e.g. UTC, Europe/London)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NTP */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-2">
|
||||
NTP Client
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ntp_enabled"
|
||||
checked={sysForm.ntp_enabled}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, ntp_enabled: e.target.checked }))}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="ntp_enabled" className="text-sm text-gray-700 dark:text-slate-300">
|
||||
NTP enabled
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Primary NTP server</label>
|
||||
<input
|
||||
className="input"
|
||||
value={sysForm.ntp_primary}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, ntp_primary: e.target.value }))}
|
||||
placeholder="0.pool.ntp.org"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Secondary NTP server</label>
|
||||
<input
|
||||
className="input"
|
||||
value={sysForm.ntp_secondary}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, ntp_secondary: e.target.value }))}
|
||||
placeholder="1.pool.ntp.org"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DNS */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-2">
|
||||
DNS
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="label">DNS Servers (comma-separated)</label>
|
||||
<input
|
||||
className="input"
|
||||
value={sysForm.dns_servers}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, dns_servers: e.target.value }))}
|
||||
placeholder="8.8.8.8,8.8.4.4"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dns_remote"
|
||||
checked={sysForm.dns_allow_remote}
|
||||
onChange={(e) => setSysForm((f) => ({ ...f, dns_allow_remote: e.target.checked }))}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="dns_remote" className="text-sm text-gray-700 dark:text-slate-300">
|
||||
Allow remote DNS requests (act as DNS server)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{saveError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{saving && <RefreshCw className="w-4 h-4 animate-spin" />}
|
||||
Save Changes
|
||||
</button>
|
||||
<button onClick={() => setEditing(false)} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-y-3 gap-x-8 text-sm">
|
||||
{[
|
||||
['Identity', sysConfig.identity || '—'],
|
||||
['Date', clockData?.date || '—'],
|
||||
['Time', clockData?.time || '—'],
|
||||
['Timezone', clockData?.timezone || '—'],
|
||||
['NTP Enabled', sysConfig.ntp.enabled !== 'no' ? 'Yes' : 'No'],
|
||||
['Primary NTP', sysConfig.ntp['primary-ntp'] || '—'],
|
||||
['Secondary NTP', sysConfig.ntp['secondary-ntp'] || '—'],
|
||||
['DNS Servers', sysConfig.dns.servers || '—'],
|
||||
['Allow Remote DNS', sysConfig.dns['allow-remote-requests'] === 'yes' ? 'Yes' : 'No'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between border-b border-gray-100 dark:border-slate-700 pb-2">
|
||||
<span className="text-gray-500 dark:text-slate-400">{k}</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white text-right">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveSuccess && (
|
||||
<div className="flex items-center gap-2 mt-3 text-sm text-green-600">
|
||||
<CheckCircle className="w-4 h-4" /> {saveSuccess}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── IP Addresses ── */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-slate-700">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">IP Addresses</h3>
|
||||
<button
|
||||
onClick={() => refetchIp()}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', ipLoading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2 text-left">Address / Prefix</th>
|
||||
<th className="table-header px-4 py-2 text-left">Network</th>
|
||||
<th className="table-header px-4 py-2 text-left">Interface</th>
|
||||
<th className="table-header px-4 py-2 text-left">Status</th>
|
||||
<th className="table-header px-4 py-2 w-12" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{ipLoading ? (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">Loading...</td></tr>
|
||||
) : (ipAddresses as IpAddress[]).length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-400">No IP addresses</td></tr>
|
||||
) : (
|
||||
(ipAddresses as IpAddress[]).map((ip) => (
|
||||
<tr key={ip['.id']} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2 font-mono text-gray-900 dark:text-white">{ip.address}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{ip.network || '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-600 dark:text-slate-300">{ip.interface}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={clsx(
|
||||
'text-xs font-medium',
|
||||
ip.disabled === 'true' ? 'text-gray-400' : 'text-green-600 dark:text-green-400'
|
||||
)}>
|
||||
{ip.dynamic === 'true' ? 'dynamic' : ip.disabled === 'true' ? 'disabled' : 'active'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{canWrite && ip.dynamic !== 'true' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Remove ${ip.address} from ${ip.interface}?`)) {
|
||||
removeIpMutation.mutate(ip['.id']);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{canWrite && <div className="p-4 border-t border-gray-100 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="label">Address / Prefix (e.g. 192.168.1.1/24)</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={newIp.address}
|
||||
onChange={(e) => setNewIp((f) => ({ ...f, address: e.target.value }))}
|
||||
placeholder="192.168.1.1/24"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-44">
|
||||
<label className="label">Interface</label>
|
||||
<select
|
||||
className="input"
|
||||
value={newIp.interface}
|
||||
onChange={(e) => setNewIp((f) => ({ ...f, interface: e.target.value }))}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{(interfaces as Interface[]).map((i) => (
|
||||
<option key={i.name} value={i.name}>{i.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addIpMutation.mutate()}
|
||||
disabled={!newIp.address || !newIp.interface || addIpMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{ipError && (
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-red-500">
|
||||
<AlertCircle className="w-4 h-4" /> {ipError}
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* ── Firmware Updates ── */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-blue-500" />
|
||||
Firmware Updates
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => checkUpdateMutation.mutate()}
|
||||
disabled={updateChecking}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', updateChecking && 'animate-spin')} />
|
||||
{updateChecking ? 'Checking...' : 'Check for Updates'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{updateError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500 mb-3">
|
||||
<AlertCircle className="w-4 h-4" /> {updateError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{installSuccess && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400 mb-3">
|
||||
<CheckCircle className="w-4 h-4" /> {installSuccess}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateInfo && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{[
|
||||
['Status', updateInfo['status'] || '—'],
|
||||
['Installed Version', installedVersion || '—'],
|
||||
['Latest Version', latestVersion || '—'],
|
||||
['Channel', updateInfo['channel'] || '—'],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between border-b border-gray-100 dark:border-slate-700 pb-2">
|
||||
<span className="text-gray-500 dark:text-slate-400">{k}</span>
|
||||
<span className={clsx(
|
||||
'font-medium text-right',
|
||||
k === 'Status' && hasUpdate ? 'text-orange-500' : 'text-gray-900 dark:text-white'
|
||||
)}>
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasUpdate ? (
|
||||
<div className="flex items-center gap-3 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-orange-500 flex-shrink-0" />
|
||||
<div className="flex-1 text-sm">
|
||||
<p className="font-medium text-orange-700 dark:text-orange-400">
|
||||
Update available: {latestVersion}
|
||||
</p>
|
||||
<p className="text-orange-600 dark:text-orange-500 text-xs mt-0.5">
|
||||
The device will reboot after installing.
|
||||
</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Install RouterOS ${latestVersion} and reboot the device?`)) {
|
||||
installUpdateMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={installUpdateMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm flex-shrink-0"
|
||||
>
|
||||
{installUpdateMutation.isPending
|
||||
? <RefreshCw className="w-4 h-4 animate-spin" />
|
||||
: <Download className="w-4 h-4" />}
|
||||
Install & Reboot
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
RouterOS is up to date
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!updateInfo && !updateChecking && !installSuccess && (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">
|
||||
Click "Check for Updates" to query the MikroTik update server.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Play, Loader2, CheckCircle, XCircle, Wifi, RotateCcw } from 'lucide-react';
|
||||
import { deviceToolsApi, devicesApi } from '../../services/api';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function RunButton({ loading, onClick, label = 'Run' }: { loading: boolean; onClick: () => void; label?: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className="btn-primary flex items-center gap-2 shrink-0"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{loading ? 'Running…' : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBox({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-300">
|
||||
<XCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultTable({ headers, rows }: { headers: string[]; rows: (string | null)[][] }) {
|
||||
if (!rows.length) return <p className="text-sm text-gray-400 dark:text-slate-500">No results.</p>;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
{headers.map((h) => (
|
||||
<th key={h} className="px-3 py-1.5 text-left text-gray-500 dark:text-slate-400 font-medium">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50 table-zebra">
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-slate-700/20">
|
||||
{row.map((cell, j) => (
|
||||
<td key={j} className="px-3 py-1.5 text-gray-700 dark:text-slate-300">{cell ?? '—'}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Ping ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function PingTool({ deviceId, interfaces }: { deviceId: number; interfaces: string[] }) {
|
||||
const [address, setAddress] = useState('');
|
||||
const [count, setCount] = useState('4');
|
||||
const [iface, setIface] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<Record<string, string>[] | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!address) return;
|
||||
setLoading(true); setError(''); setResults(null);
|
||||
try {
|
||||
const { data } = await deviceToolsApi.ping(deviceId, {
|
||||
address,
|
||||
count: parseInt(count) || 4,
|
||||
interface: iface || undefined,
|
||||
});
|
||||
setResults(data);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'Ping failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Split results into per-packet rows and summary (last row has packet-loss)
|
||||
const packets = results?.filter((r) => r['seq'] !== undefined) ?? [];
|
||||
const summary = results?.find((r) => r['sent'] !== undefined);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
className="input flex-1 min-w-48"
|
||||
placeholder="IP address or hostname"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && run()}
|
||||
/>
|
||||
<input
|
||||
className="input w-20"
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={count}
|
||||
onChange={(e) => setCount(e.target.value)}
|
||||
title="Packet count"
|
||||
/>
|
||||
{interfaces.length > 0 && (
|
||||
<select className="input w-44" value={iface} onChange={(e) => setIface(e.target.value)}>
|
||||
<option value="">Any interface</option>
|
||||
{interfaces.map((i) => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<RunButton loading={loading} onClick={run} />
|
||||
</div>
|
||||
|
||||
{error && <ErrorBox message={error} />}
|
||||
|
||||
{results && (
|
||||
<div className="space-y-3">
|
||||
<ResultTable
|
||||
headers={['Seq', 'Host', 'Time', 'TTL', 'Size', 'Status']}
|
||||
rows={packets.map((r) => [
|
||||
r['seq'] ?? null,
|
||||
r['host'] ?? null,
|
||||
r['time'] ? `${r['time']}ms` : null,
|
||||
r['ttl'] ?? null,
|
||||
r['size'] ? `${r['size']}B` : null,
|
||||
r['status'] || (r['time'] ? 'OK' : 'timeout'),
|
||||
])}
|
||||
/>
|
||||
{summary && (
|
||||
<div className="flex flex-wrap gap-4 p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg text-xs font-mono text-gray-600 dark:text-slate-400">
|
||||
<span>Sent: <b className="text-gray-900 dark:text-white">{summary['sent']}</b></span>
|
||||
<span>Received: <b className="text-gray-900 dark:text-white">{summary['received']}</b></span>
|
||||
<span>Loss: <b className={clsx(
|
||||
summary['packet-loss'] === '0%' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
)}>{summary['packet-loss']}</b></span>
|
||||
{summary['avg-rtt'] && <span>Avg RTT: <b className="text-gray-900 dark:text-white">{summary['avg-rtt']}ms</b></span>}
|
||||
{summary['min-rtt'] && <span>Min: <b className="text-gray-900 dark:text-white">{summary['min-rtt']}ms</b></span>}
|
||||
{summary['max-rtt'] && <span>Max: <b className="text-gray-900 dark:text-white">{summary['max-rtt']}ms</b></span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Traceroute ───────────────────────────────────────────────────────────────
|
||||
|
||||
function TracerouteTool({ deviceId }: { deviceId: number }) {
|
||||
const [address, setAddress] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<Record<string, string>[] | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!address) return;
|
||||
setLoading(true); setError(''); setResults(null);
|
||||
try {
|
||||
const { data } = await deviceToolsApi.traceroute(deviceId, { address });
|
||||
setResults(data);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'Traceroute failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="IP address or hostname"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && run()}
|
||||
/>
|
||||
<RunButton loading={loading} onClick={run} />
|
||||
</div>
|
||||
{loading && (
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">Tracing route — this may take up to 60 seconds…</p>
|
||||
)}
|
||||
{error && <ErrorBox message={error} />}
|
||||
{results && (
|
||||
<ResultTable
|
||||
headers={['Hop', 'Address', 'Status', 'Loss', 'Sent', 'Last', 'Avg', 'Best', 'Worst']}
|
||||
rows={results.map((r, i) => [
|
||||
String(i + 1),
|
||||
r['address'] || r['host'] || '* * *',
|
||||
r['status'] || '',
|
||||
r['loss'] ?? null,
|
||||
r['sent'] ?? null,
|
||||
r['last'] ? `${r['last']}ms` : null,
|
||||
r['avg'] ? `${r['avg']}ms` : null,
|
||||
r['best'] ? `${r['best']}ms` : null,
|
||||
r['worst'] ? `${r['worst']}ms` : null,
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── IP Scan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function IpScanTool({ deviceId, interfaces }: { deviceId: number; interfaces: string[] }) {
|
||||
const [addressRange, setAddressRange] = useState('');
|
||||
const [iface, setIface] = useState(interfaces[0] || '');
|
||||
const [rdns, setRdns] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<Record<string, string>[] | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!addressRange || !iface) return;
|
||||
setLoading(true); setError(''); setResults(null);
|
||||
try {
|
||||
const { data } = await deviceToolsApi.ipScan(deviceId, { addressRange, interface: iface, rdns });
|
||||
setResults(data);
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'IP scan failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
className="input flex-1 min-w-48"
|
||||
placeholder="Address range (e.g. 192.168.1.0/24)"
|
||||
value={addressRange}
|
||||
onChange={(e) => setAddressRange(e.target.value)}
|
||||
/>
|
||||
<select className="input w-44" value={iface} onChange={(e) => setIface(e.target.value)}>
|
||||
<option value="">Select interface…</option>
|
||||
{interfaces.map((i) => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rdns}
|
||||
onChange={(e) => setRdns(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-300 dark:border-slate-600 text-blue-600 cursor-pointer"
|
||||
/>
|
||||
Reverse DNS
|
||||
</label>
|
||||
<RunButton loading={loading} onClick={run} label="Scan" />
|
||||
</div>
|
||||
{loading && (
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">
|
||||
Scanning{rdns ? ' + resolving hostnames' : ''} — results appear when scan completes…
|
||||
</p>
|
||||
)}
|
||||
{error && <ErrorBox message={error} />}
|
||||
{results && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">{results.length} host{results.length !== 1 ? 's' : ''} found</p>
|
||||
<ResultTable
|
||||
headers={rdns ? ['IP Address', 'Hostname', 'MAC Address'] : ['IP Address', 'MAC Address']}
|
||||
rows={results.map((r) =>
|
||||
rdns
|
||||
? [r['address'] ?? null, r['hostname'] || '—', r['mac-address'] ?? null]
|
||||
: [r['address'] ?? null, r['mac-address'] ?? null]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Wake-on-LAN ──────────────────────────────────────────────────────────────
|
||||
|
||||
function WolTool({ deviceId, interfaces }: { deviceId: number; interfaces: string[] }) {
|
||||
const [mac, setMac] = useState('');
|
||||
const [iface, setIface] = useState(interfaces[0] || '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!mac || !iface) return;
|
||||
setLoading(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const { data } = await deviceToolsApi.wol(deviceId, { mac, interface: iface });
|
||||
setSuccess(data.message || 'Magic packet sent successfully');
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'WoL failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
className="input flex-1 min-w-48"
|
||||
placeholder="MAC address (e.g. AA:BB:CC:DD:EE:FF)"
|
||||
value={mac}
|
||||
onChange={(e) => setMac(e.target.value)}
|
||||
/>
|
||||
<select className="input w-44" value={iface} onChange={(e) => setIface(e.target.value)}>
|
||||
<option value="">Select interface…</option>
|
||||
{interfaces.map((i) => <option key={i} value={i}>{i}</option>)}
|
||||
</select>
|
||||
<RunButton loading={loading} onClick={run} label="Send Packet" />
|
||||
</div>
|
||||
{error && <ErrorBox message={error} />}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg text-sm text-green-700 dark:text-green-300">
|
||||
<CheckCircle className="w-4 h-4 shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Reboot ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function RebootSection({ deviceId }: { deviceId: number }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const run = async () => {
|
||||
if (!confirm('Are you sure you want to reboot this device? It will be temporarily unreachable.')) return;
|
||||
setLoading(true); setError(''); setSuccess('');
|
||||
try {
|
||||
const { data } = await devicesApi.reboot(deviceId);
|
||||
setSuccess(data.message || 'Reboot command sent.');
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || (e as Error).message || 'Reboot failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<RotateCcw className="w-4 h-4 text-orange-500 shrink-0" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Reboot Device</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Sends a reboot command to the device. It will be briefly unreachable while restarting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={run}
|
||||
disabled={loading}
|
||||
className="btn-secondary flex items-center gap-2 shrink-0 text-orange-600 dark:text-orange-400 border-orange-200 dark:border-orange-800 hover:bg-orange-50 dark:hover:bg-orange-900/20"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RotateCcw className="w-4 h-4" />}
|
||||
{loading ? 'Rebooting…' : 'Reboot'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="mt-3"><ErrorBox message={error} /></div>}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 mt-3 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg text-sm text-green-700 dark:text-green-300">
|
||||
<CheckCircle className="w-4 h-4 shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ToolCard({ title, icon: Icon, children }: { title: string; icon: React.FC<{ className?: string }>; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Icon className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ToolsTab({ deviceId }: { deviceId: number }) {
|
||||
const canWrite = useCanWrite();
|
||||
const { data: ifaceData = [] } = useQuery({
|
||||
queryKey: ['interfaces', deviceId],
|
||||
queryFn: () => devicesApi.getInterfaces(deviceId).then((r) => r.data),
|
||||
});
|
||||
|
||||
const interfaceNames = ifaceData.map((i: { name: string }) => i.name).filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{canWrite && <RebootSection deviceId={deviceId} />}
|
||||
|
||||
<ToolCard title="Ping" icon={({ className }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
|
||||
<path d="M5.636 5.636l2.122 2.122M16.243 16.243l2.121 2.121M5.636 18.364l2.122-2.122M16.243 7.757l2.121-2.121" />
|
||||
</svg>
|
||||
)}>
|
||||
<PingTool deviceId={deviceId} interfaces={interfaceNames} />
|
||||
</ToolCard>
|
||||
|
||||
<ToolCard title="Traceroute" icon={({ className }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||
</svg>
|
||||
)}>
|
||||
<TracerouteTool deviceId={deviceId} />
|
||||
</ToolCard>
|
||||
|
||||
<ToolCard title="IP Scan" icon={({ className }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8M12 17v4" />
|
||||
<circle cx="12" cy="10" r="3" /><path d="M6 10h.01M18 10h.01" />
|
||||
</svg>
|
||||
)}>
|
||||
<IpScanTool deviceId={deviceId} interfaces={interfaceNames} />
|
||||
</ToolCard>
|
||||
|
||||
{canWrite && (
|
||||
<ToolCard title="Wake-on-LAN" icon={Wifi}>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 mb-3">
|
||||
Sends a WoL magic packet from this MikroTik device to wake a sleeping host on the network.
|
||||
</p>
|
||||
<WolTool deviceId={deviceId} interfaces={interfaceNames} />
|
||||
</ToolCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, AlertCircle, RefreshCw, Pencil, Check, X, Network } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
import type { Vlan, SwitchPort } from '../../types';
|
||||
import clsx from 'clsx';
|
||||
import { useCanWrite } from '../../hooks/useCanWrite';
|
||||
|
||||
interface PortForm {
|
||||
tagged_ports: string;
|
||||
untagged_ports: string;
|
||||
}
|
||||
|
||||
export default function VlansTab({ deviceId, onGoToPorts }: { deviceId: number; onGoToPorts?: (bridgeName: string) => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
const { data: vlans = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['vlans', deviceId],
|
||||
queryFn: () => devicesApi.getVlans(deviceId).then((r) => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: portsData } = useQuery({
|
||||
queryKey: ['ports', deviceId],
|
||||
queryFn: () => devicesApi.getPorts(deviceId).then((r) => r.data),
|
||||
});
|
||||
const ports: SwitchPort[] = portsData?.ports ?? [];
|
||||
|
||||
// Find bridges where vlan-filtering is not enabled (stored as 'yes' from RouterOS or 'true' after toggle)
|
||||
const bridgesWithoutFiltering = ports.filter(
|
||||
(p) => p.type === 'bridge' && p.config_json?.['vlan-filtering'] !== 'yes' && p.config_json?.['vlan-filtering'] !== 'true'
|
||||
);
|
||||
|
||||
// ── Add form ──
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ vlan_id: '', bridge: '', tagged_ports: '', untagged_ports: '' });
|
||||
const [addError, setAddError] = useState('');
|
||||
|
||||
// ── Edit form ──
|
||||
const [editingVlan, setEditingVlan] = useState<Vlan | null>(null);
|
||||
const [editForm, setEditForm] = useState<PortForm>({ tagged_ports: '', untagged_ports: '' });
|
||||
const [editError, setEditError] = useState('');
|
||||
|
||||
// ── Delete error ──
|
||||
const [deleteError, setDeleteError] = useState('');
|
||||
|
||||
const bridgeOptions = [...new Set((vlans as Vlan[]).map((v) => v.bridge).filter(Boolean))];
|
||||
|
||||
// ── Mutations ──
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const tagged = addForm.tagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const untagged = addForm.untagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return devicesApi.addVlan(deviceId, {
|
||||
bridge: addForm.bridge,
|
||||
vlan_id: parseInt(addForm.vlan_id, 10),
|
||||
tagged_ports: tagged,
|
||||
untagged_ports: untagged,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
|
||||
setAddForm({ vlan_id: '', bridge: bridgeOptions[0] || '', tagged_ports: '', untagged_ports: '' });
|
||||
setShowAdd(false);
|
||||
setAddError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setAddError(msg || 'Failed to add VLAN');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!editingVlan) throw new Error('No VLAN selected');
|
||||
const tagged = editForm.tagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const untagged = editForm.untagged_ports.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
return devicesApi.updateVlan(deviceId, editingVlan.id, {
|
||||
tagged_ports: tagged,
|
||||
untagged_ports: untagged,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
|
||||
setEditingVlan(null);
|
||||
setEditError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setEditError(msg || 'Failed to update VLAN');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (vlanDbId: number) => devicesApi.deleteVlan(deviceId, vlanDbId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vlans', deviceId] });
|
||||
setDeleteError('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setDeleteError(msg || 'Failed to delete VLAN');
|
||||
},
|
||||
});
|
||||
|
||||
const openEdit = (vlan: Vlan) => {
|
||||
setEditingVlan(vlan);
|
||||
setEditError('');
|
||||
setShowAdd(false);
|
||||
setEditForm({
|
||||
tagged_ports: (vlan.tagged_ports ?? []).join(', '),
|
||||
untagged_ports: (vlan.untagged_ports ?? []).join(', '),
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="text-center py-8 text-gray-400">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* VLAN Filtering warning */}
|
||||
{bridgesWithoutFiltering.length > 0 && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 px-4 py-3">
|
||||
<Network className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 text-sm text-amber-800 dark:text-amber-300">
|
||||
<span className="font-semibold">VLAN Filtering is not enabled</span> on the Bridge interface
|
||||
{bridgesWithoutFiltering.length > 1
|
||||
? ` (${bridgesWithoutFiltering.map((b) => b.name).join(', ')})`
|
||||
: ` (${bridgesWithoutFiltering[0].name})`}.
|
||||
{' '}VLAN functionality is disabled — tagging and untagging rules will not be enforced.{' '}
|
||||
{onGoToPorts && (
|
||||
<button
|
||||
onClick={() => onGoToPorts(bridgesWithoutFiltering[0].name)}
|
||||
className="inline-flex items-center gap-1 font-semibold underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-200 transition-colors"
|
||||
>
|
||||
Enable VLAN Filtering on the Bridge interface →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{deleteError && (
|
||||
<span className="text-sm text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-4 h-4" />{deleteError}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</button>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAdd(!showAdd);
|
||||
setEditingVlan(null);
|
||||
setAddError('');
|
||||
setAddForm({ vlan_id: '', bridge: bridgeOptions[0] || '', tagged_ports: '', untagged_ports: '' });
|
||||
}}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add VLAN
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add VLAN form */}
|
||||
{canWrite && showAdd && (
|
||||
<div className="card p-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">New Bridge VLAN</h4>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
<label className="label">VLAN ID (1–4094)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
value={addForm.vlan_id}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, vlan_id: e.target.value }))}
|
||||
min={1} max={4094}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Bridge</label>
|
||||
{bridgeOptions.length > 0 ? (
|
||||
<select
|
||||
className="input"
|
||||
value={addForm.bridge}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, bridge: e.target.value }))}
|
||||
>
|
||||
{bridgeOptions.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={addForm.bridge}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, bridge: e.target.value }))}
|
||||
placeholder="bridge1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Tagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={addForm.tagged_ports}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, tagged_ports: e.target.value }))}
|
||||
placeholder="ether1, ether2"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Untagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={addForm.untagged_ports}
|
||||
onChange={(e) => setAddForm((f) => ({ ...f, untagged_ports: e.target.value }))}
|
||||
placeholder="ether3"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated</p>
|
||||
</div>
|
||||
</div>
|
||||
{addError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500">
|
||||
<AlertCircle className="w-4 h-4" /> {addError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => addMutation.mutate()}
|
||||
disabled={!addForm.vlan_id || !addForm.bridge || addMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
{addMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||
Add VLAN
|
||||
</button>
|
||||
<button onClick={() => setShowAdd(false)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit VLAN panel */}
|
||||
{canWrite && editingVlan && (
|
||||
<div className="card p-4 space-y-3 border-blue-300 dark:border-blue-600 border-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Edit VLAN{' '}
|
||||
<span className="font-mono text-blue-600 dark:text-blue-400">{editingVlan.vlan_id}</span>
|
||||
{editingVlan.bridge && (
|
||||
<span className="text-gray-400 dark:text-slate-500 font-normal"> on {editingVlan.bridge}</span>
|
||||
)}
|
||||
</h4>
|
||||
<button onClick={() => setEditingVlan(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label">Tagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={editForm.tagged_ports}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, tagged_ports: e.target.value }))}
|
||||
placeholder="ether1, ether2"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated interface names</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Untagged Ports</label>
|
||||
<input
|
||||
className="input font-mono"
|
||||
value={editForm.untagged_ports}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, untagged_ports: e.target.value }))}
|
||||
placeholder="ether3"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Comma-separated interface names</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-500">
|
||||
<AlertCircle className="w-4 h-4" /> {editError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => updateMutation.mutate()}
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn-primary flex items-center gap-2 text-sm"
|
||||
>
|
||||
{updateMutation.isPending
|
||||
? <RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Check className="w-3.5 h-3.5" />}
|
||||
Save Changes
|
||||
</button>
|
||||
<button onClick={() => setEditingVlan(null)} className="btn-secondary text-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VLANs table */}
|
||||
<div className="card overflow-hidden">
|
||||
{(vlans as Vlan[]).length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 dark:text-slate-500">
|
||||
No VLANs configured on this device, or device not synced yet.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">VLAN ID</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Name</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Bridge</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Tagged Ports</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Untagged Ports</th>
|
||||
{canWrite && <th className="table-header px-4 py-2.5 w-16" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{(vlans as Vlan[]).map((vlan) => (
|
||||
<tr
|
||||
key={vlan.id}
|
||||
className={clsx(
|
||||
'hover:bg-gray-50 dark:hover:bg-slate-700/30',
|
||||
editingVlan?.id === vlan.id && 'bg-blue-50 dark:bg-blue-900/10'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-2.5 font-mono font-bold text-blue-600 dark:text-blue-400">
|
||||
{vlan.vlan_id}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{vlan.name || `VLAN ${vlan.vlan_id}`}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-500 dark:text-slate-400">
|
||||
{vlan.bridge || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(vlan.tagged_ports ?? []).map((p) => (
|
||||
<span key={p} className="px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 text-xs rounded font-mono">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{!(vlan.tagged_ports?.length) && '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(vlan.untagged_ports ?? []).map((p) => (
|
||||
<span key={p} className="px-1.5 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 text-xs rounded font-mono">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
{!(vlan.untagged_ports?.length) && '—'}
|
||||
</div>
|
||||
</td>
|
||||
{canWrite && (
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => openEdit(vlan)}
|
||||
className={clsx(
|
||||
'p-1 rounded transition-colors',
|
||||
editingVlan?.id === vlan.id
|
||||
? 'text-blue-500 bg-blue-100 dark:bg-blue-900/30'
|
||||
: 'text-gray-400 hover:text-blue-500 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
title="Edit VLAN"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Remove VLAN ${vlan.vlan_id} from ${vlan.bridge}?`)) {
|
||||
deleteMutation.mutate(vlan.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="p-1 rounded text-gray-400 hover:text-red-500 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title="Delete VLAN"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { X, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { devicesApi } from '../../services/api';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
prefill?: { name?: string; ip_address?: string };
|
||||
}
|
||||
|
||||
export default function AddDeviceModal({ onClose, onSuccess, prefill }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
name: prefill?.name || '',
|
||||
ip_address: prefill?.ip_address || '',
|
||||
api_port: '8728',
|
||||
api_username: 'admin',
|
||||
api_password: '',
|
||||
ssh_port: '22',
|
||||
ssh_username: '',
|
||||
ssh_password: '',
|
||||
device_type: 'router',
|
||||
notes: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const set = (k: string, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name || !form.ip_address || !form.api_username || !form.api_password) {
|
||||
setError('Name, IP address, username, and password are required');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await devicesApi.create({
|
||||
...form,
|
||||
api_port: parseInt(form.api_port),
|
||||
ssh_port: parseInt(form.ssh_port),
|
||||
device_type: form.device_type as import('../../types').DeviceType,
|
||||
});
|
||||
onSuccess();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setError(msg || 'Failed to add device');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="card w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Add Device</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{/* Basic info */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2">
|
||||
<label className="label">Device Name *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.name}
|
||||
onChange={(e) => set('name', e.target.value)}
|
||||
placeholder="e.g. Core Switch"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">IP Address *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.ip_address}
|
||||
onChange={(e) => set('ip_address', e.target.value)}
|
||||
placeholder="192.168.1.1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Device Type</label>
|
||||
<select
|
||||
className="input"
|
||||
value={form.device_type}
|
||||
onChange={(e) => set('device_type', e.target.value)}
|
||||
>
|
||||
<option value="router">Router</option>
|
||||
<option value="switch">Switch</option>
|
||||
<option value="wireless_ap">Wireless AP</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API credentials */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider mb-3">
|
||||
RouterOS API Credentials
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Username *</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.api_username}
|
||||
onChange={(e) => set('api_username', e.target.value)}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">API Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.api_port}
|
||||
onChange={(e) => set('api_port', e.target.value)}
|
||||
placeholder="8728"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">Password *</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.api_password}
|
||||
onChange={(e) => set('api_password', e.target.value)}
|
||||
placeholder="RouterOS API password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSH credentials (optional) */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider select-none">
|
||||
SSH Credentials (optional, for backup/restore)
|
||||
</summary>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">SSH Username</label>
|
||||
<input
|
||||
className="input"
|
||||
value={form.ssh_username}
|
||||
onChange={(e) => set('ssh_username', e.target.value)}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">SSH Port</label>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.ssh_port}
|
||||
onChange={(e) => set('ssh_port', e.target.value)}
|
||||
placeholder="22"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="label">SSH Password</label>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={form.ssh_password}
|
||||
onChange={(e) => set('ssh_password', e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div>
|
||||
<label className="label">Notes (optional)</label>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
placeholder="Any notes about this device..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<Loader2 className="w-4 h-4 text-blue-500 animate-spin flex-shrink-0" />
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
Testing connection and collecting device data...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={loading} className="btn-primary flex items-center gap-2">
|
||||
{loading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
)}
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
import TopBar from './TopBar';
|
||||
|
||||
export default function AppLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-slate-900 overflow-hidden">
|
||||
{/* Mobile backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sidebar mobileOpen={sidebarOpen} onMobileClose={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<TopBar onMenuClick={() => setSidebarOpen((o) => !o)} />
|
||||
<main className="flex-1 overflow-auto p-3 sm:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Router, Wifi, AlertTriangle, X } from 'lucide-react';
|
||||
import { searchApi } from '../../services/api';
|
||||
import { format } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type SearchDevice = {
|
||||
id: number; name: string; ip_address: string;
|
||||
model?: string; device_type: string; status: string;
|
||||
};
|
||||
type SearchClient = {
|
||||
mac_address: string; hostname?: string; ip_address?: string;
|
||||
device_id: number; device_name?: string; active: boolean;
|
||||
};
|
||||
type SearchEvent = {
|
||||
id: number; message: string; severity: string;
|
||||
event_time: string; topic?: string; device_name?: string;
|
||||
};
|
||||
type SearchResults = { devices: SearchDevice[]; clients: SearchClient[]; events: SearchEvent[] };
|
||||
|
||||
const SEVERITY_ICON_CLS: Record<string, string> = {
|
||||
error: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
critical: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
|
||||
warning: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400',
|
||||
info: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
|
||||
};
|
||||
|
||||
export default function GlobalSearch() {
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResults | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlighted, setHighlighted] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Debounce the query input
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQuery(query), 280);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
// Fetch when debounced query is long enough
|
||||
useEffect(() => {
|
||||
if (debouncedQuery.length < 2) { setResults(null); return; }
|
||||
setLoading(true);
|
||||
searchApi.search(debouncedQuery)
|
||||
.then((r) => { setResults(r.data); setHighlighted(0); })
|
||||
.catch(() => setResults(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [debouncedQuery]);
|
||||
|
||||
// Ctrl+K / Cmd+K global shortcut
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
// Flat list for keyboard navigation index tracking
|
||||
const deviceCount = results?.devices.length ?? 0;
|
||||
const clientCount = results?.clients.length ?? 0;
|
||||
const eventCount = results?.events.length ?? 0;
|
||||
const totalCount = deviceCount + clientCount + eventCount;
|
||||
|
||||
const goToResult = useCallback((kind: 'device' | 'client' | 'event', id: string | number) => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
setResults(null);
|
||||
if (kind === 'device') navigate(`/devices/${id}`);
|
||||
else if (kind === 'client') navigate('/clients');
|
||||
else navigate('/events');
|
||||
}, [navigate]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!open || totalCount === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlighted((h) => (h + 1) % totalCount);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlighted((h) => (h - 1 + totalCount) % totalCount);
|
||||
} else if (e.key === 'Enter' && results) {
|
||||
e.preventDefault();
|
||||
if (highlighted < deviceCount) {
|
||||
goToResult('device', results.devices[highlighted].id);
|
||||
} else if (highlighted < deviceCount + clientCount) {
|
||||
goToResult('client', results.clients[highlighted - deviceCount].mac_address);
|
||||
} else {
|
||||
goToResult('event', results.events[highlighted - deviceCount - clientCount].id);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
const hasResults = totalCount > 0;
|
||||
const showDropdown = open && query.length >= 2;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex-1 max-w-lg">
|
||||
{/* Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-slate-500 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search devices, clients, events… (Ctrl+K)"
|
||||
className="w-full pl-9 pr-8 py-1.5 text-sm bg-gray-100 dark:bg-slate-700/80 border border-transparent focus:border-blue-500 focus:bg-white dark:focus:bg-slate-800 rounded-lg outline-none transition-colors text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-slate-500"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => { setQuery(''); setResults(null); inputRef.current?.focus(); }}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1.5 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl shadow-2xl z-50 overflow-hidden max-h-[500px] flex flex-col">
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{loading && (
|
||||
<div className="px-4 py-6 text-sm text-center text-gray-400 dark:text-slate-500">
|
||||
Searching…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !hasResults && (
|
||||
<div className="px-4 py-8 text-sm text-center text-gray-400 dark:text-slate-500">
|
||||
No results for <span className="font-medium text-gray-600 dark:text-slate-300">"{query}"</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && hasResults && results && (
|
||||
<>
|
||||
{/* ── Devices ── */}
|
||||
{results.devices.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pt-3 pb-1.5 text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
Devices
|
||||
</div>
|
||||
{results.devices.map((d, i) => (
|
||||
<button
|
||||
key={d.id}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
|
||||
highlighted === i
|
||||
? 'bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50'
|
||||
)}
|
||||
onMouseEnter={() => setHighlighted(i)}
|
||||
onClick={() => goToResult('device', d.id)}
|
||||
>
|
||||
<div className={clsx(
|
||||
'w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||
d.status === 'online'
|
||||
? 'bg-green-100 dark:bg-green-900/30'
|
||||
: 'bg-gray-100 dark:bg-slate-700'
|
||||
)}>
|
||||
<Router className={clsx(
|
||||
'w-3.5 h-3.5',
|
||||
d.status === 'online' ? 'text-green-600 dark:text-green-400' : 'text-gray-400 dark:text-slate-500'
|
||||
)} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{d.name}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 font-mono">
|
||||
{d.ip_address}{d.model ? ` · ${d.model}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'text-xs px-1.5 py-0.5 rounded font-medium flex-shrink-0',
|
||||
d.status === 'online'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-slate-700 dark:text-slate-400'
|
||||
)}>
|
||||
{d.status}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Clients ── */}
|
||||
{results.clients.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pt-3 pb-1.5 text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
Clients
|
||||
</div>
|
||||
{results.clients.map((c, i) => (
|
||||
<button
|
||||
key={c.mac_address}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
|
||||
highlighted === deviceCount + i
|
||||
? 'bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50'
|
||||
)}
|
||||
onMouseEnter={() => setHighlighted(deviceCount + i)}
|
||||
onClick={() => goToResult('client', c.mac_address)}
|
||||
>
|
||||
<div className="w-7 h-7 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<Wifi className="w-3.5 h-3.5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{c.hostname || c.mac_address}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 font-mono">
|
||||
{c.hostname ? `${c.mac_address} · ` : ''}
|
||||
{c.ip_address ? `${c.ip_address} · ` : ''}
|
||||
{c.device_name || ''}
|
||||
</div>
|
||||
</div>
|
||||
{!c.active && (
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500 flex-shrink-0">inactive</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Events ── */}
|
||||
{results.events.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pt-3 pb-1.5 text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider">
|
||||
Events
|
||||
</div>
|
||||
{results.events.map((ev, i) => (
|
||||
<button
|
||||
key={ev.id}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors',
|
||||
highlighted === deviceCount + clientCount + i
|
||||
? 'bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-slate-700/50'
|
||||
)}
|
||||
onMouseEnter={() => setHighlighted(deviceCount + clientCount + i)}
|
||||
onClick={() => goToResult('event', ev.id)}
|
||||
>
|
||||
<div className={clsx(
|
||||
'w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||
SEVERITY_ICON_CLS[ev.severity] || SEVERITY_ICON_CLS.info
|
||||
)}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">{ev.message}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">
|
||||
{ev.device_name ? `${ev.device_name} · ` : ''}
|
||||
{format(new Date(ev.event_time), 'MMM d, HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'text-xs px-1.5 py-0.5 rounded font-medium flex-shrink-0',
|
||||
SEVERITY_ICON_CLS[ev.severity] || SEVERITY_ICON_CLS.info
|
||||
)}>
|
||||
{ev.severity}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer keyboard hints */}
|
||||
{hasResults && (
|
||||
<div className="flex-shrink-0 px-3 py-2 border-t border-gray-100 dark:border-slate-700 flex items-center gap-4 text-xs text-gray-400 dark:text-slate-500">
|
||||
<span><kbd className="px-1 py-0.5 bg-gray-100 dark:bg-slate-700 rounded text-xs font-sans">↑↓</kbd> navigate</span>
|
||||
<span><kbd className="px-1 py-0.5 bg-gray-100 dark:bg-slate-700 rounded text-xs font-sans">↵</kbd> open</span>
|
||||
<span><kbd className="px-1 py-0.5 bg-gray-100 dark:bg-slate-700 rounded text-xs font-sans">Esc</kbd> close</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive,
|
||||
Settings, Network, ChevronLeft, ChevronRight, Layers, ChevronDown, SlidersHorizontal, X, Wifi,
|
||||
Server, Globe, Clock, Shield,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { APP_VERSION } from '../../version';
|
||||
import { devicesApi } from '../../services/api';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/devices', icon: Router, label: 'Devices' },
|
||||
{ to: '/clients', icon: Users, label: 'Clients' },
|
||||
{ to: '/events', icon: Bell, label: 'Events' },
|
||||
{ to: '/topology', icon: GitBranch, label: 'Topology' },
|
||||
{ to: '/backups', icon: HardDrive, label: 'Backups' },
|
||||
];
|
||||
|
||||
const switchSubItems = [
|
||||
{ to: '/switches', icon: Layers, label: 'Overview' },
|
||||
{ to: '/switches/settings', icon: SlidersHorizontal, label: 'Settings' },
|
||||
];
|
||||
|
||||
const routerSubItems = [
|
||||
{ to: '/routers', icon: Router, label: 'Overview' },
|
||||
{ to: '/routers/settings', icon: SlidersHorizontal, label: 'Settings' },
|
||||
];
|
||||
|
||||
const wirelessSubItems = [
|
||||
{ to: '/wireless', icon: Wifi, label: 'Overview' },
|
||||
{ to: '/wireless/clients', icon: Users, label: 'Clients' },
|
||||
{ to: '/wireless/settings', icon: SlidersHorizontal, label: 'Settings' },
|
||||
];
|
||||
|
||||
const networkServicesSubItems = [
|
||||
{ to: '/network-services', icon: Network, label: 'Overview' },
|
||||
{ to: '/network-services/dhcp', icon: Server, label: 'DHCP' },
|
||||
{ to: '/network-services/dns', icon: Globe, label: 'DNS' },
|
||||
{ to: '/network-services/ntp', icon: Clock, label: 'NTP' },
|
||||
{ to: '/network-services/wireguard', icon: Shield, label: 'WireGuard' },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
mobileOpen: boolean;
|
||||
onMobileClose: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const { data: devices = [] } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: () => devicesApi.list().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const hasSwitches = devices.some(d => d.device_type === 'switch');
|
||||
const hasRouters = devices.some(d => d.device_type === 'router');
|
||||
const hasWireless = devices.some(d => d.device_type === 'wireless_ap');
|
||||
|
||||
const switchesActive = location.pathname.startsWith('/switches');
|
||||
const [switchesOpen, setSwitchesOpen] = useState(switchesActive);
|
||||
const effectiveSwitchesOpen = switchesOpen || switchesActive;
|
||||
|
||||
const routersActive = location.pathname.startsWith('/routers');
|
||||
const [routersOpen, setRoutersOpen] = useState(routersActive);
|
||||
const effectiveRoutersOpen = routersOpen || routersActive;
|
||||
|
||||
const wirelessActive = location.pathname.startsWith('/wireless');
|
||||
const [wirelessOpen, setWirelessOpen] = useState(wirelessActive);
|
||||
const effectiveWirelessOpen = wirelessOpen || wirelessActive;
|
||||
|
||||
const networkServicesActive = location.pathname.startsWith('/network-services');
|
||||
const [networkServicesOpen, setNetworkServicesOpen] = useState(networkServicesActive);
|
||||
const effectiveNetworkServicesOpen = networkServicesOpen || networkServicesActive;
|
||||
|
||||
// On mobile the sidebar is always expanded (never icon-only)
|
||||
const isCollapsed = collapsed;
|
||||
|
||||
const handleNavClick = () => {
|
||||
// Close mobile drawer when navigating
|
||||
onMobileClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
// Base styles
|
||||
'flex-shrink-0 bg-slate-100 dark:bg-slate-900 border-r border-gray-200 dark:border-slate-700 flex flex-col transition-all duration-200',
|
||||
// Mobile: fixed drawer that slides in/out
|
||||
'fixed inset-y-0 left-0 z-50',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
// Desktop: static in flex layout, normal collapse behaviour
|
||||
'md:relative md:translate-x-0',
|
||||
isCollapsed ? 'md:w-14' : 'md:w-56',
|
||||
// Mobile always full width
|
||||
'w-64',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center border-b border-gray-200 dark:border-slate-700 py-5',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-4 gap-3' : 'gap-3 px-4'
|
||||
)}
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Network className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
{/* Always show text on mobile; respect collapsed on desktop */}
|
||||
<div className={clsx(isCollapsed && 'md:hidden')}>
|
||||
<div className="text-sm font-bold text-gray-900 dark:text-white leading-none">Mikrotik</div>
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400 mt-0.5 leading-none">Manager</div>
|
||||
<div className="text-[10px] text-gray-400 dark:text-slate-500 mt-1 leading-none">{APP_VERSION}</div>
|
||||
</div>
|
||||
{/* Mobile close button */}
|
||||
<button
|
||||
onClick={onMobileClose}
|
||||
className="ml-auto p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-slate-300 md:hidden"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 py-3 space-y-0.5 overflow-y-auto">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
title={isCollapsed ? label : undefined}
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-3 py-2.5 gap-3' : 'gap-3 px-3 py-2.5',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className={clsx(isCollapsed && 'md:hidden')}>{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* Switches group */}
|
||||
{hasSwitches && (
|
||||
isCollapsed ? (
|
||||
<NavLink
|
||||
to="/switches"
|
||||
title="Switches"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
isActive || switchesActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Layers className="w-4 h-4 flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setSwitchesOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
switchesActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Layers className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Switches</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveSwitchesOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
{effectiveSwitchesOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{switchSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Routers group */}
|
||||
{hasRouters && (
|
||||
isCollapsed ? (
|
||||
<NavLink
|
||||
to="/routers"
|
||||
title="Routers"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
isActive || routersActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Router className="w-4 h-4 flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setRoutersOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
routersActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Router className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Routers</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveRoutersOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
{effectiveRoutersOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{routerSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Wireless group */}
|
||||
{hasWireless && (
|
||||
isCollapsed ? (
|
||||
<NavLink
|
||||
to="/wireless"
|
||||
title="Wireless"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
isActive || wirelessActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Wifi className="w-4 h-4 flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setWirelessOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
wirelessActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Wifi className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Wireless</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveWirelessOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
{effectiveWirelessOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{wirelessSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{/* Network Services group — always shown */}
|
||||
{isCollapsed ? (
|
||||
<NavLink
|
||||
to="/network-services"
|
||||
title="Network Services"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
isActive || networkServicesActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Network className="w-4 h-4 flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setNetworkServicesOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
networkServicesActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Network className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Network Services</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveNetworkServicesOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
{effectiveNetworkServicesOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{networkServicesSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Settings + collapse toggle */}
|
||||
<div className="px-2 py-3 border-t border-gray-200 dark:border-slate-700 space-y-0.5">
|
||||
<NavLink
|
||||
to="/settings"
|
||||
title={isCollapsed ? 'Settings' : undefined}
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-3 py-2.5 gap-3' : 'gap-3 px-3 py-2.5',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||
<span className={clsx(isCollapsed && 'md:hidden')}>Settings</span>
|
||||
</NavLink>
|
||||
|
||||
{/* Collapse toggle — desktop only */}
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
className={clsx(
|
||||
'hidden md:flex w-full items-center rounded-lg text-sm font-medium transition-colors duration-150 text-gray-500 dark:text-slate-500 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-700 dark:hover:text-slate-300',
|
||||
isCollapsed ? 'justify-center px-2 py-2.5' : 'gap-3 px-3 py-2.5'
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<><ChevronLeft className="w-4 h-4 flex-shrink-0" />Collapse</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Sun, Moon, LogOut, User, Menu } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
|
||||
interface TopBarProps {
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
export default function TopBar({ onMenuClick }: TopBarProps) {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { theme, toggleTheme } = useThemeStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-14 flex-shrink-0 flex items-center gap-2 sm:gap-4 px-3 sm:px-6 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
|
||||
{/* Hamburger — mobile only */}
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="md:hidden p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors flex-shrink-0"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<GlobalSearch />
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0 ml-auto">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<Moon className="w-4 h-4" />
|
||||
) : (
|
||||
<Sun className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* User info — hide label on very small screens */}
|
||||
<div className="flex items-center gap-2 px-2 sm:px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-slate-700">
|
||||
<User className="w-3.5 h-3.5 text-gray-500 dark:text-slate-400 flex-shrink-0" />
|
||||
<span className="hidden sm:inline text-sm font-medium text-gray-700 dark:text-slate-200">
|
||||
{user?.username}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user