feat(security): Security Center — firewall, NAT, bandwidth, posture, connections (v0.14.0-beta)

A dedicated top-level Security section plus a Meraki/UniFi-grade firewall built
on the full RouterOS feature set.

Security Center (new /security page)
- Fleet hardening scores, per-device posture list, and a "Common Findings"
  rollup aggregating identical issues across the fleet
- "Manage" deep-links into the device's Security tab (?tab= support added)

Firewall & NAT (per device + in Security Center)
- Friendly rule builder: Allow/Drop/Reject chips, Any/Address/Address-List
  pickers, port presets, connection-state chips, logging, live plain-English
  preview
- Address lists as reusable objects, referenced from rules
- Rule reordering (/ip/firewall/{filter,nat}/move); per-rule hit counters
  (print stats) with dead-rule highlighting + reset
- NAT wizards: Port Forward / Masquerade / 1:1 NAT + Custom
- Safe-apply lockout guard: backend 409 on an unscoped input drop/reject that
  would sever management; UI confirms before forcing (detectLockoutRisk, tested)

Access & security
- Security posture audit: graduated, de-duplicated scoring that never proposes
  disabling the management service the platform connects through (no self-
  lockout); switch-aware input-firewall severity; service hardening table
- Bandwidth: simple-queue CRUD + one-click "Limit this client" on client detail
- Active connections viewer

These L3 features are available on all managed RouterOS devices (routers,
switches, APs), not gated by device type.

Backend: DeviceCollector address-list/move/stats/connections/queues/service
methods; device routes for all of the above; firewallSafety + firewallSummary
utils with unit tests (19 new).

