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 }) => (
| toggleSort(col)}
>
{label}
{sortCol === col ? (
sortDir === 'asc'
?
:
) : (
)}
|
))}
{showAll && (
Active |
)}
{clients.map((client) => (
{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 })
: '—'}
|
{showAll && (
|
)}
))}
{/* Pagination */}
{total > PAGE_SIZE && (
Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
)}
>
)}
);
}