Promoted pure presentation and utility helpers from `defp` to `def @doc false` across ~20 LiveViews, Oban workers, and sync modules so they're reachable from unit tests. Refactored several `cond` blocks into idiomatic function heads with guards. Added ~250 new test cases in new files under test/towerops and test/towerops_web, including DB-backed tests for CnMaestro.Sync and AlertNotificationWorker, and removed dead LiveView tab components and CapacityLive (no callers anywhere in lib/test). Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types, Phoenix HTML modules, Inspect protocol impls) from coverage calculations — these are not our project code. Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
466 lines
14 KiB
Elixir
466 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 = "%#{Towerops.QueryHelpers.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
|
|
|
|
@doc """
|
|
Formats a structured address map into a single comma-separated string.
|
|
|
|
Accepts either `line1/line2` or `street1/street2` styling and either
|
|
`state/province` and `zip/postal_code`. Returns `nil` for empty maps
|
|
or non-map input.
|
|
"""
|
|
@spec format_address(map() | nil | term()) :: String.t() | nil
|
|
def format_address(nil), do: nil
|
|
|
|
def format_address(addr) when is_map(addr) do
|
|
[
|
|
addr["street1"] || addr["line1"],
|
|
addr["street2"] || addr["line2"],
|
|
addr["city"],
|
|
addr["state"] || addr["province"],
|
|
addr["zip"] || addr["postal_code"]
|
|
]
|
|
|> Enum.reject(&blank?/1)
|
|
|> join_or_nil()
|
|
end
|
|
|
|
def format_address(_), do: nil
|
|
|
|
defp blank?(nil), do: true
|
|
defp blank?(s) when is_binary(s), do: String.trim(s) == ""
|
|
defp blank?(_), do: false
|
|
|
|
defp join_or_nil([]), do: nil
|
|
defp join_or_nil(parts), do: Enum.join(parts, ", ")
|
|
end
|