Files
the-other-dude/backend/alembic/versions/003_metrics_hypertables.py
Jason Staack b840047e19 feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:30:44 -05:00

175 lines
6.7 KiB
Python

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