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.
This commit is contained in:
Graham McIntire 2026-05-11 15:12:52 -05:00
parent 31231d9fad
commit 2695e8a0eb
3 changed files with 158 additions and 64 deletions

View file

@ -50,30 +50,31 @@ defmodule Towerops.Gaiia.InventoryCreator do
end end
@doc """ @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 Returns `{:ok, [%{"name" => name, "id" => id}]}` or `{:error, reason}`.
SNMP-discovered model name. Returns `{:ok, model_id, model_name}` or
`{:error, reason}`.
""" """
def find_matching_model(organization_id, %Device{} = device) do def list_manufacturers(organization_id) do
snmp = Repo.get_by(SnmpDevice, device_id: device.id) 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), @doc """
{:ok, client} <- build_client(organization_id), List inventory models for a specific manufacturer.
{:ok, models} <- fetch_models(client, snmp.model),
{:ok, model} <- pick_best_match(models, snmp.manufacturer) do Returns `{:ok, [%{"id" => id, "name" => name}]}` or `{:error, reason}`.
{:ok, model["id"], model["name"]} """
else def list_models(organization_id, manufacturer_id) do
false -> {:error, :no_snmp_model_info} with {:ok, client} <- build_client(organization_id) do
{:error, _} = error -> error fetch_models_for_manufacturer(client, manufacturer_id)
end end
end end
@doc """ @doc """
Create a Gaiia inventory item for a device. 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. Uses `startAddInventoryItemsJob` to create the item.
Returns `{:ok, job_id}` or `{:error, reason}`. 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") Application.get_env(:towerops, :gaiia_api_endpoint, "https://api.gaiia.com/api/v1")
end end
defp fetch_models(client, search_name) do defp fetch_manufacturers(client) do
filters = %{"name" => %{"contains" => search_name}}
case Gaiia.Queries.inventory_models( case Gaiia.Queries.inventory_models(
client, client,
%{"filters" => filters, "first" => 10}, %{"first" => 200},
"edges { node { id name manufacturer { name } } }" "edges { node { manufacturer { id name } } }"
) do ) do
{:ok, %{"edges" => edges}} when is_list(edges) -> {: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, _} -> {:ok, manufacturers}
{:error, :unexpected_response}
{:error, reason} -> {: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} {:error, :gaiia_api_error}
end end
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 {:ok, models}
manufacturer_down = String.downcase(manufacturer)
exact = {:error, reason} ->
Enum.filter(models, fn m -> Logger.error("Failed to fetch Gaiia models: #{inspect(reason)}")
mf = get_in(m, ["manufacturer", "name"]) || "" {:error, :gaiia_api_error}
String.downcase(mf) == manufacturer_down
end)
if exact == [] do
{:ok, hd(models)}
else
{:ok, hd(exact)}
end end
end end

View file

@ -13,7 +13,11 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
{:ok, {:ok,
socket socket
|> assign(:organization, org) |> 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 end
@impl true @impl true
@ -36,18 +40,67 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
|> load_reconciliation()} |> load_reconciliation()}
end 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 org_id = socket.assigns.organization.id
result = socket =
with {:ok, device} <- get_device(device_id), if socket.assigns.manufacturers == [] do
{:ok, model_id, model_name} <- InventoryCreator.find_matching_model(org_id, device), case InventoryCreator.list_manufacturers(org_id) do
{:ok, _job_id} <- InventoryCreator.create_inventory_item(org_id, device, model_id) do {:ok, manufacturers} -> assign(socket, :manufacturers, manufacturers)
{:ok, device.name, model_name} {:error, _} -> socket
end
else
socket
end end
{:noreply, {:noreply,
socket 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)) |> put_flash(:info, create_flash(result))
|> load_reconciliation()} |> load_reconciliation()}
end end
@ -86,19 +139,8 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
end end
end end
defp create_flash({:ok, device_name, model_name}) do defp create_flash({:ok, device_name}) do
t("Created Gaiia inventory item for %{device} (model: %{model}).", t("Created Gaiia inventory item for %{device}.", device: device_name)
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.")
end end
defp create_flash({:error, :no_gaiia_integration}) do defp create_flash({:error, :no_gaiia_integration}) do

View file

@ -183,13 +183,59 @@
<% end %> <% end %>
</td> </td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm"> <td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<button <%= if @open_create_for == entry.device.id do %>
phx-click="create_gaiia_item" <div class="flex items-center justify-end gap-2">
phx-value-device_id={entry.device.id} <select
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" phx-change="select_manufacturer"
> name="manufacturer_id"
{t("Create in Gaiia")} 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"
</button> >
<option value="">{t("Select manufacturer")}</option>
<%= for mfr <- @manufacturers do %>
<option
value={mfr["id"]}
selected={@selected_manufacturer == mfr["id"]}
>
{mfr["name"]}
</option>
<% end %>
</select>
<%= 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>
<% 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>
<% end %>
</td> </td>
</tr> </tr>
<% end %> <% end %>