460 lines
15 KiB
TypeScript
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">—</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">—</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>
|
|
)}
|
|
</>
|
|
)
|
|
}
|