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

@@ -23,8 +23,8 @@
### Sectors ### Sectors
- [ ] **SECT-01**: Operator can define sectors within a site (name, optional azimuth/bearing) - [x] **SECT-01**: Operator can define sectors within a site (name, optional azimuth/bearing)
- [ ] **SECT-02**: Operator can assign APs to sectors - [x] **SECT-02**: Operator can assign APs to sectors
- [ ] **SECT-03**: Sector view shows aggregate client count, bandwidth, and signal statistics per sector - [ ] **SECT-03**: Sector view shows aggregate client count, bandwidth, and signal statistics per sector
### Wireless Collection ### Wireless Collection
@@ -104,8 +104,8 @@
| DASH-02 | Phase 14 | Pending | | DASH-02 | Phase 14 | Pending |
| DASH-03 | Phase 14 | Pending | | DASH-03 | Phase 14 | Pending |
| DASH-04 | Phase 14 | Pending | | DASH-04 | Phase 14 | Pending |
| SECT-01 | Phase 14 | Pending | | SECT-01 | Phase 14 | Complete |
| SECT-02 | Phase 14 | Pending | | SECT-02 | Phase 14 | Complete |
| SECT-03 | Phase 14 | Pending | | SECT-03 | Phase 14 | Pending |
| WRCL-01 | Phase 12 | Complete | | WRCL-01 | Phase 12 | Complete |
| WRCL-02 | Phase 12 | Complete | | WRCL-02 | Phase 12 | Complete |

View File

@@ -101,12 +101,12 @@ Plans:
3. Site dashboard displays wireless link topology showing which CPEs connect to which APs with signal quality indicators 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 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 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: Plans:
- [ ] 14-01: TBD - [ ] 14-01-PLAN.md — Sector backend (migration, model, service, router), site_id device filter, wireless data APIs, frontend API clients
- [ ] 14-02: TBD - [ ] 14-02-PLAN.md — Device detail wireless station table, RF stats card, standalone wireless links page
- [ ] 14-03: TBD - [ ] 14-03-PLAN.md — Site dashboard with tabbed views (Health Grid, Sectors, Links)
### Phase 15: Signal Trending + Site Alerting ### Phase 15: Signal Trending + Site Alerting
**Goal**: Operators can track signal quality over time and receive alerts when site or sector conditions degrade **Goal**: Operators can track signal quality over time and receive alerts when site or sector conditions degrade
@@ -128,8 +128,7 @@ Plans:
| Category | Requirements | Phase | Count | | 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 | | 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 | | Site Dashboard | DASH-02, DASH-03, DASH-04 | 14 | 1/3 | In Progress| | SECT-01, SECT-02, SECT-03 | 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 | | 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 | | Signal Trending | TRND-01, TRND-02 | 15 | 2 |
| Site Alerting | ALRT-01, ALRT-02 | 15 | 2 | | Site Alerting | ALRT-01, ALRT-02 | 15 | 2 |
@@ -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 | - | | 11. Site Data Model + Foundation | 0/3 | Planning complete | - |
| 12. Per-Client Wireless Collection | 0/2 | Planning complete | - | | 12. Per-Client Wireless Collection | 0/2 | Planning complete | - |
| 13. Link Discovery + Registration Ingestion | 0/3 | 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 | - | | 15. Signal Trending + Site Alerting | 0/? | Not started | - |
--- ---

View File

@@ -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 13-03-PLAN.md stopped_at: Completed 14-01-PLAN.md
last_updated: "2026-03-19T11:13:17.840Z" last_updated: "2026-03-19T11:43:25.898Z"
progress: progress:
total_phases: 5 total_phases: 5
completed_phases: 3 completed_phases: 3
total_plans: 8 total_plans: 11
completed_plans: 8 completed_plans: 9
--- ---
# Project State # Project State
@@ -19,12 +19,12 @@ progress:
See: .planning/PROJECT.md (updated 2026-03-18) 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 **Core value:** Operators can monitor, configure, and troubleshoot their entire MikroTik fleet from a single pane of glass
**Current focus:** Phase 13link-discovery-registration-ingestion **Current focus:** Phase 14site-dashboard-sector-views-wireless-ui
## Current Position ## Current Position
Phase: 13 (link-discovery-registration-ingestion) — COMPLETE Phase: 14 (site-dashboard-sector-views-wireless-ui) — EXECUTING
Plan: 3 of 3 (all complete) Plan: 2 of 3
## Performance Metrics ## Performance Metrics
@@ -43,6 +43,7 @@ Plan: 3 of 3 (all complete)
| 13 | 2 | 5min | 2.5min | | 13 | 2 | 5min | 2.5min |
| Phase 13 P01 | 5min | 2 tasks | 4 files | | Phase 13 P01 | 5min | 2 tasks | 4 files |
| Phase 13 P03 | 3min | 2 tasks | 6 files | | Phase 13 P03 | 3min | 2 tasks | 6 files |
| Phase 14 P01 | 3min | 2 tasks | 15 files |
## Accumulated Context ## 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]: 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]: 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 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 ### Pending Todos
@@ -87,6 +91,6 @@ None yet.
## Session Continuity ## Session Continuity
Last session: 2026-03-19T11:13:17.837Z Last session: 2026-03-19T11:43:25.894Z
Stopped at: Completed 13-03-PLAN.md Stopped at: Completed 14-01-PLAN.md
Resume file: None Resume file: None

