Fix Gaiia inventory matching, mapping UX, and logo link

- Fetch MAC address from Gaiia custom fields during inventory sync
- Remove name-based device matching (model names caused false matches)
- Keep IP-only matching for device suggestions
- Make unmapped rows clickable in the Gaiia mapping table
- Point authenticated logo link to /dashboard instead of /orgs
- Fix flaky monitor test teardown race condition
This commit is contained in:
Graham McIntire 2026-02-15 17:41:29 -06:00
parent b4c0407ee0
commit 47b364a112
No known key found for this signature in database
9 changed files with 104 additions and 101 deletions

View file

@ -189,20 +189,14 @@ defmodule Towerops.Gaiia do
@doc """
Suggest Towerops Devices that might match a Gaiia Inventory Item.
Matching strategies (in confidence order):
- IP address exact match (:high confidence)
- Name containment match (:low confidence)
Matches by IP address only (:high confidence). Name matching was removed
because inventory item names are model names (e.g. "ePMP 3000"), causing
every device of the same model to match incorrectly.
Returns a list of `%{entity: device, match_type: type, confidence: level}`,
sorted by confidence (high first). Each device appears at most once per match type.
Returns a list of `%{entity: device, match_type: "ip", confidence: :high}`.
"""
def suggest_device_matches(organization_id, %InventoryItem{} = item) do
ip_matches = find_ip_matches(organization_id, item.ip_address)
name_matches = find_name_matches(organization_id, item.name)
(ip_matches ++ name_matches)
|> Enum.uniq_by(fn %{entity: d, match_type: t} -> {d.id, t} end)
|> Enum.sort_by(fn %{confidence: c} -> confidence_rank(c) end)
find_ip_matches(organization_id, item.ip_address)
end
defp find_ip_matches(_organization_id, nil), do: []
@ -216,24 +210,6 @@ defmodule Towerops.Gaiia do
|> Enum.map(fn device -> %{entity: device, match_type: "ip", confidence: :high} end)
end
defp find_name_matches(_organization_id, nil), do: []
defp find_name_matches(_organization_id, ""), do: []
defp find_name_matches(organization_id, name) do
search_term = "%#{name}%"
Device
|> where(organization_id: ^organization_id)
|> where([d], ilike(d.name, ^search_term) or ilike(^name, fragment("'%' || ? || '%'", d.name)))
|> Repo.all()
|> Repo.preload(:site)
|> Enum.map(fn device -> %{entity: device, match_type: "name", confidence: :low} end)
end
defp confidence_rank(:high), do: 0
defp confidence_rank(:medium), do: 1
defp confidence_rank(:low), do: 2
# --- Aggregate Queries ---
@doc "Sum subscriber count and MRR across all Gaiia network sites for an organization."

View file

@ -112,6 +112,14 @@ defmodule Towerops.Gaiia.Client do
... on NetworkSite { id }
}
}
fields(first: 10) {
edges {
node {
data
modelField { name }
}
}
}
}
}
pageInfo {

View file

@ -127,7 +127,7 @@ defmodule Towerops.Gaiia.Sync do
name: get_in(node, ["model", "name"]),
status: node["status"],
serial_number: nil,
mac_address: nil,
mac_address: extract_mac_address(node),
ip_address: node["ipAddressV4"],
model_name: get_in(node, ["model", "name"]),
manufacturer_name: get_in(node, ["model", "manufacturer", "name"]),
@ -138,6 +138,19 @@ defmodule Towerops.Gaiia.Sync do
}
end
defp extract_mac_address(node) do
edges = get_in(node, ["fields", "edges"]) || []
Enum.find_value(edges, fn
%{"node" => %{"data" => data, "modelField" => %{"name" => field_name}}}
when is_binary(data) and is_binary(field_name) ->
if String.contains?(String.downcase(field_name), "mac"), do: data
_ ->
nil
end)
end
defp map_billing_subscription(node) do
price_cents = get_in(node, ["productVersion", "price"])

View file

