From 55382a7ec668e90922e5d07661c16b795cca1f7b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Feb 2026 13:28:52 -0600 Subject: [PATCH] add gaiia inventory reconciliation (stage 7) --- lib/towerops/gaiia/reconciliation.ex | 141 ++++++++ .../live/org/gaiia_reconciliation_live.ex | 41 +++ .../org/gaiia_reconciliation_live.html.heex | 313 ++++++++++++++++++ lib/towerops_web/router.ex | 1 + test/towerops/gaiia/reconciliation_test.exs | 144 ++++++++ 5 files changed, 640 insertions(+) create mode 100644 lib/towerops/gaiia/reconciliation.ex create mode 100644 lib/towerops_web/live/org/gaiia_reconciliation_live.ex create mode 100644 lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex create mode 100644 test/towerops/gaiia/reconciliation_test.exs diff --git a/lib/towerops/gaiia/reconciliation.ex b/lib/towerops/gaiia/reconciliation.ex new file mode 100644 index 00000000..eb21f0c2 --- /dev/null +++ b/lib/towerops/gaiia/reconciliation.ex @@ -0,0 +1,141 @@ +defmodule Towerops.Gaiia.Reconciliation do + @moduledoc """ + Inventory reconciliation engine comparing Gaiia inventory items + against Towerops device discovery. + + Detects four types of discrepancies: + 1. Ghost devices — in Gaiia but not discovered by Towerops + 2. Untracked devices — discovered by Towerops but not in Gaiia + 3. Data mismatches — both systems have the device but fields disagree + 4. Missing mappings — Gaiia items not yet linked to any Towerops device + """ + + import Ecto.Query + + alias Towerops.Devices + alias Towerops.Gaiia.InventoryItem + alias Towerops.Repo + + @doc """ + Run a full reconciliation for an organization. + + Returns a map with: + - `:ghost_devices` — Gaiia items mapped to devices that no longer exist + - `:untracked_devices` — Towerops devices with no matching Gaiia item + - `:data_mismatches` — mapped pairs where fields disagree (IP, serial, etc.) + - `:missing_mappings` — Gaiia items with no device mapping + - `:summary` — aggregate counts + - `:reconciled_at` — timestamp + """ + def reconcile(organization_id) do + inventory_items = list_inventory_items(organization_id) + devices = Devices.list_organization_devices(organization_id) + + {mapped, unmapped_items} = partition_items(inventory_items) + mapped_device_ids = MapSet.new(mapped, fn {_item, device_id} -> device_id end) + + devices_by_id = Map.new(devices, &{&1.id, &1}) + + ghost_devices = find_ghost_devices(mapped, devices_by_id) + untracked_devices = find_untracked_devices(devices, mapped_device_ids) + data_mismatches = find_data_mismatches(mapped, devices_by_id) + missing_mappings = build_missing_mappings(unmapped_items) + + total_mapped = length(mapped) - length(ghost_devices) + + %{ + ghost_devices: ghost_devices, + untracked_devices: untracked_devices, + data_mismatches: data_mismatches, + missing_mappings: missing_mappings, + summary: %{ + total_mapped: total_mapped, + ghost_count: length(ghost_devices), + untracked_count: length(untracked_devices), + mismatch_count: length(data_mismatches), + missing_mapping_count: length(missing_mappings) + }, + reconciled_at: DateTime.truncate(DateTime.utc_now(), :second) + } + end + + defp list_inventory_items(organization_id) do + InventoryItem + |> where(organization_id: ^organization_id) + |> Repo.all() + end + + defp partition_items(items) do + {mapped, unmapped} = + Enum.split_with(items, fn item -> not is_nil(item.device_id) end) + + mapped_pairs = Enum.map(mapped, fn item -> {item, item.device_id} end) + {mapped_pairs, unmapped} + end + + # Gaiia items mapped to devices that no longer exist in Towerops + defp find_ghost_devices(mapped, devices_by_id) do + mapped + |> Enum.filter(fn {_item, device_id} -> + not Map.has_key?(devices_by_id, device_id) + end) + |> Enum.map(fn {item, _device_id} -> + %{inventory_item: item, type: :ghost} + end) + end + + # Towerops devices with no Gaiia inventory item mapped + defp find_untracked_devices(devices, mapped_device_ids) do + devices + |> Enum.reject(fn device -> MapSet.member?(mapped_device_ids, device.id) end) + |> Enum.map(fn device -> %{device: device, type: :untracked} end) + end + + # Mapped pairs where comparable fields disagree + defp find_data_mismatches(mapped, devices_by_id) do + Enum.flat_map(mapped, fn {item, device_id} -> + case Map.get(devices_by_id, device_id) do + nil -> [] + device -> compare_fields(item, device) + end + end) + end + + defp compare_fields(item, device) do + maybe_add_mismatch([], :ip_address, to_string_or_nil(device.ip_address), item.ip_address, item, device) + end + + defp maybe_add_mismatch(acc, _field, nil, _gaiia, _item, _device), do: acc + defp maybe_add_mismatch(acc, _field, _towerops, nil, _item, _device), do: acc + + defp maybe_add_mismatch(acc, field, towerops_val, gaiia_val, item, device) do + if normalize_value(towerops_val) == normalize_value(gaiia_val) do + acc + else + [ + %{ + field: field, + towerops_value: towerops_val, + gaiia_value: gaiia_val, + inventory_item: item, + device: device + } + | acc + ] + end + end + + defp normalize_value(val) when is_binary(val), do: String.downcase(String.trim(val)) + defp normalize_value(val), do: val + + defp to_string_or_nil(nil), do: nil + defp to_string_or_nil(%{address: addr}) when is_tuple(addr), do: addr |> :inet.ntoa() |> to_string() + defp to_string_or_nil(val) when is_binary(val), do: val + defp to_string_or_nil(val), do: to_string(val) + + defp build_missing_mappings(unmapped_items) do + Enum.map(unmapped_items, fn item -> + %{inventory_item: item, type: :missing_mapping} + end) + end +end diff --git a/lib/towerops_web/live/org/gaiia_reconciliation_live.ex b/lib/towerops_web/live/org/gaiia_reconciliation_live.ex new file mode 100644 index 00000000..550063e1 --- /dev/null +++ b/lib/towerops_web/live/org/gaiia_reconciliation_live.ex @@ -0,0 +1,41 @@ +defmodule ToweropsWeb.Org.GaiiaReconciliationLive do + @moduledoc false + use ToweropsWeb, :live_view + + alias Towerops.Gaiia.Reconciliation + + @impl true + def mount(_params, _session, socket) do + org = socket.assigns.current_scope.organization + + {:ok, + socket + |> assign(:organization, org) + |> assign(:page_title, "Gaiia Inventory Reconciliation")} + end + + @impl true + def handle_params(params, _url, socket) do + tab = Map.get(params, "tab", "summary") + + {:noreply, + socket + |> assign(:active_tab, tab) + |> load_reconciliation()} + end + + @impl true + def handle_event("select_tab", %{"tab" => tab}, socket) do + org = socket.assigns.organization + + {:noreply, + push_patch(socket, + to: ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation?tab=#{tab}" + )} + end + + defp load_reconciliation(socket) do + report = Reconciliation.reconcile(socket.assigns.organization.id) + assign(socket, :report, report) + end +end diff --git a/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex b/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex new file mode 100644 index 00000000..0a0d107d --- /dev/null +++ b/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex @@ -0,0 +1,313 @@ + + <.header> + Gaiia Inventory Reconciliation + <:subtitle>Compare Gaiia inventory against Towerops device discovery + + +
+
+
+ {@report.summary.total_mapped} +
+
Mapped
+
+
+
+ {@report.summary.missing_mapping_count} +
+
Unmapped Items
+
+
+
+ {@report.summary.untracked_count} +
+
Untracked Devices
+
+
+
+ {@report.summary.mismatch_count} +
+
Data Mismatches
+
+
+ +
+
+ <.link + patch={ + ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation?tab=summary" + } + class={[ + "px-4 py-2 text-sm font-medium rounded-l-lg", + @active_tab == "summary" && + "bg-blue-600 text-white dark:bg-blue-500", + @active_tab != "summary" && + "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-gray-300 dark:hover:bg-gray-800" + ]} + > + Summary + + <.link + patch={ + ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation?tab=untracked" + } + class={[ + "px-4 py-2 text-sm font-medium border-l border-gray-200 dark:border-white/10", + @active_tab == "untracked" && + "bg-blue-600 text-white dark:bg-blue-500", + @active_tab != "untracked" && + "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-gray-300 dark:hover:bg-gray-800" + ]} + > + Untracked ({@report.summary.untracked_count}) + + <.link + patch={ + ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation?tab=mismatches" + } + class={[ + "px-4 py-2 text-sm font-medium border-l border-gray-200 dark:border-white/10", + @active_tab == "mismatches" && + "bg-blue-600 text-white dark:bg-blue-500", + @active_tab != "mismatches" && + "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-gray-300 dark:hover:bg-gray-800" + ]} + > + Mismatches ({@report.summary.mismatch_count}) + + <.link + patch={ + ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation?tab=unmapped" + } + class={[ + "px-4 py-2 text-sm font-medium rounded-r-lg border-l border-gray-200 dark:border-white/10", + @active_tab == "unmapped" && + "bg-blue-600 text-white dark:bg-blue-500", + @active_tab != "unmapped" && + "bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-gray-300 dark:hover:bg-gray-800" + ]} + > + Unmapped ({@report.summary.missing_mapping_count}) + +
+
+ + <%= case @active_tab do %> + <% "untracked" -> %> +
+ <%= if @report.untracked_devices == [] do %> +

