towerops/lib/towerops/gaiia.ex
Graham McIntire 58181923f8
improve Gaiia integration UX
- Make insight messages more descriptive (explain what untracked/ghost/mismatch means)
- Clarify action button labels on insight cards
- Redesign Gaiia mapping page to be TowerOps-centric: devices/sites as primary rows, search Gaiia for what to link them to
- Add IP-based match suggestions and smart sorting (suggestions first, then unlinked, then linked)
- Add search_inventory_items/2 and search_network_sites/2 to Gaiia context
- Make IP addresses clickable links (http://<ip>)
- Add optional Gaiia App URL setting to enable deep links to inventory items and network sites
2026-03-12 13:04:17 -05:00

561 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
def search_inventory_items(organization_id, query) do
search = "%#{query}%"
InventoryItem
|> where(organization_id: ^organization_id)
|> where([i], ilike(i.name, ^search) or ilike(i.ip_address, ^search) or ilike(i.model_name, ^search))
|> order_by(:name)
|> limit(20)
|> Repo.all()
end
def search_network_sites(organization_id, query) do
search = "%#{query}%"
NetworkSite
|> where(organization_id: ^organization_id)
|> where([ns], ilike(ns.name, ^search))
|> order_by(:name)
|> limit(20)
|> Repo.all()
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