From 485748d253cb13f9de75af710ae2b2261aabcc5c Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 11 May 2026 14:17:41 -0500 Subject: [PATCH] feat(gaiia): site-based auto-matching + automatic site mapping during sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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." --- lib/towerops/gaiia.ex | 136 ++++++++++++++---- lib/towerops/gaiia/sync.ex | 28 +++- .../live/org/gaiia_reconciliation_live.ex | 47 +++--- 3 files changed, 157 insertions(+), 54 deletions(-) diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex index b143ad74..445e5df4 100644 --- a/lib/towerops/gaiia.ex +++ b/lib/towerops/gaiia.ex @@ -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) - end) + Enum.reduce(untracked, {0, []}, fn device, 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)}%" diff --git a/lib/towerops/gaiia/sync.ex b/lib/towerops/gaiia/sync.ex index 7de29c77..743aaed2 100644 --- a/lib/towerops/gaiia/sync.ex +++ b/lib/towerops/gaiia/sync.ex @@ -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"] diff --git a/lib/towerops_web/live/org/gaiia_reconciliation_live.ex b/lib/towerops_web/live/org/gaiia_reconciliation_live.ex index 5509147d..c68876ce 100644 --- a/lib/towerops_web/live/org/gaiia_reconciliation_live.ex +++ b/lib/towerops_web/live/org/gaiia_reconciliation_live.ex @@ -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.")) - |> 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 + {:noreply, + socket + |> put_flash(:info, match_flash(Gaiia.auto_match_untracked(org_id))) + |> load_reconciliation()} 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)