+ All Towerops devices have matching Gaiia inventory items. +

+ <% else %> +
+ + + + + + + + + + <%= for entry <- @report.untracked_devices do %> + + + + + + <% end %> + +
+ Device + + IP Address + + Site +
+ <.link + navigate={~p"/devices/#{entry.device.id}"} + class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + {entry.device.name} + + + {entry.device.ip_address} + + <%= if entry.device.site do %> + {entry.device.site.name} + <% end %> +
+
+ <% end %> +
+ <% "mismatches" -> %> +
+ <%= if @report.data_mismatches == [] do %> +

+ No data mismatches found between mapped devices. +

+ <% else %> +
+ + + + + + + + + + + <%= for mismatch <- @report.data_mismatches do %> + + + + + + + <% end %> + +
+ Device + + Field + + Towerops + + Gaiia +
+ <.link + navigate={~p"/devices/#{mismatch.device.id}"} + class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + {mismatch.device.name} + + + {mismatch.field} + + {mismatch.towerops_value} + + {mismatch.gaiia_value} +
+
+ <% end %> +
+ <% "unmapped" -> %> +
+ <%= if @report.missing_mappings == [] do %> +

+ All Gaiia inventory items are mapped to Towerops devices. +

+ <% else %> +
+ + + + + + + + + + + <%= for entry <- @report.missing_mappings do %> + + + + + + + <% end %> + +
+ Gaiia Item + + IP Address + + Category + + Status +
+ {entry.inventory_item.name} + + {entry.inventory_item.ip_address || "—"} + + {entry.inventory_item.category || "—"} + + {entry.inventory_item.status || "—"} +
+
+ <% end %> +
+ <% _ -> %> +
+

