- 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
561 lines
17 KiB
Elixir
561 lines
17 KiB
Elixir
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
|