import { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'; import { clientsApi } from '../services/api'; import type { Client } from '../types'; import { useCanWrite } from '../hooks/useCanWrite'; import { useSocket } from '../hooks/useSocket'; import { formatDistanceToNow } from 'date-fns'; import clsx from 'clsx'; function ClientModal({ client, onClose, }: { client: Client; onClose: () => void; }) { const queryClient = useQueryClient(); const [name, setName] = useState(client.custom_name || ''); const mutation = useMutation({ mutationFn: () => clientsApi.updateHostname(client.mac_address, name), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['clients'] }); onClose(); }, }); return (

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 PAGE_SIZE = 50; const toggleSort = (col: string) => { if (sortCol === col) setSortDir((d) => d === 'asc' ? 'desc' : 'asc'); else { setSortCol(col); setSortDir('asc'); } }; const { data, isLoading } = useQuery({ queryKey: ['clients', { search, showAll, page }], queryFn: () => clientsApi .list({ search: search || undefined, active: showAll ? undefined : true, limit: PAGE_SIZE, offset: page * PAGE_SIZE }) .then((r) => r.data), refetchInterval: 30_000, }); const [purgeResult, setPurgeResult] = useState(''); const purgeMutation = useMutation({ mutationFn: () => clientsApi.purgeStale(), onSuccess: (r) => { queryClient.invalidateQueries({ queryKey: ['clients'] }); setPurgeResult(r.data.message); setTimeout(() => setPurgeResult(''), 5000); }, }); useSocket({ 'clients:updated': () => queryClient.invalidateQueries({ queryKey: ['clients'] }), }); const rawClients = data?.clients ?? []; const total = data?.total ?? 0; const clients = useMemo(() => { const toIpNum = (ip: string) => (ip || '').split('.').reduce((n, o) => n * 256 + parseInt(o || '0', 10), 0); return [...rawClients].sort((a, b) => { let av: string | number, bv: string | number; switch (sortCol) { case 'hostname': av = a.custom_name || a.hostname || ''; bv = b.custom_name || b.hostname || ''; break; case 'vendor': av = a.vendor || ''; bv = b.vendor || ''; break; case 'ip_address': av = toIpNum(a.ip_address || ''); bv = toIpNum(b.ip_address || ''); break; case 'client_type': av = a.client_type || ''; bv = b.client_type || ''; break; case 'interface_name': av = a.interface_name || ''; bv = b.interface_name || ''; break; case 'vlan_id': av = a.vlan_id ?? -1; bv = b.vlan_id ?? -1; break; case 'device_name': av = a.device_name || ''; bv = b.device_name || ''; break; case 'last_seen': av = a.last_seen || ''; bv = b.last_seen || ''; break; default: av = ''; bv = ''; } if (av === bv) return 0; const cmp = av < bv ? -1 : 1; return sortDir === 'asc' ? cmp : -cmp; }); }, [rawClients, sortCol, sortDir]); return (
{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'}

) : ( <>
{([ { col: 'hostname', label: 'Host / Vendor / MAC', align: 'left' }, { col: 'ip_address', label: 'IP Address', align: 'left' }, { col: 'client_type', label: 'Type', align: 'left' }, { col: 'interface_name', label: 'Port', align: 'left' }, { col: 'vlan_id', label: 'VLAN', align: 'left' }, { col: 'device_name', label: 'Device', align: 'left' }, { col: 'last_seen', label: 'Last Seen', align: 'left' }, ] as { col: string; label: string; align: 'left' | 'right' }[]).map(({ col, label, align }) => ( ))} {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.last_seen ? formatDistanceToNow(new Date(client.last_seen), { addSuffix: true }) : '—'}
{/* Pagination */} {total > PAGE_SIZE && (
Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
)} )}
); }