From 3267bb134fad4d718caa8f236405a901357313a6 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 11 May 2026 14:58:37 -0500 Subject: [PATCH] feat(gaiia): create inventory items for unmatched devices Adds `Towerops.Gaiia.InventoryCreator` which: - Matches a device's SNMP-discovered manufacturer/model against Gaiia's inventory model library to find the right modelId - Calls `startAddInventoryItemsJob` to create the item, assigned to the correct Gaiia network site with MAC address from SNMP interfaces A "Create in Gaiia" button now appears next to each untracked device on the reconciliation page. --- lib/towerops/gaiia/inventory_creator.ex | 209 ++++++++++++++++++ .../live/org/gaiia_reconciliation_live.ex | 47 ++++ .../org/gaiia_reconciliation_live.html.heex | 12 + 3 files changed, 268 insertions(+) create mode 100644 lib/towerops/gaiia/inventory_creator.ex diff --git a/lib/towerops/gaiia/inventory_creator.ex b/lib/towerops/gaiia/inventory_creator.ex new file mode 100644 index 00000000..47971958 --- /dev/null +++ b/lib/towerops/gaiia/inventory_creator.ex @@ -0,0 +1,209 @@ +defmodule Towerops.Gaiia.InventoryCreator do + @moduledoc """ + Creates Gaiia inventory items for unmatched Towerops devices. + + Two-step flow: + 1. Match the device's SNMP-discovered manufacturer/model against Gaiia's + inventory model library to find the right `modelId`. + 2. Call `startAddInventoryItemsJob` to create the inventory item, assigning + it to the Gaiia network site that corresponds to the device's Towerops site. + """ + + import Ecto.Query + + alias Towerops.Devices.Device + alias Towerops.Integrations.Integration + alias Towerops.Repo + alias Towerops.Snmp.Device, as: SnmpDevice + + require Logger + + @doc """ + Build the Gaiia API client for an organization using its integration credentials. + Returns `{:ok, %Gaiia.Client{}}` or `{:error, reason}`. + """ + def build_client(organization_id) do + integration = + Repo.one( + from(i in Integration, + where: + i.organization_id == ^organization_id and + i.provider == "gaiia" and + i.enabled == true + ) + ) + + case integration do + nil -> + {:error, :no_gaiia_integration} + + %{credentials: %{"api_key" => api_key}} -> + {:ok, + Gaiia.Client.new( + endpoint: gaiia_endpoint(), + headers: [{"x-gaiia-api-key", api_key}] + )} + + _ -> + {:error, :no_api_key} + end + end + + @doc """ + Find the best-matching Gaiia inventory model for a device. + + Queries Gaiia's inventory model library, filtering by the device's + SNMP-discovered model name. Returns `{:ok, model_id, model_name}` or + `{:error, reason}`. + """ + def find_matching_model(organization_id, %Device{} = device) do + snmp = Repo.get_by(SnmpDevice, device_id: device.id) + + 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 + end + end + + @doc """ + Create a Gaiia inventory item for a device. + + Takes a matched model ID, device, and optional Gaiia network site ID. + Uses `startAddInventoryItemsJob` to create the item. + + Returns `{:ok, job_id}` or `{:error, reason}`. + """ + def create_inventory_item(organization_id, %Device{} = device, model_id, gaiia_site_id \\ nil) do + with {:ok, client} <- build_client(organization_id), + {:ok, fields} <- fetch_model_fields(client) do + call_start_add_job(client, model_id, device, fields, gaiia_site_id) + end + end + + # -- Private: Gaiia API calls -- + + defp gaiia_endpoint 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}} + + case Gaiia.Queries.inventory_models( + client, + %{"filters" => filters, "first" => 10}, + "edges { node { id name manufacturer { name } } }" + ) do + {:ok, %{"edges" => edges}} when is_list(edges) -> + {:ok, Enum.map(edges, & &1["node"])} + + {:ok, _} -> + {:error, :unexpected_response} + + {:error, reason} -> + Logger.error("Failed to query Gaiia inventory models: #{inspect(reason)}") + {:error, :gaiia_api_error} + end + end + + defp pick_best_match([], _manufacturer), do: {:error, :no_matching_model} + + defp pick_best_match(models, manufacturer) do + manufacturer_down = String.downcase(manufacturer) + + 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)} + end + end + + defp fetch_model_fields(client) do + selection = "fields { edges { node { id name isMainIdentifier type } } }" + + case Gaiia.Queries.inventory_models(client, %{"first" => 1}, "edges { node { #{selection} } }") do + {:ok, %{"edges" => [%{"node" => %{"fields" => fields}} | _]}} -> + field_nodes = get_in(fields, ["edges"]) || [] + {:ok, Enum.map(field_nodes, & &1["node"])} + + {:ok, _} -> + {:ok, []} + + {:error, reason} -> + Logger.error("Failed to fetch model fields: #{inspect(reason)}") + {:error, :gaiia_api_error} + end + end + + defp call_start_add_job(client, model_id, device, fields, gaiia_site_id) do + mac_field = Enum.find(fields, fn f -> f["isMainIdentifier"] end) + item_fields = build_item_fields(device, mac_field) + + input = %{ + "modelId" => model_id, + "items" => [%{"fields" => item_fields}], + "status" => "FUNCTIONAL", + "equipmentConditionType" => "NEW", + "purchasePriceInCents" => 0 + } + + input = + if gaiia_site_id do + Map.put(input, "assignation", %{ + "assigneeId" => gaiia_site_id, + "assigneeType" => "INVENTORY_LOCATION" + }) + else + input + end + + case Gaiia.Mutations.start_add_inventory_items_job(client, %{"input" => input}) do + {:ok, job_id} when is_binary(job_id) -> + Logger.info("Started Gaiia inventory item creation job #{job_id} for device #{device.name}") + {:ok, job_id} + + {:ok, result} -> + Logger.info("Started Gaiia inventory item creation for device #{device.name}: #{inspect(result)}") + {:ok, result} + + {:error, reason} -> + Logger.error("Failed to create Gaiia inventory item for device #{device.name}: #{inspect(reason)}") + {:error, reason} + end + end + + defp build_item_fields(_device, nil), do: [] + + defp build_item_fields(device, mac_field) do + mac = find_device_mac(device) + + if mac do + [%{"modelFieldId" => mac_field["id"], "data" => mac}] + else + [] + end + end + + defp find_device_mac(device) do + Repo.one( + from(s in Towerops.Snmp.Interface, + join: sd in SnmpDevice, + on: s.snmp_device_id == sd.id, + where: sd.device_id == ^device.id and not is_nil(s.mac_address) and s.mac_address != "", + select: s.mac_address, + limit: 1 + ) + ) + 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 c68876ce..bf79a454 100644 --- a/lib/towerops_web/live/org/gaiia_reconciliation_live.ex +++ b/lib/towerops_web/live/org/gaiia_reconciliation_live.ex @@ -3,6 +3,7 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do use ToweropsWeb, :live_view alias Towerops.Gaiia + alias Towerops.Gaiia.InventoryCreator alias Towerops.Gaiia.Reconciliation @impl true @@ -35,6 +36,22 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do |> load_reconciliation()} end + def handle_event("create_gaiia_item", %{"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} + end + + {:noreply, + socket + |> put_flash(:info, create_flash(result)) + |> load_reconciliation()} + end + def handle_event("select_tab", %{"tab" => tab}, socket) do org = socket.assigns.organization @@ -66,6 +83,36 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do ) 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, 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.") + 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 load_reconciliation(socket) do report = Reconciliation.reconcile(socket.assigns.organization.id) assign(socket, :report, report) 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 a0484138..c4ab9e7c 100644 --- a/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex +++ b/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex @@ -158,6 +158,9 @@ {t("Site")} + + {t("Actions")} + @@ -179,6 +182,15 @@ {entry.device.site.name} <% end %> + + + <% end %>