- 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
216 lines
7.9 KiB
Elixir
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
|