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:
parent
b4c0407ee0
commit
47b364a112
9 changed files with 104 additions and 101 deletions
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -112,6 +112,14 @@ defmodule Towerops.Gaiia.Client do
|
|||
... on NetworkSite { id }
|
||||
}
|
||||
}
|
||||
fields(first: 10) {
|
||||
edges {
|
||||
node {
|
||||
data
|
||||
modelField { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}} ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue