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:
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
### Signal Trending
|
### Signal Trending
|
||||||
|
|
||||||
- [ ] **TRND-01**: Operator can view per-station signal history charts showing signal strength over time
|
- [x] **TRND-01**: Operator can view per-station signal history charts showing signal strength over time
|
||||||
- [x] **TRND-02**: System detects signal degradation trends (e.g., "signal dropped 8dB over 2 weeks")
|
- [x] **TRND-02**: System detects signal degradation trends (e.g., "signal dropped 8dB over 2 weeks")
|
||||||
|
|
||||||
### Site Alerting
|
### Site Alerting
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
| WRUI-01 | Phase 14 | Complete |
|
| WRUI-01 | Phase 14 | Complete |
|
||||||
| WRUI-02 | Phase 14 | Complete |
|
| WRUI-02 | Phase 14 | Complete |
|
||||||
| WRUI-03 | Phase 14 | Complete |
|
| WRUI-03 | Phase 14 | Complete |
|
||||||
| TRND-01 | Phase 15 | Pending |
|
| TRND-01 | Phase 15 | Complete |
|
||||||
| TRND-02 | Phase 15 | Complete |
|
| TRND-02 | Phase 15 | Complete |
|
||||||
| ALRT-01 | Phase 15 | Complete |
|
| ALRT-01 | Phase 15 | Complete |
|
||||||
| ALRT-02 | Phase 15 | Complete |
|
| ALRT-02 | Phase 15 | Complete |
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ Plans:
|
|||||||
2. System detects and surfaces signal degradation trends (e.g., "signal dropped 8dB over 2 weeks")
|
2. System detects and surfaces signal degradation trends (e.g., "signal dropped 8dB over 2 weeks")
|
||||||
3. Operator can create site-scoped alert rules (e.g., "alert when >20% of devices at this site go offline")
|
3. Operator can create site-scoped alert rules (e.g., "alert when >20% of devices at this site go offline")
|
||||||
4. Operator can create sector-scoped alert rules (e.g., "alert when sector average signal drops below -75dBm")
|
4. Operator can create sector-scoped alert rules (e.g., "alert when sector average signal drops below -75dBm")
|
||||||
**Plans:** 1/3 plans executed
|
**Plans:** 2/3 plans executed
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 15-01-PLAN.md — Backend data model, services, and REST API for site alert rules, alert events, and signal history
|
- [ ] 15-01-PLAN.md — Backend data model, services, and REST API for site alert rules, alert events, and signal history
|
||||||
@@ -131,7 +131,7 @@ Plans:
|
|||||||
| Sites | SITE-01, SITE-02, SITE-03, SITE-04, SITE-05, SITE-06 | 11 | 3/3 | Complete | 2026-03-19 | DASH-01 | 11 | 1 |
|
| Sites | SITE-01, SITE-02, SITE-03, SITE-04, SITE-05, SITE-06 | 11 | 3/3 | Complete | 2026-03-19 | DASH-01 | 11 | 1 |
|
||||||
| Site Dashboard | DASH-02, DASH-03, DASH-04 | 14 | 3/3 | Complete | 2026-03-19 | SECT-01, SECT-02, SECT-03 | 14 | 3 |
|
| Site Dashboard | DASH-02, DASH-03, DASH-04 | 14 | 3/3 | Complete | 2026-03-19 | SECT-01, SECT-02, SECT-03 | 14 | 3 |
|
||||||
| Wireless Collection | WRCL-01, WRCL-02, WRCL-03, WRCL-04, WRCL-05, WRCL-06 | 12 | 2/2 | Complete | 2026-03-19 | LINK-01, LINK-02, LINK-03, LINK-04 | 13 | 3/3 | Complete | 2026-03-19 | WRUI-01, WRUI-02, WRUI-03 | 14 | 3 |
|
| Wireless Collection | WRCL-01, WRCL-02, WRCL-03, WRCL-04, WRCL-05, WRCL-06 | 12 | 2/2 | Complete | 2026-03-19 | LINK-01, LINK-02, LINK-03, LINK-04 | 13 | 3/3 | Complete | 2026-03-19 | WRUI-01, WRUI-02, WRUI-03 | 14 | 3 |
|
||||||
| Signal Trending | TRND-01, TRND-02 | 15 | 1/3 | In Progress| | ALRT-01, ALRT-02 | 15 | 2 |
|
| Signal Trending | TRND-01, TRND-02 | 15 | 2/3 | In Progress| | ALRT-01, ALRT-02 | 15 | 2 |
|
||||||
| **Total** | | | **30** |
|
| **Total** | | | **30** |
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ gsd_state_version: 1.0
|
|||||||
milestone: v9.7
|
milestone: v9.7
|
||||||
milestone_name: Tower & Site Management
|
milestone_name: Tower & Site Management
|
||||||
status: unknown
|
status: unknown
|
||||||
stopped_at: Completed 15-02-PLAN.md
|
stopped_at: Completed 15-01-PLAN.md
|
||||||
last_updated: "2026-03-19T12:18:13.853Z"
|
last_updated: "2026-03-19T12:19:31.850Z"
|
||||||
progress:
|
progress:
|
||||||
total_phases: 5
|
total_phases: 5
|
||||||
completed_phases: 4
|
completed_phases: 4
|
||||||
total_plans: 14
|
total_plans: 14
|
||||||
completed_plans: 12
|
completed_plans: 13
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
@@ -47,6 +47,7 @@ Plan: 2 of 3
|
|||||||
| Phase 14 P02 | 3min | 2 tasks | 9 files |
|
| Phase 14 P02 | 3min | 2 tasks | 9 files |
|
||||||
| Phase 14 P03 | 3min | 2 tasks | 6 files |
|
| Phase 14 P03 | 3min | 2 tasks | 6 files |
|
||||||
| Phase 15 P02 | 3min | 2 tasks | 4 files |
|
| Phase 15 P02 | 3min | 2 tasks | 4 files |
|
||||||
|
| Phase 15 P01 | 4min | 2 tasks | 10 files |
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
@@ -87,6 +88,9 @@ Decisions are logged in PROJECT.md Key Decisions table.
|
|||||||
- [Phase 14]: Used fleet summary API for CPU/memory data since devicesApi.list does not return health metrics
|
- [Phase 14]: Used fleet summary API for CPU/memory data since devicesApi.list does not return health metrics
|
||||||
- [Phase 15]: Used getattr with fallback for config settings so trend/alert services work before Plan 01 adds them to Settings class
|
- [Phase 15]: Used getattr with fallback for config settings so trend/alert services work before Plan 01 adds them to Settings class
|
||||||
- [Phase 15]: Alert events created with consecutive_hits=1 immediately; UI/API filters for >= 2 to confirm (hysteresis pattern)
|
- [Phase 15]: Alert events created with consecutive_hits=1 immediately; UI/API filters for >= 2 to confirm (hysteresis pattern)
|
||||||
|
- [Phase 15]: Site alert tables are separate from device-level alert_rules/alert_events (no coupling between systems)
|
||||||
|
- [Phase 15]: Signal history uses TimescaleDB time_bucket with 3 range presets (5min/1h/4h buckets)
|
||||||
|
- [Phase 15]: Alert event count endpoint returns simple JSON for notification bell badge
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
@@ -100,6 +104,6 @@ None yet.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-19T12:18:06.830Z
|
Last session: 2026-03-19T12:19:31.847Z
|
||||||
Stopped at: Completed 15-02-PLAN.md
|
Stopped at: Completed 15-01-PLAN.md
|
||||||
Resume file: None
|
Resume file: None
|
||||||
|
|||||||
158
frontend/src/components/wireless/SignalHistoryChart.tsx
Normal file
158
frontend/src/components/wireless/SignalHistoryChart.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Wifi } from 'lucide-react'
|
import { Wifi } from 'lucide-react'
|
||||||
import { wirelessApi, type LinkResponse } from '@/lib/api'
|
import { wirelessApi, type LinkResponse } from '@/lib/api'
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { signalColor } from './signal-color'
|
import { signalColor } from './signal-color'
|
||||||
|
import { SignalHistoryChart } from './SignalHistoryChart'
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
const diff = Date.now() - new Date(dateStr).getTime()
|
const diff = Date.now() - new Date(dateStr).getTime()
|
||||||
@@ -180,6 +181,8 @@ function APGroup({
|
|||||||
apDeviceId: string
|
apDeviceId: string
|
||||||
links: LinkResponse[]
|
links: LinkResponse[]
|
||||||
}) {
|
}) {
|
||||||
|
const [expandedLinkId, setExpandedLinkId] = useState<string | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* AP header row */}
|
{/* AP header row */}
|
||||||
@@ -198,34 +201,47 @@ function APGroup({
|
|||||||
</tr>
|
</tr>
|
||||||
{/* CPE rows */}
|
{/* CPE rows */}
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<tr
|
<React.Fragment key={link.id}>
|
||||||
key={link.id}
|
<tr
|
||||||
className="border-b border-border/50 hover:bg-elevated/50 transition-colors"
|
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}>
|
<td className="px-2 py-1.5 pl-6">
|
||||||
{link.cpe_hostname ?? link.client_mac}
|
<DeviceLink tenantId={tenantId} deviceId={link.cpe_device_id}>
|
||||||
</DeviceLink>
|
{link.cpe_hostname ?? link.client_mac}
|
||||||
</td>
|
</DeviceLink>
|
||||||
<td className={cn('px-2 py-1.5 text-right font-medium', signalColor(link.signal_strength))}>
|
</td>
|
||||||
{link.signal_strength != null ? `${link.signal_strength} dBm` : '--'}
|
<td className={cn('px-2 py-1.5 text-right font-medium', signalColor(link.signal_strength))}>
|
||||||
</td>
|
{link.signal_strength != null ? `${link.signal_strength} dBm` : '--'}
|
||||||
<td className="px-2 py-1.5 text-right text-text-secondary">
|
</td>
|
||||||
{link.tx_ccq != null ? `${link.tx_ccq}%` : '--'}
|
<td className="px-2 py-1.5 text-right text-text-secondary">
|
||||||
</td>
|
{link.tx_ccq != null ? `${link.tx_ccq}%` : '--'}
|
||||||
<td className="px-2 py-1.5 text-right text-text-secondary">
|
</td>
|
||||||
{link.tx_rate ?? '--'}
|
<td className="px-2 py-1.5 text-right text-text-secondary">
|
||||||
</td>
|
{link.tx_rate ?? '--'}
|
||||||
<td className="px-2 py-1.5 text-right text-text-secondary">
|
</td>
|
||||||
{link.rx_rate ?? '--'}
|
<td className="px-2 py-1.5 text-right text-text-secondary">
|
||||||
</td>
|
{link.rx_rate ?? '--'}
|
||||||
<td className="px-2 py-1.5 text-center">
|
</td>
|
||||||
<StateBadge state={link.state} />
|
<td className="px-2 py-1.5 text-center">
|
||||||
</td>
|
<StateBadge state={link.state} />
|
||||||
<td className="px-2 py-1.5 text-right text-text-muted text-xs">
|
</td>
|
||||||
{timeAgo(link.last_seen)}
|
<td className="px-2 py-1.5 text-right text-text-muted text-xs">
|
||||||
</td>
|
{timeAgo(link.last_seen)}
|
||||||
</tr>
|
</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>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Wifi } from 'lucide-react'
|
import { Wifi } from 'lucide-react'
|
||||||
import { wirelessApi, type RegistrationResponse } from '@/lib/api'
|
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 { TableSkeleton } from '@/components/ui/page-skeleton'
|
||||||
import { EmptyState } from '@/components/ui/empty-state'
|
import { EmptyState } from '@/components/ui/empty-state'
|
||||||
import { signalColor } from './signal-color'
|
import { signalColor } from './signal-color'
|
||||||
|
import { SignalHistoryChart } from './SignalHistoryChart'
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
const diff = Date.now() - new Date(dateStr).getTime()
|
const diff = Date.now() - new Date(dateStr).getTime()
|
||||||
@@ -32,6 +34,8 @@ interface WirelessStationTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WirelessStationTable({ tenantId, deviceId, active }: WirelessStationTableProps) {
|
export function WirelessStationTable({ tenantId, deviceId, active }: WirelessStationTableProps) {
|
||||||
|
const [expandedMac, setExpandedMac] = useState<string | null>(null)
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['device-registrations', tenantId, deviceId],
|
queryKey: ['device-registrations', tenantId, deviceId],
|
||||||
queryFn: () => wirelessApi.getDeviceRegistrations(tenantId, deviceId),
|
queryFn: () => wirelessApi.getDeviceRegistrations(tenantId, deviceId),
|
||||||
@@ -95,44 +99,57 @@ export function WirelessStationTable({ tenantId, deviceId, active }: WirelessSta
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.items.map((reg: RegistrationResponse) => (
|
{data.items.map((reg: RegistrationResponse) => (
|
||||||
<tr
|
<React.Fragment key={reg.mac_address}>
|
||||||
key={reg.mac_address}
|
<tr
|
||||||
className="border-b border-border/50 hover:bg-elevated/50 transition-colors"
|
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 className="px-2 py-1.5 font-mono text-xs text-text-secondary">
|
||||||
</td>
|
{reg.mac_address}
|
||||||
<td className="px-2 py-1.5 text-text-primary">
|
</td>
|
||||||
{reg.device_id ? (
|
<td className="px-2 py-1.5 text-text-primary">
|
||||||
<DeviceLink tenantId={tenantId} deviceId={reg.device_id}>
|
{reg.device_id ? (
|
||||||
{reg.hostname ?? reg.mac_address}
|
<DeviceLink tenantId={tenantId} deviceId={reg.device_id}>
|
||||||
</DeviceLink>
|
{reg.hostname ?? reg.mac_address}
|
||||||
) : (
|
</DeviceLink>
|
||||||
<span className="text-text-muted">{reg.hostname ?? '--'}</span>
|
) : (
|
||||||
)}
|
<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))}>
|
</td>
|
||||||
{reg.signal_strength != null ? `${reg.signal_strength} dBm` : '--'}
|
<td className={cn('px-2 py-1.5 text-right font-medium', signalColor(reg.signal_strength))}>
|
||||||
</td>
|
{reg.signal_strength != null ? `${reg.signal_strength} dBm` : '--'}
|
||||||
<td className={cn('px-2 py-1.5 text-right font-medium', ccqColor(reg.tx_ccq))}>
|
</td>
|
||||||
{reg.tx_ccq != null ? `${reg.tx_ccq}%` : '--'}
|
<td className={cn('px-2 py-1.5 text-right font-medium', ccqColor(reg.tx_ccq))}>
|
||||||
</td>
|
{reg.tx_ccq != null ? `${reg.tx_ccq}%` : '--'}
|
||||||
<td className="px-2 py-1.5 text-right text-text-secondary">
|
</td>
|
||||||
{reg.tx_rate ?? '--'}
|
<td className="px-2 py-1.5 text-right text-text-secondary">
|
||||||
</td>
|
{reg.tx_rate ?? '--'}
|
||||||
<td className="px-2 py-1.5 text-right text-text-secondary">
|
</td>
|
||||||
{reg.rx_rate ?? '--'}
|
<td className="px-2 py-1.5 text-right text-text-secondary">
|
||||||
</td>
|
{reg.rx_rate ?? '--'}
|
||||||
<td className="px-2 py-1.5 text-right text-text-secondary">
|
</td>
|
||||||
{reg.distance != null ? `${reg.distance}m` : '--'}
|
<td className="px-2 py-1.5 text-right text-text-secondary">
|
||||||
</td>
|
{reg.distance != null ? `${reg.distance}m` : '--'}
|
||||||
<td className="px-2 py-1.5 text-right text-text-secondary">
|
</td>
|
||||||
{reg.uptime ?? '--'}
|
<td className="px-2 py-1.5 text-right text-text-secondary">
|
||||||
</td>
|
{reg.uptime ?? '--'}
|
||||||
<td className="px-2 py-1.5 text-right text-text-muted text-xs">
|
</td>
|
||||||
{timeAgo(reg.last_seen)}
|
<td className="px-2 py-1.5 text-right text-text-muted text-xs">
|
||||||
</td>
|
{timeAgo(reg.last_seen)}
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user