From 3bddd6f65443a7d978263dd7b0c06118e0897d87 Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Thu, 19 Mar 2026 07:22:49 -0500 Subject: [PATCH] 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) --- .planning/REQUIREMENTS.md | 4 +- .planning/ROADMAP.md | 4 +- .planning/STATE.md | 14 +- .../wireless/SignalHistoryChart.tsx | 158 ++++++++++++++++++ .../wireless/WirelessLinksTable.tsx | 74 ++++---- .../wireless/WirelessStationTable.tsx | 93 ++++++----- 6 files changed, 271 insertions(+), 76 deletions(-) create mode 100644 frontend/src/components/wireless/SignalHistoryChart.tsx diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 9b8b800..4f87cd6 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -51,7 +51,7 @@ ### 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") ### Site Alerting @@ -120,7 +120,7 @@ | WRUI-01 | Phase 14 | Complete | | WRUI-02 | Phase 14 | Complete | | WRUI-03 | Phase 14 | Complete | -| TRND-01 | Phase 15 | Pending | +| TRND-01 | Phase 15 | Complete | | TRND-02 | Phase 15 | Complete | | ALRT-01 | Phase 15 | Complete | | ALRT-02 | Phase 15 | Complete | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index dd8a111..f0d3a87 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -117,7 +117,7 @@ Plans: 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") 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: - [ ] 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 | | 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 | -| 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** | ## Progress diff --git a/.planning/STATE.md b/.planning/STATE.md index eea0eaa..d5fcc5b 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,13 +3,13 @@ gsd_state_version: 1.0 milestone: v9.7 milestone_name: Tower & Site Management status: unknown -stopped_at: Completed 15-02-PLAN.md -last_updated: "2026-03-19T12:18:13.853Z" +stopped_at: Completed 15-01-PLAN.md +last_updated: "2026-03-19T12:19:31.850Z" progress: total_phases: 5 completed_phases: 4 total_plans: 14 - completed_plans: 12 + completed_plans: 13 --- # Project State @@ -47,6 +47,7 @@ Plan: 2 of 3 | Phase 14 P02 | 3min | 2 tasks | 9 files | | Phase 14 P03 | 3min | 2 tasks | 6 files | | Phase 15 P02 | 3min | 2 tasks | 4 files | +| Phase 15 P01 | 4min | 2 tasks | 10 files | ## 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 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]: 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 @@ -100,6 +104,6 @@ None yet. ## Session Continuity -Last session: 2026-03-19T12:18:06.830Z -Stopped at: Completed 15-02-PLAN.md +Last session: 2026-03-19T12:19:31.847Z +Stopped at: Completed 15-01-PLAN.md Resume file: None diff --git a/frontend/src/components/wireless/SignalHistoryChart.tsx b/frontend/src/components/wireless/SignalHistoryChart.tsx new file mode 100644 index 0000000..d4f8ac4 --- /dev/null +++ b/frontend/src/components/wireless/SignalHistoryChart.tsx @@ -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('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 ( +
+ {/* Header with range selector */} +
+ + Signal History + +
+ {(['24h', '7d', '30d'] as Range[]).map((r) => ( + + ))} +
+
+ + {/* Chart body */} + {isLoading ? ( +
+ +
+ ) : chartData.length === 0 ? ( +
+ No signal data available for this time range +
+ ) : ( + + + + + {/* Color band reference areas */} + + + + + + + { + const labels: Record = { + signal_avg: 'Avg Signal', + signal_min: 'Min Signal', + signal_max: 'Max Signal', + } + return [`${value} dBm`, labels[name] ?? name] + }} + /> + + + + + + )} +
+ ) +} diff --git a/frontend/src/components/wireless/WirelessLinksTable.tsx b/frontend/src/components/wireless/WirelessLinksTable.tsx index f3938ad..cd38031 100644 --- a/frontend/src/components/wireless/WirelessLinksTable.tsx +++ b/frontend/src/components/wireless/WirelessLinksTable.tsx @@ -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(null) + return ( <> {/* AP header row */} @@ -198,34 +201,47 @@ function APGroup({ {/* CPE rows */} {links.map((link) => ( - - - - {link.cpe_hostname ?? link.client_mac} - - - - {link.signal_strength != null ? `${link.signal_strength} dBm` : '--'} - - - {link.tx_ccq != null ? `${link.tx_ccq}%` : '--'} - - - {link.tx_rate ?? '--'} - - - {link.rx_rate ?? '--'} - - - - - - {timeAgo(link.last_seen)} - - + + setExpandedLinkId(expandedLinkId === link.id ? null : link.id)} + > + + + {link.cpe_hostname ?? link.client_mac} + + + + {link.signal_strength != null ? `${link.signal_strength} dBm` : '--'} + + + {link.tx_ccq != null ? `${link.tx_ccq}%` : '--'} + + + {link.tx_rate ?? '--'} + + + {link.rx_rate ?? '--'} + + + + + + {timeAgo(link.last_seen)} + + + {expandedLinkId === link.id && ( + + + + + + )} + ))} ) diff --git a/frontend/src/components/wireless/WirelessStationTable.tsx b/frontend/src/components/wireless/WirelessStationTable.tsx index 812ef2a..7ee280d 100644 --- a/frontend/src/components/wireless/WirelessStationTable.tsx +++ b/frontend/src/components/wireless/WirelessStationTable.tsx @@ -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(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 {data.items.map((reg: RegistrationResponse) => ( - - - {reg.mac_address} - - - {reg.device_id ? ( - - {reg.hostname ?? reg.mac_address} - - ) : ( - {reg.hostname ?? '--'} - )} - - - {reg.signal_strength != null ? `${reg.signal_strength} dBm` : '--'} - - - {reg.tx_ccq != null ? `${reg.tx_ccq}%` : '--'} - - - {reg.tx_rate ?? '--'} - - - {reg.rx_rate ?? '--'} - - - {reg.distance != null ? `${reg.distance}m` : '--'} - - - {reg.uptime ?? '--'} - - - {timeAgo(reg.last_seen)} - - + + setExpandedMac(expandedMac === reg.mac_address ? null : reg.mac_address)} + > + + {reg.mac_address} + + + {reg.device_id ? ( + + {reg.hostname ?? reg.mac_address} + + ) : ( + {reg.hostname ?? '--'} + )} + + + {reg.signal_strength != null ? `${reg.signal_strength} dBm` : '--'} + + + {reg.tx_ccq != null ? `${reg.tx_ccq}%` : '--'} + + + {reg.tx_rate ?? '--'} + + + {reg.rx_rate ?? '--'} + + + {reg.distance != null ? `${reg.distance}m` : '--'} + + + {reg.uptime ?? '--'} + + + {timeAgo(reg.last_seen)} + + + {expandedMac === reg.mac_address && ( + + + + + + )} + ))}