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.
This commit is contained in:
Graham McIntire 2026-05-11 14:58:37 -05:00
parent 1f9fe2ee29
commit 3267bb134f
3 changed files with 268 additions and 0 deletions

View file

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

View file

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

View file

@ -158,6 +158,9 @@
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Site")}
</th>
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Actions")}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/5 dark:bg-transparent">
@ -179,6 +182,15 @@
{entry.device.site.name}
<% end %>
</td>
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
<button
phx-click="create_gaiia_item"
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>
</td>
</tr>
<% end %>
</tbody>