feat(gaiia): one-click auto-match untracked devices by IP
Adds `Gaiia.auto_match_untracked/1` which finds unmapped Gaiia inventory items whose IP matches an untracked Towerops device and links them. Button on the reconciliation page's Untracked tab runs the match inline and shows a flash with matched/remaining counts.
This commit is contained in:
parent
6f46915eeb
commit
80837b8b26
3 changed files with 101 additions and 0 deletions
|
|
@ -117,6 +117,68 @@ defmodule Towerops.Gaiia do
|
|||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Auto-match untracked devices to unmapped Gaiia inventory items by IP address.
|
||||
|
||||
For each untracked device (no linked inventory item), looks for an unmapped
|
||||
inventory item with the same IP. If found, links them by setting `device_id`
|
||||
on the inventory item.
|
||||
|
||||
Returns `{:ok, matched_count, remaining_count}`.
|
||||
"""
|
||||
def auto_match_untracked(organization_id) do
|
||||
# Unmapped inventory items with an IP address, indexed by IP
|
||||
unmapped_by_ip =
|
||||
InventoryItem
|
||||
|> where(organization_id: ^organization_id)
|
||||
|> where([i], is_nil(i.device_id))
|
||||
|> where([i], not is_nil(i.ip_address) and i.ip_address != "")
|
||||
|> Repo.all()
|
||||
|> Enum.group_by(& &1.ip_address)
|
||||
|
||||
# Devices with no linked inventory item
|
||||
mapped_device_ids =
|
||||
InventoryItem
|
||||
|> where(organization_id: ^organization_id)
|
||||
|> where([i], not is_nil(i.device_id))
|
||||
|> select([i], i.device_id)
|
||||
|> Repo.all()
|
||||
|> MapSet.new()
|
||||
|
||||
untracked =
|
||||
Device
|
||||
|> where(organization_id: ^organization_id)
|
||||
|> where([d], not is_nil(d.ip_address))
|
||||
|> Repo.all()
|
||||
|> Enum.reject(fn d -> MapSet.member?(mapped_device_ids, d.id) end)
|
||||
|
||||
{matched, _remaining} =
|
||||
Enum.reduce(untracked, {0, []}, fn device, acc ->
|
||||
try_match_device(device, unmapped_by_ip, acc)
|
||||
end)
|
||||
|
||||
{:ok, matched, length(untracked) - matched}
|
||||
end
|
||||
|
||||
defp towerops_ip_string(nil), do: nil
|
||||
defp towerops_ip_string(%{address: addr}) when is_tuple(addr), do: addr |> :inet.ntoa() |> to_string()
|
||||
defp towerops_ip_string(ip) when is_binary(ip), do: ip
|
||||
|
||||
defp try_match_device(device, unmapped_by_ip, {count, unmatched}) do
|
||||
device_ip = towerops_ip_string(device.ip_address)
|
||||
|
||||
case Map.get(unmapped_by_ip, device_ip) do
|
||||
[item | _rest] ->
|
||||
case update_inventory_item_mapping(item, %{device_id: device.id}) do
|
||||
{:ok, _} -> {count + 1, unmatched}
|
||||
{:error, _} -> {count, [device | unmatched]}
|
||||
end
|
||||
|
||||
nil ->
|
||||
{count, [device | unmatched]}
|
||||
end
|
||||
end
|
||||
|
||||
def search_inventory_items(organization_id, query) do
|
||||
search = "%#{Towerops.QueryHelpers.sanitize_like(query)}%"
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
|
|||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Gaiia
|
||||
alias Towerops.Gaiia.Reconciliation
|
||||
|
||||
@impl true
|
||||
|
|
@ -25,6 +26,32 @@ defmodule ToweropsWeb.Org.GaiiaReconciliationLive do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("auto_match", _params, socket) do
|
||||
org_id = socket.assigns.organization.id
|
||||
|
||||
case Gaiia.auto_match_untracked(org_id) do
|
||||
{:ok, 0, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, t("No IP matches found. Link devices manually on the mapping page."))
|
||||
|> load_reconciliation()}
|
||||
|
||||
{:ok, matched, remaining} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(
|
||||
:info,
|
||||
"Auto-matched %{matched} device by IP. %{remaining} remaining."
|
||||
|> ngettext(
|
||||
"Auto-matched %{matched} devices by IP. %{remaining} remaining.",
|
||||
matched
|
||||
)
|
||||
|> then(&Gettext.dngettext(ToweropsWeb.Gettext, "", &1, &1, matched: matched, remaining: remaining))
|
||||
)
|
||||
|> load_reconciliation()}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("select_tab", %{"tab" => tab}, socket) do
|
||||
org = socket.assigns.organization
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,18 @@
|
|||
{t("All Towerops devices have matching Gaiia inventory items.")}
|
||||
</p>
|
||||
<% else %>
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<button
|
||||
phx-click="auto_match"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
|
||||
>
|
||||
<.icon name="hero-bolt" class="h-4 w-4" />
|
||||
{t("Auto-Match by IP")}
|
||||
</button>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Links devices to Gaiia items with matching IP addresses.")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue