From 58181923f8d5d9190c182544ae8ee1fe62a62480 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Mar 2026 13:04:17 -0500 Subject: [PATCH] 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://) - Add optional Gaiia App URL setting to enable deep links to inventory items and network sites --- lib/towerops/gaiia.ex | 22 + lib/towerops/workers/gaiia_insight_worker.ex | 12 +- .../live/insights_live/index.html.heex | 17 +- .../live/org/gaiia_mapping_live.ex | 269 ++++++++----- .../live/org/gaiia_mapping_live.html.heex | 377 +++++++++++------- lib/towerops_web/live/org/settings_live.ex | 25 ++ .../live/org/settings_live.html.heex | 22 + test/towerops/gaiia_test.exs | 72 ++++ 8 files changed, 565 insertions(+), 251 deletions(-) diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex index e09215a1..6132a016 100644 --- a/lib/towerops/gaiia.ex +++ b/lib/towerops/gaiia.ex @@ -116,6 +116,28 @@ defmodule Towerops.Gaiia do |> Repo.update() end + def search_inventory_items(organization_id, query) do + search = "%#{query}%" + + InventoryItem + |> where(organization_id: ^organization_id) + |> where([i], ilike(i.name, ^search) or ilike(i.ip_address, ^search) or ilike(i.model_name, ^search)) + |> order_by(:name) + |> limit(20) + |> Repo.all() + end + + def search_network_sites(organization_id, query) do + search = "%#{query}%" + + NetworkSite + |> where(organization_id: ^organization_id) + |> where([ns], ilike(ns.name, ^search)) + |> order_by(:name) + |> limit(20) + |> Repo.all() + end + # --- Billing Subscriptions --- def list_billing_subscriptions(organization_id) do diff --git a/lib/towerops/workers/gaiia_insight_worker.ex b/lib/towerops/workers/gaiia_insight_worker.ex index 5173bbd1..e9d30f5f 100644 --- a/lib/towerops/workers/gaiia_insight_worker.ex +++ b/lib/towerops/workers/gaiia_insight_worker.ex @@ -77,9 +77,9 @@ defmodule Towerops.Workers.GaiiaInsightWorker do channel: "passive", source: "gaiia", dedup_key: "untracked", - title: "#{count} #{ngettext("device", "devices", count)} not tracked in Gaiia", + title: "#{count} monitored #{ngettext("device", "devices", count)} not linked to Gaiia inventory", description: - "#{count} #{ngettext("device", "devices", count)} discovered by TowerOps #{ngettext("has", "have", count)} no matching Gaiia inventory item.", + "TowerOps is actively monitoring #{count} #{ngettext("device", "devices", count)} that #{ngettext("has", "have", count)} no matching entry in Gaiia's inventory. Without this link, subscriber impact data cannot be correlated for outages affecting #{ngettext("this device", "these devices", count)}. You may need to add #{ngettext("it", "them", count)} to Gaiia or map to existing inventory items.", metadata: %{ "dedup_key" => "untracked", "finding_type" => "untracked", @@ -102,9 +102,9 @@ defmodule Towerops.Workers.GaiiaInsightWorker do channel: "proactive", source: "gaiia", dedup_key: "ghost", - title: "#{count} Gaiia #{ngettext("item references", "items reference", count)} missing devices", + title: "#{count} Gaiia inventory #{ngettext("item", "items", count)} linked to deleted devices", description: - "#{count} Gaiia inventory #{ngettext("item is", "items are", count)} mapped to devices that no longer exist in TowerOps.", + "#{count} Gaiia inventory #{ngettext("item is", "items are", count)} still mapped to #{ngettext("a device", "devices", count)} that no longer exist in TowerOps. These stale mappings should be cleaned up to keep your inventory accurate.", metadata: %{ "dedup_key" => "ghost", "finding_type" => "ghost", @@ -125,9 +125,9 @@ defmodule Towerops.Workers.GaiiaInsightWorker do channel: "passive", source: "gaiia", dedup_key: "mismatch", - title: "#{count} data #{ngettext("mismatch", "mismatches", count)} between TowerOps and Gaiia", + title: "#{count} #{ngettext("device has", "devices have", count)} conflicting data between TowerOps and Gaiia", description: - "#{count} #{ngettext("device has", "devices have", count)} mismatched data between TowerOps and Gaiia inventory.", + "#{count} #{ngettext("device has", "devices have", count)} fields like IP address that don't match between TowerOps and Gaiia. This may cause incorrect subscriber impact analysis or indicate the same device is tracked with different info in each system.", metadata: %{ "dedup_key" => "mismatch", "finding_type" => "mismatch", diff --git a/lib/towerops_web/live/insights_live/index.html.heex b/lib/towerops_web/live/insights_live/index.html.heex index aa5889d1..5491ab01 100644 --- a/lib/towerops_web/live/insights_live/index.html.heex +++ b/lib/towerops_web/live/insights_live/index.html.heex @@ -311,7 +311,7 @@

