- 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
321 lines
9.1 KiB
Elixir
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
|