towerops/priv/repo/migrations/20260309223347_add_wireless_client_readings_hypertable.exs
Graham McIntire f09d1b296d feat: add historical wireless client tracking with TimescaleDB
- Create wireless_client_readings hypertable with compression and retention
- Add WirelessClientReading schema and batch insert function
- Update DevicePollerWorker to save historical readings
- Add signal_badge and snr_badge components to CoreComponents
2026-03-09 17:42:17 -05:00

216 lines
7.9 KiB
Elixir

defmodule Towerops.Repo.Migrations.AddWirelessClientReadingsHypertable do
@moduledoc """
Creates wireless_client_readings hypertable for historical wireless client metrics tracking.
Stores time-series snapshots of wireless client data (signal strength, SNR, distance, rates)
collected every 60 seconds during SNMP polling. Enables trend analysis, historical queries,
and proactive alerting for wireless performance issues.
TimescaleDB Configuration:
- 1-day chunks for efficient time-based queries
- Compression after 7 days (90-95% storage reduction)
- 90-day retention on raw data
- Continuous aggregates: hourly (1 year) and daily (5 years) for fast analytics
"""
use Ecto.Migration
@disable_ddl_transaction true
@disable_migration_lock true
def up do
# Create wireless_client_readings table
create table(:wireless_client_readings, primary_key: false) do
add :id, :uuid, primary_key: true
add :checked_at, :utc_datetime, null: false, primary_key: true
# Foreign keys (denormalized for query performance)
add :device_id, references(:snmp_devices, on_delete: :delete_all, type: :uuid), null: false
add :wireless_client_id,
references(:wireless_clients, on_delete: :delete_all, type: :uuid),
null: false
add :organization_id, references(:organizations, on_delete: :delete_all, type: :uuid),
null: false
# Denormalized client identifiers (avoid joins for common queries)
add :mac_address, :string, null: false
add :ip_address, :string
# Wireless metrics
add :signal_strength, :integer
add :snr, :integer
add :distance, :integer
add :tx_rate, :integer
add :rx_rate, :integer
add :uptime_seconds, :integer
# Additional metadata (vendor-specific fields, future expansion)
add :metadata, :map, default: %{}
# Timestamp (no updated_at - immutable time-series)
add :inserted_at, :utc_datetime, null: false
end
# Convert to TimescaleDB hypertable (if available)
if timescaledb_available?() do
execute("""
SELECT create_hypertable(
'wireless_client_readings',
'checked_at',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => true
)
""")
# Create indexes for common query patterns
execute(
"CREATE INDEX IF NOT EXISTS wireless_client_readings_device_id_checked_at_idx ON wireless_client_readings (device_id, checked_at DESC)"
)
execute(
"CREATE INDEX IF NOT EXISTS wireless_client_readings_mac_address_checked_at_idx ON wireless_client_readings (mac_address, checked_at DESC)"
)
execute(
"CREATE INDEX IF NOT EXISTS wireless_client_readings_organization_id_checked_at_idx ON wireless_client_readings (organization_id, checked_at DESC)"
)
execute(
"CREATE INDEX IF NOT EXISTS wireless_client_readings_checked_at_idx ON wireless_client_readings (checked_at DESC)"
)
# Enable compression (segmented by device_id + mac_address for efficient queries)
execute(
"ALTER TABLE wireless_client_readings SET (timescaledb.compress, timescaledb.compress_segmentby = 'device_id,mac_address')"
)
execute(
"SELECT add_compression_policy('wireless_client_readings', INTERVAL '7 days', if_not_exists => true)"
)
# Add retention policy (90 days for raw data)
execute(
"SELECT add_retention_policy('wireless_client_readings', INTERVAL '90 days', if_not_exists => true)"
)
# Create continuous aggregate: hourly rollup
execute("""
CREATE MATERIALIZED VIEW IF NOT EXISTS wireless_client_readings_hourly
WITH (timescaledb.continuous) AS
SELECT
device_id,
mac_address,
wireless_client_id,
organization_id,
time_bucket('1 hour', checked_at) AS bucket,
AVG(signal_strength) AS avg_signal_strength,
MIN(signal_strength) AS min_signal_strength,
MAX(signal_strength) AS max_signal_strength,
AVG(snr) AS avg_snr,
MIN(snr) AS min_snr,
MAX(snr) AS max_snr,
AVG(distance) AS avg_distance,
AVG(tx_rate) AS avg_tx_rate,
AVG(rx_rate) AS avg_rx_rate,
COUNT(*) AS sample_count
FROM wireless_client_readings
GROUP BY device_id, mac_address, wireless_client_id, organization_id, bucket
WITH NO DATA
""")
# Create continuous aggregate: daily rollup
execute("""
CREATE MATERIALIZED VIEW IF NOT EXISTS wireless_client_readings_daily
WITH (timescaledb.continuous) AS
SELECT
device_id,
mac_address,
wireless_client_id,
organization_id,
time_bucket('1 day', checked_at) AS bucket,
AVG(signal_strength) AS avg_signal_strength,
MIN(signal_strength) AS min_signal_strength,
MAX(signal_strength) AS max_signal_strength,
AVG(snr) AS avg_snr,
MIN(snr) AS min_snr,
MAX(snr) AS max_snr,
AVG(distance) AS avg_distance,
AVG(tx_rate) AS avg_tx_rate,
AVG(rx_rate) AS avg_rx_rate,
COUNT(*) AS sample_count
FROM wireless_client_readings
GROUP BY device_id, mac_address, wireless_client_id, organization_id, bucket
WITH NO DATA
""")
# Add refresh policies for continuous aggregates
execute(
"SELECT add_continuous_aggregate_policy('wireless_client_readings_hourly', start_offset => INTERVAL '3 hours', end_offset => INTERVAL '1 hour', schedule_interval => INTERVAL '1 hour', if_not_exists => true)"
)
execute(
"SELECT add_continuous_aggregate_policy('wireless_client_readings_daily', start_offset => INTERVAL '3 days', end_offset => INTERVAL '1 day', schedule_interval => INTERVAL '1 day', if_not_exists => true)"
)
# Add retention policies for continuous aggregates
execute(
"SELECT add_retention_policy('wireless_client_readings_hourly', INTERVAL '1 year', if_not_exists => true)"
)
execute(
"SELECT add_retention_policy('wireless_client_readings_daily', INTERVAL '5 years', if_not_exists => true)"
)
else
# If TimescaleDB not available (test env), create regular indexes
create index(:wireless_client_readings, [:device_id, :checked_at])
create index(:wireless_client_readings, [:mac_address, :checked_at])
create index(:wireless_client_readings, [:organization_id, :checked_at])
create index(:wireless_client_readings, [:checked_at])
end
end
def down do
if timescaledb_available?() do
# Remove refresh policies
execute(
"SELECT remove_continuous_aggregate_policy('wireless_client_readings_hourly', if_exists => true)"
)
execute(
"SELECT remove_continuous_aggregate_policy('wireless_client_readings_daily', if_exists => true)"
)
# Remove retention policies from continuous aggregates
execute(
"SELECT remove_retention_policy('wireless_client_readings_hourly', if_exists => true)"
)
execute(
"SELECT remove_retention_policy('wireless_client_readings_daily', if_exists => true)"
)
# Drop continuous aggregates
execute("DROP MATERIALIZED VIEW IF EXISTS wireless_client_readings_hourly CASCADE")
execute("DROP MATERIALIZED VIEW IF EXISTS wireless_client_readings_daily CASCADE")
# Remove retention policy
execute("SELECT remove_retention_policy('wireless_client_readings', if_exists => true)")
# Remove compression policy
execute("SELECT remove_compression_policy('wireless_client_readings', if_exists => true)")
# Drop hypertable (CASCADE removes all chunks)
execute("DROP TABLE IF EXISTS wireless_client_readings CASCADE")
else
drop table(:wireless_client_readings)
end
end
defp timescaledb_available? do
case repo().query("SELECT 1 FROM pg_available_extensions WHERE name = 'timescaledb'") do
{:ok, %{num_rows: n}} when n > 0 -> true
_ -> false
end
end
end