View File

@@ -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 (
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-wider text-text-muted">{label}</span>
<span className="text-sm font-medium text-text-primary">
{value != null ? `${value}${unit ?? ''}` : '--'}
</span>
</div>
)
}
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 <TableSkeleton rows={2} />
}
if (!data || data.items.length === 0) {
return (
<EmptyState
icon={Radio}
title="No RF stats"
description="No RF stats available for this device"
/>
)
}
return (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-text-primary px-1">RF Environment</h3>
<div className="grid gap-3 sm:grid-cols-2">
{data.items.map((stat: RFStatsResponse) => (
<div
key={stat.interface}
className="rounded-lg border border-border bg-surface p-3"
>
<div className="flex items-center gap-2 mb-3">
<Radio className="h-4 w-4 text-text-muted" />
<span className="text-sm font-semibold text-text-primary">{stat.interface}</span>
</div>
<div className="grid grid-cols-2 gap-3">
<StatValue label="Noise Floor" value={stat.noise_floor} unit=" dBm" />
<StatValue label="Channel Width" value={stat.channel_width} unit=" MHz" />
<StatValue label="TX Power" value={stat.tx_power} unit=" dBm" />
<StatValue label="Clients" value={stat.registered_clients} />
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -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<string, string> = {
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 (
<span
className={cn(
'text-[10px] font-medium uppercase px-1.5 py-0.5 rounded border',
STATE_STYLES[state] ?? STATE_STYLES.stale,
)}
>
{state}
</span>
)
}
interface WirelessLinksTableProps {
tenantId: string
siteId?: string
stateFilter?: string
showUnknownClients?: boolean
}
export function WirelessLinksTable({ tenantId, siteId }: WirelessLinksTableProps) {
const [filter, setFilter] = useState<string>('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<string, { apHostname: string; apDeviceId: string; links: LinkResponse[] }>()
const map = new Map<string, { apHostname: string; apDeviceId: string; links: LinkResponse[] }>()
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 <TableSkeleton rows={8} />
}
if (!data || data.items.length === 0) {
return (
<EmptyState
icon={Wifi}
title="No wireless links"
description="No wireless links discovered"
/>
)
}
return (
<div className="space-y-3">
{/* Filter */}
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">State:</span>
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="degraded">Degraded</SelectItem>
<SelectItem value="down">Down</SelectItem>
<SelectItem value="stale">Stale</SelectItem>
</SelectContent>
</Select>
<span className="text-xs text-text-muted ml-2">
{data.items.length} link{data.items.length !== 1 ? 's' : ''}
</span>
</div>
{/* Links grouped by AP */}
<div className="rounded-lg border border-border overflow-hidden">
<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">
CPE
</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-center">
State
</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>
{[...grouped.values()].map((group) => (
<APGroup
key={group.apDeviceId}
tenantId={tenantId}
apHostname={group.apHostname}
apDeviceId={group.apDeviceId}
links={group.links}
/>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
function APGroup({
tenantId,
apHostname,
apDeviceId,
links,
}: {
tenantId: string
apHostname: string
apDeviceId: string
links: LinkResponse[]
}) {
return (
<>
{/* AP header row */}
<tr className="bg-elevated/50">
<td colSpan={7} className="px-2 py-1.5">
<div className="flex items-center gap-2">
<Wifi className="h-3.5 w-3.5 text-text-muted" />
<DeviceLink tenantId={tenantId} deviceId={apDeviceId} className="font-semibold text-text-primary">
{apHostname}
</DeviceLink>
<span className="text-[10px] text-text-muted">
({links.length} client{links.length !== 1 ? 's' : ''})
</span>
</div>
</td>
</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>
))}
</>
)
}

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>
)
}

View File

@@ -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'
}