diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex index 64ab98a6..b143ad74 100644 --- a/lib/towerops/gaiia.ex +++ b/lib/towerops/gaiia.ex @@ -117,6 +117,68 @@ defmodule Towerops.Gaiia do |> Repo.update() end + @doc """ + Auto-match untracked devices to unmapped Gaiia inventory items by IP address. + + For each untracked device (no linked inventory item), looks for an unmapped + inventory item with the same IP. If found, links them by setting `device_id` + on the inventory item. + + Returns `{:ok, matched_count, remaining_count}`. + """ + def auto_match_untracked(organization_id) do + # Unmapped inventory items with an IP address, indexed by IP + unmapped_by_ip = + InventoryItem + |> where(organization_id: ^organization_id) + |> where([i], is_nil(i.device_id)) + |> where([i], not is_nil(i.ip_address) and i.ip_address != "") + |> Repo.all() + |> Enum.group_by(& &1.ip_address) + + # Devices with no linked inventory item + mapped_device_ids = + InventoryItem + |> where(organization_id: ^organization_id) + |> where([i], not is_nil(i.device_id)) + |> select([i], i.device_id) + |> Repo.all() + |> MapSet.new() + + untracked = + Device + |> where(organization_id: ^organization_id) + |> where([d], not is_nil(d.ip_address)) + |> Repo.all() + |> Enum.reject(fn d -> MapSet.member?(mapped_device_ids, d.id) end) + + {matched, _remaining} = + Enum.reduce(untracked, {0, []}, fn device, acc -> + try_match_device(device, unmapped_by_ip, acc) + end) + + {:ok, matched, length(untracked) - matched} + end + + defp towerops_ip_string(nil), do: nil + defp towerops_ip_string(%{address: addr}) when is_tuple(addr), do: addr |> :inet.ntoa() |> to_string() + defp towerops_ip_string(ip) when is_binary(ip), do: ip + + defp try_match_device(device, unmapped_by_ip, {count, unmatched}) do + device_ip = towerops_ip_string(device.ip_address) + + case Map.get(unmapped_by_ip, device_ip) do + [item | _rest] -> + case update_inventory_item_mapping(item, %{device_id: device.id}) do + {:ok, _} -> {count + 1, unmatched} + {:error, _} -> {count, [device | unmatched]} + end + + nil -> + {count, [device | unmatched]} + end + end + def search_inventory_items(organization_id, query) do search = "%#{Towerops.QueryHelpers.sanitize_like(query)}%" diff --git a/lib/towerops_web/live/org/gaiia_reconciliation_live.ex b/lib/towerops_web/live/org/gaiia_reconciliation_live.ex index 2b70d167..5509147d 100644 --- a/lib/towerops_web/live/org/gaiia_reconciliation_live.ex +++ b/lib/towerops_web/live/org/gaiia_reconciliation_live.ex @@ -2,6 +2,7 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do @moduledoc false use ToweropsWeb, :live_view + alias Towerops.Gaiia alias Towerops.Gaiia.Reconciliation @impl true @@ -25,6 +26,32 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do end @impl true + def handle_event("auto_match", _params, socket) do + org_id = socket.assigns.organization.id + + case Gaiia.auto_match_untracked(org_id) do + {:ok, 0, _} -> + {:noreply, + socket + |> put_flash(:info, t("No IP matches found. Link devices manually on the mapping page.")) + |> load_reconciliation()} + + {:ok, matched, remaining} -> + {:noreply, + socket + |> put_flash( + :info, + "Auto-matched %{matched} device by IP. %{remaining} remaining." + |> ngettext( + "Auto-matched %{matched} devices by IP. %{remaining} remaining.", + matched + ) + |> then(&Gettext.dngettext(ToweropsWeb.Gettext, "", &1, &1, matched: matched, remaining: remaining)) + ) + |> load_reconciliation()} + end + end + def handle_event("select_tab", %{"tab" => tab}, socket) do org = socket.assigns.organization 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 db0c5a46..a0484138 100644 --- a/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex +++ b/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex @@ -133,6 +133,18 @@ {t("All Towerops devices have matching Gaiia inventory items.")}
<% else %> +