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

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