From 80837b8b26f877fed29f2f2e7d05fd824643c301 Mon Sep 17 00:00:00 2001
From: Graham McIntire
Date: Mon, 11 May 2026 13:51:13 -0500
Subject: [PATCH] feat(gaiia): one-click auto-match untracked devices by IP
Adds `Gaiia.auto_match_untracked/1` which finds unmapped Gaiia inventory
items whose IP matches an untracked Towerops device and links them.
Button on the reconciliation page's Untracked tab runs the match
inline and shows a flash with matched/remaining counts.
---
lib/towerops/gaiia.ex | 62 +++++++++++++++++++
.../live/org/gaiia_reconciliation_live.ex | 27 ++++++++
.../org/gaiia_reconciliation_live.html.heex | 12 ++++
3 files changed, 101 insertions(+)
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 %>
+
+
+
+ {t("Links devices to Gaiia items with matching IP addresses.")}
+
+