<.icon name="hero-light-bulb" class="h-3.5 w-3.5 inline" /> - {t("Recommended actions")} + {t("To fix this:")}

<.link @@ -321,7 +321,7 @@ class="inline-flex items-center gap-1 rounded-md bg-blue-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs hover:bg-blue-700" > <.icon name="hero-arrows-right-left" class="h-3.5 w-3.5" /> - {t("Map to Gaiia inventory")} + {t("Link devices to Gaiia")} <.link navigate={ @@ -329,7 +329,8 @@ } class="inline-flex items-center gap-1 rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/20" > - <.icon name="hero-table-cells" class="h-3.5 w-3.5" /> View reconciliation + <.icon name="hero-table-cells" class="h-3.5 w-3.5" /> + {t("View comparison report")}
@@ -339,7 +340,7 @@

<.icon name="hero-light-bulb" class="h-3.5 w-3.5 inline" /> - {t("Recommended actions")} + {t("To fix this:")}

<.link @@ -348,7 +349,8 @@ } class="inline-flex items-center gap-1 rounded-md bg-amber-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs hover:bg-amber-700" > - <.icon name="hero-trash" class="h-3.5 w-3.5" /> Clean up stale mappings + <.icon name="hero-trash" class="h-3.5 w-3.5" /> + {t("Remove stale device links")}
@@ -358,7 +360,7 @@

<.icon name="hero-light-bulb" class="h-3.5 w-3.5 inline" /> - {t("Recommended actions")} + {t("To fix this:")}

