towerops/lib/towerops_web/live/org/gaiia_reconciliation_live.ex
Graham McIntire 2ff6a61bd1 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.
2026-05-11 18:35:34 -05:00

275 lines
7.9 KiB
Elixir

defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Gaiia
alias Towerops.Gaiia.InventoryCreator
alias Towerops.Gaiia.Reconciliation
require Logger
@impl true
def mount(_params, _session, socket) do
org = socket.assigns.current_scope.organization
{:ok,
socket
|> 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)}
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("auto_match", _params, socket) do
org_id = socket.assigns.organization.id
{:noreply,
socket
|> put_flash(:info, match_flash(Gaiia.auto_match_untracked(org_id)))
|> 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
socket =
if socket.assigns.manufacturers == [] do
case InventoryCreator.list_manufacturers(org_id) do
{:ok, manufacturers} -> assign(socket, :manufacturers, manufacturers)
{:error, _} -> socket
end
else
socket
end
{:noreply,
socket
|> assign(:open_create_for, device_id)
|> assign(:open_link_for, nil)
|> assign(:selected_manufacturer, nil)}
end
def handle_event("cancel_create", _params, socket) do
{:noreply,
socket
|> assign(:open_create_for, nil)
|> assign(:selected_manufacturer, nil)}
end
def handle_event("select_manufacturer", %{"manufacturer_id" => ""}, socket) do
{:noreply, assign(socket, :selected_manufacturer, nil)}
end
def handle_event("select_manufacturer", %{"manufacturer_id" => manufacturer_id}, socket) do
org_id = socket.assigns.organization.id
existing = socket.assigns.models
socket =
if Map.has_key?(existing, manufacturer_id) do
assign(socket, :selected_manufacturer, manufacturer_id)
else
case InventoryCreator.list_models(org_id, manufacturer_id) do
{:ok, []} ->
Logger.warning("Gaiia: no models for manufacturer #{manufacturer_id}")
socket
|> assign(:selected_manufacturer, manufacturer_id)
|> put_flash(:warning, t("No models found for this manufacturer."))
{:ok, models} ->
socket
|> assign(:models, Map.put(existing, manufacturer_id, models))
|> assign(:selected_manufacturer, manufacturer_id)
{:error, reason} ->
Logger.error("Gaiia: failed to fetch models: #{inspect(reason)}")
put_flash(socket, :error, t("Failed to load models. Check server logs."))
end
end
{:noreply, socket}
end
def handle_event("create_gaiia_item", %{"device_id" => _, "model_id" => ""}, socket) do
{:noreply, put_flash(socket, :warning, t("Please select a model."))}
end
def handle_event("create_gaiia_item", %{"device_id" => device_id, "model_id" => model_id}, socket) do
org_id = socket.assigns.organization.id
result =
with {:ok, device} <- get_device(device_id),
{:ok, _job_id} <- InventoryCreator.create_inventory_item(org_id, device, model_id) do
{:ok, device.name}
end
{:noreply,
socket
|> assign(:open_create_for, nil)
|> assign(:selected_manufacturer, nil)
|> put_flash(:info, create_flash(result))
|> 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
{:noreply,
push_patch(socket,
to: ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation?tab=#{tab}"
)}
end
# -- Flash helpers --
defp match_flash({:ok, 0, 0, 0, _remaining}) do
t("No matches found. Link devices manually on the mapping page.")
end
defp match_flash({:ok, mac_matched, ip_matched, site_matched, remaining}) do
total = mac_matched + ip_matched + site_matched
ngettext(
"Auto-matched %{total} device (%{mac} by MAC, %{ip} by IP, %{site} by site). %{remaining} remaining.",
"Auto-matched %{total} devices (%{mac} by MAC, %{ip} by IP, %{site} by site). %{remaining} remaining.",
total,
total: total,
mac: mac_matched,
ip: ip_matched,
site: site_matched,
remaining: remaining
)
end
defp get_device(device_id) do
case Towerops.Devices.get_device(device_id) do
nil -> {:error, :not_found}
device -> {:ok, device}
end
end
defp create_flash({:ok, device_name}) do
t("Created Gaiia inventory item for %{device}.", device: device_name)
end
defp create_flash({:error, :no_gaiia_integration}) do
t("Gaiia integration not configured.")
end
defp create_flash({:error, reason}) 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)
end
end