From 2695e8a0ebb2df18e7f3311c2ade0e7a342749fb Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 11 May 2026 15:12:52 -0500 Subject: [PATCH] refactor(gaiia): manufacturer/model dropdowns for inventory creation Replaces auto-matching with explicit two-step selection: 1. Select manufacturer from Gaiia's inventory model library (dropdown) 2. Select model for that manufacturer (dropdown, filtered live) 3. Click Create Manufacturers and models are fetched from Gaiia on demand and cached in socket assigns. Only one device form is open at a time. --- lib/towerops/gaiia/inventory_creator.ex | 80 +++++++++--------- .../live/org/gaiia_reconciliation_live.ex | 82 ++++++++++++++----- .../org/gaiia_reconciliation_live.html.heex | 60 ++++++++++++-- 3 files changed, 158 insertions(+), 64 deletions(-) diff --git a/lib/towerops/gaiia/inventory_creator.ex b/lib/towerops/gaiia/inventory_creator.ex index 47971958..609e19b2 100644 --- a/lib/towerops/gaiia/inventory_creator.ex +++ b/lib/towerops/gaiia/inventory_creator.ex @@ -50,30 +50,31 @@ defmodule Towerops.Gaiia.InventoryCreator do end @doc """ - Find the best-matching Gaiia inventory model for a device. + List all manufacturers from Gaiia's inventory model library. - Queries Gaiia's inventory model library, filtering by the device's - SNMP-discovered model name. Returns `{:ok, model_id, model_name}` or - `{:error, reason}`. + Returns `{:ok, [%{"name" => name, "id" => id}]}` or `{:error, reason}`. """ - def find_matching_model(organization_id, %Device{} = device) do - snmp = Repo.get_by(SnmpDevice, device_id: device.id) + def list_manufacturers(organization_id) do + with {:ok, client} <- build_client(organization_id) do + fetch_manufacturers(client) + end + end - with true <- is_binary(snmp && snmp.manufacturer) and is_binary(snmp && snmp.model), - {:ok, client} <- build_client(organization_id), - {:ok, models} <- fetch_models(client, snmp.model), - {:ok, model} <- pick_best_match(models, snmp.manufacturer) do - {:ok, model["id"], model["name"]} - else - false -> {:error, :no_snmp_model_info} - {:error, _} = error -> error + @doc """ + List inventory models for a specific manufacturer. + + Returns `{:ok, [%{"id" => id, "name" => name}]}` or `{:error, reason}`. + """ + def list_models(organization_id, manufacturer_id) do + with {:ok, client} <- build_client(organization_id) do + fetch_models_for_manufacturer(client, manufacturer_id) end end @doc """ Create a Gaiia inventory item for a device. - Takes a matched model ID, device, and optional Gaiia network site ID. + Takes a model ID, device, and optional Gaiia network site ID. Uses `startAddInventoryItemsJob` to create the item. Returns `{:ok, job_id}` or `{:error, reason}`. @@ -91,41 +92,46 @@ defmodule Towerops.Gaiia.InventoryCreator do Application.get_env(:towerops, :gaiia_api_endpoint, "https://api.gaiia.com/api/v1") end - defp fetch_models(client, search_name) do - filters = %{"name" => %{"contains" => search_name}} - + defp fetch_manufacturers(client) do case Gaiia.Queries.inventory_models( client, - %{"filters" => filters, "first" => 10}, - "edges { node { id name manufacturer { name } } }" + %{"first" => 200}, + "edges { node { manufacturer { id name } } }" ) do {:ok, %{"edges" => edges}} when is_list(edges) -> - {:ok, Enum.map(edges, & &1["node"])} + manufacturers = + edges + |> Enum.map(fn e -> get_in(e, ["node", "manufacturer"]) end) + |> Enum.reject(&is_nil/1) + |> Enum.uniq_by(& &1["id"]) + |> Enum.sort_by(& &1["name"]) - {:ok, _} -> - {:error, :unexpected_response} + {:ok, manufacturers} {:error, reason} -> - Logger.error("Failed to query Gaiia inventory models: #{inspect(reason)}") + Logger.error("Failed to fetch Gaiia manufacturers: #{inspect(reason)}") {:error, :gaiia_api_error} end end - defp pick_best_match([], _manufacturer), do: {:error, :no_matching_model} + defp fetch_models_for_manufacturer(client, manufacturer_id) do + case Gaiia.Queries.inventory_models( + client, + %{"first" => 200}, + "edges { node { id name manufacturer { id } } }" + ) do + {:ok, %{"edges" => edges}} when is_list(edges) -> + models = + edges + |> Enum.map(& &1["node"]) + |> Enum.filter(fn m -> get_in(m, ["manufacturer", "id"]) == manufacturer_id end) + |> Enum.sort_by(& &1["name"]) - defp pick_best_match(models, manufacturer) do - manufacturer_down = String.downcase(manufacturer) + {:ok, models} - exact = - Enum.filter(models, fn m -> - mf = get_in(m, ["manufacturer", "name"]) || "" - String.downcase(mf) == manufacturer_down - end) - - if exact == [] do - {:ok, hd(models)} - else - {:ok, hd(exact)} + {:error, reason} -> + Logger.error("Failed to fetch Gaiia models: #{inspect(reason)}") + {:error, :gaiia_api_error} end end diff --git a/lib/towerops_web/live/org/gaiia_reconciliation_live.ex b/lib/towerops_web/live/org/gaiia_reconciliation_live.ex index c346faae..44e3e353 100644 --- a/lib/towerops_web/live/org/gaiia_reconciliation_live.ex +++ b/lib/towerops_web/live/org/gaiia_reconciliation_live.ex @@ -13,7 +13,11 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do {:ok, socket |> assign(:organization, org) - |> assign(:page_title, t("Gaiia Inventory Reconciliation"))} + |> assign(:page_title, t("Gaiia Inventory Reconciliation")) + |> assign(:open_create_for, nil) + |> assign(:manufacturers, []) + |> assign(:models, %{}) + |> assign(:selected_manufacturer, nil)} end @impl true @@ -36,18 +40,67 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do |> load_reconciliation()} end - def handle_event("create_gaiia_item", %{"device_id" => device_id}, socket) do + def handle_event("open_create_form", %{"device_id" => device_id}, socket) do org_id = socket.assigns.organization.id - result = - with {:ok, device} <- get_device(device_id), - {:ok, model_id, model_name} <- InventoryCreator.find_matching_model(org_id, device), - {:ok, _job_id} <- InventoryCreator.create_inventory_item(org_id, device, model_id) do - {:ok, device.name, model_name} + 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(: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" => 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, models} -> + socket + |> assign(:models, Map.put(existing, manufacturer_id, models)) + |> assign(:selected_manufacturer, manufacturer_id) + + {:error, _} -> + socket + end + end + + {:noreply, socket} + 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 @@ -86,19 +139,8 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do end end - defp create_flash({:ok, device_name, model_name}) do - t("Created Gaiia inventory item for %{device} (model: %{model}).", - device: device_name, - model: model_name - ) - end - - defp create_flash({:error, :no_snmp_model_info}) do - t("Device has no SNMP manufacturer/model data. Run SNMP discovery first.") - end - - defp create_flash({:error, :no_matching_model}) do - t("No matching Gaiia inventory model found for this device.") + 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 diff --git a/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex b/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex index c4ab9e7c..7759a687 100644 --- a/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex +++ b/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex @@ -183,13 +183,59 @@ <% end %> - + <%= if @open_create_for == entry.device.id do %> +
+ + <%= if @selected_manufacturer do %> +
+ + + +
+ <% end %> + +
+ <% else %> + + <% end %> <% end %>