feat(15-03): add signal history charts with expandable rows in station and link tables

- Create SignalHistoryChart with recharts LineChart, green/yellow/red reference bands, and 24h/7d/30d range selector
- Add expandable rows to WirelessStationTable (click station to see signal history)
- Add expandable rows to WirelessLinksTable CPE rows (click link to see signal history)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-19 07:22:49 -05:00
parent ef82a0d294
commit 3bddd6f654
6 changed files with 271 additions and 76 deletions

View File

@@ -0,0 +1,158 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceArea,
} from 'recharts'
import { Loader2 } from 'lucide-react'
import { signalHistoryApi, type SignalHistoryPoint } from '@/lib/api'
import { cn } from '@/lib/utils'
type Range = '24h' | '7d' | '30d'
interface SignalHistoryChartProps {
tenantId: string
deviceId: string
macAddress: string
}
function formatTimestamp(ts: string, range: Range): string {
const d = new Date(ts)
if (range === '24h') {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
if (range === '7d') {
return d.toLocaleDateString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
}
export function SignalHistoryChart({ tenantId, deviceId, macAddress }: SignalHistoryChartProps) {
const [range, setRange] = useState<Range>('7d')
const { data, isLoading } = useQuery({
queryKey: ['signal-history', tenantId, deviceId, macAddress, range],
queryFn: () => signalHistoryApi.get(tenantId, deviceId, macAddress, range),
})
const chartData = (data?.items ?? []).map((pt: SignalHistoryPoint) => ({
...pt,
label: formatTimestamp(pt.timestamp, range),
}))
return (
<div className="rounded-lg bg-elevated/40 border border-border/50 p-3">
{/* Header with range selector */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
Signal History
</span>
<div className="flex gap-1">
{(['24h', '7d', '30d'] as Range[]).map((r) => (
<button
key={r}
onClick={(e) => { e.stopPropagation(); setRange(r) }}
className={cn(
'px-2 py-0.5 text-[10px] font-medium rounded transition-colors',
range === r
? 'bg-accent text-white'
: 'bg-elevated text-text-muted hover:text-text-secondary',
)}
>
{r}
</button>
))}
</div>
</div>
{/* Chart body */}
{isLoading ? (
<div className="flex items-center justify-center h-[200px]">
<Loader2 className="h-5 w-5 animate-spin text-text-muted" />
</div>
) : chartData.length === 0 ? (
<div className="flex items-center justify-center h-[200px] text-sm text-text-muted">
No signal data available for this time range
</div>
) : (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData} margin={{ top: 5, right: 10, left: 5, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
{/* Color band reference areas */}
<ReferenceArea y1={-65} y2={0} fill="#22c55e" fillOpacity={0.05} />
<ReferenceArea y1={-80} y2={-65} fill="#eab308" fillOpacity={0.05} />
<ReferenceArea y1={-100} y2={-80} fill="#ef4444" fillOpacity={0.05} />
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: 'rgba(255,255,255,0.4)' }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
interval="preserveStartEnd"
/>
<YAxis
domain={['auto', 'auto']}
tick={{ fontSize: 10, fill: 'rgba(255,255,255,0.4)' }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.1)' }}
label={{
value: 'dBm',
angle: -90,
position: 'insideLeft',
style: { fontSize: 10, fill: 'rgba(255,255,255,0.4)' },
}}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(15,15,20,0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '6px',
fontSize: '11px',
}}
labelStyle={{ color: 'rgba(255,255,255,0.6)' }}
formatter={(value: number, name: string) => {
const labels: Record<string, string> = {
signal_avg: 'Avg Signal',
signal_min: 'Min Signal',
signal_max: 'Max Signal',
}
return [`${value} dBm`, labels[name] ?? name]
}}
/>
<Line
type="monotone"
dataKey="signal_avg"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="signal_min"
stroke="rgba(59,130,246,0.3)"
strokeWidth={1}
strokeDasharray="3 3"
dot={false}
/>
<Line
type="monotone"
dataKey="signal_max"
stroke="rgba(59,130,246,0.3)"
strokeWidth={1}
strokeDasharray="3 3"
dot={false}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react'
import React, { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Wifi } from 'lucide-react'
import { wirelessApi, type LinkResponse } from '@/lib/api'
@@ -14,6 +14,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { signalColor } from './signal-color'
import { SignalHistoryChart } from './SignalHistoryChart'
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
@@ -180,6 +181,8 @@ function APGroup({
apDeviceId: string
links: LinkResponse[]
}) {
const [expandedLinkId, setExpandedLinkId] = useState<string | null>(null)
return (
<>
{/* AP header row */}
@@ -198,34 +201,47 @@ function APGroup({
</tr>
{/* CPE rows */}
{links.map((link) => (
<tr
key={link.id}
className="border-b border-border/50 hover:bg-elevated/50 transition-colors"
>
<td className="px-2 py-1.5 pl-6">
<DeviceLink tenantId={tenantId} deviceId={link.cpe_device_id}>
{link.cpe_hostname ?? link.client_mac}
</DeviceLink>
</td>
<td className={cn('px-2 py-1.5 text-right font-medium', signalColor(link.signal_strength))}>
{link.signal_strength != null ? `${link.signal_strength} dBm` : '--'}
</td>
<td className="px-2 py-1.5 text-right text-text-secondary">
{link.tx_ccq != null ? `${link.tx_ccq}%` : '--'}
</td>
<td className="px-2 py-1.5 text-right text-text-secondary">
{link.tx_rate ?? '--'}
</td>
<td className="px-2 py-1.5 text-right text-text-secondary">
{link.rx_rate ?? '--'}
</td>
<td className="px-2 py-1.5 text-center">
<StateBadge state={link.state} />
</td>
<td className="px-2 py-1.5 text-right text-text-muted text-xs">
{timeAgo(link.last_seen)}
</td>
</tr>
<React.Fragment key={link.id}>
<tr
className="border-b border-border/50 hover:bg-elevated/50 transition-colors cursor-pointer"
onClick={() => setExpandedLinkId(expandedLinkId === link.id ? null : link.id)}
>
<td className="px-2 py-1.5 pl-6">
<DeviceLink tenantId={tenantId} deviceId={link.cpe_device_id}>
{link.cpe_hostname ?? link.client_mac}
</DeviceLink>
</td>
<td className={cn('px-2 py-1.5 text-right font-medium', signalColor(link.signal_strength))}>
{link.signal_strength != null ? `${link.signal_strength} dBm` : '--'}
</td>
<td className="px-2 py-1.5 text-right text-text-secondary">
{link.tx_ccq != null ? `${link.tx_ccq}%` : '--'}
</td>
<td className="px-2 py-1.5 text-right text-text-secondary">
{link.tx_rate ?? '--'}
</td>
<td className="px-2 py-1.5 text-right text-text-secondary">
{link.rx_rate ?? '--'}
</td>
<td className="px-2 py-1.5 text-center">
<StateBadge state={link.state} />
</td>
<td className="px-2 py-1.5 text-right text-text-muted text-xs">
{timeAgo(link.last_seen)}
</td>
</tr>
{expandedLinkId === link.id && (
<tr>
<td colSpan={7} className="px-3 py-3 bg-elevated/20">
<SignalHistoryChart
tenantId={tenantId}
deviceId={link.ap_device_id}
macAddress={link.client_mac}
/>
</td>
</tr>
)}
</React.Fragment>
))}
</>
)

View File

@@ -1,3 +1,4 @@
import React, { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Wifi } from 'lucide-react'
import { wirelessApi, type RegistrationResponse } from '@/lib/api'
@@ -6,6 +7,7 @@ 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'
import { SignalHistoryChart } from './SignalHistoryChart'
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
@@ -32,6 +34,8 @@ interface WirelessStationTableProps {
}
export function WirelessStationTable({ tenantId, deviceId, active }: WirelessStationTableProps) {
const [expandedMac, setExpandedMac] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['device-registrations', tenantId, deviceId],
queryFn: () => wirelessApi.getDeviceRegistrations(tenantId, deviceId),
@@ -95,44 +99,57 @@ export function WirelessStationTable({ tenantId, deviceId, active }: WirelessSta
</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>
<React.Fragment key={reg.mac_address}>
<tr
className="border-b border-border/50 hover:bg-elevated/50 transition-colors cursor-pointer"
onClick={() => setExpandedMac(expandedMac === reg.mac_address ? null : reg.mac_address)}
>
<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>
{expandedMac === reg.mac_address && (
<tr>
<td colSpan={9} className="px-3 py-3 bg-elevated/20">
<SignalHistoryChart
tenantId={tenantId}
deviceId={deviceId}
macAddress={reg.mac_address}
/>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>