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:
@@ -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
|
||||
@@ -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*
|
||||
|
||||
@@ -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"))
|
||||
Reference in New Issue
Block a user