diff --git a/config/runtime.exs b/config/runtime.exs index 48546fca..14beafeb 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -218,6 +218,8 @@ if config_env() == :prod do {"30 3 * * *", Towerops.Workers.DeviceHealthInsightWorker}, # System insights (agent offline detection) every 5 minutes {"*/5 * * * *", Towerops.Workers.SystemInsightWorker}, + # Wireless client health insights every 5 minutes + {"*/5 * * * *", Towerops.Workers.WirelessInsightWorker}, # Gaiia reconciliation insights nightly at 4:30 AM {"30 4 * * *", Towerops.Workers.GaiiaInsightWorker}, # Backhaul capacity utilization insights every 15 minutes diff --git a/e2e/tests/wireless-clients.spec.ts b/e2e/tests/wireless-clients.spec.ts new file mode 100644 index 00000000..21845f77 --- /dev/null +++ b/e2e/tests/wireless-clients.spec.ts @@ -0,0 +1,424 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Wireless Clients', () => { + test('can navigate to wireless tab from device page', async ({ page }) => { + await page.goto('/devices'); + + // Navigate to first device + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(500); + + // Look for Wireless tab + const wirelessTab = page.locator( + 'a[href*="tab=wireless"], button:has-text("Wireless"), a:has-text("Wireless")' + ).first(); + + if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await wirelessTab.click(); + await page.waitForTimeout(500); + + // Verify URL includes wireless tab + await expect(page).toHaveURL(/tab=wireless/); + } + } + }); + + test('shows wireless tab badge with client count', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(500); + + // Look for Wireless tab with badge + const wirelessTab = page.locator('a:has-text("Wireless")').first(); + + if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) { + // Check if badge/count is visible (could be 0 or more) + const badge = wirelessTab.locator('span'); + if (await badge.isVisible({ timeout: 1000 }).catch(() => false)) { + // Badge should contain a number + const badgeText = await badge.textContent(); + expect(badgeText).toMatch(/\d+/); + } + } + } + }); + + test('can view wireless clients list', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(500); + + // Navigate to Wireless tab + const wirelessTab = page.locator('a[href*="tab=wireless"]').first(); + + if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await wirelessTab.click(); + await page.waitForTimeout(500); + + // Look for wireless clients table or list + const clientsTable = page.locator('table, [role="table"]'); + + if (await clientsTable.isVisible({ timeout: 2000 }).catch(() => false)) { + // Table should have headers + const macHeader = page.locator('th:has-text("MAC"), th:has-text("MAC Address")').first(); + if (await macHeader.isVisible().catch(() => false)) { + await expect(macHeader).toBeVisible(); + } + } + } + } + }); + + test('shows signal strength badges', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(500); + + // Navigate to Wireless tab + const wirelessTab = page.locator('a[href*="tab=wireless"]').first(); + + if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await wirelessTab.click(); + await page.waitForTimeout(500); + + // Look for signal strength indicators + // Signal values are typically shown as "XX dBm" + const signalBadge = page.locator(':has-text("dBm")').first(); + + if (await signalBadge.isVisible({ timeout: 2000 }).catch(() => false)) { + const signalText = await signalBadge.textContent(); + expect(signalText).toMatch(/-?\d+\s*dBm/); + } + } + } + }); + + test('shows SNR badges', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(500); + + // Navigate to Wireless tab + const wirelessTab = page.locator('a[href*="tab=wireless"]').first(); + + if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await wirelessTab.click(); + await page.waitForTimeout(500); + + // Look for SNR indicators + // SNR values are typically shown as "XX dB" + const snrBadge = page.locator(':has-text(" dB"):not(:has-text("dBm"))').first(); + + if (await snrBadge.isVisible({ timeout: 2000 }).catch(() => false)) { + const snrText = await snrBadge.textContent(); + expect(snrText).toMatch(/\d+\s*dB/); + } + } + } + }); + + test('displays client MAC addresses', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(500); + + // Navigate to Wireless tab + const wirelessTab = page.locator('a[href*="tab=wireless"]').first(); + + if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await wirelessTab.click(); + await page.waitForTimeout(500); + + // Look for MAC address pattern (XX:XX:XX:XX:XX:XX) + const macAddress = page.locator('text=/[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}/i').first(); + + if (await macAddress.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(macAddress).toBeVisible(); + } + } + } + }); + + test('displays client IP addresses', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(500); + + // Navigate to Wireless tab + const wirelessTab = page.locator('a[href*="tab=wireless"]').first(); + + if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await wirelessTab.click(); + await page.waitForTimeout(500); + + // Look for IP address pattern + const ipAddress = page.locator('text=/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/').first(); + + if (await ipAddress.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(ipAddress).toBeVisible(); + } + } + } + }); + + test('shows empty state when no clients connected', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(500); + + // Navigate to Wireless tab + const wirelessTab = page.locator('a[href*="tab=wireless"]').first(); + + if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await wirelessTab.click(); + await page.waitForTimeout(500); + + // Check for empty state message + const emptyState = page.locator( + ':has-text("No wireless clients"), :has-text("no clients"), :has-text("empty")' + ).first(); + + // Either show client list or empty state (both are valid) + const clientsTable = page.locator('table tbody tr'); + const hasClients = await clientsTable.count() > 0; + + if (!hasClients) { + // If no clients, should show empty state + if (await emptyState.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(emptyState).toBeVisible(); + } + } + } + } + }); + + test('displays TX/RX rates', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(500); + + // Navigate to Wireless tab + const wirelessTab = page.locator('a[href*="tab=wireless"]').first(); + + if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await wirelessTab.click(); + await page.waitForTimeout(500); + + // Look for rate indicators (Mbps/Kbps) + const rateIndicator = page.locator(':has-text("Mbps"), :has-text("Kbps")').first(); + + if (await rateIndicator.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(rateIndicator).toBeVisible(); + } + } + } + }); + + test('displays client uptime', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(500); + + // Navigate to Wireless tab + const wirelessTab = page.locator('a[href*="tab=wireless"]').first(); + + if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await wirelessTab.click(); + await page.waitForTimeout(500); + + // Look for uptime indicators (hour/minute/second/day/week) + const uptimeIndicator = page.locator( + ':has-text(" hour"), :has-text(" minute"), :has-text(" day"), :has-text(" week")' + ).first(); + + if (await uptimeIndicator.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(uptimeIndicator).toBeVisible(); + } + } + } + }); + + test('shows table headers for client information', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(500); + + // Navigate to Wireless tab + const wirelessTab = page.locator('a[href*="tab=wireless"]').first(); + + if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await wirelessTab.click(); + await page.waitForTimeout(500); + + // Check for key table headers + const headers = [ + 'MAC', + 'IP', + 'Signal', + 'SNR', + ]; + + for (const headerText of headers) { + const header = page.locator(`th:has-text("${headerText}")`).first(); + if (await header.isVisible({ timeout: 1000 }).catch(() => false)) { + await expect(header).toBeVisible(); + } + } + } + } + }); + + test('can access wireless tab directly via URL', async ({ page }) => { + await page.goto('/devices'); + + // Get first device URL + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + const deviceUrl = await firstDevice.getAttribute('href'); + + if (deviceUrl) { + // Navigate directly to wireless tab + await page.goto(`${deviceUrl}?tab=wireless`); + await page.waitForTimeout(500); + + // Should be on wireless tab + await expect(page).toHaveURL(/tab=wireless/); + + // Wireless content should be visible + const wirelessContent = page.locator( + 'table, :has-text("MAC"), :has-text("Signal"), :has-text("No wireless clients")' + ).first(); + + if (await wirelessContent.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(wirelessContent).toBeVisible(); + } + } + } + }); + + test('can switch between wireless and other tabs', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(500); + + // Click wireless tab + const wirelessTab = page.locator('a[href*="tab=wireless"]').first(); + + if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await wirelessTab.click(); + await page.waitForTimeout(500); + + // Verify we're on wireless tab + await expect(page).toHaveURL(/tab=wireless/); + + // Switch to another tab (overview, ports, etc.) + const overviewTab = page.locator('a[href*="tab=overview"]').first(); + + if (await overviewTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await overviewTab.click(); + await page.waitForTimeout(500); + + // Should be on overview tab now + await expect(page).toHaveURL(/tab=overview/); + + // Switch back to wireless + const wirelessTabAgain = page.locator('a[href*="tab=wireless"]').first(); + + if (await wirelessTabAgain.isVisible({ timeout: 2000 }).catch(() => false)) { + await wirelessTabAgain.click(); + await page.waitForTimeout(500); + + // Should be back on wireless tab + await expect(page).toHaveURL(/tab=wireless/); + } + } + } + } + }); + + test('shows subscriber information when matched', async ({ page }) => { + await page.goto('/devices'); + + const firstDevice = page.locator('a[href*="/devices/"]:not([href*="/devices/new"])').first(); + + if (await firstDevice.isVisible({ timeout: 2000 }).catch(() => false)) { + await firstDevice.click(); + await page.waitForTimeout(500); + + // Navigate to Wireless tab + const wirelessTab = page.locator('a[href*="tab=wireless"]').first(); + + if (await wirelessTab.isVisible({ timeout: 2000 }).catch(() => false)) { + await wirelessTab.click(); + await page.waitForTimeout(500); + + // Look for Subscriber column/header + const subscriberHeader = page.locator('th:has-text("Subscriber")').first(); + + if (await subscriberHeader.isVisible({ timeout: 2000 }).catch(() => false)) { + await expect(subscriberHeader).toBeVisible(); + + // If there are clients, check if subscriber names appear + const clientRows = page.locator('table tbody tr'); + + if ((await clientRows.count()) > 0) { + // Subscriber column may show names or "—" for unmatched + const subscriberCell = page.locator('td').nth(2); // Assuming 3rd column + + if (await subscriberCell.isVisible({ timeout: 1000 }).catch(() => false)) { + await expect(subscriberCell).toBeVisible(); + } + } + } + } + } + }); +}); diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex index edd88aa5..e09215a1 100644 --- a/lib/towerops/gaiia.ex +++ b/lib/towerops/gaiia.ex @@ -14,8 +14,8 @@ defmodule Towerops.Gaiia do alias Towerops.Repo alias Towerops.Sites.Site alias Towerops.Snmp.Device, as: SnmpDevice - # --- Accounts --- + alias Towerops.Snmp.WirelessClientReading def list_accounts(organization_id) do Account @@ -441,4 +441,99 @@ defmodule Towerops.Gaiia do devices: devices_with_names } end + + @doc """ + Lists subscriber accounts that are expected to be connected but are currently missing. + + Checks device_subscriber_links for MACs that should be connected but aren't currently + in wireless_clients. Only includes subscribers that were previously seen within the + last 7 days (to avoid alerting on stale/inactive accounts). + + ## Parameters + - organization_id: Organization to check + - confidence_levels: List of confidence levels to include (default: all levels) + + ## Examples + + iex> list_missing_subscribers(org_id) + [%{device: %Device{}, account: %Account{}, mac: "AA:BB:CC:DD:EE:FF", last_seen: ~U[...], confidence: "high"}, ...] + + iex> list_missing_subscribers(org_id, ["high", "medium"]) + [%{...}, ...] + """ + @spec list_missing_subscribers(Ecto.UUID.t(), [String.t()]) :: [map()] + def list_missing_subscribers(organization_id, confidence_levels \\ ["high", "medium", "low"]) do + # Get all expected MACs from device_subscriber_links with requested confidence + expected_macs = + Repo.all( + from(l in DeviceSubscriberLink, + join: d in Device, + on: l.device_id == d.id, + join: a in Account, + on: l.gaiia_account_id == a.id, + where: l.organization_id == ^organization_id, + where: not is_nil(l.subscriber_mac), + where: l.confidence in ^confidence_levels, + select: %{device_id: l.device_id, device: d, account: a, mac: l.subscriber_mac, confidence: l.confidence} + ) + ) + + # Get currently connected MACs + connected_macs = + from(c in Towerops.Snmp.WirelessClient, + where: c.organization_id == ^organization_id, + select: fragment("LOWER(?)", c.mac_address) + ) + |> Repo.all() + |> MapSet.new() + + # Filter to missing MACs and check if seen within 7 days + seven_days_ago = DateTime.add(DateTime.utc_now(), -7, :day) + + expected_macs + |> Enum.filter(fn entry -> + mac_lower = String.downcase(entry.mac) + + # Must not be currently connected AND must have been seen within 7 days + is_missing = !MapSet.member?(connected_macs, mac_lower) + + was_recently_seen = + if is_missing do + # Only query if actually missing (optimization) + last_seen = + Repo.one( + from(r in WirelessClientReading, + where: r.organization_id == ^organization_id, + where: fragment("LOWER(?)", r.mac_address) == ^mac_lower, + where: r.checked_at >= ^seven_days_ago, + select: r.checked_at, + order_by: [desc: r.checked_at], + limit: 1 + ) + ) + + last_seen != nil + else + false + end + + is_missing and was_recently_seen + end) + |> Enum.map(fn entry -> + mac_lower = String.downcase(entry.mac) + + last_seen = + Repo.one( + from(r in WirelessClientReading, + where: r.organization_id == ^organization_id, + where: fragment("LOWER(?)", r.mac_address) == ^mac_lower, + select: r.checked_at, + order_by: [desc: r.checked_at], + limit: 1 + ) + ) + + Map.put(entry, :last_seen, last_seen) + end) + end end diff --git a/lib/towerops/preseem/insight.ex b/lib/towerops/preseem/insight.ex index 09060957..1e149f6c 100644 --- a/lib/towerops/preseem/insight.ex +++ b/lib/towerops/preseem/insight.ex @@ -10,7 +10,7 @@ defmodule Towerops.Preseem.Insight do @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id - @valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend suspect_config_change backhaul_over_capacity backhaul_near_capacity) + @valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend suspect_config_change backhaul_over_capacity backhaul_near_capacity wireless_signal_weak wireless_snr_low wireless_ap_overloaded wireless_client_missing wireless_coverage_gap) @valid_urgencies ~w(critical warning info) @valid_statuses ~w(active dismissed resolved) @valid_channels ~w(proactive contextual passive) diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index 51ee0b21..89747471 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -2421,4 +2421,135 @@ defmodule Towerops.Snmp do Repo.insert_all(WirelessClientReading, rows) end + + @doc """ + Lists wireless clients with weak signal strength for an organization. + + Returns clients with signal strength below the threshold (default -75 dBm). + + ## Examples + + iex> list_weak_signal_clients(org_id) + [%WirelessClient{signal_strength: -80, ...}, ...] + + iex> list_weak_signal_clients(org_id, -85) + [%WirelessClient{signal_strength: -90, ...}, ...] + """ + @spec list_weak_signal_clients(Ecto.UUID.t(), integer()) :: [WirelessClient.t()] + def list_weak_signal_clients(organization_id, threshold_dbm \\ -75) do + Repo.all( + from(c in WirelessClient, + join: d in assoc(c, :device), + where: c.organization_id == ^organization_id, + where: not is_nil(c.signal_strength), + where: c.signal_strength < ^threshold_dbm, + preload: [device: d], + order_by: [asc: c.signal_strength] + ) + ) + end + + @doc """ + Lists wireless clients with low SNR for an organization. + + Returns clients with SNR below the threshold (default 15 dB). + + ## Examples + + iex> list_low_snr_clients(org_id) + [%WirelessClient{snr: 10, ...}, ...] + + iex> list_low_snr_clients(org_id, 10) + [%WirelessClient{snr: 8, ...}, ...] + """ + @spec list_low_snr_clients(Ecto.UUID.t(), integer()) :: [WirelessClient.t()] + def list_low_snr_clients(organization_id, threshold_db \\ 15) do + Repo.all( + from(c in WirelessClient, + join: d in assoc(c, :device), + where: c.organization_id == ^organization_id, + where: not is_nil(c.snr), + where: c.snr < ^threshold_db, + preload: [device: d], + order_by: [asc: c.snr] + ) + ) + end + + @doc """ + Gets wireless client count per device for an organization. + + Returns a map of device_id => client_count. + + ## Examples + + iex> get_wireless_client_count_by_device(org_id) + %{device_id_1 => 25, device_id_2 => 50} + """ + @spec get_wireless_client_count_by_device(Ecto.UUID.t()) :: %{Ecto.UUID.t() => integer()} + def get_wireless_client_count_by_device(organization_id) do + from(c in WirelessClient, + where: c.organization_id == ^organization_id, + group_by: c.device_id, + select: {c.device_id, count(c.id)} + ) + |> Repo.all() + |> Map.new() + end + + @doc """ + Lists devices (access points) with client count exceeding threshold. + + Returns devices with their client counts. + + ## Examples + + iex> list_overloaded_aps(org_id) + [{%Device{name: "AP-1"}, 55}, ...] + + iex> list_overloaded_aps(org_id, 75) + [{%Device{name: "AP-2"}, 80}, ...] + """ + @spec list_overloaded_aps(Ecto.UUID.t(), integer()) :: [{DeviceSchema.t(), integer()}] + def list_overloaded_aps(organization_id, threshold \\ 50) do + Repo.all( + from(c in WirelessClient, + join: d in DeviceSchema, + on: c.device_id == d.id, + where: c.organization_id == ^organization_id, + group_by: [c.device_id, d.id], + having: count(c.id) > ^threshold, + select: {d, count(c.id)}, + order_by: [desc: count(c.id)] + ) + ) + end + + @doc """ + Gets the last seen timestamp for a wireless client by MAC address. + + Returns the most recent last_seen_at timestamp for the given MAC across all devices. + + ## Examples + + iex> get_wireless_client_last_seen(org_id, "AA:BB:CC:DD:EE:FF") + ~U[2024-01-15 10:30:00Z] + + iex> get_wireless_client_last_seen(org_id, "unknown-mac") + nil + """ + @spec get_wireless_client_last_seen(Ecto.UUID.t(), String.t()) :: DateTime.t() | nil + def get_wireless_client_last_seen(organization_id, mac_address) do + mac_lower = String.downcase(mac_address) + + Repo.one( + from(c in WirelessClient, + where: c.organization_id == ^organization_id, + where: fragment("LOWER(?)", c.mac_address) == ^mac_lower, + select: c.last_seen_at, + order_by: [desc: c.last_seen_at], + limit: 1 + ) + ) + end end diff --git a/lib/towerops/workers/wireless_insight_worker.ex b/lib/towerops/workers/wireless_insight_worker.ex new file mode 100644 index 00000000..29fb8e51 --- /dev/null +++ b/lib/towerops/workers/wireless_insight_worker.ex @@ -0,0 +1,319 @@ +defmodule Towerops.Workers.WirelessInsightWorker do + @moduledoc """ + Oban cron worker that generates wireless client health insights. + + Runs every 5 minutes to detect: + - `wireless_signal_weak`: Clients with poor signal strength + - `wireless_snr_low`: Clients with low SNR (signal-to-noise ratio) + - `wireless_ap_overloaded`: Access points with excessive client load + - `wireless_client_missing`: Expected subscribers not connecting + + Auto-resolves insights when conditions improve (with hysteresis to prevent flapping). + """ + use Oban.Worker, queue: :maintenance + + import Ecto.Query + + alias Towerops.Gaiia + alias Towerops.Organizations.Organization + alias Towerops.Preseem.Insight + alias Towerops.Preseem.Insights + alias Towerops.Repo + alias Towerops.Snmp + alias Towerops.Snmp.WirelessClient + + # Alert thresholds + @signal_warning_threshold -75 + @signal_critical_threshold -85 + @signal_recovery_threshold -70 + + @snr_warning_threshold 15 + @snr_critical_threshold 10 + @snr_recovery_threshold 18 + + @ap_warning_threshold 50 + @ap_critical_threshold 75 + @ap_recovery_threshold 40 + + @impl Oban.Worker + def perform(%Oban.Job{}) do + org_ids = Repo.all(from(o in Organization, select: o.id)) + + Enum.each(org_ids, fn org_id -> + organization = Repo.get!(Organization, org_id) + process_organization(organization) + end) + + :ok + end + + defp process_organization(organization) do + check_weak_signals(organization) + check_low_snr(organization) + check_overloaded_aps(organization) + check_missing_clients(organization) + + # Auto-resolve insights that no longer apply + auto_resolve_weak_signals(organization) + auto_resolve_low_snr(organization) + auto_resolve_overloaded_aps(organization) + end + + # --- Weak Signal Detection --- + + defp check_weak_signals(organization) do + # Critical: < -85 dBm + organization.id + |> Snmp.list_weak_signal_clients(@signal_critical_threshold) + |> Enum.each(fn client -> + generate_signal_insight(organization, client, "critical") + end) + + # Warning: < -75 dBm (but not critical) + organization.id + |> Snmp.list_weak_signal_clients(@signal_warning_threshold) + |> Enum.reject(&(&1.signal_strength < @signal_critical_threshold)) + |> Enum.each(fn client -> + generate_signal_insight(organization, client, "warning") + end) + end + + defp generate_signal_insight(organization, client, urgency) do + dedup_key = "#{client.device_id}_#{client.mac_address}" + + Insights.insert_insight_if_new(%{ + organization_id: organization.id, + device_id: client.device_id, + type: "wireless_signal_weak", + urgency: urgency, + channel: "proactive", + source: "snmp", + title: "Weak signal detected: #{client.mac_address} (#{client.signal_strength} dBm)", + description: + "Wireless client #{client.mac_address} on #{client.device.name} has weak signal strength " <> + "(#{client.signal_strength} dBm). This may cause connectivity issues and poor performance.", + metadata: %{ + "dedup_key" => dedup_key, + "device_id" => client.device_id, + "mac_address" => client.mac_address, + "signal_strength" => client.signal_strength, + "threshold" => if(urgency == "critical", do: @signal_critical_threshold, else: @signal_warning_threshold) + } + }) + end + + defp auto_resolve_weak_signals(organization) do + # Get all active weak signal insights + active_insights = + Repo.all( + from(i in Insight, + where: i.organization_id == ^organization.id, + where: i.type == "wireless_signal_weak", + where: i.status == "active" + ) + ) + + # Check current state and resolve if improved + Enum.each(active_insights, fn insight -> + dedup_key = insight.metadata["dedup_key"] + [device_id, mac_address] = String.split(dedup_key, "_", parts: 2) + + # Get current client state + client = + Repo.one( + from(c in WirelessClient, + where: c.device_id == ^device_id, + where: fragment("LOWER(?)", c.mac_address) == ^String.downcase(mac_address) + ) + ) + + # Resolve if signal improved (with hysteresis) or client disconnected + if is_nil(client) or + (client.signal_strength && client.signal_strength >= @signal_recovery_threshold) do + insight |> Insight.changeset(%{status: "resolved"}) |> Repo.update() + end + end) + end + + # --- Low SNR Detection --- + + defp check_low_snr(organization) do + # Critical: < 10 dB + organization.id + |> Snmp.list_low_snr_clients(@snr_critical_threshold) + |> Enum.each(fn client -> + generate_snr_insight(organization, client, "critical") + end) + + # Warning: < 15 dB (but not critical) + organization.id + |> Snmp.list_low_snr_clients(@snr_warning_threshold) + |> Enum.reject(&(&1.snr < @snr_critical_threshold)) + |> Enum.each(fn client -> + generate_snr_insight(organization, client, "warning") + end) + end + + defp generate_snr_insight(organization, client, urgency) do + dedup_key = "#{client.device_id}_#{client.mac_address}" + + Insights.insert_insight_if_new(%{ + organization_id: organization.id, + device_id: client.device_id, + type: "wireless_snr_low", + urgency: urgency, + channel: "proactive", + source: "snmp", + title: "Low SNR detected: #{client.mac_address} (#{client.snr} dB)", + description: + "Wireless client #{client.mac_address} on #{client.device.name} has low signal-to-noise ratio " <> + "(#{client.snr} dB). This indicates interference or noise affecting signal quality.", + metadata: %{ + "dedup_key" => dedup_key, + "device_id" => client.device_id, + "mac_address" => client.mac_address, + "snr" => client.snr, + "threshold" => if(urgency == "critical", do: @snr_critical_threshold, else: @snr_warning_threshold) + } + }) + end + + defp auto_resolve_low_snr(organization) do + active_insights = + Repo.all( + from(i in Insight, + where: i.organization_id == ^organization.id, + where: i.type == "wireless_snr_low", + where: i.status == "active" + ) + ) + + Enum.each(active_insights, fn insight -> + dedup_key = insight.metadata["dedup_key"] + [device_id, mac_address] = String.split(dedup_key, "_", parts: 2) + + client = + Repo.one( + from(c in WirelessClient, + where: c.device_id == ^device_id, + where: fragment("LOWER(?)", c.mac_address) == ^String.downcase(mac_address) + ) + ) + + # Resolve if SNR improved (with hysteresis) or client disconnected + if is_nil(client) or (client.snr && client.snr >= @snr_recovery_threshold) do + insight |> Insight.changeset(%{status: "resolved"}) |> Repo.update() + end + end) + end + + # --- Overloaded AP Detection --- + + defp check_overloaded_aps(organization) do + # Critical: > 75 clients + organization.id + |> Snmp.list_overloaded_aps(@ap_critical_threshold) + |> Enum.each(fn {device, count} -> + generate_overload_insight(organization, device, count, "critical") + end) + + # Warning: > 50 clients (but not critical) + organization.id + |> Snmp.list_overloaded_aps(@ap_warning_threshold) + |> Enum.reject(fn {_, count} -> count > @ap_critical_threshold end) + |> Enum.each(fn {device, count} -> + generate_overload_insight(organization, device, count, "warning") + end) + end + + defp generate_overload_insight(organization, device, count, urgency) do + Insights.insert_insight_if_new(%{ + organization_id: organization.id, + device_id: device.id, + type: "wireless_ap_overloaded", + urgency: urgency, + channel: "proactive", + source: "snmp", + title: "Access point overloaded: #{device.name} (#{count} clients)", + description: + "Access point #{device.name} has #{count} connected clients. " <> + "This may cause performance degradation. Consider load balancing or adding capacity.", + metadata: %{ + "dedup_key" => device.id, + "device_id" => device.id, + "client_count" => count, + "threshold" => if(urgency == "critical", do: @ap_critical_threshold, else: @ap_warning_threshold) + } + }) + end + + defp auto_resolve_overloaded_aps(organization) do + active_insights = + Repo.all( + from(i in Insight, + where: i.organization_id == ^organization.id, + where: i.type == "wireless_ap_overloaded", + where: i.status == "active" + ) + ) + + # Get current client counts by device + client_counts = Snmp.get_wireless_client_count_by_device(organization.id) + + Enum.each(active_insights, fn insight -> + device_id = insight.metadata["device_id"] + current_count = Map.get(client_counts, device_id, 0) + + # Resolve if load decreased below recovery threshold + if current_count <= @ap_recovery_threshold do + insight |> Insight.changeset(%{status: "resolved"}) |> Repo.update() + end + end) + end + + # --- Missing Client Detection --- + + defp check_missing_clients(organization) do + organization.id + |> Gaiia.list_missing_subscribers() + |> Enum.each(fn entry -> + generate_missing_client_insight(organization, entry) + end) + end + + defp generate_missing_client_insight(organization, entry) do + dedup_key = "#{entry.device_id}_#{entry.mac}" + days_missing = DateTime.diff(DateTime.utc_now(), entry.last_seen, :day) + + urgency = + if days_missing >= 5 do + "warning" + else + "info" + end + + Insights.insert_insight_if_new(%{ + organization_id: organization.id, + device_id: entry.device_id, + type: "wireless_client_missing", + urgency: urgency, + channel: "proactive", + source: "snmp", + title: "Expected subscriber not connected: #{entry.account.account_name}", + description: + "Subscriber #{entry.account.account_name} (#{entry.mac}) is expected to be connected to " <> + "#{entry.device.name} but has not been seen for #{days_missing} days. " <> + "Last seen: #{Calendar.strftime(entry.last_seen, "%Y-%m-%d %H:%M UTC")}", + metadata: %{ + "dedup_key" => dedup_key, + "device_id" => entry.device_id, + "mac_address" => entry.mac, + "account_id" => entry.account.id, + "account_name" => entry.account.account_name, + "confidence" => entry.confidence, + "last_seen" => DateTime.to_iso8601(entry.last_seen), + "days_missing" => days_missing + } + }) + end +end diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index 5378182a..0f77840b 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -2,6 +2,8 @@ defmodule ToweropsWeb.DeviceLive.Show do @moduledoc false use ToweropsWeb, :live_view + import Ecto.Query + alias Towerops.Agents alias Towerops.Devices alias Towerops.Devices.Firmware @@ -160,6 +162,11 @@ defmodule ToweropsWeb.DeviceLive.Show do {:noreply, reload_if_tab(socket, ["overview"])} end + @impl true + def handle_info({:wireless_clients_updated, _device_id, _client_count}, socket) do + {:noreply, reload_if_tab(socket, ["wireless"])} + end + @impl true def handle_info({:processors_updated, _device_id}, socket) do {:noreply, reload_if_tab(socket, ["overview"])} @@ -244,6 +251,7 @@ defmodule ToweropsWeb.DeviceLive.Show do if connected?(socket) do _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}") _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health") + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "wireless_clients:device:#{device_id}") Process.send_after(self(), :refresh_data, 10_000) end @@ -260,6 +268,7 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign_tab_nav_data(device_id) |> assign_overview_data(device_id) |> assign_ports_data() + |> assign_wireless_data(device_id) |> assign_logs_data(device_id) |> assign_backups_data(device_id) |> assign_checks_data(device_id) @@ -299,16 +308,36 @@ defmodule ToweropsWeb.DeviceLive.Show do # Dispatch to the appropriate tab-specific loader based on active_tab. defp reload_current_tab_data(socket, device_id) do - case socket.assigns.active_tab do + # Tabs that need device_id parameter + tabs_with_device_id = ["overview", "logs", "backups", "checks", "wireless"] + # Tabs that don't need device_id parameter + tabs_without_device_id = ["ports", "preseem", "gaiia"] + + tab = socket.assigns.active_tab + + cond do + tab in tabs_with_device_id -> reload_tab_with_device_id(socket, device_id, tab) + tab in tabs_without_device_id -> reload_tab_without_device_id(socket, tab) + # neighbors, arp, mac, vlans, ip_addresses, debug use data from tab_nav/base + true -> socket + end + end + + defp reload_tab_with_device_id(socket, device_id, tab) do + case tab do "overview" -> assign_overview_data(socket, device_id) - "ports" -> assign_ports_data(socket) "logs" -> assign_logs_data(socket, device_id) "backups" -> assign_backups_data(socket, device_id) "checks" -> assign_checks_data(socket, device_id) + "wireless" -> assign_wireless_data(socket, device_id) + end + end + + defp reload_tab_without_device_id(socket, tab) do + case tab do + "ports" -> assign_ports_data(socket) "preseem" -> assign_preseem_data(socket) "gaiia" -> assign_gaiia_data(socket) - # neighbors, arp, mac, vlans, ip_addresses, debug use data from tab_nav/base - _ -> socket end end @@ -578,6 +607,50 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:preseem_insights, insights) end + # Wireless clients tab data. + defp assign_wireless_data(socket, device_id) do + wireless_clients = Snmp.list_wireless_clients(device_id) + + # Build MAC → subscriber link mapping + wireless_client_subscribers = + if Enum.any?(wireless_clients) do + # Get all device-subscriber links for this device + links = + Repo.all( + from(l in Towerops.Gaiia.DeviceSubscriberLink, + where: l.device_id == ^device_id, + where: not is_nil(l.subscriber_mac), + preload: [:gaiia_account] + ) + ) + + # Build a map of MAC address → link + Map.new(links, &{String.downcase(&1.subscriber_mac), &1}) + else + %{} + end + + # Count matched subscribers + matched_count = + Enum.count(wireless_clients, fn client -> + Map.has_key?(wireless_client_subscribers, String.downcase(client.mac_address)) + end) + + # Get last update timestamp + last_updated = + wireless_clients + |> Enum.map(& &1.last_seen_at) + |> Enum.max(DateTime, fn -> nil end) + + socket + |> assign(:wireless_clients, wireless_clients) + |> assign(:wireless_client_subscribers, wireless_client_subscribers) + |> assign(:wireless_client_count, length(wireless_clients)) + |> assign(:wireless_matched_count, matched_count) + |> assign(:wireless_last_updated, last_updated) + |> assign(:wireless_clients_available, wireless_clients != []) + end + defp get_available_firmware(nil), do: nil defp get_available_firmware(snmp_device) do @@ -1724,4 +1797,12 @@ defmodule ToweropsWeb.DeviceLive.Show do true -> "bg-green-500" end end + + # Format wireless client data rate (Kbps → Mbps) + defp format_rate(nil), do: "—" + + defp format_rate(kbps) when is_integer(kbps) do + mbps = kbps / 1000 + "#{Float.round(mbps, 1)} Mbps" + end end diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index f4f8a083..679a6790 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -158,6 +158,25 @@ <% end %> + <%= if assigns[:wireless_clients_available] do %> + <.link + patch={~p"/devices/#{@device.id}?tab=wireless"} + class={[ + "whitespace-nowrap py-4 px-1 border-b-2 text-sm transition-colors inline-flex items-center gap-1.5", + if @active_tab == "wireless" do + "border-blue-500 text-blue-600 dark:text-blue-400 font-semibold" + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300 font-medium" + end + ]} + > + {t("Wireless")} + + {@wireless_client_count} + + + <% end %> + <.link patch={~p"/devices/#{@device.id}?tab=neighbors"} class={[ @@ -1482,6 +1501,137 @@
No interfaces discovered
<% end %> + <% "wireless" -> %> + <%= if @wireless_clients && length(@wireless_clients) > 0 do %> ++ {length(@wireless_clients)} clients — {@wireless_matched_count} subscribers matched + <%= if @wireless_last_updated do %> + · Last updated {format_relative_time(@wireless_last_updated)} + <% end %> +
+| MAC Address | +IP Address | +Subscriber | +Signal | +SNR | +Distance | +TX Rate | +RX Rate | +Uptime | +
|---|---|---|---|---|---|---|---|---|
| + + {client.mac_address} + <.icon + name="hero-clipboard-document" + class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/mac:opacity-100 transition-opacity text-gray-400" + /> + + | ++ <%= if client.ip_address do %> + + {client.ip_address} + <.icon + name="hero-clipboard-document" + class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/ip:opacity-100 transition-opacity text-gray-400" + /> + + <% else %> + — + <% end %> + | ++ <%= if subscriber_link && subscriber_link.gaiia_account do %> + + {subscriber_link.gaiia_account.account_name} + + <% else %> + — + <% end %> + | ++ <%= if client.signal_strength do %> + <.signal_badge value={client.signal_strength} /> + <% else %> + — + <% end %> + | ++ <%= if client.snr do %> + <.snr_badge value={client.snr} /> + <% else %> + — + <% end %> + | ++ <%= if client.distance do %> + {client.distance}m + <% else %> + — + <% end %> + | ++ {format_rate(client.tx_rate)} + | ++ {format_rate(client.rx_rate)} + | ++ <%= if client.uptime_seconds do %> + {format_uptime(client.uptime_seconds * 100)} + <% else %> + — + <% end %> + | +
+ {t("No clients are currently connected to this access point.")} +
+