diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index d18b41e..2789adf 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -29,12 +29,12 @@ ### Wireless Collection -- [ ] **WRCL-01**: Poller collects per-client registration table data from APs (MAC, signal, CCQ, TX/RX rates, distance, uptime) on a 5-minute cadence -- [ ] **WRCL-02**: Poller collects per-interface RF stats (noise floor, channel width, TX power, registered client count) via monitor command -- [ ] **WRCL-03**: Per-client wireless data publishes to a dedicated NATS stream (separate from DEVICE_EVENTS) to prevent stream saturation -- [ ] **WRCL-04**: Per-client wireless data stores in a dedicated hypertable with 30-day retention (separate from existing wireless_metrics) -- [ ] **WRCL-05**: Poller handles RouterOS v6/v7 field differences gracefully (CCQ absent in v7 wifi package) -- [ ] **WRCL-06**: Signal strength parsing handles RouterOS format variations (e.g., `-67@5GHz` suffix) +- [x] **WRCL-01**: Poller collects per-client registration table data from APs (MAC, signal, CCQ, TX/RX rates, distance, uptime) on a 5-minute cadence +- [x] **WRCL-02**: Poller collects per-interface RF stats (noise floor, channel width, TX power, registered client count) via monitor command +- [x] **WRCL-03**: Per-client wireless data publishes to a dedicated NATS stream (separate from DEVICE_EVENTS) to prevent stream saturation +- [x] **WRCL-04**: Per-client wireless data stores in a dedicated hypertable with 30-day retention (separate from existing wireless_metrics) +- [x] **WRCL-05**: Poller handles RouterOS v6/v7 field differences gracefully (CCQ absent in v7 wifi package) +- [x] **WRCL-06**: Signal strength parsing handles RouterOS format variations (e.g., `-67@5GHz` suffix) ### Link Discovery @@ -107,12 +107,12 @@ | SECT-01 | Phase 14 | Pending | | SECT-02 | Phase 14 | Pending | | SECT-03 | Phase 14 | Pending | -| WRCL-01 | Phase 12 | Pending | -| WRCL-02 | Phase 12 | Pending | -| WRCL-03 | Phase 12 | Pending | -| WRCL-04 | Phase 12 | Pending | -| WRCL-05 | Phase 12 | Pending | -| WRCL-06 | Phase 12 | Pending | +| WRCL-01 | Phase 12 | Complete | +| WRCL-02 | Phase 12 | Complete | +| WRCL-03 | Phase 12 | Complete | +| WRCL-04 | Phase 12 | Complete | +| WRCL-05 | Phase 12 | Complete | +| WRCL-06 | Phase 12 | Complete | | LINK-01 | Phase 13 | Pending | | LINK-02 | Phase 13 | Pending | | LINK-03 | Phase 13 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 2e3eae9..06d9148 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -35,7 +35,7 @@ v9.7 transforms TOD from a flat device list into a site-aware fleet management p - Decimal phases (11.1, 11.2): Urgent insertions (marked with INSERTED) - [x] **Phase 11: Site Data Model + Foundation** - Sites CRUD, device assignment, site list with health rollup (completed 2026-03-19) -- [ ] **Phase 12: Per-Client Wireless Collection** - Poller extension to collect registration table and per-interface RF stats +- [x] **Phase 12: Per-Client Wireless Collection** - Poller extension to collect registration table and per-interface RF stats (completed 2026-03-19) - [ ] **Phase 13: Link Discovery + Registration Ingestion** - Backend NATS consumer, MAC resolution, AP-CPE link state machine - [ ] **Phase 14: Site Dashboard + Sector Views + Wireless UI** - Site detail page, sector-centric view, per-station wireless tables - [ ] **Phase 15: Signal Trending + Site Alerting** - Signal history charts, degradation detection, site/sector alert rules @@ -69,7 +69,7 @@ Plans: 3. Per-client data publishes to a dedicated WIRELESS_REGISTRATIONS NATS stream (not DEVICE_EVENTS) 4. Per-client data stores in a dedicated hypertable with 30-day retention 5. Collection works correctly on both RouterOS v6 (wireless package) and v7 (wifi package) with graceful handling of missing fields -**Plans:** 2 plans +**Plans:** 2/2 plans complete Plans: - [ ] 12-01-PLAN.md — Go poller per-client registration collector, signal parser, RF monitor, NATS stream and publisher @@ -84,11 +84,12 @@ Plans: 2. Link state follows a temporal state machine (discovered, active, degraded, down, stale) with consecutive-miss threshold to prevent false flapping 3. Discovered links are stored in a materialized wireless_links table for fast dashboard queries 4. Wireless clients whose MACs do not match any managed device appear as "unknown clients" with their signal and rate data preserved -**Plans**: TBD +**Plans:** 3 plans Plans: -- [ ] 13-01: TBD -- [ ] 13-02: TBD +- [ ] 13-01-PLAN.md — Go poller interface collector (/interface/print) and DEVICE_EVENTS publisher +- [ ] 13-02-PLAN.md — Backend device_interfaces and wireless_links table migrations with ORM models +- [ ] 13-03-PLAN.md — Link discovery subscriber, interface subscriber, link REST API, and app wiring ### Phase 14: Site Dashboard + Sector Views + Wireless UI **Goal**: Operators can drill into any site to see device health, sector-organized AP/CPE views, and per-station wireless details on device pages @@ -129,8 +130,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 | | Sectors | SECT-01, SECT-02, SECT-03 | 14 | 3 | -| Wireless Collection | WRCL-01, WRCL-02, WRCL-03, WRCL-04, WRCL-05, WRCL-06 | 12 | 6 | -| Link Discovery | LINK-01, LINK-02, LINK-03, LINK-04 | 13 | 4 | +| 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 | 4 | | Wireless UI | WRUI-01, WRUI-02, WRUI-03 | 14 | 3 | | Signal Trending | TRND-01, TRND-02 | 15 | 2 | | Site Alerting | ALRT-01, ALRT-02 | 15 | 2 | @@ -145,7 +145,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/? | Not started | - | +| 13. Link Discovery + Registration Ingestion | 0/3 | Planning complete | - | | 14. Site Dashboard + Sector Views + Wireless UI | 0/? | Not started | - | | 15. Signal Trending + Site Alerting | 0/? | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index f370cb6..672a008 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,14 +2,14 @@ gsd_state_version: 1.0 milestone: v9.7 milestone_name: Tower & Site Management -status: phase-complete -stopped_at: Completed 11-03-PLAN.md -last_updated: "2026-03-19T02:53:16Z" +status: unknown +stopped_at: Completed 12-01-PLAN.md +last_updated: "2026-03-19T10:40:03.896Z" progress: total_phases: 5 - completed_phases: 1 - total_plans: 3 - completed_plans: 3 + completed_phases: 2 + total_plans: 5 + completed_plans: 5 --- # 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 11 — site-data-model-foundation +**Current focus:** Phase 12 — per-client-wireless-collection ## Current Position -Phase: 11 (site-data-model-foundation) — COMPLETE -Plan: 3 of 3 (all complete) +Phase: 12 (per-client-wireless-collection) — COMPLETE +Plan: 2 of 2 (all complete) ## Performance Metrics @@ -45,6 +45,9 @@ Plan: 3 of 3 (all complete) | Phase 11 P01 | 3min | 2 tasks | 9 files | | Phase 11 P02 | 6min | 3 tasks | 8 files | | Phase 11 P03 | 3min | 2 tasks | 5 files | +| Phase 12 P01 | 3min | 2 tasks | 6 files | +| Phase 12 P02 | 3min | 2 tasks | 3 files | +| Phase 12 P01 | 3min | 2 tasks | 6 files | ### Decisions @@ -58,6 +61,9 @@ Decisions are logged in PROJECT.md Key Decisions table. - [Phase 11]: Used Dialog for delete confirmation (no AlertDialog component in UI library) - [Phase 11]: Site column placed after Model in fleet table for logical grouping - [Phase 11]: Viewers see site name text, operators get Select dropdown for assignment +- [Phase 12]: Used unified tenant_isolation RLS policy with super_admin OR clause (matching codebase convention) instead of separate super_admin_bypass policy +- [Phase 12]: WIRELESS_REGISTRATIONS NATS stream uses 30-day retention (vs 24h for DEVICE_EVENTS) for historical client analytics +- [Phase 12]: RF monitor collection gated on wireless interface presence to avoid unnecessary API calls ### Pending Todos @@ -71,6 +77,6 @@ None yet. ## Session Continuity -Last session: 2026-03-19T02:53:16Z -Stopped at: Completed 11-03-PLAN.md (Phase 11 complete) +Last session: 2026-03-19T10:40:03.893Z +Stopped at: Completed 12-01-PLAN.md Resume file: None diff --git a/poller/internal/device/interfaces_test.go b/poller/internal/device/interfaces_test.go new file mode 100644 index 0000000..1e1f09f --- /dev/null +++ b/poller/internal/device/interfaces_test.go @@ -0,0 +1,72 @@ +package device + +import ( + "testing" +) + +func TestInterfaceInfoFields(t *testing.T) { + // Verify struct compiles with expected fields and JSON tags. + info := InterfaceInfo{ + Name: "ether1", + MacAddress: "aa:bb:cc:dd:ee:ff", + Type: "ether", + Running: true, + } + + if info.Name != "ether1" { + t.Errorf("Name = %q, want %q", info.Name, "ether1") + } + if info.MacAddress != "aa:bb:cc:dd:ee:ff" { + t.Errorf("MacAddress = %q, want %q", info.MacAddress, "aa:bb:cc:dd:ee:ff") + } + if info.Type != "ether" { + t.Errorf("Type = %q, want %q", info.Type, "ether") + } + if !info.Running { + t.Error("Running = false, want true") + } +} + +func TestInterfaceMACLowercasing(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "already lowercase", input: "aa:bb:cc:dd:ee:ff", want: "aa:bb:cc:dd:ee:ff"}, + {name: "uppercase", input: "AA:BB:CC:DD:EE:FF", want: "aa:bb:cc:dd:ee:ff"}, + {name: "mixed case", input: "Aa:Bb:Cc:Dd:Ee:Ff", want: "aa:bb:cc:dd:ee:ff"}, + {name: "empty", input: "", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeMACAddress(tt.input) + if got != tt.want { + t.Errorf("normalizeMACAddress(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestInterfaceRunningParsing(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {name: "true string", input: "true", want: true}, + {name: "false string", input: "false", want: false}, + {name: "empty string", input: "", want: false}, + {name: "yes string", input: "yes", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseRunning(tt.input) + if got != tt.want { + t.Errorf("parseRunning(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +}