add gaiia inventory reconciliation (stage 7)
This commit is contained in:
parent
6da05d461b
commit
55382a7ec6
5 changed files with 640 additions and 0 deletions
141
lib/towerops/gaiia/reconciliation.ex
Normal file
141
lib/towerops/gaiia/reconciliation.ex
Normal 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
|
||||
41
lib/towerops_web/live/org/gaiia_reconciliation_live.ex
Normal file
41
lib/towerops_web/live/org/gaiia_reconciliation_live.ex
Normal 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
|
||||
313
lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex
Normal file
313
lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex
Normal 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>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
144
test/towerops/gaiia/reconciliation_test.exs
Normal file
144
test/towerops/gaiia/reconciliation_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue