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>
This commit is contained in:
174
backend/alembic/versions/003_metrics_hypertables.py
Normal file
174
backend/alembic/versions/003_metrics_hypertables.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""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"))
|
||||
Reference in New Issue
Block a user