fix(gaiia): add ghost devices tab, manual link, and refresh to reconciliation

Fix auto_match return tuple order (mac/ip/site were swapped). Add ghost
devices tab for cleaning up stale mappings to deleted devices. Add "Link"
button on untracked devices to manually link to existing Gaiia items.
Add manual refresh button for on-demand reconciliation.
This commit is contained in:
Graham McIntire 2026-05-11 18:35:34 -05:00
parent ecec8ba845
commit 2ff6a61bd1
3 changed files with 301 additions and 51 deletions

View file

@ -190,7 +190,7 @@ defmodule Towerops.Gaiia do
remaining = total_devices - MapSet.size(mapped_device_ids)
{:ok, ip_matched, site_matched, mac_matched, remaining}
{:ok, mac_matched, ip_matched, site_matched, remaining}
end
defp match_phase(organization_id, unmapped_index, matcher_fn) do

View file

@ -17,6 +17,9 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
|> assign(:organization, org)
|> assign(:page_title, t("Gaiia Inventory Reconciliation"))
|> assign(:open_create_for, nil)
|> assign(:open_link_for, nil)
|> assign(:link_search_query, "")
|> assign(:link_search_results, [])
|> assign(:manufacturers, [])
|> assign(:models, %{})
|> assign(:selected_manufacturer, nil)}
@ -42,6 +45,10 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
|> load_reconciliation()}
end
def handle_event("refresh", _params, socket) do
{:noreply, load_reconciliation(socket)}
end
def handle_event("open_create_form", %{"device_id" => device_id}, socket) do
org_id = socket.assigns.organization.id
@ -58,6 +65,7 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
{:noreply,
socket
|> assign(:open_create_for, device_id)
|> assign(:open_link_for, nil)
|> assign(:selected_manufacturer, nil)}
end
@ -124,6 +132,81 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
|> load_reconciliation()}
end
# -- Link to existing Gaiia item from untracked tab --
def handle_event("start_link_search", %{"device_id" => device_id}, socket) do
{:noreply,
socket
|> assign(:open_link_for, device_id)
|> assign(:open_create_for, nil)
|> assign(:link_search_query, "")
|> assign(:link_search_results, [])}
end
def handle_event("cancel_link_search", _params, socket) do
{:noreply,
socket
|> assign(:open_link_for, nil)
|> assign(:link_search_query, "")
|> assign(:link_search_results, [])}
end
def handle_event("search_gaiia_items", %{"query" => query}, socket) do
org_id = socket.assigns.organization.id
results =
if String.length(query) >= 2 do
org_id
|> Gaiia.search_inventory_items(query)
|> Enum.filter(&is_nil(&1.device_id))
else
[]
end
{:noreply, socket |> assign(:link_search_query, query) |> assign(:link_search_results, results)}
end
def handle_event("link_to_gaiia", %{"device_id" => device_id, "gaiia_id" => gaiia_id}, socket) do
org_id = socket.assigns.organization.id
result =
case Gaiia.get_inventory_item(org_id, gaiia_id) do
nil -> {:error, :not_found}
item -> Gaiia.update_inventory_item_mapping(item, %{device_id: device_id})
end
{:noreply,
socket
|> assign(:open_link_for, nil)
|> assign(:link_search_query, "")
|> assign(:link_search_results, [])
|> put_flash(:info, link_flash(result))
|> load_reconciliation()}
end
# -- Ghost device cleanup --
def handle_event("unlink_ghost", %{"gaiia_id" => gaiia_id}, socket) do
org_id = socket.assigns.organization.id
case Gaiia.get_inventory_item(org_id, gaiia_id) do
nil ->
{:noreply, put_flash(socket, :error, t("Gaiia inventory item not found"))}
item ->
case Gaiia.update_inventory_item_mapping(item, %{device_id: nil}) do
{:ok, _} ->
{:noreply,
socket
|> load_reconciliation()
|> put_flash(:info, t("Unlinked stale device mapping."))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to unlink stale mapping."))}
end
end
end
def handle_event("select_tab", %{"tab" => tab}, socket) do
org = socket.assigns.organization
@ -133,6 +216,8 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
)}
end
# -- Flash helpers --
defp match_flash({:ok, 0, 0, 0, _remaining}) do
t("No matches found. Link devices manually on the mapping page.")
end
@ -171,6 +256,18 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
t("Failed to create Gaiia inventory item: %{reason}", reason: inspect(reason))
end
defp link_flash({:ok, _item}) do
t("Device linked to Gaiia inventory item.")
end
defp link_flash({:error, :not_found}) do
t("Gaiia inventory item not found.")
end
defp link_flash({:error, _reason}) do
t("Failed to link device.")
end
defp load_reconciliation(socket) do
report = Reconciliation.reconcile(socket.assigns.organization.id)
assign(socket, :report, report)

View file

