"""Add TimescaleDB hypertables for metrics and denormalized columns on devices. Revision ID: 003 Revises: 002 Create Date: 2026-02-25 This migration: 1. Creates interface_metrics hypertable for per-interface traffic counters. 2. Creates health_metrics hypertable for per-device CPU/memory/disk/temperature. 3. Creates wireless_metrics hypertable for per-interface wireless client stats. 4. Adds last_cpu_load and last_memory_used_pct denormalized columns to devices for efficient fleet table display without joining hypertables. 5. Applies RLS tenant_isolation policies and appropriate GRANTs on all hypertables. """ from typing import Sequence, Union import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "003" down_revision: Union[str, None] = "002" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: conn = op.get_bind() # ========================================================================= # CREATE interface_metrics HYPERTABLE # ========================================================================= # Stores per-interface byte counters from /interface/print on every poll cycle. # rx_bps/tx_bps are stored as NULL — computed at query time via LAG() window # function to avoid delta state in the poller. conn.execute(sa.text(""" CREATE TABLE IF NOT EXISTS interface_metrics ( time TIMESTAMPTZ NOT NULL, device_id UUID NOT NULL, tenant_id UUID NOT NULL, interface TEXT NOT NULL, rx_bytes BIGINT, tx_bytes BIGINT, rx_bps BIGINT, tx_bps BIGINT ) """)) conn.execute(sa.text( "SELECT create_hypertable('interface_metrics', 'time', if_not_exists => TRUE)" )) conn.execute(sa.text( "CREATE INDEX IF NOT EXISTS idx_interface_metrics_device_time " "ON interface_metrics (device_id, time DESC)" )) conn.execute(sa.text("ALTER TABLE interface_metrics ENABLE ROW LEVEL SECURITY")) conn.execute(sa.text(""" CREATE POLICY tenant_isolation ON interface_metrics USING (tenant_id::text = current_setting('app.current_tenant')) """)) conn.execute(sa.text("GRANT SELECT, INSERT ON interface_metrics TO app_user")) conn.execute(sa.text("GRANT SELECT, INSERT ON interface_metrics TO poller_user")) # ========================================================================= # CREATE health_metrics HYPERTABLE # ========================================================================= # Stores per-device system health metrics from /system/resource/print and # /system/health/print on every poll cycle. # temperature is nullable — not all RouterOS devices have temperature sensors. conn.execute(sa.text(""" CREATE TABLE IF NOT EXISTS health_metrics ( time TIMESTAMPTZ NOT NULL, device_id UUID NOT NULL, tenant_id UUID NOT NULL, cpu_load SMALLINT, free_memory BIGINT, total_memory BIGINT, free_disk BIGINT, total_disk BIGINT, temperature SMALLINT ) """)) conn.execute(sa.text( "SELECT create_hypertable('health_metrics', 'time', if_not_exists => TRUE)" )) conn.execute(sa.text( "CREATE INDEX IF NOT EXISTS idx_health_metrics_device_time " "ON health_metrics (device_id, time DESC)" )) conn.execute(sa.text("ALTER TABLE health_metrics ENABLE ROW LEVEL SECURITY")) conn.execute(sa.text(""" CREATE POLICY tenant_isolation ON health_metrics USING (tenant_id::text = current_setting('app.current_tenant')) """)) conn.execute(sa.text("GRANT SELECT, INSERT ON health_metrics TO app_user")) conn.execute(sa.text("GRANT SELECT, INSERT ON health_metrics TO poller_user")) # ========================================================================= # CREATE wireless_metrics HYPERTABLE # ========================================================================= # Stores per-wireless-interface aggregated client stats from # /interface/wireless/registration-table/print (v6) or # /interface/wifi/registration-table/print (v7). # ccq may be 0 on RouterOS v7 (not available in the WiFi API path). # avg_signal is dBm (negative integer, e.g. -67). conn.execute(sa.text(""" CREATE TABLE IF NOT EXISTS wireless_metrics ( time TIMESTAMPTZ NOT NULL, device_id UUID NOT NULL, tenant_id UUID NOT NULL, interface TEXT NOT NULL, client_count SMALLINT, avg_signal SMALLINT, ccq SMALLINT, frequency INTEGER ) """)) conn.execute(sa.text( "SELECT create_hypertable('wireless_metrics', 'time', if_not_exists => TRUE)" )) conn.execute(sa.text( "CREATE INDEX IF NOT EXISTS idx_wireless_metrics_device_time " "ON wireless_metrics (device_id, time DESC)" )) conn.execute(sa.text("ALTER TABLE wireless_metrics ENABLE ROW LEVEL SECURITY")) conn.execute(sa.text(""" CREATE POLICY tenant_isolation ON wireless_metrics USING (tenant_id::text = current_setting('app.current_tenant')) """)) conn.execute(sa.text("GRANT SELECT, INSERT ON wireless_metrics TO app_user")) conn.execute(sa.text("GRANT SELECT, INSERT ON wireless_metrics TO poller_user")) # ========================================================================= # ADD DENORMALIZED COLUMNS TO devices TABLE # ========================================================================= # These columns are updated by the metrics subscriber alongside each # health_metrics insert, enabling the fleet table to display CPU and memory # usage without a JOIN to the hypertable. op.add_column( "devices", sa.Column("last_cpu_load", sa.SmallInteger(), nullable=True), ) op.add_column( "devices", sa.Column("last_memory_used_pct", sa.SmallInteger(), nullable=True), ) def downgrade() -> None: # Remove denormalized columns from devices first op.drop_column("devices", "last_memory_used_pct") op.drop_column("devices", "last_cpu_load") conn = op.get_bind() # Drop hypertables (CASCADE handles indexes, policies, and chunks) conn.execute(sa.text("DROP TABLE IF EXISTS wireless_metrics CASCADE")) conn.execute(sa.text("DROP TABLE IF EXISTS health_metrics CASCADE")) conn.execute(sa.text("DROP TABLE IF EXISTS interface_metrics CASCADE"))