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
241 lines
6.4 KiB
Elixir
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
|