diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex index 445e5df4..d1ccdeab 100644 --- a/lib/towerops/gaiia.ex +++ b/lib/towerops/gaiia.ex @@ -15,6 +15,7 @@ defmodule Towerops.Gaiia do alias Towerops.Repo alias Towerops.Sites.Site alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.Interface, as: SnmpInterface # --- Accounts --- alias Towerops.Snmp.WirelessClientReading @@ -136,7 +137,22 @@ defmodule Towerops.Gaiia do |> where([i], is_nil(i.device_id)) |> Repo.all() - # Phase 1: IP-based matching + # Phase 1: MAC-based matching (highest confidence — exact identifier). + # Gaiia equipment is assigned by MAC (usually br0), so this catches + # devices that have no IP in Gaiia but are already inventoried. + unmapped_by_mac = + unmapped_items + |> Enum.reject(fn i -> is_nil(i.mac_address) or i.mac_address == "" end) + |> Enum.group_by(&normalize_mac(&1.mac_address)) + + device_macs = build_device_mac_index(organization_id) + + {mac_matched, _after_mac} = + match_phase(organization_id, unmapped_by_mac, fn device, items -> + mac_matcher(device, items, device_macs) + end) + + # Phase 2: IP-based matching unmapped_by_ip = unmapped_items |> Enum.reject(fn i -> is_nil(i.ip_address) or i.ip_address == "" end) @@ -144,7 +160,7 @@ defmodule Towerops.Gaiia do {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 + # Phase 3: 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) @@ -174,7 +190,7 @@ defmodule Towerops.Gaiia do remaining = total_devices - MapSet.size(mapped_device_ids) - {:ok, ip_matched, site_matched, remaining} + {:ok, ip_matched, site_matched, mac_matched, remaining} end defp match_phase(organization_id, unmapped_index, matcher_fn) do @@ -241,6 +257,44 @@ defmodule Towerops.Gaiia do end) end + # Builds a map of device_id → normalized MAC address from SNMP interfaces. + # Uses the first non-null MAC found — typically the br0/bridge interface. + defp build_device_mac_index(organization_id) do + SnmpInterface + |> join(:inner, [si], sd in SnmpDevice, on: si.snmp_device_id == sd.id) + |> join(:inner, [_si, sd], d in Device, on: sd.device_id == d.id) + |> where([_si, _sd, d], d.organization_id == ^organization_id) + |> where([si], not is_nil(si.mac_address) and si.mac_address != "") + |> select([si, sd], {sd.device_id, si.mac_address}) + |> Repo.all() + |> Enum.group_by(fn {device_id, _mac} -> device_id end, fn {_device_id, mac} -> normalize_mac(mac) end) + end + + defp mac_matcher(device, unmapped_by_mac, device_macs) do + case Map.get(device_macs, device.id) do + macs when is_list(macs) and macs != [] -> + find_mac_match(macs, unmapped_by_mac) + + _ -> + :no_match + end + end + + defp find_mac_match(macs, unmapped_by_mac) do + Enum.find_value(macs, :no_match, fn mac -> + case Map.get(unmapped_by_mac, mac) do + [item | _rest] -> {:match, item} + _ -> nil + end + end) + end + + defp normalize_mac(mac) when is_binary(mac) do + mac + |> String.downcase() + |> String.replace(~r/[:.\-\s]/, "") + end + # Builds a map of Towerops site_id → list of Gaiia network site gaiia_ids defp build_site_network_map(organization_id) do NetworkSite diff --git a/lib/towerops_web/live/org/gaiia_reconciliation_live.ex b/lib/towerops_web/live/org/gaiia_reconciliation_live.ex index 6a10f6f6..a32bec46 100644 --- a/lib/towerops_web/live/org/gaiia_reconciliation_live.ex +++ b/lib/towerops_web/live/org/gaiia_reconciliation_live.ex @@ -129,18 +129,19 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do )} end - defp match_flash({:ok, 0, 0, _remaining}) do + defp match_flash({:ok, 0, 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 + defp match_flash({:ok, mac_matched, ip_matched, site_matched, remaining}) do + total = mac_matched + ip_matched + site_matched ngettext( - "Auto-matched %{total} device (%{ip} by IP, %{site} by site). %{remaining} remaining.", - "Auto-matched %{total} devices (%{ip} by IP, %{site} by site). %{remaining} remaining.", + "Auto-matched %{total} device (%{mac} by MAC, %{ip} by IP, %{site} by site). %{remaining} remaining.", + "Auto-matched %{total} devices (%{mac} by MAC, %{ip} by IP, %{site} by site). %{remaining} remaining.", total, total: total, + mac: mac_matched, ip: ip_matched, site: site_matched, remaining: remaining