ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
175 lines
6.7 KiB
Python
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"))
|