feat(gaiia): MAC-based auto-matching (phase 1, highest confidence)

Adds MAC address matching as the primary matching phase:
- Normalizes MACs (strips colons/dots/dashes) from SNMP interface data
- Matches against gaiia_inventory_items.mac_address (synced from Gaiia)
- Phase 1: MAC → Phase 2: IP → Phase 3: Site

Flash now shows breakdown: "Auto-matched 47 devices (12 by MAC, 34 by
IP, 1 by site). 17 remaining."
This commit is contained in:
Graham McIntire 2026-05-11 16:09:41 -05:00
parent 40dadededd
commit 3483d9d5f0
2 changed files with 63 additions and 8 deletions

View file

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

View file

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