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 */}
+
+
+
+
+
+ |
+ CPE
+ |
+
+ Signal
+ |
+
+ CCQ
+ |
+
+ TX Rate
+ |
+
+ RX Rate
+ |
+
+ State
+ |
+
+ Last Seen
+ |
+
+
+
+ {[...grouped.values()].map((group) => (
+
+ ))}
+
+
+
+
+
+ )
+}
+
+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})
+
+
+
+
+
+
+ |
+ MAC
+ |
+
+ Hostname
+ |
+
+ Signal
+ |
+
+ CCQ
+ |
+
+ TX Rate
+ |
+
+ RX Rate
+ |
+
+ Distance
+ |
+
+ Uptime
+ |
+
+ Last Seen
+ |
+
+
+
+ {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)}
+ |
+
+ ))}
+
+
+
+
+ )
+}
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'
+}