/** * ClientsTab -- Connected client device table with ARP/DHCP merge and * expandable wireless detail rows. * * Displays all client devices discovered on a MikroTik device via ARP + DHCP + * wireless registration tables. Supports column sorting, text search filtering, * and expandable rows for wireless clients showing signal/tx/rx/uptime. * * Props follow the active-guard pattern: data is only fetched when `active` * is true (i.e. the tab is visible). Auto-refreshes every 30 seconds. */ import { useState, useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { Search, Wifi, Cable, ChevronDown, ChevronRight, ArrowUpDown, ArrowUp, ArrowDown, Signal, Users, RefreshCw, } from 'lucide-react' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { TableSkeleton } from '@/components/ui/page-skeleton' import { cn } from '@/lib/utils' import { networkApi, type ClientDevice } from '@/lib/networkApi' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface ClientsTabProps { tenantId: string deviceId: string /** Active guard -- only fetch when the tab is visible */ active: boolean } type SortField = 'ip' | 'mac' | 'hostname' | 'interface' | 'status' type SortDir = 'asc' | 'desc' // --------------------------------------------------------------------------- // Signal strength helpers // --------------------------------------------------------------------------- function parseSignalDbm(signal: string | null): number | null { if (!signal) return null // RouterOS formats signal as e.g. "-65dBm" or "-65" const match = signal.match(/-?\d+/) return match ? parseInt(match[0], 10) : null } function signalColor(dbm: number): string { if (dbm > -65) return 'text-success' if (dbm >= -75) return 'text-warning' return 'text-error' } function signalLabel(dbm: number): string { if (dbm > -65) return 'Good' if (dbm >= -75) return 'Fair' return 'Poor' } // --------------------------------------------------------------------------- // Sort icon helper // --------------------------------------------------------------------------- function SortIcon({ field, currentField, currentDir }: { field: SortField currentField: SortField currentDir: SortDir }) { if (field !== currentField) { return } return currentDir === 'asc' ? : } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function ClientsTab({ tenantId, deviceId, active }: ClientsTabProps) { const [sortField, setSortField] = useState('ip') const [sortDir, setSortDir] = useState('asc') const [searchQuery, setSearchQuery] = useState('') const [expandedMac, setExpandedMac] = useState(null) const { data, isLoading, isError, error, refetch, isFetching } = useQuery({ queryKey: ['clients', tenantId, deviceId], queryFn: () => networkApi.getClients(tenantId, deviceId), enabled: active, refetchInterval: 30_000, }) // Toggle sort: if same column, flip direction; otherwise set new column ascending const handleSort = (field: SortField) => { if (field === sortField) { setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')) } else { setSortField(field) setSortDir('asc') } } const getAriaSort = (field: SortField): 'ascending' | 'descending' | 'none' => { if (field !== sortField) return 'none' return sortDir === 'asc' ? 'ascending' : 'descending' } // Filter and sort the client list const clients = useMemo(() => { if (!data?.clients) return [] const query = searchQuery.toLowerCase().trim() // Filter by search query const filtered = query ? data.clients.filter( (c) => c.ip.toLowerCase().includes(query) || c.mac.toLowerCase().includes(query) || (c.hostname && c.hostname.toLowerCase().includes(query)), ) : data.clients // Sort return [...filtered].sort((a, b) => { let cmp = 0 switch (sortField) { case 'ip': { // Numeric IP sort const aParts = a.ip.split('.').map(Number) const bParts = b.ip.split('.').map(Number) for (let i = 0; i < 4; i++) { cmp = (aParts[i] || 0) - (bParts[i] || 0) if (cmp !== 0) break } break } case 'mac': cmp = a.mac.localeCompare(b.mac) break case 'hostname': cmp = (a.hostname || '').localeCompare(b.hostname || '') break case 'interface': cmp = a.interface.localeCompare(b.interface) break case 'status': cmp = a.status.localeCompare(b.status) break } return sortDir === 'asc' ? cmp : -cmp }) }, [data, searchQuery, sortField, sortDir]) // Stats const totalClients = data?.clients.length ?? 0 const wirelessCount = data?.clients.filter((c) => c.is_wireless).length ?? 0 const reachableCount = data?.clients.filter((c) => c.status === 'reachable').length ?? 0 // Loading state if (isLoading) { return } // Error state if (isError) { return (

Failed to load client devices

{(error as Error)?.message || 'Unknown error'}

) } return (
{/* Search bar and stats row */}
setSearchQuery(e.target.value)} className="pl-8 h-8" />
{totalClients} clients {wirelessCount} wireless {reachableCount} reachable
{/* Empty state */} {clients.length === 0 && (

No clients found

{searchQuery ? 'No clients match your search query' : 'No connected client devices detected on this device'}

)} {/* Client table */} {clients.length > 0 && (
{/* Expand chevron column */} {clients.map((client) => { const isExpanded = expandedMac === client.mac const canExpand = client.is_wireless return ( setExpandedMac(isExpanded ? null : client.mac)} /> ) })}
Status handleSort('ip')} aria-sort={getAriaSort('ip')} > IP Address handleSort('mac')} aria-sort={getAriaSort('mac')} > MAC Address handleSort('hostname')} aria-sort={getAriaSort('hostname')} > Hostname handleSort('interface')} aria-sort={getAriaSort('interface')} > Interface Type
)} {/* Last updated timestamp */} {data?.timestamp && (

Last updated: {new Date(data.timestamp).toLocaleTimeString()}

)}
) } // --------------------------------------------------------------------------- // Client row sub-component // --------------------------------------------------------------------------- function ClientRow({ client, isExpanded, canExpand, onToggle, }: { client: ClientDevice isExpanded: boolean canExpand: boolean onToggle: () => void }) { const dbm = parseSignalDbm(client.signal_strength) return ( <> {/* Expand chevron */} {canExpand && ( isExpanded ? : )} {/* Status dot */} {/* IP */} {client.ip} {/* MAC */} {client.mac} {/* Hostname */} {client.hostname ? ( {client.hostname} ) : ( )} {/* Interface */} {client.interface || '\u2014'} {/* Type badge */} {client.is_wireless ? ( WiFi ) : ( Wired )} {/* Expanded wireless detail row */} {isExpanded && canExpand && (
{/* Signal strength */}
Signal Strength {dbm !== null ? ( {client.signal_strength} ({signalLabel(dbm)}) ) : ( )}
{/* TX Rate */}
TX Rate {client.tx_rate || '\u2014'}
{/* RX Rate */}
RX Rate {client.rx_rate || '\u2014'}
{/* Uptime */}
Uptime {client.uptime || '\u2014'}
)} ) }