towerops/lib/towerops/trace.ex
Graham McIntire efaf5558ff refactor: convert 6 Gleam modules to idiomatic Elixir with TDD (#196)
Phase 1: Foundation Types (100% complete)
- query_helpers: SQL LIKE sanitization with pipe operators
- numeric: Integer parsing with pattern matching guards
- result: Pure Elixir Result monad (map, and_then, unwrap_or)

Phase 2: Ecto Domain Types (100% complete)
- ip_address: IPv4/IPv6 validation using :inet directly
- mac_address: Multi-format MAC parsing (colon/hyphen/dot/compact)
- snmp_oid: OID parsing/manipulation with recursive pattern matching

All 198 tests passing across converted modules.
API changed from Gleam-style {:some/:none to idiomatic {:ok/:error.
Refactored parse_numeric_oid to use with statement, reducing nesting depth.

Reviewed-on: graham/towerops-web#196
2026-03-28 09:52:07 -05:00

457 lines
14 KiB
Elixir

defmodule Towerops.Trace do
@moduledoc """
Context for the Subscriber Trace / Help Desk Mode feature.
Provides unified search across Gaiia accounts, inventory items, devices,
and Preseem access points, then assembles a full "patient chart" view
of a subscriber's network status.
"""
import Ecto.Query
alias Towerops.Alerts.Alert
alias Towerops.ConfigChanges.ConfigChangeEvent
alias Towerops.Devices.Device
alias Towerops.Gaiia.Account
alias Towerops.Gaiia.BillingSubscription
alias Towerops.Gaiia.InventoryItem
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.Insight
alias Towerops.Preseem.SubscriberMetric
alias Towerops.Repo
alias Towerops.Sites.Site
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
@doc """
Search across multiple data sources for a given query string.
Returns a list of `%{type, id, label, sublabel, data}` maps.
"""
def search(organization_id, query) when is_binary(query) do
query = String.trim(query)
if String.length(query) < 2 do
[]
else
pattern = "%#{sanitize_like(query)}%"
accounts = search_accounts(organization_id, pattern)
inventory = search_inventory(organization_id, pattern)
sites = search_sites(organization_id, pattern)
devices = search_devices(organization_id, pattern)
access_points = search_access_points(organization_id, pattern)
Enum.take(accounts ++ inventory ++ sites ++ devices ++ access_points, 25)
end
end
defp search_accounts(organization_id, pattern) do
Account
|> where(organization_id: ^organization_id)
|> where([a], ilike(a.name, ^pattern) or ilike(a.readable_id, ^pattern) or ilike(a.gaiia_id, ^pattern))
|> limit(10)
|> Repo.all()
|> Enum.map(fn a ->
address_str = format_address(a.address)
sublabel = Enum.join(Enum.reject([a.readable_id, a.status, address_str], &is_nil/1), " · ")
%{type: :account, id: a.id, gaiia_id: a.gaiia_id, label: a.name || a.gaiia_id, sublabel: sublabel, data: a}
end)
end
defp search_inventory(organization_id, pattern) do
InventoryItem
|> where(organization_id: ^organization_id)
|> where(
[i],
ilike(i.ip_address, ^pattern) or ilike(i.serial_number, ^pattern) or
ilike(i.name, ^pattern) or ilike(i.mac_address, ^pattern)
)
|> limit(10)
|> preload(:device)
|> Repo.all()
|> Enum.map(fn i ->
sublabel = Enum.join(Enum.reject([i.ip_address, i.model_name, i.serial_number], &is_nil/1), " · ")
%{type: :inventory_item, id: i.id, label: i.name || i.ip_address || i.gaiia_id, sublabel: sublabel, data: i}
end)
end
defp search_sites(organization_id, pattern) do
Site
|> where(organization_id: ^organization_id)
|> where([s], ilike(s.name, ^pattern) or ilike(s.location, ^pattern) or ilike(s.address, ^pattern))
|> limit(10)
|> Repo.all()
|> Enum.map(fn s ->
sublabel = Enum.join(Enum.reject([s.location, s.address], &is_nil/1), " · ")
%{type: :site, id: s.id, label: s.name, sublabel: sublabel, data: s}
end)
end
defp search_devices(organization_id, pattern) do
Device
|> where(organization_id: ^organization_id)
|> where([d], ilike(d.ip_address, ^pattern) or ilike(d.name, ^pattern))
|> limit(10)
|> preload(:site)
|> Repo.all()
|> Enum.map(fn d ->
site_name = if d.site && d.site.name, do: d.site.name
sublabel = Enum.join(Enum.reject([d.ip_address, site_name, to_string(d.status)], &is_nil/1), " · ")
%{type: :device, id: d.id, label: d.name || d.ip_address, sublabel: sublabel, data: d}
end)
end
defp search_access_points(organization_id, pattern) do
AccessPoint
|> where(organization_id: ^organization_id)
|> where([ap], ilike(ap.name, ^pattern) or ilike(ap.ip_address, ^pattern))
|> limit(10)
|> preload(:device)
|> Repo.all()
|> Enum.map(fn ap ->
sublabel =
Enum.join(Enum.reject([ap.ip_address, ap.model, "#{ap.subscriber_count || 0} subscribers"], &is_nil/1), " · ")
%{type: :access_point, id: ap.id, label: ap.name, sublabel: sublabel, data: ap}
end)
end
# ---------------------------------------------------------------------------
# Trace Assembly
# ---------------------------------------------------------------------------
@doc """
Given a search result, assemble the full trace view.
Returns a map with all sections of the "patient chart".
"""
def assemble_trace(organization_id, type, id) do
case type do
:account -> trace_from_account(organization_id, id)
:inventory_item -> trace_from_inventory(organization_id, id)
:site -> trace_from_site(organization_id, id)
:device -> trace_from_device(organization_id, id)
:access_point -> trace_from_access_point(organization_id, id)
_ -> nil
end
end
defp trace_from_account(organization_id, account_id) do
account = Repo.get_by(Account, id: account_id, organization_id: organization_id)
if !account, do: throw(:not_found)
subscriptions = list_account_subscriptions(organization_id, account.gaiia_id)
inventory_items = list_account_inventory(organization_id, account.gaiia_id)
# Find connected device through inventory items
{device, access_point} = find_device_and_ap(inventory_items)
build_trace(account, subscriptions, inventory_items, device, access_point, organization_id)
catch
:not_found -> nil
end
defp trace_from_inventory(organization_id, item_id) do
item =
InventoryItem
|> where(id: ^item_id, organization_id: ^organization_id)
|> preload(:device)
|> Repo.one()
if !item, do: throw(:not_found)
# Find account if linked
account =
if item.assigned_account_gaiia_id do
Repo.get_by(Account, organization_id: organization_id, gaiia_id: item.assigned_account_gaiia_id)
end
subscriptions = if account, do: list_account_subscriptions(organization_id, account.gaiia_id), else: []
inventory_items = if account, do: list_account_inventory(organization_id, account.gaiia_id), else: [item]
device = if item.device_id, do: Device |> Repo.get(item.device_id) |> Repo.preload(:site)
access_point = if device, do: Repo.get_by(AccessPoint, device_id: device.id)
build_trace(account, subscriptions, inventory_items, device, access_point, organization_id)
catch
:not_found -> nil
end
defp trace_from_site(organization_id, site_id) do
site =
Site
|> where(id: ^site_id, organization_id: ^organization_id)
|> Repo.one()
if !site, do: throw(:not_found)
devices =
Device
|> where(organization_id: ^organization_id, site_id: ^site_id)
|> preload(:site)
|> Repo.all()
alerts = load_site_alerts(devices)
%{
site: site,
devices: devices,
alerts: alerts
}
catch
:not_found -> nil
end
defp trace_from_device(organization_id, device_id) do
device =
Device
|> where(id: ^device_id, organization_id: ^organization_id)
|> preload(:site)
|> Repo.one()
if !device, do: throw(:not_found)
access_point = Repo.get_by(AccessPoint, device_id: device.id)
# Find accounts served by this device through inventory items
inventory_items =
InventoryItem
|> where(organization_id: ^organization_id, device_id: ^device_id)
|> Repo.all()
account =
case inventory_items do
[first | _] when not is_nil(first.assigned_account_gaiia_id) ->
Repo.get_by(Account, organization_id: organization_id, gaiia_id: first.assigned_account_gaiia_id)
_ ->
nil
end
subscriptions = if account, do: list_account_subscriptions(organization_id, account.gaiia_id), else: []
build_trace(account, subscriptions, inventory_items, device, access_point, organization_id)
catch
:not_found -> nil
end
defp trace_from_access_point(organization_id, ap_id) do
access_point =
AccessPoint
|> where(id: ^ap_id, organization_id: ^organization_id)
|> preload(:device)
|> Repo.one()
if !access_point, do: throw(:not_found)
device = if access_point.device_id, do: Repo.preload(access_point.device, :site)
build_trace(nil, [], [], device, access_point, organization_id)
catch
:not_found -> nil
end
# ---------------------------------------------------------------------------
# Build the trace map
# ---------------------------------------------------------------------------
defp build_trace(account, subscriptions, inventory_items, device, access_point, organization_id) do
device_id = if device, do: device.id
%{
subscriber: build_subscriber_info(account, subscriptions),
inventory_items: inventory_items,
device: device,
access_point: access_point,
qoe_metrics: build_qoe_metrics(access_point),
peer_impact: build_peer_impact(access_point, organization_id),
alerts: load_device_alerts(device_id),
config_changes: load_recent_config_changes(device_id),
insights: load_device_insights(device_id, organization_id)
}
end
defp build_subscriber_info(nil, _subscriptions), do: nil
defp build_subscriber_info(account, subscriptions) do
primary_sub = List.first(subscriptions)
%{
name: account.name,
account_id: account.readable_id || account.gaiia_id,
status: account.status,
address: format_address(account.address),
mrr: account.mrr,
customer_since: account.inserted_at,
plan: if(primary_sub, do: primary_sub.product_name),
plan_mrr: if(primary_sub, do: primary_sub.mrr_amount),
speed_download: if(primary_sub, do: primary_sub.speed_download),
speed_upload: if(primary_sub, do: primary_sub.speed_upload),
subscription_count: length(subscriptions),
subscriptions: subscriptions
}
end
defp build_qoe_metrics(nil), do: nil
defp build_qoe_metrics(access_point) do
latest_metric =
SubscriberMetric
|> where(preseem_access_point_id: ^access_point.id)
|> order_by(desc: :recorded_at)
|> limit(1)
|> Repo.one()
%{
qoe_score: access_point.qoe_score,
capacity_score: access_point.capacity_score,
rf_score: access_point.rf_score,
subscriber_count: access_point.subscriber_count,
airtime_utilization: access_point.airtime_utilization,
latest_metric: latest_metric
}
end
defp build_peer_impact(nil, _org_id), do: nil
defp build_peer_impact(access_point, organization_id) do
# Count subscribers on same AP
subscriber_count = access_point.subscriber_count || 0
# Find other APs at the same device (same tower)
peer_aps =
if access_point.device_id do
AccessPoint
|> where(organization_id: ^organization_id)
|> where(device_id: ^access_point.device_id)
|> where([ap], ap.id != ^access_point.id)
|> Repo.all()
else
[]
end
# Check for active alerts on the device
device_alerts =
if access_point.device_id do
Alert
|> where(device_id: ^access_point.device_id)
|> where([a], is_nil(a.resolved_at))
|> Repo.aggregate(:count)
else
0
end
%{
subscriber_count: subscriber_count,
peer_ap_count: length(peer_aps),
peer_aps: peer_aps,
active_alert_count: device_alerts
}
end
defp load_site_alerts([]), do: []
defp load_site_alerts(devices) do
device_ids = Enum.map(devices, & &1.id)
Alert
|> where([a], a.device_id in ^device_ids)
|> order_by(desc: :triggered_at)
|> limit(10)
|> preload([:device, :acknowledged_by])
|> Repo.all()
end
defp load_device_alerts(nil), do: []
defp load_device_alerts(device_id) do
Alert
|> where(device_id: ^device_id)
|> order_by(desc: :triggered_at)
|> limit(10)
|> preload([:device, :acknowledged_by])
|> Repo.all()
end
defp load_recent_config_changes(nil), do: []
defp load_recent_config_changes(device_id) do
since = DateTime.add(DateTime.utc_now(), -24 * 3600, :second)
ConfigChangeEvent
|> where(device_id: ^device_id)
|> where([c], c.changed_at >= ^since)
|> order_by(desc: :changed_at)
|> limit(10)
|> Repo.all()
end
defp load_device_insights(nil, _org_id), do: []
defp load_device_insights(device_id, _organization_id) do
Insight
|> where(device_id: ^device_id)
|> where(status: "active")
|> order_by(desc: :inserted_at)
|> limit(10)
|> Repo.all()
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp find_device_and_ap(inventory_items) do
# Find the first inventory item that has a linked device
item_with_device = Enum.find(inventory_items, & &1.device_id)
if item_with_device do
device = Device |> Repo.get(item_with_device.device_id) |> Repo.preload(:site)
access_point = Repo.get_by(AccessPoint, device_id: device.id)
{device, access_point}
else
{nil, nil}
end
end
defp list_account_subscriptions(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
defp list_account_inventory(organization_id, account_gaiia_id) do
InventoryItem
|> where(organization_id: ^organization_id, assigned_account_gaiia_id: ^account_gaiia_id)
|> preload(:device)
|> Repo.all()
end
defp format_address(nil), do: nil
defp format_address(addr) when is_map(addr) do
parts =
Enum.reject(
[
addr["street1"] || addr["line1"],
addr["street2"] || addr["line2"],
addr["city"],
addr["state"] || addr["province"],
addr["zip"] || addr["postal_code"]
],
&(is_nil(&1) or String.trim(&1) == "")
)
if parts == [], do: nil, else: Enum.join(parts, ", ")
end
defp format_address(_), do: nil
defp sanitize_like(query), do: Towerops.QueryHelpers.sanitize_like(query)
end