feat(gaiia): site-based auto-matching + automatic site mapping during sync

Two improvements to Gaiia reconciliation:

1. Auto-match now uses two-phase matching:
   - Phase 1: IP match (high confidence, same as before)
   - Phase 2: Site match — if a device is at a Towerops site linked to a
     Gaiia network site, match it to unmapped inventory assigned to that site

2. Gaiia sync now automatically maps network sites to Towerops sites by
   name during sync. When a Gaiia network site name matches a Towerops
   site name, `site_id` is set on the `gaiia_network_sites` row. This
   enables the site-based matching in phase 2.

Flash message now shows breakdown: "Auto-matched 47 devices (34 by IP,
13 by site). 17 remaining."
This commit is contained in:
Graham McIntire 2026-05-11 14:17:41 -05:00
parent c00be779f9
commit 485748d253
3 changed files with 157 additions and 54 deletions

View file

@ -118,25 +118,66 @@ defmodule Towerops.Gaiia do
end
@doc """
Auto-match untracked devices to unmapped Gaiia inventory items by IP address.
Auto-match untracked devices to unmapped Gaiia inventory items.
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.
Two-phase matching:
1. IP match (high confidence) device and inventory item share an IP
2. Site match (medium confidence) device's Towerops site is linked to a
Gaiia network site, and an unmapped inventory item is assigned to that
Gaiia network site
Returns `{:ok, matched_count, remaining_count}`.
Returns `{:ok, ip_matched, site_matched, remaining_count}`.
"""
def auto_match_untracked(organization_id) do
# Unmapped inventory items with an IP address, indexed by IP
unmapped_by_ip =
# Pre-load unmapped inventory items and their site assignments
unmapped_items =
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()
# Phase 1: IP-based matching
unmapped_by_ip =
unmapped_items
|> Enum.reject(fn i -> is_nil(i.ip_address) or i.ip_address == "" end)
|> Enum.group_by(& &1.ip_address)
# Devices with no linked inventory item
{ip_matched, _after_ip} = match_phase(organization_id, unmapped_by_ip, &ip_matcher/2)
# Phase 2: Site-based matching — devices at a Towerops site linked to a
# Gaiia network site get matched to inventory items assigned to that site
site_network_map = build_site_network_map(organization_id)
unmapped_by_site =
unmapped_items
|> Enum.reject(fn i -> is_nil(i.assigned_network_site_gaiia_id) end)
|> Enum.group_by(& &1.assigned_network_site_gaiia_id)
{site_matched, _after_site} =
match_phase(organization_id, unmapped_by_site, fn device, items ->
site_matcher(device, items, site_network_map)
end)
# Count remaining
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()
total_devices =
Device
|> where(organization_id: ^organization_id)
|> Repo.aggregate(:count, :id)
remaining = total_devices - MapSet.size(mapped_device_ids)
{:ok, ip_matched, site_matched, remaining}
end
defp match_phase(organization_id, unmapped_index, matcher_fn) do
mapped_device_ids =
InventoryItem
|> where(organization_id: ^organization_id)
@ -148,37 +189,72 @@ defmodule Towerops.Gaiia do
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)
apply_match(device, unmapped_index, matcher_fn, acc)
end)
end
{:ok, matched, length(untracked) - matched}
defp apply_match(device, unmapped_index, matcher_fn, {matched, unmatched}) do
case matcher_fn.(device, unmapped_index) do
{:match, item} ->
case update_inventory_item_mapping(item, %{device_id: device.id}) do
{:ok, _} -> {matched + 1, unmatched}
{:error, _} -> {matched, [device | unmatched]}
end
:no_match ->
{matched, [device | unmatched]}
end
end
defp ip_matcher(device, unmapped_by_ip) do
device_ip = towerops_ip_string(device.ip_address)
if device_ip do
case Map.get(unmapped_by_ip, device_ip) do
[item | _rest] -> {:match, item}
_ -> :no_match
end
else
:no_match
end
end
defp site_matcher(device, unmapped_by_site, site_network_map) do
with site_id when is_binary(site_id) <- device.site_id,
gaiia_ids when is_list(gaiia_ids) <- Map.get(site_network_map, site_id) do
find_first_match(gaiia_ids, unmapped_by_site)
else
_ -> :no_match
end
end
defp find_first_match(gaiia_ids, unmapped_by_site) do
Enum.find_value(gaiia_ids, :no_match, fn gaiia_id ->
case Map.get(unmapped_by_site, gaiia_id) do
[item | _rest] -> {:match, item}
_ -> nil
end
end)
end
# Builds a map of Towerops site_id → list of Gaiia network site gaiia_ids
defp build_site_network_map(organization_id) do
NetworkSite
|> where(organization_id: ^organization_id)
|> where([ns], not is_nil(ns.site_id))
|> select([ns], {ns.site_id, ns.gaiia_id})
|> Repo.all()
|> Enum.group_by(fn {site_id, _gaiia_id} -> site_id end, fn {_site_id, gaiia_id} -> gaiia_id end)
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

@ -74,7 +74,7 @@ defmodule Towerops.Gaiia.Sync do
end
defp upsert_node(org_id, node, :network_site) do
Gaiia.upsert_network_site(org_id, map_network_site(node))
Gaiia.upsert_network_site(org_id, map_network_site(node, org_id))
end
defp upsert_node(org_id, node, :inventory_item) do
@ -101,19 +101,41 @@ defmodule Towerops.Gaiia.Sync do
}
end
defp map_network_site(node) do
defp map_network_site(node, org_id) do
ip_block_edges = get_in(node, ["ipBlocks", "edges"]) || []
ip_blocks = Enum.map(ip_block_edges, & &1["node"]["block"])
gaiia_name = node["name"]
%{
gaiia_id: node["id"],
name: node["name"],
name: gaiia_name,
address: map_address(node["address"]),
ip_blocks: ip_blocks,
site_id: find_matching_site_id(org_id, gaiia_name),
raw_data: node
}
end
defp find_matching_site_id(org_id, gaiia_name) when is_binary(gaiia_name) and gaiia_name != "" do
import Ecto.Query
alias Towerops.Repo
alias Towerops.Sites.Site
search = "%#{Towerops.QueryHelpers.sanitize_like(gaiia_name)}%"
Repo.one(
from(s in Site,
where: s.organization_id == ^org_id,
where: ilike(s.name, ^search) or ilike(s.name, ^gaiia_name),
limit: 1,
select: s.id
)
)
end
defp find_matching_site_id(_org_id, _name), do: nil
defp map_inventory_item(node) do
assignation = node["assignation"]

View file

@ -29,27 +29,10 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
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."))
|> put_flash(:info, match_flash(Gaiia.auto_match_untracked(org_id)))
|> 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
@ -61,6 +44,28 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
)}
end
defp match_flash({:ok, 0, 0, _remaining}) do
t("No matches found. Link devices manually on the mapping page.")
end
defp match_flash({:ok, ip_matched, site_matched, remaining}) do
total = ip_matched + site_matched
"Auto-matched %{total} device (%{ip} by IP, %{site} by site). %{remaining} remaining."
|> ngettext(
"Auto-matched %{total} devices (%{ip} by IP, %{site} by site). %{remaining} remaining.",
total
)
|> then(
&Gettext.dngettext(ToweropsWeb.Gettext, "", &1, &1,
total: total,
ip: ip_matched,
site: site_matched,
remaining: remaining
)
)
end
defp load_reconciliation(socket) do
report = Reconciliation.reconcile(socket.assigns.organization.id)
assign(socket, :report, report)