@ -193,7 +193,7 @@ defmodule ToweropsWeb.Layouts do
<div class="flex flex-shrink-0 items-center ml-2 md:ml-0">
<.link
navigate={~p"/orgs"}
navigate={~p"/dashboard"}
class="text-lg sm:text-xl font-bold text-gray-900 dark:text-white"
>
Towerops

View file

@ -169,7 +169,31 @@
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/5 dark:bg-transparent">
<%= for item <- @items do %>
<tr id={"row-#{item.id}"} class="hover:bg-gray-50 dark:hover:bg-white/5">
<tr
id={"row-#{item.id}"}
class={[
"hover:bg-gray-50 dark:hover:bg-white/5",
if(
(@tab == "sites" and is_nil(item.site_id)) or
(@tab == "devices" and is_nil(item.device_id)),
do: "cursor-pointer"
)
]}
phx-click={
if(
(@tab == "sites" and is_nil(item.site_id)) or
(@tab == "devices" and is_nil(item.device_id)),
do: "start_link"
)
}
phx-value-id={
if(
(@tab == "sites" and is_nil(item.site_id)) or
(@tab == "devices" and is_nil(item.device_id)),
do: item.gaiia_id
)
}
>
<td class="px-4 py-4 text-sm font-medium text-gray-900 dark:text-white">
<div class="flex flex-wrap items-center gap-2">
<span class="break-all">{item.name || "Unnamed"}</span>

View file

@ -7,7 +7,14 @@ defmodule SnmpKit.SnmpLib.MonitorTest do
# Start a fresh monitor for each test, or use existing one
case Monitor.start_link(name: nil) do
{:ok, pid} ->
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
on_exit(fn ->
try do
GenServer.stop(pid)
catch
:exit, _ -> :ok
end
end)
{:ok, monitor: pid}
{:error, {:already_started, pid}} ->

View file

