towerops/lib/towerops/gaiia.ex
Graham McIntire 6fd03ace16
feat: add comprehensive wireless client tracking and monitoring
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)
2026-03-10 09:57:12 -05:00

539 lines
17 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Towerops.Gaiia do
@moduledoc """
Context for querying Gaiia integration data.
"""
import Ecto.Query
alias Towerops.Devices.Device
alias Towerops.Gaiia.Account
alias Towerops.Gaiia.BillingSubscription
alias Towerops.Gaiia.DeviceSubscriberLink
alias Towerops.Gaiia.InventoryItem
alias Towerops.Gaiia.NetworkSite
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
|> where(organization_id: ^organization_id)
|> order_by(:name)
|> Repo.all()
end
def get_account(organization_id, gaiia_id) do
Repo.get_by(Account, organization_id: organization_id, gaiia_id: gaiia_id)
end
def upsert_account(organization_id, attrs) do
attrs = Map.put(attrs, :organization_id, organization_id)
%Account{}
|> Account.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :organization_id, :gaiia_id, :inserted_at]},
conflict_target: [:organization_id, :gaiia_id],
returning: true
)
end
def count_active_subscribers(organization_id) do
Account
|> where(organization_id: ^organization_id)
|> where([a], a.subscription_count > 0)
|> Repo.aggregate(:count)
end
def sum_active_mrr(organization_id) do
BillingSubscription
|> where(organization_id: ^organization_id)
|> where(status: "ACTIVE")
|> Repo.aggregate(:sum, :mrr_amount) || Decimal.new(0)
end
# --- Network Sites ---
def list_network_sites(organization_id) do
NetworkSite
|> where(organization_id: ^organization_id)
|> order_by(:name)
|> Repo.all()
end
def get_network_site(organization_id, gaiia_id) do
Repo.get_by(NetworkSite, organization_id: organization_id, gaiia_id: gaiia_id)
end
def upsert_network_site(organization_id, attrs) do
attrs = Map.put(attrs, :organization_id, organization_id)
%NetworkSite{}
|> NetworkSite.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :organization_id, :gaiia_id, :site_id, :inserted_at]},
conflict_target: [:organization_id, :gaiia_id],
returning: true
)
end
def update_network_site_mapping(%NetworkSite{} = network_site, attrs) do
network_site
|> NetworkSite.changeset(attrs)
|> Repo.update()
end
# --- Inventory Items ---
def list_inventory_items(organization_id) do
InventoryItem
|> where(organization_id: ^organization_id)
|> order_by(:name)
|> Repo.all()
end
def get_inventory_item(organization_id, gaiia_id) do
Repo.get_by(InventoryItem, organization_id: organization_id, gaiia_id: gaiia_id)
end
def upsert_inventory_item(organization_id, attrs) do
attrs = Map.put(attrs, :organization_id, organization_id)
%InventoryItem{}
|> InventoryItem.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :organization_id, :gaiia_id, :device_id, :inserted_at]},
conflict_target: [:organization_id, :gaiia_id],
returning: true
)
end
def update_inventory_item_mapping(%InventoryItem{} = inventory_item, attrs) do
inventory_item
|> InventoryItem.changeset(attrs)
|> Repo.update()
end
# --- Billing Subscriptions ---
def list_billing_subscriptions(organization_id) do
BillingSubscription
|> where(organization_id: ^organization_id)
|> order_by(:product_name)
|> Repo.all()
end
def list_billing_subscriptions_for_account(organization_id, account_gaiia_id) do
BillingSubscription
|> where(organization_id: ^organization_id, account_gaiia_id: ^account_gaiia_id)
|> order_by(:product_name)
|> Repo.all()
end
def get_billing_subscription(organization_id, gaiia_id) do
Repo.get_by(BillingSubscription, organization_id: organization_id, gaiia_id: gaiia_id)
end
def upsert_billing_subscription(organization_id, attrs) do
attrs = Map.put(attrs, :organization_id, organization_id)
%BillingSubscription{}
|> BillingSubscription.changeset(attrs)
|> Repo.insert(
on_conflict: {:replace_all_except, [:id, :organization_id, :gaiia_id, :inserted_at]},
conflict_target: [:organization_id, :gaiia_id],
returning: true
)
end
# --- Reverse lookups (Towerops entity → Gaiia entity) ---
@doc "Find the Gaiia inventory item mapped to a Towerops device."
def get_inventory_item_for_device(device_id) do
InventoryItem
|> where(device_id: ^device_id)
|> limit(1)
|> Repo.one()
end
@doc "Find the Gaiia network site mapped to a Towerops site."
def get_network_site_for_site(site_id) do
NetworkSite
|> where(site_id: ^site_id)
|> limit(1)
|> Repo.one()
end
# --- Match Suggestions ---
@doc """
Suggest Towerops Sites that might match a Gaiia Network Site.
Matches by case-insensitive name containment (bidirectional).
Returns a list of `%{entity: site, match_type: "name", confidence: :medium}`.
"""
def suggest_site_matches(_organization_id, %NetworkSite{name: nil}), do: []
def suggest_site_matches(_organization_id, %NetworkSite{name: ""}), do: []
def suggest_site_matches(organization_id, %NetworkSite{name: gaiia_name}) do
search_term = "%#{gaiia_name}%"
# Find sites where either name contains the other (bidirectional)
Site
|> where(organization_id: ^organization_id)
|> where([s], ilike(s.name, ^search_term) or ilike(^gaiia_name, fragment("'%' || ? || '%'", s.name)))
|> Repo.all()
|> Enum.map(fn site -> %{entity: site, match_type: "name", confidence: :medium} end)
end
@doc """
Suggest Towerops Devices that might match a Gaiia Inventory Item.
Matches by IP address only (:high confidence). Name matching was removed
because inventory item names are model names (e.g. "ePMP 3000"), causing
every device of the same model to match incorrectly.
Returns a list of `%{entity: device, match_type: "ip", confidence: :high}`.
"""
def suggest_device_matches(organization_id, %InventoryItem{} = item) do
find_ip_matches(organization_id, item.ip_address)
end
defp find_ip_matches(_organization_id, nil), do: []
defp find_ip_matches(_organization_id, ""), do: []
defp find_ip_matches(organization_id, ip_address) do
Device
|> where(organization_id: ^organization_id, ip_address: ^ip_address)
|> Repo.all()
|> Repo.preload(:site)
|> Enum.map(fn device -> %{entity: device, match_type: "ip", confidence: :high} end)
end
# --- Aggregate Queries ---
@doc "Sum subscriber count and MRR across all Gaiia network sites for an organization."
def get_org_subscriber_totals(organization_id) do
result =
NetworkSite
|> where(organization_id: ^organization_id)
|> select([ns], %{
total_subscribers: coalesce(sum(ns.account_count), 0),
total_mrr: coalesce(sum(ns.total_mrr), 0)
})
|> Repo.one()
%{
total_subscribers: result.total_subscribers,
total_mrr: result.total_mrr
}
end
@doc """
Returns subscriber counts and MRR for multiple devices in a single batch query.
Joins through `gaiia_inventory_items` (which map devices to Gaiia accounts)
then counts distinct accounts and sums active MRR from `gaiia_billing_subscriptions`.
Returns `%{device_id => %{subscriber_count: integer, mrr: Decimal.t()}}`.
"""
def get_device_subscriber_counts(device_ids) when is_list(device_ids) do
if device_ids == [] do
%{}
else
DeviceSubscriberLink
|> where([l], l.device_id in ^device_ids)
|> group_by([l], l.device_id)
|> select([l], %{
device_id: l.device_id,
subscriber_count: count(l.gaiia_account_id, :distinct)
})
|> Repo.all()
|> Map.new(fn row ->
{row.device_id, %{subscriber_count: row.subscriber_count, mrr: nil}}
end)
end
end
@doc "Get subscriber count and MRR for a specific Towerops site via its mapped Gaiia network site."
def get_site_subscriber_summary(site_id) do
NetworkSite
|> where(site_id: ^site_id)
|> limit(1)
|> select([ns], %{account_count: ns.account_count, total_mrr: ns.total_mrr})
|> Repo.one()
end
# --- Device Impact Queries ---
@doc """
Get subscriber impact for a single device.
Returns `%{subscriber_count: integer, mrr: Decimal.t(), accounts: [%Account{}]}`.
"""
def get_device_impact(device_id) do
account_ids =
DeviceSubscriberLink
|> where(device_id: ^device_id)
|> select([l], l.gaiia_account_id)
|> Repo.all()
|> Enum.uniq()
if account_ids == [] do
%{subscriber_count: 0, mrr: Decimal.new(0), accounts: []}
else
accounts =
Account
|> where([a], a.id in ^account_ids)
|> Repo.all()
mrr =
BillingSubscription
|> join(:inner, [bs], a in Account,
on: bs.account_gaiia_id == a.gaiia_id and bs.organization_id == a.organization_id
)
|> where([bs, a], a.id in ^account_ids)
|> where([bs], bs.status == "ACTIVE")
|> Repo.aggregate(:sum, :mrr_amount) || Decimal.new(0)
%{subscriber_count: length(accounts), mrr: mrr, accounts: accounts}
end
end
@doc """
Get subscriber impact for multiple devices (batch).
Returns `%{device_id => %{subscriber_count: integer, mrr: Decimal.t()}}`.
Deduplicates accounts across devices: if the same account is linked to
multiple devices, it is attributed only to the device with the
highest-priority match method (wireless > ARP > site fallback).
"""
def get_devices_impact(device_ids) when is_list(device_ids) do
if device_ids == [] do
%{}
else
# Fetch all links with account gaiia_id for MRR lookup
links =
DeviceSubscriberLink
|> where([l], l.device_id in ^device_ids)
|> join(:inner, [l], a in Account, on: l.gaiia_account_id == a.id)
|> select([l, a], %{
device_id: l.device_id,
gaiia_account_id: l.gaiia_account_id,
account_gaiia_id: a.gaiia_id,
organization_id: a.organization_id,
match_method: l.match_method,
subscriber_ip: l.subscriber_ip
})
|> Repo.all()
|> Enum.reject(&cgnat_ip?/1)
# Deduplicate: per account, keep only the link with the best match method
best_links =
links
|> Enum.group_by(& &1.gaiia_account_id)
|> Enum.map(fn {_acct_id, acct_links} ->
Enum.min_by(acct_links, &match_method_priority/1)
end)
# Group by device and compute subscriber counts + MRR
best_links
|> Enum.group_by(& &1.device_id)
|> Map.new(fn {device_id, device_links} ->
account_gaiia_ids =
device_links
|> Enum.map(& &1.account_gaiia_id)
|> Enum.uniq()
org_id = hd(device_links).organization_id
mrr = compute_mrr_for_accounts(org_id, account_gaiia_ids)
{device_id, %{subscriber_count: length(account_gaiia_ids), mrr: mrr}}
end)
end
end
defp match_method_priority(%{match_method: "wireless_mac"}), do: 1
defp match_method_priority(%{match_method: "wireless_ip"}), do: 2
defp match_method_priority(%{match_method: "arp_mac"}), do: 3
defp match_method_priority(%{match_method: "arp_ip"}), do: 4
defp match_method_priority(%{match_method: "site_fallback"}), do: 5
defp match_method_priority(_), do: 6
# RFC 6598 CGNAT range: 100.64.0.0/10 (100.64.0.0 100.127.255.255)
# These IPs appear in router ARP tables but not on APs, causing false matches.
defp cgnat_ip?(%{subscriber_ip: nil}), do: false
defp cgnat_ip?(%{subscriber_ip: ""}), do: false
defp cgnat_ip?(%{subscriber_ip: ip}) do
case String.split(ip, ".") do
[first, second | _] ->
with {100, ""} <- Integer.parse(first),
{octet2, ""} <- Integer.parse(second) do
octet2 >= 64 and octet2 <= 127
else
_ -> false
end
_ ->
false
end
end
defp compute_mrr_for_accounts(_org_id, []), do: Decimal.new(0)
defp compute_mrr_for_accounts(org_id, account_gaiia_ids) do
BillingSubscription
|> where(organization_id: ^org_id)
|> where([bs], bs.account_gaiia_id in ^account_gaiia_ids)
|> where([bs], bs.status == "ACTIVE")
|> Repo.aggregate(:sum, :mrr_amount) || Decimal.new(0)
end
@doc """
Get subscriber impact for a site (all devices at site).
Returns `%{subscriber_count: integer, mrr: Decimal.t(), devices: [%{device_id, name, count, mrr}]}`.
"""
def get_site_impact(site_id) do
device_ids =
Device
|> where(site_id: ^site_id)
|> select([d], d.id)
|> Repo.all()
impact = get_devices_impact(device_ids)
# Detect router devices via SNMP sys_descr
router_device_ids =
SnmpDevice
|> where([sd], sd.device_id in ^device_ids)
|> where([sd], ilike(sd.sys_descr, "%RouterOS%"))
|> select([sd], sd.device_id)
|> Repo.all()
|> MapSet.new()
devices_with_names =
Device
|> where([d], d.id in ^device_ids)
|> select([d], {d.id, d.name})
|> Repo.all()
|> Enum.map(fn {id, name} ->
if MapSet.member?(router_device_ids, id) do
%{device_id: id, name: name, count: 0, mrr: Decimal.new(0)}
else
di = Map.get(impact, id, %{subscriber_count: 0, mrr: Decimal.new(0)})
%{device_id: id, name: name, count: di.subscriber_count, mrr: di.mrr}
end
end)
# Site totals = sum of non-router per-device impacts
# (already CGNAT-filtered and deduplicated)
{total_subs, total_mrr} =
Enum.reduce(devices_with_names, {0, Decimal.new(0)}, fn dev, {subs, mrr} ->
{subs + dev.count, Decimal.add(mrr, dev.mrr)}
end)
%{
subscriber_count: total_subs,
mrr: total_mrr,
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