towerops/lib/towerops_web/live/org/gaiia_mapping_live.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

321 lines
9.1 KiB
Elixir

defmodule ToweropsWeb.Org.GaiiaMappingLive do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Devices
alias Towerops.Gaiia
alias Towerops.Integrations
alias Towerops.Repo
alias Towerops.Sites
@impl true
def mount(_params, _session, socket) do
org = socket.assigns.current_scope.organization
gaiia_app_url =
case Integrations.get_integration(org.id, "gaiia") do
{:ok, integration} ->
case integration.credentials["app_url"] do
url when is_binary(url) and url != "" -> String.trim_trailing(url, "/")
_ -> nil
end
{:error, _} ->
nil
end
{:ok,
socket
|> assign(:page_title, t("Link Devices & Sites to Gaiia"))
|> assign(:organization, org)
|> assign(:gaiia_app_url, gaiia_app_url)
|> assign(:tab, "devices")
|> assign(:filter, "all")
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])
|> load_data()}
end
@impl true
def handle_params(params, _url, socket) do
tab =
case params["tab"] do
t when t in ~w(sites devices) -> t
_ -> "devices"
end
filter =
case params["filter"] do
f when f in ~w(all linked unlinked) -> f
_ -> "all"
end
{:noreply,
socket
|> assign(:tab, tab)
|> assign(:filter, filter)
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])
|> load_data()}
end
@impl true
def handle_event("start_link", %{"id" => id}, socket) do
{:noreply,
socket
|> assign(:linking_id, id)
|> assign(:search_query, "")
|> assign(:search_results, [])}
end
@impl true
def handle_event("cancel_link", _params, socket) do
{:noreply,
socket
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])}
end
@impl true
def handle_event("search", %{"query" => query}, socket) do
org = socket.assigns.organization
results =
if String.length(query) >= 2 do
case socket.assigns.tab do
"sites" -> Gaiia.search_network_sites(org.id, query)
"devices" -> Gaiia.search_inventory_items(org.id, query)
end
else
[]
end
{:noreply, socket |> assign(:search_query, query) |> assign(:search_results, results)}
end
@impl true
def handle_event("link_device", %{"device-id" => device_id, "gaiia-id" => gaiia_id}, socket) do
org = socket.assigns.organization
case Gaiia.get_inventory_item(org.id, gaiia_id) do
nil ->
{:noreply, put_flash(socket, :error, t("Gaiia inventory item not found"))}
item ->
case Gaiia.update_inventory_item_mapping(item, %{device_id: device_id}) do
{:ok, _} ->
{:noreply,
socket
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])
|> load_data()
|> put_flash(:info, t("Device linked to Gaiia inventory"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to link device"))}
end
end
end
@impl true
def handle_event("unlink_device", %{"device-id" => device_id}, socket) do
case Gaiia.get_inventory_item_for_device(device_id) do
nil ->
{:noreply, put_flash(socket, :error, t("No linked Gaiia item found"))}
item ->
case Gaiia.update_inventory_item_mapping(item, %{device_id: nil}) do
{:ok, _} ->
{:noreply,
socket
|> load_data()
|> put_flash(:info, t("Device unlinked from Gaiia"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to unlink device"))}
end
end
end
@impl true
def handle_event("link_site", %{"site-id" => site_id, "gaiia-id" => gaiia_id}, socket) do
org = socket.assigns.organization
case Gaiia.get_network_site(org.id, gaiia_id) do
nil ->
{:noreply, put_flash(socket, :error, t("Gaiia network site not found"))}
network_site ->
case Gaiia.update_network_site_mapping(network_site, %{site_id: site_id}) do
{:ok, _} ->
{:noreply,
socket
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])
|> load_data()
|> put_flash(:info, t("Site linked to Gaiia"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to link site"))}
end
end
end
@impl true
def handle_event("unlink_site", %{"site-id" => site_id}, socket) do
case Gaiia.get_network_site_for_site(site_id) do
nil ->
{:noreply, put_flash(socket, :error, t("No linked Gaiia site found"))}
network_site ->
case Gaiia.update_network_site_mapping(network_site, %{site_id: nil}) do
{:ok, _} ->
{:noreply,
socket
|> load_data()
|> put_flash(:info, t("Site unlinked from Gaiia"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to unlink site"))}
end
end
end
defp load_data(socket) do
org_id = socket.assigns.organization.id
tab = socket.assigns.tab
filter = socket.assigns.filter
case tab do
"sites" -> load_sites_with_gaiia(socket, org_id, filter)
"devices" -> load_devices_with_gaiia(socket, org_id, filter)
end
end
defp load_devices_with_gaiia(socket, org_id, filter) do
devices =
org_id
|> Devices.list_organization_devices()
|> Repo.preload(:site)
inventory_items = Gaiia.list_inventory_items(org_id)
item_by_device =
inventory_items
|> Enum.filter(&(not is_nil(&1.device_id)))
|> Map.new(&{&1.device_id, &1})
rows =
Enum.map(devices, fn device ->
%{device: device, inventory_item: Map.get(item_by_device, device.id)}
end)
filtered = apply_device_filter(rows, filter)
unmapped_items_by_ip =
inventory_items
|> Enum.filter(&(is_nil(&1.device_id) and &1.ip_address not in [nil, ""]))
|> Enum.group_by(& &1.ip_address)
suggestions =
Enum.reduce(filtered, %{}, fn row, acc ->
add_device_suggestion(acc, row, unmapped_items_by_ip)
end)
sorted =
Enum.sort_by(filtered, fn row ->
cond do
Map.has_key?(suggestions, row.device.id) -> 0
is_nil(row.inventory_item) -> 1
true -> 2
end
end)
socket
|> assign(:items, sorted)
|> assign(:suggestions, suggestions)
end
defp load_sites_with_gaiia(socket, org_id, filter) do
sites = Sites.list_organization_sites(org_id)
network_sites = Gaiia.list_network_sites(org_id)
ns_by_site =
network_sites
|> Enum.filter(&(not is_nil(&1.site_id)))
|> Map.new(&{&1.site_id, &1})
rows =
Enum.map(sites, fn site ->
%{site: site, network_site: Map.get(ns_by_site, site.id)}
end)
filtered = apply_site_filter(rows, filter)
unmapped_ns = Enum.filter(network_sites, &is_nil(&1.site_id))
suggestions =
Enum.reduce(filtered, %{}, fn row, acc ->
add_site_suggestion(acc, row, unmapped_ns)
end)
sorted =
Enum.sort_by(filtered, fn row ->
cond do
Map.has_key?(suggestions, row.site.id) -> 0
is_nil(row.network_site) -> 1
true -> 2
end
end)
socket
|> assign(:items, sorted)
|> assign(:suggestions, suggestions)
end
defp add_device_suggestion(acc, %{inventory_item: item}, _) when not is_nil(item), do: acc
defp add_device_suggestion(acc, row, unmapped_items_by_ip) do
device_ip = to_string(row.device.ip_address)
matches = Map.get(unmapped_items_by_ip, device_ip, [])
if matches == [] do
acc
else
Map.put(acc, row.device.id, Enum.map(matches, &%{entity: &1, match_type: "ip", confidence: :high}))
end
end
defp add_site_suggestion(acc, %{network_site: ns}, _) when not is_nil(ns), do: acc
defp add_site_suggestion(acc, %{site: %{name: name}}, _) when name in [nil, ""], do: acc
defp add_site_suggestion(acc, row, unmapped_ns) do
site_name_lower = String.downcase(row.site.name)
matches =
Enum.filter(unmapped_ns, fn ns ->
ns.name &&
(String.contains?(String.downcase(ns.name), site_name_lower) or
String.contains?(site_name_lower, String.downcase(ns.name)))
end)
if matches == [] do
acc
else
Map.put(acc, row.site.id, Enum.map(matches, &%{entity: &1, match_type: "name", confidence: :medium}))
end
end
defp apply_device_filter(rows, "linked"), do: Enum.filter(rows, &(not is_nil(&1.inventory_item)))
defp apply_device_filter(rows, "unlinked"), do: Enum.filter(rows, &is_nil(&1.inventory_item))
defp apply_device_filter(rows, _), do: rows
defp apply_site_filter(rows, "linked"), do: Enum.filter(rows, &(not is_nil(&1.network_site)))
defp apply_site_filter(rows, "unlinked"), do: Enum.filter(rows, &is_nil(&1.network_site))
defp apply_site_filter(rows, _), do: rows
end