@ -217,8 +217,8 @@ defmodule Towerops.Gaiia.MatchSuggestionsTest do
assert confidence in [:high, :medium]
end
test "matches device by name case-insensitively", %{org: org} do
device =
test "does not match by name alone (IP-only matching)", %{org: org} do
_device =
device_fixture(%{
organization_id: org.id,
name: "Router Alpha",
@ -232,36 +232,32 @@ defmodule Towerops.Gaiia.MatchSuggestionsTest do
ip_address: "10.99.99.99"
})
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
assert length(results) == 1
assert [%{entity: matched, match_type: "name", confidence: :low}] = results
assert matched.id == device.id
assert Gaiia.suggest_device_matches(org.id, gaiia_item) == []
end
test "matches device by partial name (contains)", %{org: org} do
test "matches by IP even when names differ", %{org: org} do
device =
device_fixture(%{
organization_id: org.id,
name: "Core Router Alpha Main",
ip_address: "192.168.1.1"
ip_address: "10.0.0.1"
})
{:ok, gaiia_item} =
Gaiia.upsert_inventory_item(org.id, %{
gaiia_id: "gaiia-item-1",
name: "Router Alpha",
ip_address: "10.99.99.99"
name: "Totally Different",
ip_address: "10.0.0.1"
})
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
assert length(results) == 1
assert [%{entity: matched, match_type: "name"}] = results
assert [%{entity: matched, match_type: "ip", confidence: :high}] = results
assert matched.id == device.id
end
test "returns multiple match types for same device sorted by confidence", %{org: org} do
test "returns only IP match when both IP and name match same device", %{org: org} do
device =
device_fixture(%{
organization_id: org.id,
@ -278,17 +274,9 @@ defmodule Towerops.Gaiia.MatchSuggestionsTest do
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
assert length(results) >= 2
# All matches should reference the same device
matched_ids = results |> Enum.map(fn %{entity: d} -> d.id end) |> Enum.uniq()
assert matched_ids == [device.id]
# IP match should come before name match (higher confidence first)
match_types = Enum.map(results, fn %{match_type: t} -> t end)
ip_index = Enum.find_index(match_types, &(&1 == "ip"))
name_index = Enum.find_index(match_types, &(&1 == "name"))
assert ip_index < name_index
assert length(results) == 1
assert [%{entity: matched, match_type: "ip", confidence: :high}] = results
assert matched.id == device.id
end
test "does not match devices from other organizations", %{org: org} do
@ -328,7 +316,7 @@ defmodule Towerops.Gaiia.MatchSuggestionsTest do
assert Gaiia.suggest_device_matches(org.id, gaiia_item) == []
end
test "IP match has higher confidence than name match", %{org: org} do
test "only matches by IP, ignores name-only matches", %{org: org} do
device_ip =
device_fixture(%{
organization_id: org.id,
@ -336,7 +324,7 @@ defmodule Towerops.Gaiia.MatchSuggestionsTest do
ip_address: "10.0.0.1"
})
device_name =
_device_name =
device_fixture(%{
organization_id: org.id,
name: "Router Alpha",
@ -352,44 +340,10 @@ defmodule Towerops.Gaiia.MatchSuggestionsTest do
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
assert length(results) == 2
# IP match should appear first (higher confidence)
assert [first | _rest] = results
assert first.entity.id == device_ip.id
assert first.match_type == "ip"
# Name match should be last
name_match = Enum.find(results, fn %{match_type: t} -> t == "name" end)
assert name_match.entity.id == device_name.id
assert name_match.confidence == :low
end
test "does not produce duplicate matches for same device and match type", %{org: org} do
device =
device_fixture(%{
organization_id: org.id,
name: "Router Alpha",
ip_address: "10.0.0.1"
})
{:ok, gaiia_item} =
Gaiia.upsert_inventory_item(org.id, %{
gaiia_id: "gaiia-item-1",
name: "Router Alpha",
ip_address: "10.0.0.1"
})
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
# Each match type should appear at most once per device
type_device_pairs =
Enum.map(results, fn %{entity: d, match_type: t} -> {d.id, t} end)
assert type_device_pairs == Enum.uniq(type_device_pairs)
# Verify the device is present
assert Enum.any?(results, fn %{entity: d} -> d.id == device.id end)
# Only the IP match should be returned
assert length(results) == 1
assert [%{entity: matched, match_type: "ip", confidence: :high}] = results
assert matched.id == device_ip.id
end
end
end

View file

@ -57,7 +57,10 @@ defmodule Towerops.Gaiia.SyncTest do
assert site.ip_blocks == ["10.0.0.0/24"]
end
test "maps inventory item fields correctly", %{org: org, integration: integration} do
test "maps inventory item fields correctly including MAC from custom fields", %{
org: org,
integration: integration
} do
stub_all_endpoints()
{:ok, _} = Sync.sync_organization(integration)
@ -69,6 +72,7 @@ defmodule Towerops.Gaiia.SyncTest do
assert item.manufacturer_name == "Ubiquiti"
assert item.category == "AP"
assert item.assigned_account_gaiia_id == "acct-1"
assert item.mac_address == "AA:BB:CC:DD:EE:FF"
end
test "maps billing subscription fields correctly", %{org: org, integration: integration} do
@ -284,6 +288,22 @@ defmodule Towerops.Gaiia.SyncTest do
"assignation" => %{
"assigneeType" => "Account",
"assignee" => %{"id" => "acct-1"}
},
"fields" => %{
"edges" => [
%{
"node" => %{
"data" => "AA:BB:CC:DD:EE:FF",
"modelField" => %{"name" => "MAC Address"}
}
},
%{
"node" => %{
"data" => "SN12345",
"modelField" => %{"name" => "Serial Number"}
}
}
]
}
}
}

View file

@ -117,9 +117,10 @@ defmodule Towerops.Splynx.ClientTest do
if count == 1 do
# Return exactly 100 to trigger pagination
items = Enum.map(1..100, fn i ->
%{"id" => i, "name" => "Customer #{i}", "status" => "active", "mrr_total" => "50.0000"}
end)
items =
Enum.map(1..100, fn i ->
%{"id" => i, "name" => "Customer #{i}", "status" => "active", "mrr_total" => "50.0000"}
end)
Req.Test.json(conn, items)
else