towerops/lib/towerops_web/live/org/gaiia_mapping_live.ex
Graham McIntire 3cd2303b6f
add gaiia integration stages 1-2: sync engine and entity mapping UI
Stage 1 - Foundation:
- GraphQL client with Relay cursor pagination and rate limiting
- 4 Ecto schemas: accounts, network sites, inventory items, billing subscriptions
- Gaiia context with upsert operations preserving user mappings
- Sync module orchestrating full organization sync
- Oban cron worker running every 15 minutes
- Migration creating 4 tables with org-scoped unique constraints

Stage 2 - Entity Mapping:
- GaiiaMappingLive with two tabs (Network Sites / Inventory Items)
- Match suggestion engine (IP/name matching with confidence levels)
- Inline search and link/unlink UI following Preseem devices pattern
- Filter by all/mapped/unmapped with URL-driven state
- Amber suggestion badges for potential matches
2026-02-13 10:31:32 -06:00

241 lines
6.4 KiB
Elixir

defmodule ToweropsWeb.Org.GaiiaMappingLive do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Devices
alias Towerops.Gaiia
alias Towerops.Repo
alias Towerops.Sites
@impl true
def mount(_params, _session, socket) do
org = socket.assigns.current_scope.organization
{:ok,
socket
|> assign(:organization, org)
|> assign(:tab, "sites")
|> assign(:filter, "all")
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])
|> load_data()}
end
@impl true
def handle_params(params, _url, socket) do
tab =
case params["tab"] do
t when t in ~w(sites devices) -> t
_ -> "sites"
end
filter =
case params["filter"] do
f when f in ~w(all mapped unmapped) -> f
_ -> "all"
end
{:noreply,
socket
|> assign(:tab, tab)
|> assign(:filter, filter)
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])
|> load_data()}
end
@impl true
def handle_event("start_link", %{"id" => id}, socket) do
{:noreply,
socket
|> assign(:linking_id, id)
|> assign(:search_query, "")
|> assign(:search_results, [])}
end
@impl true
def handle_event("cancel_link", _params, socket) do
{:noreply,
socket
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])}
end
@impl true
def handle_event("search", %{"query" => query}, socket) do
org = socket.assigns.organization
results =
if String.length(query) >= 2 do
case socket.assigns.tab do
"sites" -> search_sites(org.id, query)
"devices" -> Devices.search_devices(org.id, query)
end
else
[]
end
{:noreply, socket |> assign(:search_query, query) |> assign(:search_results, results)}
end
@impl true
def handle_event("link_site", %{"gaiia-id" => gaiia_id, "site-id" => site_id}, socket) do
org = socket.assigns.organization
case Gaiia.get_network_site(org.id, gaiia_id) do
nil ->
{:noreply, put_flash(socket, :error, "Gaiia network site not found")}
network_site ->
case Gaiia.update_network_site_mapping(network_site, %{site_id: site_id}) do
{:ok, _} ->
{:noreply,
socket
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])
|> load_data()
|> put_flash(:info, "Site linked successfully")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to link site")}
end
end
end
@impl true
def handle_event("unlink_site", %{"gaiia-id" => gaiia_id}, socket) do
org = socket.assigns.organization
case Gaiia.get_network_site(org.id, gaiia_id) do
nil ->
{:noreply, put_flash(socket, :error, "Gaiia network site not found")}
network_site ->
case Gaiia.update_network_site_mapping(network_site, %{site_id: nil}) do
{:ok, _} ->
{:noreply,
socket
|> load_data()
|> put_flash(:info, "Site unlinked")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to unlink site")}
end
end
end
@impl true
def handle_event("link_device", %{"gaiia-id" => gaiia_id, "device-id" => device_id}, socket) do
org = socket.assigns.organization
case Gaiia.get_inventory_item(org.id, gaiia_id) do
nil ->
{:noreply, put_flash(socket, :error, "Gaiia inventory item not found")}
item ->
case Gaiia.update_inventory_item_mapping(item, %{device_id: device_id}) do
{:ok, _} ->
{:noreply,
socket
|> assign(:linking_id, nil)
|> assign(:search_query, "")
|> assign(:search_results, [])
|> load_data()
|> put_flash(:info, "Device linked successfully")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to link device")}
end
end
end
@impl true
def handle_event("unlink_device", %{"gaiia-id" => gaiia_id}, socket) do
org = socket.assigns.organization
case Gaiia.get_inventory_item(org.id, gaiia_id) do
nil ->
{:noreply, put_flash(socket, :error, "Gaiia inventory item not found")}
item ->
case Gaiia.update_inventory_item_mapping(item, %{device_id: nil}) do
{:ok, _} ->
{:noreply,
socket
|> load_data()
|> put_flash(:info, "Device unlinked")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to unlink device")}
end
end
end
defp load_data(socket) do
org_id = socket.assigns.organization.id
tab = socket.assigns.tab
filter = socket.assigns.filter
case tab do
"sites" -> load_network_sites(socket, org_id, filter)
"devices" -> load_inventory_items(socket, org_id, filter)
end
end
defp load_network_sites(socket, org_id, filter) do
sites =
org_id
|> Gaiia.list_network_sites()
|> Repo.preload(:site)
|> filter_by_mapping(filter)
suggestions =
Enum.reduce(sites, %{}, fn site, acc ->
matches = Gaiia.suggest_site_matches(org_id, site)
if matches == [], do: acc, else: Map.put(acc, site.gaiia_id, matches)
end)
socket
|> assign(:items, sites)
|> assign(:suggestions, suggestions)
end
defp load_inventory_items(socket, org_id, filter) do
items =
org_id
|> Gaiia.list_inventory_items()
|> Repo.preload(:device)
|> filter_by_mapping(filter)
suggestions =
Enum.reduce(items, %{}, fn item, acc ->
matches = Gaiia.suggest_device_matches(org_id, item)
if matches == [], do: acc, else: Map.put(acc, item.gaiia_id, matches)
end)
socket
|> assign(:items, items)
|> assign(:suggestions, suggestions)
end
defp filter_by_mapping(items, "mapped") do
Enum.filter(items, &mapped?/1)
end
defp filter_by_mapping(items, "unmapped") do
Enum.reject(items, &mapped?/1)
end
defp filter_by_mapping(items, _), do: items
defp mapped?(%Gaiia.NetworkSite{site_id: site_id}), do: not is_nil(site_id)
defp mapped?(%Gaiia.InventoryItem{device_id: device_id}), do: not is_nil(device_id)
defp search_sites(organization_id, query) do
Sites.search_sites(organization_id, query)
end
end