Implements real-time wireless client monitoring with historical tracking, LiveView UI, proactive alerting, and cross-browser e2e tests. Phase 1: Historical Tracking - Add TimescaleDB hypertable for wireless_client_readings - Batch insert client metrics every 60 seconds from DevicePollerWorker - 90-day retention with compression after 7 days - Continuous aggregates for hourly (1 year) and daily (5 years) rollups Phase 2: LiveView UI - Add wireless tab to device detail page - Real-time client list with PubSub updates - Signal strength and SNR badges with 5-level thresholds - Display MAC, IP, subscriber, TX/RX rates, distance, uptime - Subscriber matching via device_subscriber_links - Empty state handling Phase 3: Proactive Alerting - WirelessInsightWorker runs every 5 minutes via Oban cron - 4 insight types with auto-resolution: * wireless_signal_weak: < -75 dBm (warning), < -85 dBm (critical) * wireless_snr_low: < 15 dB (warning), < 10 dB (critical) * wireless_ap_overloaded: > 50 clients (warning), > 75 clients (critical) * wireless_client_missing: expected subscribers not connecting - Hysteresis thresholds prevent alert flapping - Multi-organization isolation with proper deduplication Code Quality: - Refactored reload_current_tab_data to reduce cyclomatic complexity - Combined double Enum.filter into single pass for efficiency - Fixed length/1 comparison to use empty list check - All Credo checks passing Testing: - 28 unit tests (ExUnit) - 100% passing - 15 e2e tests (Playwright) - 100% passing across chromium/firefox/webkit - Total: 73 tests, all passing Files changed: - lib/towerops/workers/wireless_insight_worker.ex (NEW) - lib/towerops_web/live/device_live/show.ex (wireless tab + refactoring) - lib/towerops_web/live/device_live/show.html.heex (wireless template) - lib/towerops/snmp.ex (5 new query functions) - lib/towerops/gaiia.ex (list_missing_subscribers) - lib/towerops/preseem/insight.ex (5 new insight types) - config/runtime.exs (Oban cron schedule) - test/support/fixtures/snmp_fixtures.ex (NEW) - test/towerops/workers/wireless_insight_worker_test.exs (NEW) - test/towerops_web/live/device_live/show_test.exs (9 new tests) - e2e/tests/wireless-clients.spec.ts (NEW - 15 cross-browser tests)
549 lines
16 KiB
Elixir
549 lines
16 KiB
Elixir
defmodule Towerops.Workers.WirelessInsightWorkerTest do
|
|
use Towerops.DataCase, async: true
|
|
|
|
import Ecto.Query
|
|
|
|
alias Towerops.Preseem.Insight
|
|
alias Towerops.Repo
|
|
alias Towerops.Workers.WirelessInsightWorker
|
|
|
|
setup do
|
|
user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
|
|
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
|
|
|
|
device =
|
|
Towerops.DevicesFixtures.device_fixture(%{
|
|
organization_id: organization.id,
|
|
name: "Test AP",
|
|
ip_address: "192.168.1.1"
|
|
})
|
|
|
|
snmp_device =
|
|
Towerops.SnmpFixtures.snmp_device_fixture(%{
|
|
device_id: device.id,
|
|
sys_name: "ap",
|
|
sys_descr: "Access Point"
|
|
})
|
|
|
|
%{organization: organization, device: device, snmp_device: snmp_device}
|
|
end
|
|
|
|
describe "weak signal detection" do
|
|
test "generates critical insight for signal < -85 dBm", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
# Create client with critical signal strength
|
|
_client =
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
mac_address: "AA:BB:CC:DD:EE:01",
|
|
signal_strength: -90
|
|
})
|
|
|
|
# Run worker
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
# Check insight was created
|
|
insight =
|
|
Repo.one(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_signal_weak"
|
|
)
|
|
)
|
|
|
|
assert insight
|
|
assert insight.urgency == "critical"
|
|
assert insight.status == "active"
|
|
assert insight.metadata["signal_strength"] == -90
|
|
assert insight.metadata["threshold"] == -85
|
|
assert insight.title =~ "AA:BB:CC:DD:EE:01"
|
|
assert insight.title =~ "-90 dBm"
|
|
end
|
|
|
|
test "generates warning insight for signal < -75 dBm (but > -85)", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
# Create client with warning signal strength
|
|
_client =
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
mac_address: "AA:BB:CC:DD:EE:02",
|
|
signal_strength: -80
|
|
})
|
|
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
insight =
|
|
Repo.one(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_signal_weak"
|
|
)
|
|
)
|
|
|
|
assert insight
|
|
assert insight.urgency == "warning"
|
|
assert insight.metadata["signal_strength"] == -80
|
|
assert insight.metadata["threshold"] == -75
|
|
end
|
|
|
|
test "does not generate insight for good signal (>= -75 dBm)", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
_client =
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
signal_strength: -65
|
|
})
|
|
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
count =
|
|
Repo.aggregate(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_signal_weak"
|
|
),
|
|
:count
|
|
)
|
|
|
|
assert count == 0
|
|
end
|
|
|
|
test "auto-resolves insight when signal improves", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
# Create client with weak signal
|
|
client =
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
mac_address: "AA:BB:CC:DD:EE:03",
|
|
signal_strength: -90
|
|
})
|
|
|
|
# Generate insight
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
# Verify insight exists
|
|
insight =
|
|
Repo.one!(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_signal_weak"
|
|
)
|
|
)
|
|
|
|
assert insight.status == "active"
|
|
|
|
# Update client to good signal (above recovery threshold)
|
|
client
|
|
|> Ecto.Changeset.change(signal_strength: -65)
|
|
|> Repo.update!()
|
|
|
|
# Run worker again
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
# Verify insight auto-resolved
|
|
updated_insight = Repo.reload!(insight)
|
|
assert updated_insight.status == "resolved"
|
|
refute updated_insight.dismissed_at
|
|
end
|
|
|
|
test "auto-resolves insight when client disconnects", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
client =
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
mac_address: "AA:BB:CC:DD:EE:04",
|
|
signal_strength: -90
|
|
})
|
|
|
|
# Generate insight
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
insight =
|
|
Repo.one!(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_signal_weak"
|
|
)
|
|
)
|
|
|
|
# Delete client (simulates disconnect)
|
|
Repo.delete!(client)
|
|
|
|
# Run worker again
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
# Verify insight auto-resolved
|
|
updated_insight = Repo.reload!(insight)
|
|
assert updated_insight.status == "resolved"
|
|
end
|
|
|
|
test "does not auto-resolve if signal still below recovery threshold", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
client =
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
mac_address: "AA:BB:CC:DD:EE:05",
|
|
signal_strength: -90
|
|
})
|
|
|
|
# Generate insight
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
insight =
|
|
Repo.one!(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_signal_weak"
|
|
)
|
|
)
|
|
|
|
# Update to -74 dBm (below -70 dBm recovery threshold)
|
|
client
|
|
|> Ecto.Changeset.change(signal_strength: -74)
|
|
|> Repo.update!()
|
|
|
|
# Run worker again
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
# Verify insight still active (hysteresis prevents flapping)
|
|
updated_insight = Repo.reload!(insight)
|
|
assert updated_insight.status == "active"
|
|
end
|
|
end
|
|
|
|
describe "low SNR detection" do
|
|
test "generates critical insight for SNR < 10 dB", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
_client =
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
mac_address: "BB:BB:CC:DD:EE:01",
|
|
snr: 5
|
|
})
|
|
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
insight =
|
|
Repo.one(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_snr_low"
|
|
)
|
|
)
|
|
|
|
assert insight
|
|
assert insight.urgency == "critical"
|
|
assert insight.metadata["snr"] == 5
|
|
assert insight.metadata["threshold"] == 10
|
|
end
|
|
|
|
test "generates warning insight for SNR < 15 dB (but >= 10)", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
_client =
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
snr: 12
|
|
})
|
|
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
insight =
|
|
Repo.one(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_snr_low"
|
|
)
|
|
)
|
|
|
|
assert insight
|
|
assert insight.urgency == "warning"
|
|
assert insight.metadata["threshold"] == 15
|
|
end
|
|
|
|
test "auto-resolves when SNR improves above recovery threshold", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
client =
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
mac_address: "BB:BB:CC:DD:EE:02",
|
|
snr: 5
|
|
})
|
|
|
|
# Generate insight
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
insight =
|
|
Repo.one!(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_snr_low"
|
|
)
|
|
)
|
|
|
|
# Improve SNR above recovery threshold (18 dB)
|
|
client |> Ecto.Changeset.change(snr: 20) |> Repo.update!()
|
|
|
|
# Run worker again
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
updated_insight = Repo.reload!(insight)
|
|
assert updated_insight.status == "resolved"
|
|
end
|
|
end
|
|
|
|
describe "overloaded AP detection" do
|
|
test "generates critical insight for > 75 clients", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
# Create 80 clients
|
|
for i <- 1..80 do
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
mac_address: i |> Integer.to_string(16) |> String.pad_leading(12, "0") |> format_mac()
|
|
})
|
|
end
|
|
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
insight =
|
|
Repo.one(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_ap_overloaded"
|
|
)
|
|
)
|
|
|
|
assert insight
|
|
assert insight.urgency == "critical"
|
|
assert insight.metadata["client_count"] == 80
|
|
assert insight.metadata["threshold"] == 75
|
|
assert insight.title =~ "80 clients"
|
|
end
|
|
|
|
test "generates warning insight for > 50 clients (but <= 75)", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
# Create 60 clients
|
|
for i <- 1..60 do
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
mac_address: i |> Integer.to_string(16) |> String.pad_leading(12, "0") |> format_mac()
|
|
})
|
|
end
|
|
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
insight =
|
|
Repo.one(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_ap_overloaded"
|
|
)
|
|
)
|
|
|
|
assert insight
|
|
assert insight.urgency == "warning"
|
|
assert insight.metadata["client_count"] == 60
|
|
assert insight.metadata["threshold"] == 50
|
|
end
|
|
|
|
test "auto-resolves when client count drops below recovery threshold", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
# Create 80 clients
|
|
clients =
|
|
for i <- 1..80 do
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
mac_address: i |> Integer.to_string(16) |> String.pad_leading(12, "0") |> format_mac()
|
|
})
|
|
end
|
|
|
|
# Generate insight
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
insight =
|
|
Repo.one!(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_ap_overloaded"
|
|
)
|
|
)
|
|
|
|
# Remove clients to bring count below recovery threshold (40)
|
|
clients |> Enum.take(45) |> Enum.each(&Repo.delete!/1)
|
|
|
|
# Run worker again
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
updated_insight = Repo.reload!(insight)
|
|
assert updated_insight.status == "resolved"
|
|
end
|
|
end
|
|
|
|
describe "deduplication" do
|
|
test "does not create duplicate insights for same client", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
_client =
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
mac_address: "CC:CC:CC:DD:EE:01",
|
|
signal_strength: -90
|
|
})
|
|
|
|
# Run worker twice
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
# Should only have one insight
|
|
count =
|
|
Repo.aggregate(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_signal_weak"
|
|
),
|
|
:count
|
|
)
|
|
|
|
assert count == 1
|
|
end
|
|
|
|
test "does not create duplicate insights for same AP", %{
|
|
device: device,
|
|
snmp_device: snmp_device,
|
|
organization: organization
|
|
} do
|
|
# Create 80 clients for overload
|
|
for i <- 1..80 do
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
mac_address: i |> Integer.to_string(16) |> String.pad_leading(12, "0") |> format_mac()
|
|
})
|
|
end
|
|
|
|
# Run worker twice
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
# Should only have one overload insight
|
|
count =
|
|
Repo.aggregate(
|
|
from(i in Insight,
|
|
where: i.organization_id == ^organization.id,
|
|
where: i.type == "wireless_ap_overloaded"
|
|
),
|
|
:count
|
|
)
|
|
|
|
assert count == 1
|
|
end
|
|
end
|
|
|
|
describe "multi-organization isolation" do
|
|
test "only processes insights for each organization's own devices", %{
|
|
device: device,
|
|
snmp_device: snmp_device
|
|
} do
|
|
# Create second organization with device
|
|
user2 = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
|
|
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org 2"}, user2.id)
|
|
|
|
device2 =
|
|
Towerops.DevicesFixtures.device_fixture(%{
|
|
organization_id: org2.id,
|
|
name: "AP 2"
|
|
})
|
|
|
|
snmp_device2 =
|
|
Towerops.SnmpFixtures.snmp_device_fixture(%{
|
|
device_id: device2.id
|
|
})
|
|
|
|
# Create weak signal clients for both orgs
|
|
_client1 =
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device.id,
|
|
device_id: device.id,
|
|
signal_strength: -90
|
|
})
|
|
|
|
_client2 =
|
|
Towerops.SnmpFixtures.wireless_client_fixture(%{
|
|
snmp_device_id: snmp_device2.id,
|
|
device_id: device2.id,
|
|
signal_strength: -90
|
|
})
|
|
|
|
# Run worker
|
|
assert :ok = WirelessInsightWorker.perform(%Oban.Job{})
|
|
|
|
# Each organization should have its own insight
|
|
insights = Repo.all(from(i in Insight, where: i.type == "wireless_signal_weak"))
|
|
assert length(insights) == 2
|
|
|
|
# Verify insights belong to correct organizations
|
|
org_ids = insights |> Enum.map(& &1.organization_id) |> Enum.sort()
|
|
expected_org_ids = Enum.sort([device.organization_id, device2.organization_id])
|
|
assert org_ids == expected_org_ids
|
|
end
|
|
end
|
|
|
|
# Helper to format MAC address with colons
|
|
defp format_mac(hex_string) do
|
|
hex_string
|
|
|> String.graphemes()
|
|
|> Enum.chunk_every(2)
|
|
|> Enum.map_join(":", &Enum.join/1)
|
|
|> String.upcase()
|
|
end
|
|
end
|