Files
the-other-dude/frontend/src/components/network/ClientsTab.tsx
Jason Staack 17037e4936 feat(ui): replace skeleton loaders with honest loading states
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 13:40:58 -05:00

460 lines
15 KiB
TypeScript

/**
* 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 <ArrowUpDown className="ml-1 h-3 w-3 text-text-muted" />
}
return currentDir === 'asc'
? <ArrowUp className="ml-1 h-3 w-3 text-accent" />
: <ArrowDown className="ml-1 h-3 w-3 text-accent" />
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ClientsTab({ tenantId, deviceId, active }: ClientsTabProps) {
const [sortField, setSortField] = useState<SortField>('ip')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const [searchQuery, setSearchQuery] = useState('')
const [expandedMac, setExpandedMac] = useState<string | null>(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 <TableSkeleton />
}
// Error state
if (isError) {
return (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<p className="text-sm">Failed to load client devices</p>
<p className="text-xs mt-1">{(error as Error)?.message || 'Unknown error'}</p>
<button
onClick={() => refetch()}
className="mt-3 text-xs text-accent hover:underline"
>
Retry
</button>
</div>
)
}
return (
<div className="space-y-4">
{/* Search bar and stats row */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-text-muted" />
<Input
placeholder="Search by IP, MAC, or hostname..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8"
/>
</div>
<div className="flex items-center gap-2">
<Badge className="gap-1">
<Users className="h-3 w-3" />
{totalClients} clients
</Badge>
<Badge className="gap-1">
<Wifi className="h-3 w-3" />
{wirelessCount} wireless
</Badge>
<Badge className="gap-1">
<span className="h-2 w-2 rounded-full bg-success inline-block" />
{reachableCount} reachable
</Badge>
<button
onClick={() => refetch()}
className="ml-2 p-1 rounded hover:bg-elevated transition-colors"
title="Refresh client list"
>
<RefreshCw className={cn('h-3.5 w-3.5 text-text-muted', isFetching && 'animate-spin')} />
</button>
</div>
</div>
{/* Empty state */}
{clients.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-text-muted">
<Users className="h-10 w-10 mb-2 opacity-40" />
<p className="text-sm font-medium">No clients found</p>
<p className="text-xs mt-1">
{searchQuery
? 'No clients match your search query'
: 'No connected client devices detected on this device'}
</p>
</div>
)}
{/* Client table */}
{clients.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="bg-panel text-text-secondary text-left">
{/* Expand chevron column */}
<th className="w-8 px-3 py-2.5" />
<th className="px-3 py-2.5 w-16">Status</th>
<th
className="px-3 py-2.5 cursor-pointer select-none hover:text-text-primary transition-colors"
onClick={() => handleSort('ip')}
aria-sort={getAriaSort('ip')}
>
<span className="inline-flex items-center">
IP Address
<SortIcon field="ip" currentField={sortField} currentDir={sortDir} />
</span>
</th>
<th
className="px-3 py-2.5 cursor-pointer select-none hover:text-text-primary transition-colors"
onClick={() => handleSort('mac')}
aria-sort={getAriaSort('mac')}
>
<span className="inline-flex items-center">
MAC Address
<SortIcon field="mac" currentField={sortField} currentDir={sortDir} />
</span>
</th>
<th
className="px-3 py-2.5 cursor-pointer select-none hover:text-text-primary transition-colors"
onClick={() => handleSort('hostname')}
aria-sort={getAriaSort('hostname')}
>
<span className="inline-flex items-center">
Hostname
<SortIcon field="hostname" currentField={sortField} currentDir={sortDir} />
</span>
</th>
<th
className="px-3 py-2.5 cursor-pointer select-none hover:text-text-primary transition-colors"
onClick={() => handleSort('interface')}
aria-sort={getAriaSort('interface')}
>
<span className="inline-flex items-center">
Interface
<SortIcon field="interface" currentField={sortField} currentDir={sortDir} />
</span>
</th>
<th className="px-3 py-2.5">Type</th>
</tr>
</thead>
<tbody>
{clients.map((client) => {
const isExpanded = expandedMac === client.mac
const canExpand = client.is_wireless
return (
<ClientRow
key={client.mac}
client={client}
isExpanded={isExpanded}
canExpand={canExpand}
onToggle={() => setExpandedMac(isExpanded ? null : client.mac)}
/>
)
})}
</tbody>
</table>
</div>
)}
{/* Last updated timestamp */}
{data?.timestamp && (
<p className="text-xs text-text-muted text-right">
Last updated: {new Date(data.timestamp).toLocaleTimeString()}
</p>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// 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 (
<>
<tr
className={cn(
'border-t border-border hover:bg-elevated/50 transition-colors',
canExpand && 'cursor-pointer',
)}
onClick={canExpand ? onToggle : undefined}
>
{/* Expand chevron */}
<td className="px-3 py-2.5">
{canExpand && (
isExpanded
? <ChevronDown className="h-3.5 w-3.5 text-text-muted" />
: <ChevronRight className="h-3.5 w-3.5 text-text-muted" />
)}
</td>
{/* Status dot */}
<td className="px-3 py-2.5">
<span
className={cn(
'inline-block h-2 w-2 rounded-full',
client.status === 'reachable' ? 'bg-success' : 'bg-text-muted',
)}
title={client.status}
/>
</td>
{/* IP */}
<td className="px-3 py-2.5 font-mono text-xs">{client.ip}</td>
{/* MAC */}
<td className="px-3 py-2.5 font-mono text-xs text-text-secondary">{client.mac}</td>
{/* Hostname */}
<td className="px-3 py-2.5">
{client.hostname ? (
<span className="text-text-primary">{client.hostname}</span>
) : (
<span className="text-text-muted">&mdash;</span>
)}
</td>
{/* Interface */}
<td className="px-3 py-2.5 text-text-secondary">{client.interface || '\u2014'}</td>
{/* Type badge */}
<td className="px-3 py-2.5">
{client.is_wireless ? (
<Badge className="gap-1 bg-accent/10 text-accent border-accent/30">
<Wifi className="h-3 w-3" />
WiFi
</Badge>
) : (
<Badge className="gap-1">
<Cable className="h-3 w-3" />
Wired
</Badge>
)}
</td>
</tr>
{/* Expanded wireless detail row */}
{isExpanded && canExpand && (
<tr className="border-t border-border/50 bg-elevated/30">
<td colSpan={7} className="px-6 py-3">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-xs">
{/* Signal strength */}
<div>
<span className="text-text-muted block mb-0.5">Signal Strength</span>
{dbm !== null ? (
<span className={cn('font-medium flex items-center gap-1', signalColor(dbm))}>
<Signal className="h-3.5 w-3.5" />
{client.signal_strength} ({signalLabel(dbm)})
</span>
) : (
<span className="text-text-muted">&mdash;</span>
)}
</div>
{/* TX Rate */}
<div>
<span className="text-text-muted block mb-0.5">TX Rate</span>
<span className="font-medium text-text-primary">
{client.tx_rate || '\u2014'}
</span>
</div>
{/* RX Rate */}
<div>
<span className="text-text-muted block mb-0.5">RX Rate</span>
<span className="font-medium text-text-primary">
{client.rx_rate || '\u2014'}
</span>
</div>
{/* Uptime */}
<div>
<span className="text-text-muted block mb-0.5">Uptime</span>
<span className="font-medium text-text-primary">
{client.uptime || '\u2014'}
</span>
</div>
</div>
</td>
</tr>
)}
</>
)
}