feat(12-02): create wireless_registrations and rf_monitor_stats hypertables

- wireless_registrations hypertable with per-client columns (mac, signal, rates, uptime)
- rf_monitor_stats hypertable for RF environment data (noise floor, channel width, tx power)
- RLS tenant_isolation with super_admin bypass on both tables
- Composite indexes: device+time, mac+time (for Phase 13 link discovery)
- 30-day retention policies on both hypertables
- GRANTs for app_user and poller_user

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-19 05:35:56 -05:00
parent 1858c88e8b
commit d12e9e280b
2 changed files with 185 additions and 6 deletions

View File

@@ -69,11 +69,11 @@ Plans:
3. Per-client data publishes to a dedicated WIRELESS_REGISTRATIONS NATS stream (not DEVICE_EVENTS) 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 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 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: Plans:
- [ ] 12-01: TBD - [ ] 12-01-PLAN.md — Go poller per-client registration collector, signal parser, RF monitor, NATS stream and publisher
- [ ] 12-02: TBD - [ ] 12-02-PLAN.md — Backend wireless_registrations hypertable migration and NATS subscriber
### Phase 13: Link Discovery + Registration Ingestion ### Phase 13: Link Discovery + Registration Ingestion
**Goal**: Backend automatically discovers AP-CPE relationships from wireless registration data and maintains link state with temporal stability **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 | | 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 | 3 |
| Sectors | 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 | 6 | | 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 | | Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 11. Site Data Model + Foundation | 0/3 | Planning complete | - | | 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 | - | | 13. Link Discovery + Registration Ingestion | 0/? | Not started | - |
| 14. Site Dashboard + Sector Views + Wireless UI | 0/? | Not started | - | | 14. Site Dashboard + Sector Views + Wireless UI | 0/? | Not started | - |
| 15. Signal Trending + Site Alerting | 0/? | Not started | - | | 15. Signal Trending + Site Alerting | 0/? | Not started | - |
--- ---
*Roadmap created: 2026-03-18* *Roadmap created: 2026-03-18*
*Last updated: 2026-03-18* *Last updated: 2026-03-19*

View File

@@ -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"))