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:
parent
ecec8ba845
commit
2ff6a61bd1
3 changed files with 301 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,62 +217,121 @@
|
|||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<%= if @open_create_for == entry.device.id do %>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<form phx-change="select_manufacturer">
|
||||
<select
|
||||
name="manufacturer_id"
|
||||
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"
|
||||
>
|
||||
<option value="">{t("Select manufacturer")}</option>
|
||||
<%= for mfr <- @manufacturers do %>
|
||||
<option
|
||||
value={mfr["id"]}
|
||||
selected={@selected_manufacturer == mfr["id"]}
|
||||
>
|
||||
{mfr["name"]}
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
<%= if @selected_manufacturer do %>
|
||||
<form
|
||||
phx-submit="create_gaiia_item"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input type="hidden" name="device_id" value={entry.device.id} />
|
||||
<%= cond do %>
|
||||
<% @open_create_for == entry.device.id -> %>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<form phx-change="select_manufacturer">
|
||||
<select
|
||||
name="model_id"
|
||||
name="manufacturer_id"
|
||||
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"
|
||||
>
|
||||
<option value="">{t("Select model")}</option>
|
||||
<%= for model <- Map.get(@models, @selected_manufacturer, []) do %>
|
||||
<option value={model["id"]}>{model["name"]}</option>
|
||||
<option value="">{t("Select manufacturer")}</option>
|
||||
<%= for mfr <- @manufacturers do %>
|
||||
<option
|
||||
value={mfr["id"]}
|
||||
selected={@selected_manufacturer == mfr["id"]}
|
||||
>
|
||||
{mfr["name"]}
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<button class="rounded bg-indigo-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-indigo-700">
|
||||
{t("Create")}
|
||||
</button>
|
||||
</form>
|
||||
<% end %>
|
||||
<button
|
||||
phx-click="cancel_create"
|
||||
class="rounded px-2 py-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="open_create_form"
|
||||
phx-value-device_id={entry.device.id}
|
||||
class="inline-flex items-center gap-1 rounded bg-indigo-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-indigo-700"
|
||||
>
|
||||
{t("Create in Gaiia")}
|
||||
</button>
|
||||
<%= if @selected_manufacturer do %>
|
||||
<form
|
||||
phx-submit="create_gaiia_item"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input type="hidden" name="device_id" value={entry.device.id} />
|
||||
<select
|
||||
name="model_id"
|
||||
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"
|
||||
>
|
||||
<option value="">{t("Select model")}</option>
|
||||
<%= for model <- Map.get(@models, @selected_manufacturer, []) do %>
|
||||
<option value={model["id"]}>{model["name"]}</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<button class="rounded bg-indigo-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-indigo-700">
|
||||
{t("Create")}
|
||||
</button>
|
||||
</form>
|
||||
<% end %>
|
||||
<button
|
||||
phx-click="cancel_create"
|
||||
class="rounded px-2 py-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
<% @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}
|
||||
class="inline-flex items-center gap-1 rounded bg-indigo-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-indigo-700"
|
||||
>
|
||||
{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 %>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue