add gaiia inventory reconciliation (stage 7)

This commit is contained in:
Graham McIntire 2026-02-13 13:28:52 -06:00
parent 6da05d461b
commit 55382a7ec6
No known key found for this signature in database
5 changed files with 640 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,313 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="settings"
>
<.header>
Gaiia Inventory Reconciliation
<:subtitle>Compare Gaiia inventory against Towerops device discovery</:subtitle>
</.header>
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-gray-800/50">
<div class="text-2xl font-bold text-gray-900 dark:text-white">
{@report.summary.total_mapped}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Mapped</div>
</div>
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-600/30 dark:bg-yellow-900/20">
<div class="text-2xl font-bold text-yellow-800 dark:text-yellow-400">
{@report.summary.missing_mapping_count}
</div>
<div class="text-sm text-yellow-700 dark:text-yellow-500">Unmapped Items</div>
</div>
<div class="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-600/30 dark:bg-blue-900/20">
<div class="text-2xl font-bold text-blue-800 dark:text-blue-400">
{@report.summary.untracked_count}
</div>
<div class="text-sm text-blue-700 dark:text-blue-500">Untracked Devices</div>
</div>
<div class="rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-600/30 dark:bg-red-900/20">
<div class="text-2xl font-bold text-red-800 dark:text-red-400">
{@report.summary.mismatch_count}
</div>
<div class="text-sm text-red-700 dark:text-red-500">Data Mismatches</div>
</div>
</div>
<div class="mt-6 mb-6">
<div class="inline-flex rounded-lg border border-gray-200 dark:border-white/10">
<.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>
<.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>
<.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>
<.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})
</.link>
</div>
</div>
<%= case @active_tab do %>
<% "untracked" -> %>
<div class="space-y-2">
<%= if @report.untracked_devices == [] do %>
<p class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
All Towerops devices have matching Gaiia inventory items.
</p>
<% else %>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Device
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
IP Address
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Site
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/5 dark:bg-transparent">
<%= for entry <- @report.untracked_devices do %>
<tr>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
<.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}
</.link>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{entry.device.ip_address}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
<%= if entry.device.site do %>
{entry.device.site.name}
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
<% "mismatches" -> %>
<div class="space-y-2">
<%= if @report.data_mismatches == [] do %>
<p class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
No data mismatches found between mapped devices.
</p>
<% else %>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Device
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Field
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Towerops
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Gaiia
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/5 dark:bg-transparent">
<%= for mismatch <- @report.data_mismatches do %>
<tr>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
<.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}
</.link>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{mismatch.field}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm font-mono text-gray-900 dark:text-white">
{mismatch.towerops_value}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm font-mono text-red-600 dark:text-red-400">
{mismatch.gaiia_value}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
<% "unmapped" -> %>
<div class="space-y-2">
<%= if @report.missing_mappings == [] do %>
<p class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
All Gaiia inventory items are mapped to Towerops devices.
</p>
<% else %>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Gaiia Item
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
IP Address
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Category
</th>
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/5 dark:bg-transparent">
<%= for entry <- @report.missing_mappings do %>
<tr>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
{entry.inventory_item.name}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm font-mono text-gray-600 dark:text-gray-400">
{entry.inventory_item.ip_address || "—"}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{entry.inventory_item.category || "—"}
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
{entry.inventory_item.status || "—"}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
<% _ -> %>
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Last reconciled: {Calendar.strftime(@report.reconciled_at, "%Y-%m-%d %H:%M:%S UTC")}
</p>
<%= if @report.summary.total_mapped == 0 && @report.summary.missing_mapping_count == 0 && @report.summary.untracked_count == 0 do %>
<div class="py-8 text-center">
<.icon
name="hero-check-circle"
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
/>
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
No inventory data
</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Sync Gaiia data and map inventory items to see reconciliation results.
</p>
<.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" />
</.link>
</div>
<% else %>
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-white/10 dark:bg-gray-800/50">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Overview</h3>
<dl class="mt-4 space-y-3">
<div class="flex justify-between text-sm">
<dt class="text-gray-600 dark:text-gray-400">
Mapped devices (Gaiia ↔ Towerops)
</dt>
<dd class="font-medium text-gray-900 dark:text-white">
{@report.summary.total_mapped}
</dd>
</div>
<div class="flex justify-between text-sm">
<dt class="text-gray-600 dark:text-gray-400">
Unmapped Gaiia items (no Towerops link)
</dt>
<dd class="font-medium text-yellow-600 dark:text-yellow-400">
{@report.summary.missing_mapping_count}
</dd>
</div>
<div class="flex justify-between text-sm">
<dt class="text-gray-600 dark:text-gray-400">
Untracked Towerops devices (not in Gaiia)
</dt>
<dd class="font-medium text-blue-600 dark:text-blue-400">
{@report.summary.untracked_count}
</dd>
</div>
<div class="flex justify-between text-sm">
<dt class="text-gray-600 dark:text-gray-400">
Data mismatches (IP, serial, etc.)
</dt>
<dd class="font-medium text-red-600 dark:text-red-400">
{@report.summary.mismatch_count}
</dd>
</div>
</dl>
</div>
<% end %>
</div>
<% end %>
</Layouts.authenticated>

View file

@ -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

View file

@ -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