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
- [ ] **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 |

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

View File

@@ -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 13link-discovery-registration-ingestion
**Current focus:** Phase 14site-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

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