Security: bump nodemailer 8 -> 9.0.1 to clear GHSA-p6gq-j5cr-w38f (our
createTransport/sendMail usage is unaffected by the advisory's raw-option path).

Verified live against RouterOS devices; lint/typecheck/build/jest green in both
workspaces; npm audit --audit-level=high clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-06-18 09:24:22 -07:00
co-authored by Claude Opus 4.8
parent 623e54120d
commit ee9c9ff9f7
22 changed files with 1988 additions and 656 deletions
@@ -0,0 +1,97 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { RefreshCw, Activity, Search } from 'lucide-react';
import { devicesApi } from '../../services/api';
import { formatBytes } from '../../utils/firewallSummary';
import clsx from 'clsx';
// Split a RouterOS connection address ("192.168.1.10:443") into ip + port.
function splitAddr(a?: string): { ip: string; port: string } {
if (!a) return { ip: '', port: '' };
const i = a.lastIndexOf(':');
return i > 0 ? { ip: a.slice(0, i), port: a.slice(i + 1) } : { ip: a, port: '' };
}
const PROTO_COLOR: Record<string, string> = {
tcp: 'text-blue-600 dark:text-blue-400', udp: 'text-violet-600 dark:text-violet-400',
icmp: 'text-amber-600 dark:text-amber-400',
};
export default function ConnectionsTab({ deviceId }: { deviceId: number }) {
const [search, setSearch] = useState('');
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ['connections', deviceId],
queryFn: () => devicesApi.getConnections(deviceId, 500).then(r => r.data),
refetchInterval: 15_000,
});
const all = data?.connections ?? [];
const total = data?.total ?? 0;
const q = search.trim().toLowerCase();
const rows = q ? all.filter(c => JSON.stringify(c).toLowerCase().includes(q)) : all;
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-cyan-50 dark:bg-cyan-900/20 rounded-lg flex items-center justify-center"><Activity className="w-3.5 h-3.5 text-cyan-600 dark:text-cyan-400" /></div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Active Connections <span className="text-gray-400 font-normal">({total}{total > all.length ? `, showing ${all.length}` : ''})</span>
</h3>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
<input className="input pl-8 py-1.5 text-xs w-56" placeholder="Filter IP / port / proto…" value={search} onChange={e => setSearch(e.target.value)} />
</div>
<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>
</div>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400"><RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading connections…</div>
) : rows.length === 0 ? (
<div className="card p-8 text-center text-gray-400">{all.length === 0 ? 'No active connections (connection tracking may be disabled).' : 'No connections match your filter.'}</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">Proto</th>
<th className="table-header px-3 py-2.5 text-left">Source</th>
<th className="table-header px-3 py-2.5 text-left">Destination</th>
<th className="table-header px-3 py-2.5 text-left">State</th>
<th className="table-header px-3 py-2.5 text-right">Rate ↓/↑</th>
<th className="table-header px-3 py-2.5 text-right">Bytes</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
{rows.map((c, i) => {
const src = splitAddr(c['src-address']);
const dst = splitAddr(c['dst-address']);
const proto = (c.protocol || '').toLowerCase();
const origRate = c['orig-rate'] || '0';
const replRate = c['repl-rate'] || '0';
const bytes = (c['orig-bytes'] ? Number(c['orig-bytes']) : 0) + (c['repl-bytes'] ? Number(c['repl-bytes']) : 0);
return (
<tr key={c['.id'] ?? i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
<td className={clsx('px-3 py-2 text-xs font-semibold uppercase', PROTO_COLOR[proto] ?? 'text-gray-500')}>{proto || '—'}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-600 dark:text-slate-300">{src.ip}{src.port && <span className="text-gray-400">:{src.port}</span>}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-600 dark:text-slate-300">{dst.ip}{dst.port && <span className="text-gray-400">:{dst.port}</span>}</td>
<td className="px-3 py-2 text-xs text-gray-500 dark:text-slate-400">{c['tcp-state'] || c.state || '—'}</td>
<td className="px-3 py-2 text-right text-xs text-gray-500 dark:text-slate-400 font-mono">{replRate !== '0' || origRate !== '0' ? `${replRate}/${origRate}` : '—'}</td>
<td className="px-3 py-2 text-right text-xs text-gray-500 dark:text-slate-400">{bytes > 0 ? formatBytes(bytes) : '—'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<p className="text-xs text-gray-400">Live from the device&apos;s connection tracking table, auto-refreshing every 15s.</p>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,163 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Pencil, RefreshCw, Check, X, Gauge, AlertCircle } from 'lucide-react';
import { devicesApi } from '../../services/api';
import { useCanWrite } from '../../hooks/useCanWrite';
import clsx from 'clsx';
type Row = Record<string, string> & { '.id': string };
function errMsg(err: unknown) {
return (err as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Operation failed';
}
// RouterOS simple-queue max-limit is "upload/download" (e.g. "10M/50M").
function splitLimit(v?: string): { up: string; down: string } {
if (!v) return { up: '', down: '' };
const [up, down] = v.split('/');
return { up: up || '', down: down || '' };
}
function joinLimit(up: string, down: string): string {
return `${up || '0'}/${down || '0'}`;
}
interface QForm { name: string; target: string; up: string; down: string; comment: string; disabled: boolean }
const EMPTY: QForm = { name: '', target: '', up: '10M', down: '50M', comment: '', disabled: false };
const RATE_PRESETS = ['1M', '5M', '10M', '25M', '50M', '100M'];
function QueueModal({ title, form, setForm, onSave, onClose, isPending, error }: {
title: string; form: QForm; setForm: React.Dispatch<React.SetStateAction<QForm>>;
onSave: () => void; onClose: () => void; isPending: boolean; error: string;
}) {
const set = (k: keyof QForm, v: string | boolean) => setForm(f => ({ ...f, [k]: v }));
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-md 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-4">
<div><label className="label">Name</label>
<input className="input" value={form.name} onChange={e => set('name', e.target.value)} placeholder="e.g. limit-guest-pc" /></div>
<div><label className="label">Target (IP, subnet, or interface)</label>
<input className="input font-mono" value={form.target} onChange={e => set('target', e.target.value)} placeholder="192.168.1.50 or 192.168.1.0/24" /></div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Max Upload</label>
<input className="input font-mono" value={form.up} onChange={e => set('up', e.target.value)} placeholder="10M" />
</div>
<div>
<label className="label">Max Download</label>
<input className="input font-mono" value={form.down} onChange={e => set('down', e.target.value)} placeholder="50M" />
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{RATE_PRESETS.map(r => (
<button key={r} type="button" onClick={() => set('down', r)}
className="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-slate-600 text-gray-600 dark:text-slate-300 hover:border-blue-400">↓ {r}</button>
))}
</div>
<p className="text-xs text-gray-400">Use suffixes: <span className="font-mono">k</span>, <span className="font-mono">M</span>, <span className="font-mono">G</span> (bits/sec). Leave a field as <span className="font-mono">0</span> for unlimited.</p>
<div><label className="label">Comment</label>
<input className="input" value={form.comment} onChange={e => set('comment', e.target.value)} placeholder="Optional" /></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-1">
<button onClick={onClose} className="btn-secondary">Cancel</button>
<button onClick={onSave} disabled={isPending || !form.name || !form.target} className="btn-primary flex items-center gap-2">
{isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} Save
</button>
</div>
</div>
</div>
</div>
);
}
export default function QueuesTab({ deviceId }: { deviceId: number }) {
const qc = useQueryClient();
const canWrite = useCanWrite();
const [editing, setEditing] = useState<Row | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState<QForm>(EMPTY);
const [err, setErr] = useState('');
const { data: queues = [], isLoading, refetch, isFetching } = useQuery({
queryKey: ['queues', deviceId], queryFn: () => devicesApi.getQueues(deviceId).then(r => r.data as Row[]),
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['queues', deviceId] });
const payload = (f: QForm) => ({ name: f.name, target: f.target, max_limit: joinLimit(f.up, f.down), comment: f.comment || undefined, disabled: f.disabled });
const addMut = useMutation({ mutationFn: () => devicesApi.addQueue(deviceId, payload(form)),
onSuccess: () => { invalidate(); setShowAdd(false); setErr(''); }, onError: e => setErr(errMsg(e)) });
const updMut = useMutation({ mutationFn: (id: string) => devicesApi.updateQueue(deviceId, id, payload(form)),
onSuccess: () => { invalidate(); setEditing(null); setErr(''); }, onError: e => setErr(errMsg(e)) });
const delMut = useMutation({ mutationFn: (id: string) => devicesApi.removeQueue(deviceId, id), onSuccess: invalidate });
const openEdit = (q: Row) => { const lim = splitLimit(q['max-limit']); setForm({ name: q.name ?? '', target: q.target ?? '', up: lim.up, down: lim.down, comment: q.comment ?? '', disabled: q.disabled === 'true' }); setErr(''); setEditing(q); };
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg flex items-center justify-center"><Gauge className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" /></div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Bandwidth Queues <span className="text-gray-400 font-normal">({queues.length})</span></h3>
</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={() => { setForm(EMPTY); setErr(''); setShowAdd(true); }} className="btn-primary flex items-center gap-1.5 text-xs py-1.5"><Plus className="w-3.5 h-3.5" /> Add Queue</button>}
</div>
</div>
<p className="text-xs text-gray-400">Simple queues cap upload/download for a client IP, subnet, or interface. Limit a single client quickly from its detail page.</p>
{isLoading ? <div className="text-center py-8 text-gray-400"><RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading…</div>
: queues.length === 0 ? <div className="card p-8 text-center text-gray-400">No bandwidth queues defined.</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">Name</th>
<th className="table-header px-3 py-2.5 text-left">Target</th>
<th className="table-header px-3 py-2.5 text-left">Max ↑/↓</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-20" />}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
{queues.map((q, i) => {
const disabled = q.disabled === 'true';
const lim = splitLimit(q['max-limit']);
return (
<tr key={q['.id'] ?? i} className={clsx('hover:bg-gray-50 dark:hover:bg-slate-700/30', disabled && 'opacity-40')}>
<td className="px-3 py-2 text-xs font-medium text-gray-900 dark:text-white">{q.name}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-600 dark:text-slate-300">{q.target || '—'}</td>
<td className="px-3 py-2 font-mono text-xs text-emerald-600 dark:text-emerald-400">{lim.up || '0'} / {lim.down || '0'}</td>
<td className="px-3 py-2 text-xs text-gray-400 italic max-w-[160px] truncate">{q.comment || ''}</td>
{canWrite && (
<td className="px-3 py-2">
<div className="flex items-center gap-1 justify-end">
<button onClick={() => openEdit(q)} className="p-1 rounded text-gray-400 hover:text-blue-500"><Pencil className="w-3.5 h-3.5" /></button>
<button onClick={() => { if (confirm(`Delete queue "${q.name}"?`)) delMut.mutate(q['.id']); }} className="p-1 rounded text-gray-400 hover:text-red-500"><Trash2 className="w-3.5 h-3.5" /></button>
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
{showAdd && <QueueModal title="Add Bandwidth Queue" form={form} setForm={setForm} isPending={addMut.isPending} error={err} onClose={() => setShowAdd(false)} onSave={() => addMut.mutate()} />}
{editing && <QueueModal title="Edit Bandwidth Queue" form={form} setForm={setForm} isPending={updMut.isPending} error={err} onClose={() => setEditing(null)} onSave={() => updMut.mutate(editing['.id'])} />}
</div>
);
}
@@ -0,0 +1,169 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ShieldCheck, ShieldAlert, RefreshCw, Check, AlertTriangle, Lock } from 'lucide-react';
import { devicesApi } from '../../services/api';
import type { SecurityCheck } from '../../services/api';
import { useCanWrite } from '../../hooks/useCanWrite';
import clsx from 'clsx';
type Row = Record<string, string> & { '.id': string };
const SEV_STYLE: Record<string, { ring: string; text: string; label: string }> = {
high: { ring: 'border-red-300 dark:border-red-800 bg-red-50 dark:bg-red-900/20', text: 'text-red-600 dark:text-red-400', label: 'High' },
medium: { ring: 'border-amber-300 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400', label: 'Medium' },
low: { ring: 'border-blue-300 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20', text: 'text-blue-600 dark:text-blue-400', label: 'Low' },
ok: { ring: 'border-green-300 dark:border-green-800 bg-green-50 dark:bg-green-900/20', text: 'text-green-600 dark:text-green-400', label: 'OK' },
};
function scoreColor(score: number) {
if (score >= 85) return 'text-green-600 dark:text-green-400';
if (score >= 60) return 'text-amber-600 dark:text-amber-400';
return 'text-red-600 dark:text-red-400';
}
export default function SecurityTab({ deviceId }: { deviceId: number }) {
const qc = useQueryClient();
const canWrite = useCanWrite();
const { data: posture, isLoading, refetch, isFetching } = useQuery({
queryKey: ['security-posture', deviceId],
queryFn: () => devicesApi.getSecurityPosture(deviceId).then(r => r.data),
});
const { data: servicesRaw = [] } = useQuery({
queryKey: ['services', deviceId],
queryFn: () => devicesApi.getServices(deviceId).then(r => r.data as Row[]),
});
// Some devices report duplicate /ip/service rows (e.g. api twice) — show one per name.
const services = Array.from(new Map(servicesRaw.map(s => [s.name ?? s['.id'], s])).values());
// The RouterOS service the platform connects through — never let it be
// disabled from here, or MikroTik Manager loses control of the device.
const { data: device } = useQuery({
queryKey: ['device', deviceId],
queryFn: () => devicesApi.get(deviceId).then(r => r.data),
});
const mgmtService = device?.api_port === 8729 ? 'api-ssl' : 'api';
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['security-posture', deviceId] });
qc.invalidateQueries({ queryKey: ['services', deviceId] });
};
const toggleSvc = useMutation({
mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) => devicesApi.setServiceDisabled(deviceId, id, disabled),
onSuccess: invalidate,
});
const checks: SecurityCheck[] = posture?.checks ?? [];
const score = posture?.score ?? 100;
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-green-50 dark:bg-green-900/20 rounded-lg flex items-center justify-center"><ShieldCheck className="w-3.5 h-3.5 text-green-600 dark:text-green-400" /></div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Security Posture</h3>
</div>
<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')} /> Re-scan
</button>
</div>
{isLoading ? (
<div className="text-center py-8 text-gray-400"><RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Auditing device…</div>
) : (
<>
{/* Score + summary */}
<div className="card p-5 flex items-center gap-5">
<div className="text-center">
<div className={clsx('text-4xl font-bold', scoreColor(score))}>{score}</div>
<div className="text-xs text-gray-400 uppercase tracking-wide" title="Heuristic hardening indicator, not an absolute grade">Hardening</div>
</div>
<div className="flex-1">
{checks.length === 0 ? (
<div className="flex items-center gap-2 text-green-600 dark:text-green-400 text-sm font-medium"><ShieldCheck className="w-4 h-4" /> No issues found — this device passes all baseline checks.</div>
) : (
<div className="flex items-center gap-2 text-gray-600 dark:text-slate-300 text-sm"><ShieldAlert className="w-4 h-4 text-amber-500" /> {checks.length} issue{checks.length !== 1 ? 's' : ''} found. Review and remediate below.</div>
)}
</div>
</div>
{/* Findings */}
{checks.length > 0 && (
<div className="space-y-2">
{checks.map(c => {
const s = SEV_STYLE[c.severity] ?? SEV_STYLE.low;
return (
<div key={c.id} className={clsx('border rounded-lg p-3 flex items-start gap-3', s.ring)}>
<AlertTriangle className={clsx('w-4 h-4 flex-shrink-0 mt-0.5', s.text)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900 dark:text-white">{c.title}</span>
<span className={clsx('text-[10px] font-bold uppercase px-1.5 py-0.5 rounded', s.text)}>{s.label}</span>
</div>
<p className="text-xs text-gray-600 dark:text-slate-400 mt-0.5">{c.detail}</p>
</div>
{canWrite && c.serviceId && (
<button onClick={() => toggleSvc.mutate({ id: c.serviceId!, disabled: true })} disabled={toggleSvc.isPending}
className="btn-secondary text-xs py-1 flex items-center gap-1.5 flex-shrink-0"><Lock className="w-3 h-3" /> Disable</button>
)}
</div>
);
})}
</div>
)}
{/* IP services table */}
<div className="card overflow-hidden">
<div className="px-4 py-2.5 border-b border-gray-200 dark:border-slate-700 text-sm font-semibold text-gray-700 dark:text-slate-200">Management Services</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-3 py-2 text-left">Service</th>
<th className="table-header px-3 py-2 text-left">Port</th>
<th className="table-header px-3 py-2 text-left">Allowed From</th>
<th className="table-header px-3 py-2 text-left">Status</th>
{canWrite && <th className="table-header px-3 py-2 w-24" />}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
{services.map((s, i) => {
const enabled = s.disabled !== 'true';
const insecure = ['telnet', 'ftp', 'www'].includes(s.name ?? '') || (s.name === 'api' && mgmtService !== 'api');
const isMgmt = s.name === mgmtService;
return (
<tr key={s['.id'] ?? i} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
<td className={clsx('px-3 py-2 text-xs font-medium', enabled && insecure ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white')}>
{s.name}{isMgmt && <span className="ml-1.5 text-[10px] font-normal text-blue-500" title="MikroTik Manager connects through this service">(managed via)</span>}
</td>
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{s.port || '—'}</td>
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{s.address || 'any'}</td>
<td className="px-3 py-2">
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
enabled ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400')}>
<span className={clsx('w-1.5 h-1.5 rounded-full', enabled ? 'bg-green-500' : 'bg-gray-400')} />
{enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
{canWrite && (
<td className="px-3 py-2 text-right">
{isMgmt ? (
<span className="text-[11px] text-gray-400 flex items-center gap-1 justify-end" title="Disabling this would cut MikroTik Manager off from the device">
<Lock className="w-3 h-3" /> in use
</span>
) : (
<button onClick={() => toggleSvc.mutate({ id: s['.id'], disabled: enabled })} disabled={toggleSvc.isPending}
className="text-xs text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 flex items-center gap-1 ml-auto">
{enabled ? <><Lock className="w-3 h-3" />Disable</> : <><Check className="w-3 h-3" />Enable</>}
</button>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
);
}
@@ -15,6 +15,7 @@ const navItems = [
{ to: '/devices', icon: Router, label: 'Devices' },
{ to: '/clients', icon: Users, label: 'Clients' },
{ to: '/traffic', icon: Activity, label: 'Traffic' },
{ to: '/security', icon: Shield, label: 'Security' },
{ to: '/events', icon: Bell, label: 'Events' },
{ to: '/topology', icon: GitBranch, label: 'Topology' },
{ to: '/backups', icon: HardDrive, label: 'Backups' },