feat(14-02): add wireless station table, RF stats card, and links table components

- WirelessStationTable: per-station client table with signal/CCQ color coding
- RFStatsCard: per-interface RF environment stats display
- WirelessLinksTable: AP-CPE link topology grouped by AP with state badges
- Shared signalColor helper for consistent signal strength visualization

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-19 06:46:00 -05:00
parent 430cab98a8
commit eec89b802a
7 changed files with 481 additions and 21 deletions

View File

@@ -0,0 +1,142 @@
import { useQuery } from '@tanstack/react-query'
import { Wifi } from 'lucide-react'
import { wirelessApi, type RegistrationResponse } from '@/lib/api'
import { cn } from '@/lib/utils'
import { DeviceLink } from '@/components/ui/device-link'
import { TableSkeleton } from '@/components/ui/page-skeleton'
import { EmptyState } from '@/components/ui/empty-state'
import { signalColor } from './signal-color'
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
function ccqColor(ccq: number | null): string {
if (ccq == null) return 'text-text-muted'
if (ccq >= 80) return 'text-success'
if (ccq >= 60) return 'text-warning'
return 'text-error'
}
interface WirelessStationTableProps {
tenantId: string
deviceId: string
active: boolean
}
export function WirelessStationTable({ tenantId, deviceId, active }: WirelessStationTableProps) {
const { data, isLoading } = useQuery({
queryKey: ['device-registrations', tenantId, deviceId],
queryFn: () => wirelessApi.getDeviceRegistrations(tenantId, deviceId),
enabled: active,
refetchInterval: active ? 60_000 : false,
})
if (isLoading) {
return <TableSkeleton rows={5} />
}
if (!data || data.items.length === 0) {
return (
<EmptyState
icon={Wifi}
title="No wireless clients"
description="No wireless clients connected to this device"
/>
)
}
return (
<div className="rounded-lg border border-border overflow-hidden">
<div className="px-3 py-2 border-b border-border bg-elevated/30">
<h3 className="text-sm font-semibold text-text-primary">
Wireless Stations ({data.items.length})
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-left">
MAC
</th>
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-left">
Hostname
</th>
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
Signal
</th>
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
CCQ
</th>
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
TX Rate
</th>
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
RX Rate
</th>
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
Distance
</th>
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
Uptime
</th>
<th scope="col" className="px-2 py-2 text-[10px] uppercase tracking-wider font-semibold text-text-muted text-right">
Last Seen
</th>
</tr>
</thead>
<tbody>
{data.items.map((reg: RegistrationResponse) => (
<tr
key={reg.mac_address}
className="border-b border-border/50 hover:bg-elevated/50 transition-colors"
>
<td className="px-2 py-1.5 font-mono text-xs text-text-secondary">
{reg.mac_address}
</td>
<td className="px-2 py-1.5 text-text-primary">
{reg.device_id ? (
<DeviceLink tenantId={tenantId} deviceId={reg.device_id}>
{reg.hostname ?? reg.mac_address}
</DeviceLink>
) : (
<span className="text-text-muted">{reg.hostname ?? '--'}</span>
)}
</td>
<td className={cn('px-2 py-1.5 text-right font-medium', signalColor(reg.signal_strength))}>
{reg.signal_strength != null ? `${reg.signal_strength} dBm` : '--'}
</td>
<td className={cn('px-2 py-1.5 text-right font-medium', ccqColor(reg.tx_ccq))}>
{reg.tx_ccq != null ? `${reg.tx_ccq}%` : '--'}
</td>
<td className="px-2 py-1.5 text-right text-text-secondary">
{reg.tx_rate ?? '--'}
</td>
<td className="px-2 py-1.5 text-right text-text-secondary">
{reg.rx_rate ?? '--'}
</td>
<td className="px-2 py-1.5 text-right text-text-secondary">
{reg.distance != null ? `${reg.distance}m` : '--'}
</td>
<td className="px-2 py-1.5 text-right text-text-secondary">
{reg.uptime ?? '--'}
</td>
<td className="px-2 py-1.5 text-right text-text-muted text-xs">
{timeAgo(reg.last_seen)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}