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:
parent
c00be779f9
commit
485748d253
3 changed files with 157 additions and 54 deletions
|
|
@ -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)}%"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue