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:
parent
1f9fe2ee29
commit
3267bb134f
3 changed files with 268 additions and 0 deletions
209
lib/towerops/gaiia/inventory_creator.ex
Normal file
209
lib/towerops/gaiia/inventory_creator.ex
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue