Files
MIKROTIK-RMM/frontend/src/pages/ClientsPage.tsx
T
2GT-RichandClaude Opus 4.8 9dfb8a5333 feat: Guest WiFi, network intelligence, firmware orchestration, platform automation, api-ssl (v0.16.3-beta)
Guest WiFi (Hotspot): one-click captive-portal wizard that creates the guest
SSID (virtual AP per radio), VLAN/bridge segregation + L3 VLAN interface, pool,
DHCP, portal, rate-limited guest profile, and masquerade; vouchers with limits +
printable sheets; live guest sessions; walled garden.

Network intelligence: per-client device fingerprinting (vendor/hostname →
category) with a persisted per-client override on the detail page; rogue/neighbor
AP detection from stored AP scans (evil-twin flagging); Mist-style anomaly
insights (client-count/CPU vs same-hour 14-day baseline + error bursts) in the
Operations engine.

Firmware orchestration: staged wave-based RouterOS rollouts (canary first) with
pre-upgrade backup, reboot-ride-out, version verification, halt-on-failure, and
scheduling; live per-device progress + cancel.

Platform & automation: scoped API tokens (mtm_, read/write, hashed at rest,
Bearer auth in requireAuth); outbound webhooks (12 events, HMAC-signed) fired
from the alert pipeline + firmware orchestrator; scheduled email reports.

api-ssl: RouterOSClient negotiates TLS on port 8729 and accepts RouterOS's
default self-signed certs (rejectUnauthorized:false), so SSL-secured API devices
connect without manual cert trust. Fixes #57.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:58:02 -07:00

