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
@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

View file

@ -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

View file

@ -183,13 +183,59 @@
<% 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">
<select
phx-change="select_manufacturer"
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>
<%= 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="create_gaiia_item"
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>
</tr>
<% end %>