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.
This commit is contained in:
Graham McIntire 2026-05-11 13:51:13 -05:00
parent 6f46915eeb
commit 80837b8b26
3 changed files with 101 additions and 0 deletions

View file

@ -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)}%"

View file

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

View file

@ -133,6 +133,18 @@
{t("All Towerops devices have matching Gaiia inventory items.")}
</p>
<% else %>
<div class="mb-4 flex items-center gap-3">
<button
phx-click="auto_match"
class="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
<.icon name="hero-bolt" class="h-4 w-4" />
{t("Auto-Match by IP")}
</button>
<span class="text-sm text-gray-500 dark:text-gray-400">
{t("Links devices to Gaiia items with matching IP addresses.")}
</span>
</div>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/50">