<.link @@ -367,7 +369,8 @@ } class="inline-flex items-center gap-1 rounded-md bg-blue-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs hover:bg-blue-700" > - <.icon name="hero-table-cells" class="h-3.5 w-3.5" /> Review mismatches + <.icon name="hero-table-cells" class="h-3.5 w-3.5" /> + {t("Review conflicting data")}
diff --git a/lib/towerops_web/live/org/gaiia_mapping_live.ex b/lib/towerops_web/live/org/gaiia_mapping_live.ex index d4227b25..d80e2425 100644 --- a/lib/towerops_web/live/org/gaiia_mapping_live.ex +++ b/lib/towerops_web/live/org/gaiia_mapping_live.ex @@ -4,6 +4,7 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do alias Towerops.Devices alias Towerops.Gaiia + alias Towerops.Integrations alias Towerops.Repo alias Towerops.Sites @@ -11,11 +12,24 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do 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("Gaiia Entity Mapping")) + |> assign(:page_title, t("Link Devices & Sites to Gaiia")) |> assign(:organization, org) - |> assign(:tab, "sites") + |> assign(:gaiia_app_url, gaiia_app_url) + |> assign(:tab, "devices") |> assign(:filter, "all") |> assign(:linking_id, nil) |> assign(:search_query, "") @@ -28,12 +42,12 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do tab = case params["tab"] do t when t in ~w(sites devices) -> t - _ -> "sites" + _ -> "devices" end filter = case params["filter"] do - f when f in ~w(all mapped unmapped) -> f + f when f in ~w(all linked unlinked) -> f _ -> "all" end @@ -72,8 +86,8 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do results = if String.length(query) >= 2 do case socket.assigns.tab do - "sites" -> search_sites(org.id, query) - "devices" -> Devices.search_devices(org.id, query) + "sites" -> Gaiia.search_network_sites(org.id, query) + "devices" -> Gaiia.search_inventory_items(org.id, query) end else [] @@ -83,54 +97,7 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do end @impl true - def handle_event("link_site", %{"gaiia-id" => gaiia_id, "site-id" => site_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 successfully"))} - - {:error, _} -> - {:noreply, put_flash(socket, :error, t("Failed to link site"))} - end - end - end - - @impl true - def handle_event("unlink_site", %{"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: nil}) do - {:ok, _} -> - {:noreply, - socket - |> load_data() - |> put_flash(:info, t("Site unlinked"))} - - {:error, _} -> - {:noreply, put_flash(socket, :error, t("Failed to unlink site"))} - end - end - end - - @impl true - def handle_event("link_device", %{"gaiia-id" => gaiia_id, "device-id" => device_id}, socket) do + 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 @@ -146,7 +113,7 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do |> assign(:search_query, "") |> assign(:search_results, []) |> load_data() - |> put_flash(:info, t("Device linked successfully"))} + |> put_flash(:info, t("Device linked to Gaiia inventory"))} {:error, _} -> {:noreply, put_flash(socket, :error, t("Failed to link device"))} @@ -155,12 +122,10 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do end @impl true - def handle_event("unlink_device", %{"gaiia-id" => gaiia_id}, socket) do - org = socket.assigns.organization - - case Gaiia.get_inventory_item(org.id, gaiia_id) do + 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("Gaiia inventory item not found"))} + {:noreply, put_flash(socket, :error, t("No linked Gaiia item found"))} item -> case Gaiia.update_inventory_item_mapping(item, %{device_id: nil}) do @@ -168,7 +133,7 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do {:noreply, socket |> load_data() - |> put_flash(:info, t("Device unlinked"))} + |> put_flash(:info, t("Device unlinked from Gaiia"))} {:error, _} -> {:noreply, put_flash(socket, :error, t("Failed to unlink device"))} @@ -176,73 +141,181 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do 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_network_sites(socket, org_id, filter) - "devices" -> load_inventory_items(socket, org_id, filter) + "sites" -> load_sites_with_gaiia(socket, org_id, filter) + "devices" -> load_devices_with_gaiia(socket, org_id, filter) end end - defp load_network_sites(socket, org_id, filter) do - sites = + defp load_devices_with_gaiia(socket, org_id, filter) do + devices = org_id - |> Gaiia.list_network_sites() + |> Devices.list_organization_devices() |> Repo.preload(:site) - |> filter_by_mapping(filter) - suggestions = - Enum.reduce(sites, %{}, fn site, acc -> - matches = Gaiia.suggest_site_matches(org_id, site) - if matches == [], do: acc, else: Map.put(acc, site.gaiia_id, matches) + 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) - # Sort items with suggestions first - sorted_sites = Enum.sort_by(sites, fn site -> if Map.has_key?(suggestions, site.gaiia_id), do: 0, else: 1 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_sites) + |> assign(:items, sorted) |> assign(:suggestions, suggestions) end - defp load_inventory_items(socket, org_id, filter) do - items = - org_id - |> Gaiia.list_inventory_items() - |> Repo.preload(:device) - |> filter_by_mapping(filter) + 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) - suggestions = - Enum.reduce(items, %{}, fn item, acc -> - matches = Gaiia.suggest_device_matches(org_id, item) - if matches == [], do: acc, else: Map.put(acc, item.gaiia_id, matches) + 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) - # Sort items with suggestions first - sorted_items = Enum.sort_by(items, fn item -> if Map.has_key?(suggestions, item.gaiia_id), do: 0, else: 1 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_items) + |> assign(:items, sorted) |> assign(:suggestions, suggestions) end - defp filter_by_mapping(items, "mapped") do - Enum.filter(items, &mapped?/1) + 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 filter_by_mapping(items, "unmapped") do - Enum.reject(items, &mapped?/1) + 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 filter_by_mapping(items, _), do: items + 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 mapped?(%Gaiia.NetworkSite{site_id: site_id}), do: not is_nil(site_id) - defp mapped?(%Gaiia.InventoryItem{device_id: device_id}), do: not is_nil(device_id) - - defp search_sites(organization_id, query) do - Sites.search_sites(organization_id, query) - end + 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 diff --git a/lib/towerops_web/live/org/gaiia_mapping_live.html.heex b/lib/towerops_web/live/org/gaiia_mapping_live.html.heex index 3891f93b..9f0b26df 100644 --- a/lib/towerops_web/live/org/gaiia_mapping_live.html.heex +++ b/lib/towerops_web/live/org/gaiia_mapping_live.html.heex @@ -12,11 +12,11 @@

