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 %>