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([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 ( ); } 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 (

Edit Client

MAC {client.mac_address}
{client.vendor && (
Vendor {client.vendor}
)} {client.ip_address && (
IP {client.ip_address}
)} {client.interface_name && (
Port {client.interface_name}
)} {client.hostname && (
Discovered Hostname {client.hostname}
)}
setName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') mutation.mutate(); }} autoFocus />

Overrides the auto-discovered hostname. Persists even when the client is offline.

{mutation.isError && (

Failed to save name.

)}
); } 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(null); const [sortCol, setSortCol] = useState('last_seen'); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); const [refreshInterval, setRefreshInterval] = useState(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 (
{editingClient && ( setEditingClient(null)} /> )}

Clients {total > 0 && ( ({total} {showAll ? 'total' : 'online'}) )}

{/* Filters */}
{ setSearch(e.target.value); setPage(0); }} />
{canWrite && ( )} {purgeResult && ( {purgeResult} )}
{isLoading ? (
Loading...
) : clients.length === 0 ? (

{showAll ? 'No clients found' : 'No active clients detected'}

) : ( <>
{showAll && } {([ { 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 }) => ( ))} {showAll && ( )} {clients.map((client) => ( {showAll && ( )} ))}
toggleSort(col)} > {label} {sortCol === col ? ( sortDir === 'asc' ? : ) : ( )} Active
{client.custom_name && ( custom )} {canWrite && ( )}
{client.vendor && (
{client.vendor}
)}
{client.mac_address}
{client.ip_address || '—'} {client.client_type === 'wireless' ? ( ) : ( )} {client.client_type} {client.signal_strength != null && ( ({client.signal_strength} dBm) )} {client.interface_name || '—'} {client.vlan_id != null ? {client.vlan_id} : '—'} {client.device_name || '—'} {client.traffic_today_bytes ? formatDataBytes(client.traffic_today_bytes) : '—'} {client.last_seen ? formatDistanceToNow(new Date(client.last_seen), { addSuffix: true }) : '—'}
{/* Pagination */} {total > 0 && (() => { const totalPages = Math.max(1, Math.ceil(total / pageSize)); return (
{/* Entries-per-page + range summary */}
Show entries ({page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total})
{/* Numbered page selection */} {totalPages > 1 && (
{getPageList(page, totalPages).map((item, i) => item === 'ellipsis' ? ( … ) : ( ) )}
)}
); })()} )}
); }