- {t("Gaiia Entity Mapping")} + {t("Link Devices & Sites to Gaiia")}

{t( - "Link Gaiia entities to your Towerops sites and devices. Match suggestions are shown in amber." + "For each of your monitored devices and sites, find the matching Gaiia inventory item. This enables subscriber impact data when devices go down." )}

@@ -28,13 +28,13 @@ navigate={~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"} class="border-b-2 border-indigo-500 px-1 pb-3 text-sm font-medium text-indigo-600 dark:text-indigo-400" > - {t("Entity Mapping")} + {t("Device & Site Linking")} <.link navigate={~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"} class="border-b-2 border-transparent px-1 pb-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-white" > - {t("Reconciliation")} + {t("Comparison Report")} @@ -42,22 +42,6 @@ <%!-- Tab navigation --%>
- <%!-- Filter tabs --%> + <%!-- Filter pills --%>
<.link - :for={f <- ~w(all mapped unmapped)} + :for={{f, label} <- [{"all", "All"}, {"linked", "Linked"}, {"unlinked", "Unlinked"}]} patch={ ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping?tab=#{@tab}&filter=#{f}" } @@ -93,7 +93,7 @@ ) ]} > - {String.capitalize(f)} + {label}
@@ -109,22 +109,29 @@ class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />

- <%= if @tab == "sites" do %> - {t("No network sites found")} - <% else %> - {t("No inventory items found")} + <%= case {@tab, @filter} do %> + <% {"devices", "linked"} -> %> + {t("No linked devices yet")} + <% {"devices", "unlinked"} -> %> + {t("All devices are linked to Gaiia")} + <% {"sites", "linked"} -> %> + {t("No linked sites yet")} + <% {"sites", "unlinked"} -> %> + {t("All sites are linked to Gaiia")} + <% {"devices", _} -> %> + {t("No devices in your organization")} + <% _ -> %> + {t("No sites in your organization")} <% end %>

- <%= case @filter do %> - <% "mapped" -> %> - {t("No mapped entities yet. Sync from Gaiia and link entities below.")} - <% "unmapped" -> %> - {t("All entities are mapped.")} + <%= case {@tab, @filter} do %> + <% {_, "unlinked"} -> %> + {t("Nothing left to do here.")} + <% {_, "linked"} -> %> + {t("Link devices or sites using the Unlinked filter.")} <% _ -> %> - {t( - "No Gaiia data has been synced yet. Enable the Gaiia integration and run a sync." - )} + {t("Add devices to TowerOps to get started.")} <% end %>

@@ -137,13 +144,7 @@ scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400" > - {t("Name")} - - - {t("Gaiia ID")} + {if @tab == "devices", do: t("Your Device"), else: t("Your Site")} <%= if @tab == "devices" do %> - {if @tab == "sites", do: "Linked Site", else: "Linked Device"} + {if @tab == "devices", + do: t("Gaiia Inventory Item"), + else: t("Gaiia Network Site")} - <%= for item <- @items do %> + <%= for row <- @items do %> + <% row_id = if @tab == "devices", do: row.device.id, else: row.site.id + + is_linked = + if @tab == "devices", + do: not is_nil(row.inventory_item), + else: not is_nil(row.network_site) %> - + <%!-- Device / Site column --%> +
- {item.name || "Unnamed"} - <%= if Map.has_key?(@suggestions, item.gaiia_id) do %> - - <.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" /> Suggestion - + <%= if @tab == "devices" do %> +
+
+ <.link + navigate={~p"/devices/#{row.device.id}"} + class="font-medium text-gray-900 hover:text-indigo-600 dark:text-white dark:hover:text-indigo-400" + > + {row.device.name || row.device.ip_address || "Unnamed"} + + <%= if Map.has_key?(@suggestions, row.device.id) do %> + + <.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" /> + Match found + + <% end %> +
+ <%= if row.device.site do %> +
+ {row.device.site.name} +
+ <% end %> +
+ <% else %> +
+
+ + {row.site.name || "Unnamed"} + + <%= if Map.has_key?(@suggestions, row.site.id) do %> + + <.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" /> + Match found + + <% end %> +
+
<% end %> - - {item.gaiia_id} -
- - {item.gaiia_id} - + + <%!-- IP Address (devices only) --%> <%= if @tab == "devices" do %> - {item.ip_address || "-"} + <%= if row.device.ip_address do %> + + {row.device.ip_address} + + <% else %> + — + <% end %> <% end %> - - <%= if @tab == "sites" and item.site do %> - - {item.site.name} - - <% end %> - <%= if @tab == "devices" and item.device do %> - <.link - navigate={~p"/devices/#{item.device.id}"} - class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300" - > - {item.device.name || item.device.ip_address} - - <% end %> - <%= if (@tab == "sites" and is_nil(item.site_id)) or (@tab == "devices" and is_nil(item.device_id)) do %> - - + + <%!-- Gaiia Item / Network Site column --%> + + <%= if @tab == "devices" do %> + <%= if row.inventory_item do %> +
+ <%= if @gaiia_app_url do %> + + {row.inventory_item.name || "Unnamed"} + <.icon + name="hero-arrow-top-right-on-square" + class="inline h-3 w-3 ml-0.5 opacity-50" + /> + + <% else %> + + {row.inventory_item.name || "Unnamed"} + + <% end %> + <%= if row.inventory_item.model_name do %> +
+ {row.inventory_item.model_name} +
+ <% end %> +
+ <% else %> + + Not linked + + <% end %> + <% else %> + <%= if row.network_site do %> + <%= if @gaiia_app_url do %> + + {row.network_site.name || "Unnamed"} + <.icon + name="hero-arrow-top-right-on-square" + class="inline h-3 w-3 ml-0.5 opacity-50" + /> + + <% else %> + + {row.network_site.name || "Unnamed"} + + <% end %> + <% else %> + + Not linked + + <% end %> <% end %> + + <%!-- Actions column --%> - <%= if @tab == "sites" do %> - <%= if item.site_id do %> + <%= if @tab == "devices" do %> + <%= if row.inventory_item do %> <% end %> <% else %> - <%= if item.device_id do %> + <%= if row.network_site do %> @@ -381,7 +475,10 @@ <%= if @search_query != "" and String.length(@search_query) >= 2 and @search_results == [] do %>

- No results found matching "{@search_query}" + No Gaiia {if(@tab == "sites", + do: "network sites", + else: "inventory items" + )} matching "{@search_query}"

<% end %> @@ -389,7 +486,7 @@ type="button" id="cancel-link" phx-click="cancel_link" - class="self-start text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" + class="flex-shrink-0 self-start text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300" > {t("Cancel")} diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex index 213cad37..b636023d 100644 --- a/lib/towerops_web/live/org/settings_live.ex +++ b/lib/towerops_web/live/org/settings_live.ex @@ -527,6 +527,31 @@ defmodule ToweropsWeb.Org.SettingsLive do end end + def handle_event("save_gaiia_app_url", %{"value" => url}, socket) do + case Map.get(socket.assigns.integrations, "gaiia") do + nil -> + {:noreply, socket} + + integration -> + updated_credentials = + Map.put(integration.credentials, "app_url", String.trim(url)) + + case Integrations.update_integration(integration, %{credentials: updated_credentials}) do + {:ok, _updated} -> + integrations = load_integrations(socket.assigns.organization.id) + + {:noreply, + socket + |> assign(:integrations, integrations) + |> assign(:active_billing_provider, active_billing_provider(integrations)) + |> put_flash(:info, t("Gaiia app URL saved"))} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, t("Failed to save Gaiia app URL"))} + end + end + end + @impl true def handle_event("toggle_enabled", %{"provider" => provider}, socket) do case Map.get(socket.assigns.integrations, provider) do diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index 9796f377..74b88ad8 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -2144,6 +2144,28 @@ +
+ +

+ {t( + "Your Gaiia app URL (e.g. https://app.gaiia.com/your-org). Used to create direct links to inventory items and network sites." + )} +

+
+ +
+
+