543 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import {
Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown,
ChevronLeft, ChevronRight, RefreshCw,
} from 'lucide-react';
import { CATEGORY_META } from '../utils/clientCategories';
import { clientsApi } from '../services/api';
import type { Client } from '../types';
import { useCanWrite } from '../hooks/useCanWrite';
import { useSocket } from '../hooks/useSocket';
import { formatDistanceToNow } from 'date-fns';
import clsx from 'clsx';
const REFRESH_OPTIONS = [
{ label: 'Not Updating', value: null },
{ label: '30 seconds', value: 30_000 },
{ label: '1 minute', value: 60_000 },
{ label: '3 minutes', value: 180_000 },
] as const;
const STORAGE_KEY = 'clients-refresh-interval';
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const;
const PAGE_SIZE_KEY = 'clients-page-size';
function readStoredPageSize(): number {
const n = Number(localStorage.getItem(PAGE_SIZE_KEY));
return (PAGE_SIZE_OPTIONS as readonly number[]).includes(n) ? n : 50;
}
// Build a compact list of page numbers (1-based) with ellipses, always keeping
// the first, last, current page and its immediate neighbours visible.
function getPageList(current0: number, totalPages: number): (number | 'ellipsis')[] {
const cur = current0 + 1;
const wanted = new Set<number>([1, totalPages]);
for (let i = cur - 1; i <= cur + 1; i++) {
if (i >= 1 && i <= totalPages) wanted.add(i);
}
const sorted = Array.from(wanted).sort((a, b) => a - b);
const out: (number | 'ellipsis')[] = [];
let prev = 0;
for (const p of sorted) {
if (prev && p - prev > 1) out.push('ellipsis');
out.push(p);
prev = p;
}
return out;
}
function CategoryIcon({ category }: { category?: string }) {
const meta = category && category !== 'unknown' ? CATEGORY_META[category] : undefined;
if (!meta) return null;
const Icon = meta.icon;
return (
<span title={meta.label} className="flex-shrink-0 text-gray-400 dark:text-slate-500">
<Icon className="w-3.5 h-3.5" />
</span>
);
}
function formatDataBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function readStoredInterval(): number | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === 'null') return null;
const n = Number(raw);
return REFRESH_OPTIONS.some((o) => o.value === n) ? n : 30_000;
}
function ClientModal({
client,
onClose,
}: {
client: Client;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [name, setName] = useState(client.custom_name || '');
const mutation = useMutation({
mutationFn: () => clientsApi.updateHostname(client.mac_address, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients'] });
onClose();
},
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Edit Client</h2>
<button onClick={onClose} className="p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3 text-sm text-gray-500 dark:text-slate-400 mb-4">
<div className="flex justify-between">
<span>MAC</span>
<span className="font-mono text-gray-800 dark:text-white">{client.mac_address}</span>
</div>
{client.vendor && (
<div className="flex justify-between">
<span>Vendor</span>
<span className="text-gray-800 dark:text-white">{client.vendor}</span>
</div>
)}
{client.ip_address && (
<div className="flex justify-between">
<span>IP</span>
<span className="font-mono text-gray-800 dark:text-white">{client.ip_address}</span>
</div>
)}
{client.interface_name && (
<div className="flex justify-between">
<span>Port</span>
<span className="font-mono text-gray-800 dark:text-white">{client.interface_name}</span>
</div>
)}
{client.hostname && (
<div className="flex justify-between">
<span>Discovered Hostname</span>
<span className="font-mono text-gray-800 dark:text-white">{client.hostname}</span>
</div>
)}
</div>
<label className="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1">
Custom Name
</label>
<input
type="text"
className="input w-full mb-1"
placeholder={client.hostname || 'e.g. Office Printer'}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') mutation.mutate(); }}
autoFocus
/>
<p className="text-xs text-gray-400 dark:text-slate-500 mb-4">
Overrides the auto-discovered hostname. Persists even when the client is offline.
</p>
<div className="flex justify-end gap-2">
<button onClick={onClose} className="btn-secondary">Cancel</button>
<button
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className="btn-primary"
>
{mutation.isPending ? 'Saving…' : 'Save'}
</button>
</div>
{mutation.isError && (
<p className="mt-2 text-xs text-red-500">Failed to save name.</p>
)}
</div>
</div>
);
}
export default function ClientsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const canWrite = useCanWrite();
const [search, setSearch] = useState('');
const [showAll, setShowAll] = useState(false);
const [page, setPage] = useState(0);
const [editingClient, setEditingClient] = useState<Client | null>(null);
const [sortCol, setSortCol] = useState<string>('last_seen');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [refreshInterval, setRefreshInterval] = useState<number | null>(readStoredInterval);
const refreshIntervalRef = useRef(refreshInterval);
useEffect(() => { refreshIntervalRef.current = refreshInterval; }, [refreshInterval]);
const [pageSize, setPageSize] = useState(readStoredPageSize);
const handleIntervalChange = (val: number | null) => {
setRefreshInterval(val);
refreshIntervalRef.current = val;
localStorage.setItem(STORAGE_KEY, String(val));
};
const handlePageSizeChange = (n: number) => {
setPageSize(n);
setPage(0);
localStorage.setItem(PAGE_SIZE_KEY, String(n));
};
// Sorting is server-side (across the whole dataset, not just the current
// page), so changing the sort returns to page 1 and refetches.
const toggleSort = (col: string) => {
if (sortCol === col) setSortDir((d) => d === 'asc' ? 'desc' : 'asc');
else { setSortCol(col); setSortDir('asc'); }
setPage(0);
};
const { data, isLoading } = useQuery({
queryKey: ['clients', { search, showAll, page, pageSize, sortCol, sortDir }],
queryFn: () =>
clientsApi
.list({ search: search || undefined, active: showAll ? undefined : true, limit: pageSize, offset: page * pageSize, sort: sortCol, dir: sortDir })
.then((r) => r.data),
refetchInterval: refreshInterval ?? false,
// Keep the current rows on screen while a sort/page/search refetch is in
// flight so the table doesn't blank out and jump.
placeholderData: keepPreviousData,
});
const [purgeResult, setPurgeResult] = useState('');
const purgeMutation = useMutation({
mutationFn: () => clientsApi.purgeStale(),
onSuccess: (r) => {
queryClient.invalidateQueries({ queryKey: ['clients'] });
setPurgeResult(r.data.message);
setTimeout(() => setPurgeResult(''), 5000);
},
});
useSocket({
'clients:updated': () => {
if (refreshIntervalRef.current !== null) {
queryClient.invalidateQueries({ queryKey: ['clients'] });
}
},
});
// Server returns rows already sorted + paginated for the active sort column.
const clients = data?.clients ?? [];
const total = data?.total ?? 0;
return (
<div className="space-y-4">
{editingClient && (
<ClientModal client={editingClient} onClose={() => setEditingClient(null)} />
)}
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
Clients
{total > 0 && (
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-slate-400">
({total} {showAll ? 'total' : 'online'})
</span>
)}
</h1>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<div className="relative w-full sm:flex-1 sm:max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
className="input pl-9"
placeholder="Search hostname, MAC, IP, or vendor…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
</div>
<button
onClick={() => { setShowAll((s) => !s); setPage(0); }}
className={clsx(
'px-3 py-2 rounded-lg text-sm font-medium transition-colors',
showAll
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 border border-amber-300 dark:border-amber-700'
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300'
)}
>
{showAll ? 'Active Only' : 'Show Inactive'}
</button>
{canWrite && (
<button
onClick={() => {
if (confirm('Purge all inactive clients older than the configured retention period?')) {
purgeMutation.mutate();
}
}}
disabled={purgeMutation.isPending}
className="btn-secondary flex items-center gap-1.5 text-sm"
title="Delete inactive client records older than the retention period (set in Settings)"
>
<Trash2 className="w-4 h-4" />
Purge Stale
</button>
)}
{purgeResult && (
<span className="text-xs text-green-600 dark:text-green-400">{purgeResult}</span>
)}
<div className="flex items-center gap-1.5 ml-auto">
<RefreshCw className="w-3.5 h-3.5 text-ink-4" />
<select
value={String(refreshInterval)}
onChange={(e) => {
const raw = e.target.value;
handleIntervalChange(raw === 'null' ? null : Number(raw));
}}
className="text-sm rounded-lg border border-line bg-surface-2 text-ink px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-accent"
>
{REFRESH_OPTIONS.map((o) => (
<option key={String(o.value)} value={String(o.value)}>
{o.label}
</option>
))}
</select>
</div>
</div>
{isLoading ? (
<div className="flex items-center justify-center h-48 text-gray-400">Loading...</div>
) : clients.length === 0 ? (
<div className="card p-12 flex flex-col items-center gap-3 text-center">
<Users className="w-12 h-12 text-gray-300 dark:text-slate-600" />
<p className="text-gray-500 dark:text-slate-400">
{showAll ? 'No clients found' : 'No active clients detected'}
</p>
</div>
) : (
<>
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm table-fixed">
<colgroup>
<col style={{ width: '22%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '11%' }} />
<col style={{ width: '7%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
{showAll && <col style={{ width: '6%' }} />}
</colgroup>
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700">
{([
{ col: 'hostname', label: 'Host / Vendor / MAC', align: 'left' },
{ col: 'ip_address', label: 'IP Address', align: 'left' },
{ col: 'client_type', label: 'Type', align: 'left' },
{ col: 'interface_name', label: 'Port', align: 'left' },
{ col: 'vlan_id', label: 'VLAN', align: 'left' },
{ col: 'device_name', label: 'Device', align: 'left' },
{ col: 'traffic_today_bytes', label: 'Data (today)', align: 'left' },
{ col: 'last_seen', label: 'Last Seen', align: 'left' },
] as { col: string; label: string; align: 'left' | 'right' }[]).map(({ col, label, align }) => (
<th
key={col}
className={clsx('table-header px-4 py-2.5 cursor-pointer select-none whitespace-nowrap', `text-${align}`)}
onClick={() => toggleSort(col)}
>
<span className="inline-flex items-center gap-1">
{label}
{sortCol === col ? (
sortDir === 'asc'
? <ChevronUp className="w-3 h-3 text-blue-500" />
: <ChevronDown className="w-3 h-3 text-blue-500" />
) : (
<ChevronsUpDown className="w-3 h-3 text-gray-300 dark:text-slate-600" />
)}
</span>
</th>
))}
{showAll && (
<th className="table-header px-4 py-2.5 text-center">Active</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
{clients.map((client) => (
<tr
key={client.id}
className="hover:bg-gray-50 dark:hover:bg-slate-700/30"
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-1.5 min-w-0">
<CategoryIcon category={client.device_category} />
<button
onClick={() => navigate(`/clients/${encodeURIComponent(client.mac_address)}`)}
className="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left truncate"
>
{client.custom_name || client.hostname || (
<span className="text-gray-400 dark:text-slate-500 italic">No hostname</span>
)}
</button>
{client.custom_name && (
<span className="text-xs text-blue-500 dark:text-blue-400 ml-1 flex-shrink-0">custom</span>
)}
{canWrite && (
<button
onClick={(e) => { e.stopPropagation(); setEditingClient(client); }}
className="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 flex-shrink-0"
title="Edit name"
>
<Pencil className="w-3 h-3 text-gray-300 dark:text-slate-600 hover:text-gray-500 dark:hover:text-slate-400" />
</button>
)}
</div>
{client.vendor && (
<div className="text-xs text-gray-400 dark:text-slate-500 truncate">{client.vendor}</div>
)}
<div className="text-xs font-mono text-gray-400 dark:text-slate-500 truncate">
{client.mac_address}
</div>
</td>
<td className="px-4 py-2.5 font-mono text-gray-600 dark:text-slate-400 truncate">
{client.ip_address || '—'}
</td>
<td className="px-4 py-2.5">
<span
className={clsx(
'inline-flex items-center gap-1 text-xs font-medium',
client.client_type === 'wireless'
? 'text-purple-600 dark:text-purple-400'
: 'text-blue-600 dark:text-blue-400'
)}
>
{client.client_type === 'wireless' ? (
<Wifi className="w-3 h-3" />
) : (
<Network className="w-3 h-3" />
)}
{client.client_type}
{client.signal_strength != null && (
<span className="text-gray-400 ml-1">({client.signal_strength} dBm)</span>
)}
</span>
</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400 truncate">
{client.interface_name || '—'}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
{client.vlan_id != null
? <span className="px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded font-mono">{client.vlan_id}</span>
: '—'}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400 truncate">
{client.device_name || '—'}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400 whitespace-nowrap">
{client.traffic_today_bytes ? formatDataBytes(client.traffic_today_bytes) : '—'}
</td>
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500">
{client.last_seen
? formatDistanceToNow(new Date(client.last_seen), { addSuffix: true })
: '—'}
</td>
{showAll && (
<td className="px-4 py-2.5 text-center">
<span
className={clsx(
'w-2 h-2 rounded-full inline-block',
client.active ? 'bg-green-500' : 'bg-gray-300 dark:bg-slate-600'
)}
/>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{total > 0 && (() => {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 text-sm">
{/* Entries-per-page + range summary */}
<div className="flex items-center gap-2 text-gray-500 dark:text-slate-400">
<span>Show</span>
<select
value={pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className="text-sm rounded-lg border border-line bg-surface-2 text-ink px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-accent"
>
{PAGE_SIZE_OPTIONS.map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
<span>entries</span>
<span className="ml-1 hidden sm:inline">
({page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total})
</span>
</div>
{/* Numbered page selection */}
{totalPages > 1 && (
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
aria-label="Previous page"
className="inline-flex items-center justify-center w-8 h-8 rounded-lg text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
{getPageList(page, totalPages).map((item, i) =>
item === 'ellipsis' ? (
<span key={`e${i}`} className="px-1.5 text-gray-400 dark:text-slate-500 select-none">…</span>
) : (
<button
key={item}
onClick={() => setPage(item - 1)}
aria-current={item - 1 === page ? 'page' : undefined}
className={clsx(
'inline-flex items-center justify-center min-w-[2rem] h-8 px-2 rounded-lg text-sm font-medium transition-colors',
item - 1 === page
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700'
)}
>
{item}
</button>
)
)}
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
aria-label="Next page"
className="inline-flex items-center justify-center w-8 h-8 rounded-lg text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
);
})()}
</>
)}
</div>
);
}