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