diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 0b6cf71..2e3eae9 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -69,11 +69,11 @@ 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**: TBD +**Plans:** 2 plans Plans: -- [ ] 12-01: TBD -- [ ] 12-02: TBD +- [ ] 12-01-PLAN.md — Go poller per-client registration collector, signal parser, RF monitor, NATS stream and publisher +- [ ] 12-02-PLAN.md — Backend wireless_registrations hypertable migration and NATS subscriber ### Phase 13: Link Discovery + Registration Ingestion **Goal**: Backend automatically discovers AP-CPE relationships from wireless registration data and maintains link state with temporal stability @@ -126,7 +126,7 @@ 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 | +| 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 | @@ -144,11 +144,11 @@ Phases execute in numeric order: 11 -> 11.x -> 12 -> 12.x -> 13 -> 13.x -> 14 -> | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| | 11. Site Data Model + Foundation | 0/3 | Planning complete | - | -| 12. Per-Client Wireless Collection | 0/? | Not started | - | +| 12. Per-Client Wireless Collection | 0/2 | Planning complete | - | | 13. Link Discovery + Registration Ingestion | 0/? | Not started | - | | 14. Site Dashboard + Sector Views + Wireless UI | 0/? | Not started | - | | 15. Signal Trending + Site Alerting | 0/? | Not started | - | --- *Roadmap created: 2026-03-18* -*Last updated: 2026-03-18* +*Last updated: 2026-03-19* diff --git a/backend/alembic/versions/031_wireless_registrations_hypertable.py b/backend/alembic/versions/031_wireless_registrations_hypertable.py new file mode 100644 index 0000000..17e04ea --- /dev/null +++ b/backend/alembic/versions/031_wireless_registrations_hypertable.py @@ -0,0 +1,179 @@ +"""Create wireless_registrations hypertable for per-client wireless data. + +Revision ID: 031 +Revises: 030 +Create Date: 2026-03-19 + +Stores per-client registration table rows from RouterOS devices: +- Each row = one wireless client connected to one AP interface +- Collected every poll cycle by the Go poller +- Published via WIRELESS_REGISTRATIONS NATS stream +- 30-day retention (shorter than 90-day health/interface metrics) + +Also creates rf_monitor_stats hypertable for per-interface RF environment +data (noise floor, channel width, tx power, registered client count). +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "031" +down_revision: Union[str, None] = "030" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + + # ========================================================================= + # CREATE wireless_registrations HYPERTABLE + # ========================================================================= + # Stores per-client registration table rows from RouterOS wireless interfaces. + # One row per connected client per poll cycle. + # signal_strength is dBm (negative integer, e.g. -67). + # tx_ccq is 0-100 percentage (may be 0 on RouterOS v7 WiFi path). + conn.execute( + sa.text(""" + CREATE TABLE IF NOT EXISTS wireless_registrations ( + time TIMESTAMPTZ NOT NULL, + device_id UUID NOT NULL, + tenant_id UUID NOT NULL, + interface TEXT NOT NULL, + mac_address TEXT NOT NULL, + signal_strength SMALLINT, + tx_ccq SMALLINT, + tx_rate TEXT, + rx_rate TEXT, + uptime TEXT, + distance INTEGER, + last_ip TEXT, + tx_signal_strength SMALLINT, + bytes TEXT + ) + """) + ) + + conn.execute( + sa.text( + "SELECT create_hypertable('wireless_registrations', 'time', if_not_exists => TRUE)" + ) + ) + + # Primary lookup: device + time range + conn.execute( + sa.text( + "CREATE INDEX IF NOT EXISTS idx_wireless_reg_device_time " + "ON wireless_registrations (device_id, time DESC)" + ) + ) + + # MAC lookup for Phase 13 link discovery MAC resolution + conn.execute( + sa.text( + "CREATE INDEX IF NOT EXISTS idx_wireless_reg_mac_time " + "ON wireless_registrations (mac_address, time DESC)" + ) + ) + + conn.execute(sa.text("ALTER TABLE wireless_registrations ENABLE ROW LEVEL SECURITY")) + + conn.execute( + sa.text(""" + CREATE POLICY tenant_isolation ON wireless_registrations + USING ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + WITH CHECK ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + """) + ) + + conn.execute(sa.text("GRANT SELECT, INSERT ON wireless_registrations TO app_user")) + conn.execute(sa.text("GRANT SELECT, INSERT ON wireless_registrations TO poller_user")) + + # 30-day retention (shorter than 90-day health/interface metrics -- wireless + # registration data is high-volume and primarily useful for recent analysis) + conn.execute( + sa.text("SELECT add_retention_policy('wireless_registrations', INTERVAL '30 days')") + ) + + # ========================================================================= + # CREATE rf_monitor_stats HYPERTABLE + # ========================================================================= + # Stores per-interface RF environment data from the wireless monitor: + # noise floor, channel width, tx power, and registered client count. + # Time-series for trending RF conditions across the fleet. + conn.execute( + sa.text(""" + CREATE TABLE IF NOT EXISTS rf_monitor_stats ( + time TIMESTAMPTZ NOT NULL, + device_id UUID NOT NULL, + tenant_id UUID NOT NULL, + interface TEXT NOT NULL, + noise_floor SMALLINT, + channel_width TEXT, + tx_power SMALLINT, + registered_clients SMALLINT + ) + """) + ) + + conn.execute( + sa.text( + "SELECT create_hypertable('rf_monitor_stats', 'time', if_not_exists => TRUE)" + ) + ) + + conn.execute( + sa.text( + "CREATE INDEX IF NOT EXISTS idx_rf_monitor_device_time " + "ON rf_monitor_stats (device_id, time DESC)" + ) + ) + + conn.execute(sa.text("ALTER TABLE rf_monitor_stats ENABLE ROW LEVEL SECURITY")) + + conn.execute( + sa.text(""" + CREATE POLICY tenant_isolation ON rf_monitor_stats + USING ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + WITH CHECK ( + tenant_id::text = current_setting('app.current_tenant', true) + OR current_setting('app.current_tenant', true) = 'super_admin' + ) + """) + ) + + conn.execute(sa.text("GRANT SELECT, INSERT ON rf_monitor_stats TO app_user")) + conn.execute(sa.text("GRANT SELECT, INSERT ON rf_monitor_stats TO poller_user")) + + conn.execute( + sa.text("SELECT add_retention_policy('rf_monitor_stats', INTERVAL '30 days')") + ) + + +def downgrade() -> None: + conn = op.get_bind() + + # Remove retention policies before dropping tables + conn.execute( + sa.text("SELECT remove_retention_policy('rf_monitor_stats', if_exists => true)") + ) + conn.execute(sa.text("DROP TABLE IF EXISTS rf_monitor_stats CASCADE")) + + conn.execute( + sa.text( + "SELECT remove_retention_policy('wireless_registrations', if_exists => true)" + ) + ) + conn.execute(sa.text("DROP TABLE IF EXISTS wireless_registrations CASCADE"))