@ -37,7 +37,7 @@
</nav>
</div>
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-5">
<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}
@ -62,9 +62,15 @@
</div>
<div class="text-sm text-red-700 dark:text-red-500">Data Mismatches</div>
</div>
<div class="rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-600/30 dark:bg-purple-900/20">
<div class="text-2xl font-bold text-purple-800 dark:text-purple-400">
{@report.summary.ghost_count}
</div>
<div class="text-sm text-purple-700 dark:text-purple-500">Ghosts</div>
</div>
</div>
<div class="mt-6 mb-6">
<div class="mt-6 mb-6 flex items-center justify-between">
<div class="inline-flex rounded-lg border border-gray-200 dark:border-white/10">
<.link
patch={
@ -113,7 +119,7 @@
~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",
"px-4 py-2 text-sm font-medium border-l border-gray-200 dark:border-white/10",
@active_tab == "unmapped" &&
"bg-blue-600 text-white dark:bg-blue-500",
@active_tab != "unmapped" &&
@ -122,7 +128,29 @@
>
Unmapped ({@report.summary.missing_mapping_count})
</.link>
<.link
patch={
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation?tab=ghosts"
}
class={[
"px-4 py-2 text-sm font-medium rounded-r-lg border-l border-gray-200 dark:border-white/10",
@active_tab == "ghosts" &&
"bg-blue-600 text-white dark:bg-blue-500",
@active_tab != "ghosts" &&
"bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-gray-300 dark:hover:bg-gray-800"
]}
>
Ghosts ({@report.summary.ghost_count})
</.link>
</div>
<button
phx-click="refresh"
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800/50 dark:text-gray-300 dark:hover:bg-gray-800"
>
<.icon name="hero-arrow-path" class="h-4 w-4" />
{t("Refresh")}
</button>
</div>
<%= case @active_tab do %>
@ -189,7 +217,8 @@
<% end %>
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<%= if @open_create_for == entry.device.id do %>
<%= cond do %>
<% @open_create_for == entry.device.id -> %>
<div class="flex items-center justify-end gap-2">
<form phx-change="select_manufacturer">
<select
@ -234,7 +263,35 @@
{t("Cancel")}
</button>
</div>
<% else %>
<% @open_link_for == entry.device.id -> %>
<div class="flex items-center justify-end gap-2">
<form phx-change="search_gaiia_items" class="flex items-center gap-2">
<input type="hidden" name="device_id" value={entry.device.id} />
<input
type="text"
name="query"
value={@link_search_query}
placeholder={t("Search by name or IP...")}
class="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300"
autocomplete="off"
/>
</form>
<button
phx-click="cancel_link_search"
class="rounded px-2 py-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
>
{t("Cancel")}
</button>
</div>
<% true -> %>
<div class="flex items-center justify-end gap-2">
<button
phx-click="start_link_search"
phx-value-device_id={entry.device.id}
class="rounded bg-white px-2.5 py-1 text-xs font-medium text-gray-700 border border-gray-300 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{t("Link")}
</button>
<button
phx-click="open_create_form"
phx-value-device_id={entry.device.id}
@ -242,9 +299,39 @@
>
{t("Create in Gaiia")}
</button>
</div>
<% end %>
</td>
</tr>
<%!-- Search results row for link search --%>
<%= if @open_link_for == entry.device.id and @link_search_results != [] do %>
<tr>
<td colspan="4" class="px-4 py-2 bg-gray-50 dark:bg-gray-800/50">
<div class="space-y-1">
<%= for item <- @link_search_results do %>
<div class="flex items-center justify-between rounded border border-gray-200 bg-white px-3 py-2 dark:border-white/10 dark:bg-gray-800">
<div>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{item.name}
</span>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
{item.ip_address || "—"}
</span>
</div>
<button
phx-click="link_to_gaiia"
phx-value-device_id={entry.device.id}
phx-value-gaiia_id={item.gaiia_id}
class="rounded bg-indigo-600 px-2 py-1 text-xs font-medium text-white hover:bg-indigo-700"
>
{t("Link")}
</button>
</div>
<% end %>
</div>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
@ -350,6 +437,64 @@
</div>
<% end %>
</div>
<% "ghosts" -> %>
<div class="space-y-2">
<%= if @report.ghost_devices == [] do %>
<p class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{t("No stale mappings — all linked Gaiia items reference existing Towerops devices.")}
</p>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400">
{t(
"These Gaiia inventory items are linked to Towerops devices that no longer exist. Unlink them to restore them to the unmapped pool."
)}
</p>
<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">
{t("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">
{t("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">
{t("Status")}
</th>
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Actions")}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/5 dark:bg-transparent">
<%= for entry <- @report.ghost_devices 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.status || "—"}
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<button
phx-click="unlink_ghost"
phx-value-gaiia_id={entry.inventory_item.gaiia_id}
class="rounded bg-purple-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-purple-700"
>
{t("Unlink")}
</button>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
<% _ -> %>
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
@ -415,6 +560,14 @@
{@report.summary.mismatch_count}
</dd>
</div>
<div class="flex justify-between text-sm">
<dt class="text-gray-600 dark:text-gray-400">
{t("Ghost mappings (stale device links)")}
</dt>
<dd class="font-medium text-purple-600 dark:text-purple-400">
{@report.summary.ghost_count}
</dd>
</div>
</dl>
</div>
<% end %>