From eec89b802a48bd0638114cd391850e21556c27af Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Thu, 19 Mar 2026 06:46:00 -0500 Subject: [PATCH] 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) --- .planning/REQUIREMENTS.md | 8 +- .planning/ROADMAP.md | 15 +- .planning/STATE.md | 22 +- .../src/components/wireless/RFStatsCard.tsx | 69 ++++++ .../wireless/WirelessLinksTable.tsx | 232 ++++++++++++++++++ .../wireless/WirelessStationTable.tsx | 142 +++++++++++ .../src/components/wireless/signal-color.ts | 14 ++ 7 files changed, 481 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/wireless/RFStatsCard.tsx create mode 100644 frontend/src/components/wireless/WirelessLinksTable.tsx create mode 100644 frontend/src/components/wireless/WirelessStationTable.tsx create mode 100644 frontend/src/components/wireless/signal-color.ts diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index fda40d9..d2c6fd1 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -23,8 +23,8 @@ ### Sectors -- [ ] **SECT-01**: Operator can define sectors within a site (name, optional azimuth/bearing) -- [ ] **SECT-02**: Operator can assign APs to sectors +- [x] **SECT-01**: Operator can define sectors within a site (name, optional azimuth/bearing) +- [x] **SECT-02**: Operator can assign APs to sectors - [ ] **SECT-03**: Sector view shows aggregate client count, bandwidth, and signal statistics per sector ### Wireless Collection @@ -104,8 +104,8 @@ | DASH-02 | Phase 14 | Pending | | DASH-03 | Phase 14 | Pending | | DASH-04 | Phase 14 | Pending | -| SECT-01 | Phase 14 | Pending | -| SECT-02 | Phase 14 | Pending | +| SECT-01 | Phase 14 | Complete | +| SECT-02 | Phase 14 | Complete | | SECT-03 | Phase 14 | Pending | | WRCL-01 | Phase 12 | Complete | | WRCL-02 | Phase 12 | Complete | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index eb53e2e..a4ed0fa 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -101,12 +101,12 @@ Plans: 3. Site dashboard displays wireless link topology showing which CPEs connect to which APs with signal quality indicators 4. Device detail page shows a per-station wireless table (connected clients with MAC, signal, CCQ, TX/RX rates, distance, uptime) and per-interface RF stats 5. Operator can define sectors within a site, assign APs to sectors, and view aggregate stats per sector -**Plans**: TBD +**Plans:** 1/3 plans executed Plans: -- [ ] 14-01: TBD -- [ ] 14-02: TBD -- [ ] 14-03: TBD +- [ ] 14-01-PLAN.md — Sector backend (migration, model, service, router), site_id device filter, wireless data APIs, frontend API clients +- [ ] 14-02-PLAN.md — Device detail wireless station table, RF stats card, standalone wireless links page +- [ ] 14-03-PLAN.md — Site dashboard with tabbed views (Health Grid, Sectors, Links) ### Phase 15: Signal Trending + Site Alerting **Goal**: Operators can track signal quality over time and receive alerts when site or sector conditions degrade @@ -128,9 +128,8 @@ Plans: | Category | Requirements | Phase | Count | |----------|-------------|-------|-------| | 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 | -| Sectors | 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 | +| Site Dashboard | DASH-02, DASH-03, DASH-04 | 14 | 1/3 | In Progress| | 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 | 2 | | Site Alerting | ALRT-01, ALRT-02 | 15 | 2 | | **Total** | | | **30** | @@ -145,7 +144,7 @@ Phases execute in numeric order: 11 -> 11.x -> 12 -> 12.x -> 13 -> 13.x -> 14 -> | 11. Site Data Model + Foundation | 0/3 | Planning complete | - | | 12. Per-Client Wireless Collection | 0/2 | Planning complete | - | | 13. Link Discovery + Registration Ingestion | 0/3 | Planning complete | - | -| 14. Site Dashboard + Sector Views + Wireless UI | 0/? | Not started | - | +| 14. Site Dashboard + Sector Views + Wireless UI | 0/3 | Planning complete | - | | 15. Signal Trending + Site Alerting | 0/? | Not started | - | --- diff --git a/.planning/STATE.md b/.planning/STATE.md index b796106..1679b75 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 13-03-PLAN.md -last_updated: "2026-03-19T11:13:17.840Z" +stopped_at: Completed 14-01-PLAN.md +last_updated: "2026-03-19T11:43:25.898Z" progress: total_phases: 5 completed_phases: 3 - total_plans: 8 - completed_plans: 8 + total_plans: 11 + completed_plans: 9 --- # Project State @@ -19,12 +19,12 @@ progress: See: .planning/PROJECT.md (updated 2026-03-18) **Core value:** Operators can monitor, configure, and troubleshoot their entire MikroTik fleet from a single pane of glass -**Current focus:** Phase 13 — link-discovery-registration-ingestion +**Current focus:** Phase 14 — site-dashboard-sector-views-wireless-ui ## Current Position -Phase: 13 (link-discovery-registration-ingestion) — COMPLETE -Plan: 3 of 3 (all complete) +Phase: 14 (site-dashboard-sector-views-wireless-ui) — EXECUTING +Plan: 2 of 3 ## Performance Metrics @@ -43,6 +43,7 @@ Plan: 3 of 3 (all complete) | 13 | 2 | 5min | 2.5min | | Phase 13 P01 | 5min | 2 tasks | 4 files | | Phase 13 P03 | 3min | 2 tasks | 6 files | +| Phase 14 P01 | 3min | 2 tasks | 15 files | ## Accumulated Context @@ -74,6 +75,9 @@ Decisions are logged in PROJECT.md Key Decisions table. - [Phase 13]: InterfaceInfo (identity/link discovery) kept separate from InterfaceStats (traffic counters) - [Phase 13]: Link discovery uses separate durable consumer on WIRELESS_REGISTRATIONS for independent processing - [Phase 13]: Unknown clients query uses DISTINCT ON (mac_address) for most recent data per MAC +- [Phase 14]: Sector CRUD nested under sites path (/sites/{sid}/sectors) matching REST hierarchy +- [Phase 14]: Device sector assignment uses PUT /devices/{did}/sector with nullable sector_id for set/clear +- [Phase 14]: Wireless registration queries join device_interfaces for MAC-to-hostname resolution ### Pending Todos @@ -87,6 +91,6 @@ None yet. ## Session Continuity -Last session: 2026-03-19T11:13:17.837Z -Stopped at: Completed 13-03-PLAN.md +Last session: 2026-03-19T11:43:25.894Z +Stopped at: Completed 14-01-PLAN.md Resume file: None diff --git a/frontend/src/components/wireless/RFStatsCard.tsx b/frontend/src/components/wireless/RFStatsCard.tsx new file mode 100644 index 0000000..b911c40 --- /dev/null +++ b/frontend/src/components/wireless/RFStatsCard.tsx @@ -0,0 +1,69 @@ +import { useQuery } from '@tanstack/react-query' +import { Radio } from 'lucide-react' +import { wirelessApi, type RFStatsResponse } from '@/lib/api' +import { EmptyState } from '@/components/ui/empty-state' +import { TableSkeleton } from '@/components/ui/page-skeleton' + +interface RFStatsCardProps { + tenantId: string + deviceId: string + active: boolean +} + +function StatValue({ label, value, unit }: { label: string; value: number | null; unit?: string }) { + return ( +
+ {label} + + {value != null ? `${value}${unit ?? ''}` : '--'} + +
+ ) +} + +export function RFStatsCard({ tenantId, deviceId, active }: RFStatsCardProps) { + const { data, isLoading } = useQuery({ + queryKey: ['device-rf-stats', tenantId, deviceId], + queryFn: () => wirelessApi.getDeviceRFStats(tenantId, deviceId), + enabled: active, + }) + + if (isLoading) { + return + } + + if (!data || data.items.length === 0) { + return ( + + ) + } + + return ( +
+

RF Environment

+
+ {data.items.map((stat: RFStatsResponse) => ( +
+
+ + {stat.interface} +
+
+ + + + +
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/wireless/WirelessLinksTable.tsx b/frontend/src/components/wireless/WirelessLinksTable.tsx new file mode 100644 index 0000000..f3938ad --- /dev/null +++ b/frontend/src/components/wireless/WirelessLinksTable.tsx @@ -0,0 +1,232 @@ +import { useState, useMemo } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Wifi } from 'lucide-react' +import { wirelessApi, type LinkResponse } 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +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` +} + +const STATE_STYLES: Record = { + active: 'bg-success/20 text-success border-success/40', + degraded: 'bg-warning/20 text-warning border-warning/40', + down: 'bg-error/20 text-error border-error/40', + stale: 'bg-elevated text-text-muted border-border', + discovered: 'bg-info/20 text-info border-info/40', +} + +function StateBadge({ state }: { state: string }) { + return ( + + {state} + + ) +} + +interface WirelessLinksTableProps { + tenantId: string + siteId?: string + stateFilter?: string + showUnknownClients?: boolean +} + +export function WirelessLinksTable({ tenantId, siteId }: WirelessLinksTableProps) { + const [filter, setFilter] = useState('all') + + const { data, isLoading } = useQuery({ + queryKey: ['wireless-links', tenantId, siteId, filter], + queryFn: () => { + if (siteId) { + return wirelessApi.getSiteLinks(tenantId, siteId) + } + const params = filter !== 'all' ? { state: filter } : undefined + return wirelessApi.getLinks(tenantId, params) + }, + }) + + // Group links by AP device + const grouped = useMemo(() => { + if (!data?.items) return new Map() + const map = new Map() + for (const link of data.items) { + const key = link.ap_device_id + if (!map.has(key)) { + map.set(key, { + apHostname: link.ap_hostname ?? link.ap_device_id, + apDeviceId: link.ap_device_id, + links: [], + }) + } + map.get(key)!.links.push(link) + } + return map + }, [data]) + + if (isLoading) { + return + } + + if (!data || data.items.length === 0) { + return ( + + ) + } + + return ( +
+ {/* Filter */} +
+ State: + + + {data.items.length} link{data.items.length !== 1 ? 's' : ''} + +
+ + {/* Links grouped by AP */} +
+
+ + + + + + + + + + + + + + {[...grouped.values()].map((group) => ( + + ))} + +
+ CPE + + Signal + + CCQ + + TX Rate + + RX Rate + + State + + Last Seen +
+
+
+
+ ) +} + +function APGroup({ + tenantId, + apHostname, + apDeviceId, + links, +}: { + tenantId: string + apHostname: string + apDeviceId: string + links: LinkResponse[] +}) { + return ( + <> + {/* AP header row */} + + +
+ + + {apHostname} + + + ({links.length} client{links.length !== 1 ? 's' : ''}) + +
+ + + {/* 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)} + + + ))} + + ) +} diff --git a/frontend/src/components/wireless/WirelessStationTable.tsx b/frontend/src/components/wireless/WirelessStationTable.tsx new file mode 100644 index 0000000..812ef2a --- /dev/null +++ b/frontend/src/components/wireless/WirelessStationTable.tsx @@ -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 + } + + if (!data || data.items.length === 0) { + return ( + + ) + } + + return ( +
+
+

+ Wireless Stations ({data.items.length}) +

+
+
+ + + + + + + + + + + + + + + + {data.items.map((reg: RegistrationResponse) => ( + + + + + + + + + + + + ))} + +
+ MAC + + Hostname + + Signal + + CCQ + + TX Rate + + RX Rate + + Distance + + Uptime + + Last Seen +
+ {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)} +
+
+
+ ) +} diff --git a/frontend/src/components/wireless/signal-color.ts b/frontend/src/components/wireless/signal-color.ts new file mode 100644 index 0000000..7ce4e7a --- /dev/null +++ b/frontend/src/components/wireless/signal-color.ts @@ -0,0 +1,14 @@ +/** + * Returns a Tailwind text color class based on wireless signal strength. + * + * Thresholds: + * >= -65 dBm -> green (good) + * >= -75 dBm -> yellow (marginal) + * < -75 dBm -> red (poor) + */ +export function signalColor(dbm: number | null): string { + if (dbm == null) return 'text-text-muted' + if (dbm >= -65) return 'text-success' + if (dbm >= -75) return 'text-warning' + return 'text-error' +}