From 2ff6a61bd12c0034cd50a02a892ae6a7557a327f Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 11 May 2026 18:35:34 -0500 Subject: [PATCH] fix(gaiia): add ghost devices tab, manual link, and refresh to reconciliation Fix auto_match return tuple order (mac/ip/site were swapped). Add ghost devices tab for cleaning up stale mappings to deleted devices. Add "Link" button on untracked devices to manually link to existing Gaiia items. Add manual refresh button for on-demand reconciliation. --- lib/towerops/gaiia.ex | 2 +- .../live/org/gaiia_reconciliation_live.ex | 97 +++++++ .../org/gaiia_reconciliation_live.html.heex | 253 ++++++++++++++---- 3 files changed, 301 insertions(+), 51 deletions(-) diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex index c71b9c36..027731da 100644 --- a/lib/towerops/gaiia.ex +++ b/lib/towerops/gaiia.ex @@ -190,7 +190,7 @@ defmodule Towerops.Gaiia do remaining = total_devices - MapSet.size(mapped_device_ids) - {:ok, ip_matched, site_matched, mac_matched, remaining} + {:ok, mac_matched, ip_matched, site_matched, remaining} end defp match_phase(organization_id, unmapped_index, matcher_fn) do diff --git a/lib/towerops_web/live/org/gaiia_reconciliation_live.ex b/lib/towerops_web/live/org/gaiia_reconciliation_live.ex index 7ec462b3..494b2d37 100644 --- a/lib/towerops_web/live/org/gaiia_reconciliation_live.ex +++ b/lib/towerops_web/live/org/gaiia_reconciliation_live.ex @@ -17,6 +17,9 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do |> assign(:organization, org) |> assign(:page_title, t("Gaiia Inventory Reconciliation")) |> assign(:open_create_for, nil) + |> assign(:open_link_for, nil) + |> assign(:link_search_query, "") + |> assign(:link_search_results, []) |> assign(:manufacturers, []) |> assign(:models, %{}) |> assign(:selected_manufacturer, nil)} @@ -42,6 +45,10 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do |> load_reconciliation()} end + def handle_event("refresh", _params, socket) do + {:noreply, load_reconciliation(socket)} + end + def handle_event("open_create_form", %{"device_id" => device_id}, socket) do org_id = socket.assigns.organization.id @@ -58,6 +65,7 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do {:noreply, socket |> assign(:open_create_for, device_id) + |> assign(:open_link_for, nil) |> assign(:selected_manufacturer, nil)} end @@ -124,6 +132,81 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do |> load_reconciliation()} end + # -- Link to existing Gaiia item from untracked tab -- + + def handle_event("start_link_search", %{"device_id" => device_id}, socket) do + {:noreply, + socket + |> assign(:open_link_for, device_id) + |> assign(:open_create_for, nil) + |> assign(:link_search_query, "") + |> assign(:link_search_results, [])} + end + + def handle_event("cancel_link_search", _params, socket) do + {:noreply, + socket + |> assign(:open_link_for, nil) + |> assign(:link_search_query, "") + |> assign(:link_search_results, [])} + end + + def handle_event("search_gaiia_items", %{"query" => query}, socket) do + org_id = socket.assigns.organization.id + + results = + if String.length(query) >= 2 do + org_id + |> Gaiia.search_inventory_items(query) + |> Enum.filter(&is_nil(&1.device_id)) + else + [] + end + + {:noreply, socket |> assign(:link_search_query, query) |> assign(:link_search_results, results)} + end + + def handle_event("link_to_gaiia", %{"device_id" => device_id, "gaiia_id" => gaiia_id}, socket) do + org_id = socket.assigns.organization.id + + result = + case Gaiia.get_inventory_item(org_id, gaiia_id) do + nil -> {:error, :not_found} + item -> Gaiia.update_inventory_item_mapping(item, %{device_id: device_id}) + end + + {:noreply, + socket + |> assign(:open_link_for, nil) + |> assign(:link_search_query, "") + |> assign(:link_search_results, []) + |> put_flash(:info, link_flash(result)) + |> load_reconciliation()} + end + + # -- Ghost device cleanup -- + + def handle_event("unlink_ghost", %{"gaiia_id" => gaiia_id}, socket) do + org_id = socket.assigns.organization.id + + 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: nil}) do + {:ok, _} -> + {:noreply, + socket + |> load_reconciliation() + |> put_flash(:info, t("Unlinked stale device mapping."))} + + {:error, _} -> + {:noreply, put_flash(socket, :error, t("Failed to unlink stale mapping."))} + end + end + end + def handle_event("select_tab", %{"tab" => tab}, socket) do org = socket.assigns.organization @@ -133,6 +216,8 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do )} end + # -- Flash helpers -- + defp match_flash({:ok, 0, 0, 0, _remaining}) do t("No matches found. Link devices manually on the mapping page.") end @@ -171,6 +256,18 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do t("Failed to create Gaiia inventory item: %{reason}", reason: inspect(reason)) end + defp link_flash({:ok, _item}) do + t("Device linked to Gaiia inventory item.") + end + + defp link_flash({:error, :not_found}) do + t("Gaiia inventory item not found.") + end + + defp link_flash({:error, _reason}) do + t("Failed to link device.") + end + defp load_reconciliation(socket) do report = Reconciliation.reconcile(socket.assigns.organization.id) assign(socket, :report, report) diff --git a/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex b/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex index 9e092451..6b067b77 100644 --- a/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex +++ b/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex @@ -37,7 +37,7 @@ -
+
{@report.summary.total_mapped} @@ -62,9 +62,15 @@
Data Mismatches
+
+
+ {@report.summary.ghost_count} +
+
Ghosts
+
-
+
<.link patch={ @@ -113,7 +119,7 @@ ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation?tab=unmapped" } class={[ - "px-4 py-2 text-sm font-medium rounded-r-lg border-l border-gray-200 dark:border-white/10", + "px-4 py-2 text-sm font-medium border-l border-gray-200 dark:border-white/10", @active_tab == "unmapped" && "bg-blue-600 text-white dark:bg-blue-500", @active_tab != "unmapped" && @@ -122,7 +128,29 @@ > Unmapped ({@report.summary.missing_mapping_count}) + <.link + patch={ + ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation?tab=ghosts" + } + class={[ + "px-4 py-2 text-sm font-medium rounded-r-lg border-l border-gray-200 dark:border-white/10", + @active_tab == "ghosts" && + "bg-blue-600 text-white dark:bg-blue-500", + @active_tab != "ghosts" && + "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-gray-300 dark:hover:bg-gray-800" + ]} + > + Ghosts ({@report.summary.ghost_count}) +
+ +
<%= case @active_tab do %> @@ -189,62 +217,121 @@ <% end %> - <%= if @open_create_for == entry.device.id do %> -
-
- -
- <%= if @selected_manufacturer do %> -
- + <%= cond do %> + <% @open_create_for == entry.device.id -> %> +
+ - - <% end %> - -
- <% else %> - + <%= if @selected_manufacturer do %> +
+ + + +
+ <% end %> + +
+ <% @open_link_for == entry.device.id -> %> +
+
+ + +
+ +
+ <% true -> %> +
+ + +
<% end %> + <%!-- Search results row for link search --%> + <%= if @open_link_for == entry.device.id and @link_search_results != [] do %> + + +
+ <%= for item <- @link_search_results do %> +
+
+ + {item.name} + + + {item.ip_address || "—"} + +
+ +
+ <% end %> +
+ + + <% end %> <% end %> @@ -350,6 +437,64 @@
<% end %>
+ <% "ghosts" -> %> +
+ <%= if @report.ghost_devices == [] do %> +

+ {t("No stale mappings — all linked Gaiia items reference existing Towerops devices.")} +

+ <% else %> +

+ {t( + "These Gaiia inventory items are linked to Towerops devices that no longer exist. Unlink them to restore them to the unmapped pool." + )} +

+
+ + + + + + + + + + + <%= for entry <- @report.ghost_devices do %> + + + + + + + <% end %> + +
+ {t("Gaiia Item")} + + {t("IP Address")} + + {t("Status")} + + {t("Actions")} +
+ {entry.inventory_item.name} + + {entry.inventory_item.ip_address || "—"} + + {entry.inventory_item.status || "—"} + + +
+
+ <% end %> +
<% _ -> %>

@@ -415,6 +560,14 @@ {@report.summary.mismatch_count}

+
+
+ {t("Ghost mappings (stale device links)")} +
+
+ {@report.summary.ghost_count} +
+
<% end %>