diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex index e37da734..59478394 100644 --- a/lib/towerops/gaiia.ex +++ b/lib/towerops/gaiia.ex @@ -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." diff --git a/lib/towerops/gaiia/client.ex b/lib/towerops/gaiia/client.ex index b9807070..e43b8b1e 100644 --- a/lib/towerops/gaiia/client.ex +++ b/lib/towerops/gaiia/client.ex @@ -112,6 +112,14 @@ defmodule Towerops.Gaiia.Client do ... on NetworkSite { id } } } + fields(first: 10) { + edges { + node { + data + modelField { name } + } + } + } } } pageInfo { diff --git a/lib/towerops/gaiia/sync.ex b/lib/towerops/gaiia/sync.ex index cb3c6435..5c65e04c 100644 --- a/lib/towerops/gaiia/sync.ex +++ b/lib/towerops/gaiia/sync.ex @@ -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"]) diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index 70cffbcf..95363d02 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -193,7 +193,7 @@ defmodule ToweropsWeb.Layouts do
<.link - navigate={~p"/orgs"} + navigate={~p"/dashboard"} class="text-lg sm:text-xl font-bold text-gray-900 dark:text-white" > Towerops diff --git a/lib/towerops_web/live/org/gaiia_mapping_live.html.heex b/lib/towerops_web/live/org/gaiia_mapping_live.html.heex index 1ca9d6e6..3891f93b 100644 --- a/lib/towerops_web/live/org/gaiia_mapping_live.html.heex +++ b/lib/towerops_web/live/org/gaiia_mapping_live.html.heex @@ -169,7 +169,31 @@ <%= for item <- @items do %> - +
{item.name || "Unnamed"} diff --git a/test/snmpkit/snmp_lib/monitor_test.exs b/test/snmpkit/snmp_lib/monitor_test.exs index 9afe647f..cbc1b161 100644 --- a/test/snmpkit/snmp_lib/monitor_test.exs +++ b/test/snmpkit/snmp_lib/monitor_test.exs @@ -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}} -> diff --git a/test/towerops/gaiia/match_suggestions_test.exs b/test/towerops/gaiia/match_suggestions_test.exs index 51eec6a9..c83ee7b6 100644 --- a/test/towerops/gaiia/match_suggestions_test.exs +++ b/test/towerops/gaiia/match_suggestions_test.exs @@ -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 diff --git a/test/towerops/gaiia/sync_test.exs b/test/towerops/gaiia/sync_test.exs index 985a8d4d..5ebadc44 100644 --- a/test/towerops/gaiia/sync_test.exs +++ b/test/towerops/gaiia/sync_test.exs @@ -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"} + } + } + ] } } } diff --git a/test/towerops/splynx/client_test.exs b/test/towerops/splynx/client_test.exs index 0344b159..37cc099f 100644 --- a/test/towerops/splynx/client_test.exs +++ b/test/towerops/splynx/client_test.exs @@ -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