+ Last reconciled: {Calendar.strftime(@report.reconciled_at, "%Y-%m-%d %H:%M:%S UTC")} +

+ + <%= if @report.summary.total_mapped == 0 && @report.summary.missing_mapping_count == 0 && @report.summary.untracked_count == 0 do %> +
+ <.icon + name="hero-check-circle" + class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" + /> +

+ No inventory data +

+

+ Sync Gaiia data and map inventory items to see reconciliation results. +

+ <.link + navigate={~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"} + class="mt-4 inline-flex items-center gap-1 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + Go to Entity Mapping <.icon name="hero-arrow-right" class="h-4 w-4" /> + +
+ <% else %> +
+

Overview

+
+
+
+ Mapped devices (Gaiia ↔ Towerops) +
+
+ {@report.summary.total_mapped} +
+
+
+
+ Unmapped Gaiia items (no Towerops link) +
+
+ {@report.summary.missing_mapping_count} +
+
+
+
+ Untracked Towerops devices (not in Gaiia) +
+
+ {@report.summary.untracked_count} +
+
+
+
+ Data mismatches (IP, serial, etc.) +
+
+ {@report.summary.mismatch_count} +
+
+
+
+ <% end %> +
+ <% end %> +
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index c518c25a..29201044 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -320,6 +320,7 @@ defmodule ToweropsWeb.Router do live "/settings/integrations/preseem/devices", Org.PreseemDevicesLive, :index live "/settings/integrations/preseem/insights", Org.PreseemInsightsLive, :index live "/settings/integrations/gaiia/mapping", Org.GaiiaMappingLive, :index + live "/settings/integrations/gaiia/reconciliation", Org.GaiiaReconciliationLive, :index end end diff --git a/test/towerops/gaiia/reconciliation_test.exs b/test/towerops/gaiia/reconciliation_test.exs new file mode 100644 index 00000000..457970cf --- /dev/null +++ b/test/towerops/gaiia/reconciliation_test.exs @@ -0,0 +1,144 @@ +defmodule Towerops.Gaiia.ReconciliationTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Gaiia + alias Towerops.Gaiia.Reconciliation + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{org: org} + end + + describe "reconcile/1" do + test "returns empty report when no inventory items exist", %{org: org} do + result = Reconciliation.reconcile(org.id) + + assert result.ghost_devices == [] + assert result.untracked_devices == [] + assert result.data_mismatches == [] + assert result.missing_mappings == [] + assert result.summary.total_mapped == 0 + end + + test "detects ghost devices (in Gaiia but not discovered by Towerops)", %{org: org} do + {:ok, _item} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "gaiia-inv-1", + name: "Ghost Router", + ip_address: "10.0.0.99", + status: "active" + }) + + # No matching Towerops device exists + result = Reconciliation.reconcile(org.id) + + assert length(result.missing_mappings) == 1 + [missing] = result.missing_mappings + assert missing.inventory_item.name == "Ghost Router" + end + + test "detects untracked devices (in Towerops but not in Gaiia)", %{org: org} do + _device = device_fixture(%{organization_id: org.id, name: "Untracked AP"}) + + result = Reconciliation.reconcile(org.id) + + assert length(result.untracked_devices) == 1 + [untracked] = result.untracked_devices + assert untracked.device.name == "Untracked AP" + end + + test "detects IP address mismatches on mapped devices", %{org: org} do + device = + device_fixture(%{ + organization_id: org.id, + name: "Tower Router", + ip_address: "10.0.0.1" + }) + + {:ok, _item} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "gaiia-inv-1", + name: "Tower Router", + ip_address: "10.0.0.99" + }) + + Gaiia.update_inventory_item_mapping( + Gaiia.get_inventory_item(org.id, "gaiia-inv-1"), + %{device_id: device.id} + ) + + result = Reconciliation.reconcile(org.id) + + assert length(result.data_mismatches) == 1 + [mismatch] = result.data_mismatches + assert mismatch.field == :ip_address + assert mismatch.towerops_value == "10.0.0.1" + assert mismatch.gaiia_value == "10.0.0.99" + end + + test "does not flag matched devices with matching data", %{org: org} do + device = + device_fixture(%{ + organization_id: org.id, + name: "Tower Router", + ip_address: "10.0.0.1" + }) + + {:ok, _item} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "gaiia-inv-1", + name: "Tower Router", + ip_address: "10.0.0.1" + }) + + Gaiia.update_inventory_item_mapping( + Gaiia.get_inventory_item(org.id, "gaiia-inv-1"), + %{device_id: device.id} + ) + + result = Reconciliation.reconcile(org.id) + + assert result.data_mismatches == [] + assert result.untracked_devices == [] + assert result.missing_mappings == [] + assert result.summary.total_mapped == 1 + end + + test "summary counts are correct", %{org: org} do + device1 = + device_fixture(%{organization_id: org.id, name: "Mapped Router", ip_address: "10.0.0.1"}) + + _device2 = device_fixture(%{organization_id: org.id, name: "Untracked AP"}) + + {:ok, _item1} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "gaiia-inv-1", + name: "Mapped Router", + ip_address: "10.0.0.1" + }) + + Gaiia.update_inventory_item_mapping( + Gaiia.get_inventory_item(org.id, "gaiia-inv-1"), + %{device_id: device1.id} + ) + + {:ok, _item2} = + Gaiia.upsert_inventory_item(org.id, %{ + gaiia_id: "gaiia-inv-2", + name: "Unmapped Item", + status: "active" + }) + + result = Reconciliation.reconcile(org.id) + + assert result.summary.total_mapped == 1 + assert result.summary.untracked_count == 1 + assert result.summary.missing_mapping_count == 1 + end + end +end