improve Gaiia integration UX
- Make insight messages more descriptive (explain what untracked/ghost/mismatch means) - Clarify action button labels on insight cards - Redesign Gaiia mapping page to be TowerOps-centric: devices/sites as primary rows, search Gaiia for what to link them to - Add IP-based match suggestions and smart sorting (suggestions first, then unlinked, then linked) - Add search_inventory_items/2 and search_network_sites/2 to Gaiia context - Make IP addresses clickable links (http://<ip>) - Add optional Gaiia App URL setting to enable deep links to inventory items and network sites
This commit is contained in:
parent
1b8fb606a5
commit
58181923f8
8 changed files with 565 additions and 251 deletions
|
|
@ -116,6 +116,28 @@ defmodule Towerops.Gaiia do
|
|||
|> Repo.update()
|
||||
end
|
||||
|
||||
def search_inventory_items(organization_id, query) do
|
||||
search = "%#{query}%"
|
||||
|
||||
InventoryItem
|
||||
|> where(organization_id: ^organization_id)
|
||||
|> where([i], ilike(i.name, ^search) or ilike(i.ip_address, ^search) or ilike(i.model_name, ^search))
|
||||
|> order_by(:name)
|
||||
|> limit(20)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def search_network_sites(organization_id, query) do
|
||||
search = "%#{query}%"
|
||||
|
||||
NetworkSite
|
||||
|> where(organization_id: ^organization_id)
|
||||
|> where([ns], ilike(ns.name, ^search))
|
||||
|> order_by(:name)
|
||||
|> limit(20)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
# --- Billing Subscriptions ---
|
||||
|
||||
def list_billing_subscriptions(organization_id) do
|
||||
|
|
|
|||
|
|
@ -77,9 +77,9 @@ defmodule Towerops.Workers.GaiiaInsightWorker do
|
|||
channel: "passive",
|
||||
source: "gaiia",
|
||||
dedup_key: "untracked",
|
||||
title: "#{count} #{ngettext("device", "devices", count)} not tracked in Gaiia",
|
||||
title: "#{count} monitored #{ngettext("device", "devices", count)} not linked to Gaiia inventory",
|
||||
description:
|
||||
"#{count} #{ngettext("device", "devices", count)} discovered by TowerOps #{ngettext("has", "have", count)} no matching Gaiia inventory item.",
|
||||
"TowerOps is actively monitoring #{count} #{ngettext("device", "devices", count)} that #{ngettext("has", "have", count)} no matching entry in Gaiia's inventory. Without this link, subscriber impact data cannot be correlated for outages affecting #{ngettext("this device", "these devices", count)}. You may need to add #{ngettext("it", "them", count)} to Gaiia or map to existing inventory items.",
|
||||
metadata: %{
|
||||
"dedup_key" => "untracked",
|
||||
"finding_type" => "untracked",
|
||||
|
|
@ -102,9 +102,9 @@ defmodule Towerops.Workers.GaiiaInsightWorker do
|
|||
channel: "proactive",
|
||||
source: "gaiia",
|
||||
dedup_key: "ghost",
|
||||
title: "#{count} Gaiia #{ngettext("item references", "items reference", count)} missing devices",
|
||||
title: "#{count} Gaiia inventory #{ngettext("item", "items", count)} linked to deleted devices",
|
||||
description:
|
||||
"#{count} Gaiia inventory #{ngettext("item is", "items are", count)} mapped to devices that no longer exist in TowerOps.",
|
||||
"#{count} Gaiia inventory #{ngettext("item is", "items are", count)} still mapped to #{ngettext("a device", "devices", count)} that no longer exist in TowerOps. These stale mappings should be cleaned up to keep your inventory accurate.",
|
||||
metadata: %{
|
||||
"dedup_key" => "ghost",
|
||||
"finding_type" => "ghost",
|
||||
|
|
@ -125,9 +125,9 @@ defmodule Towerops.Workers.GaiiaInsightWorker do
|
|||
channel: "passive",
|
||||
source: "gaiia",
|
||||
dedup_key: "mismatch",
|
||||
title: "#{count} data #{ngettext("mismatch", "mismatches", count)} between TowerOps and Gaiia",
|
||||
title: "#{count} #{ngettext("device has", "devices have", count)} conflicting data between TowerOps and Gaiia",
|
||||
description:
|
||||
"#{count} #{ngettext("device has", "devices have", count)} mismatched data between TowerOps and Gaiia inventory.",
|
||||
"#{count} #{ngettext("device has", "devices have", count)} fields like IP address that don't match between TowerOps and Gaiia. This may cause incorrect subscriber impact analysis or indicate the same device is tracked with different info in each system.",
|
||||
metadata: %{
|
||||
"dedup_key" => "mismatch",
|
||||
"finding_type" => "mismatch",
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@
|
|||
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
|
||||
<p class="text-xs font-medium text-blue-800 dark:text-blue-300 mb-2">
|
||||
<.icon name="hero-light-bulb" class="h-3.5 w-3.5 inline" />
|
||||
{t("Recommended actions")}
|
||||
{t("To fix this:")}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<.link
|
||||
|
|
@ -321,7 +321,7 @@
|
|||
class="inline-flex items-center gap-1 rounded-md bg-blue-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs hover:bg-blue-700"
|
||||
>
|
||||
<.icon name="hero-arrows-right-left" class="h-3.5 w-3.5" />
|
||||
{t("Map to Gaiia inventory")}
|
||||
{t("Link devices to Gaiia")}
|
||||
</.link>
|
||||
<.link
|
||||
navigate={
|
||||
|
|
@ -329,7 +329,8 @@
|
|||
}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/20"
|
||||
>
|
||||
<.icon name="hero-table-cells" class="h-3.5 w-3.5" /> View reconciliation
|
||||
<.icon name="hero-table-cells" class="h-3.5 w-3.5" />
|
||||
{t("View comparison report")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -339,7 +340,7 @@
|
|||
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20">
|
||||
<p class="text-xs font-medium text-amber-800 dark:text-amber-300 mb-2">
|
||||
<.icon name="hero-light-bulb" class="h-3.5 w-3.5 inline" />
|
||||
{t("Recommended actions")}
|
||||
{t("To fix this:")}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<.link
|
||||
|
|
@ -348,7 +349,8 @@
|
|||
}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-amber-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs hover:bg-amber-700"
|
||||
>
|
||||
<.icon name="hero-trash" class="h-3.5 w-3.5" /> Clean up stale mappings
|
||||
<.icon name="hero-trash" class="h-3.5 w-3.5" />
|
||||
{t("Remove stale device links")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -358,7 +360,7 @@
|
|||
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
|
||||
<p class="text-xs font-medium text-blue-800 dark:text-blue-300 mb-2">
|
||||
<.icon name="hero-light-bulb" class="h-3.5 w-3.5 inline" />
|
||||
{t("Recommended actions")}
|
||||
{t("To fix this:")}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<.link
|
||||
|
|
@ -367,7 +369,8 @@
|
|||
}
|
||||
class="inline-flex items-center gap-1 rounded-md bg-blue-600 px-2.5 py-1.5 text-xs font-medium text-white shadow-xs hover:bg-blue-700"
|
||||
>
|
||||
<.icon name="hero-table-cells" class="h-3.5 w-3.5" /> Review mismatches
|
||||
<.icon name="hero-table-cells" class="h-3.5 w-3.5" />
|
||||
{t("Review conflicting data")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
|
|||
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Gaiia
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Sites
|
||||
|
||||
|
|
@ -11,11 +12,24 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
|
|||
def mount(_params, _session, socket) do
|
||||
org = socket.assigns.current_scope.organization
|
||||
|
||||
gaiia_app_url =
|
||||
case Integrations.get_integration(org.id, "gaiia") do
|
||||
{:ok, integration} ->
|
||||
case integration.credentials["app_url"] do
|
||||
url when is_binary(url) and url != "" -> String.trim_trailing(url, "/")
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
{:error, _} ->
|
||||
nil
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, t("Gaiia Entity Mapping"))
|
||||
|> assign(:page_title, t("Link Devices & Sites to Gaiia"))
|
||||
|> assign(:organization, org)
|
||||
|> assign(:tab, "sites")
|
||||
|> assign(:gaiia_app_url, gaiia_app_url)
|
||||
|> assign(:tab, "devices")
|
||||
|> assign(:filter, "all")
|
||||
|> assign(:linking_id, nil)
|
||||
|> assign(:search_query, "")
|
||||
|
|
@ -28,12 +42,12 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
|
|||
tab =
|
||||
case params["tab"] do
|
||||
t when t in ~w(sites devices) -> t
|
||||
_ -> "sites"
|
||||
_ -> "devices"
|
||||
end
|
||||
|
||||
filter =
|
||||
case params["filter"] do
|
||||
f when f in ~w(all mapped unmapped) -> f
|
||||
f when f in ~w(all linked unlinked) -> f
|
||||
_ -> "all"
|
||||
end
|
||||
|
||||
|
|
@ -72,8 +86,8 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
|
|||
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)
|
||||
"sites" -> Gaiia.search_network_sites(org.id, query)
|
||||
"devices" -> Gaiia.search_inventory_items(org.id, query)
|
||||
end
|
||||
else
|
||||
[]
|
||||
|
|
@ -83,54 +97,7 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
|
|||
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, t("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, t("Site linked successfully"))}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, t("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, t("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, t("Site unlinked"))}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, t("Failed to unlink site"))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("link_device", %{"gaiia-id" => gaiia_id, "device-id" => device_id}, socket) do
|
||||
def handle_event("link_device", %{"device-id" => device_id, "gaiia-id" => gaiia_id}, socket) do
|
||||
org = socket.assigns.organization
|
||||
|
||||
case Gaiia.get_inventory_item(org.id, gaiia_id) do
|
||||
|
|
@ -146,7 +113,7 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
|
|||
|> assign(:search_query, "")
|
||||
|> assign(:search_results, [])
|
||||
|> load_data()
|
||||
|> put_flash(:info, t("Device linked successfully"))}
|
||||
|> put_flash(:info, t("Device linked to Gaiia inventory"))}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, t("Failed to link device"))}
|
||||
|
|
@ -155,12 +122,10 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
|
|||
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
|
||||
def handle_event("unlink_device", %{"device-id" => device_id}, socket) do
|
||||
case Gaiia.get_inventory_item_for_device(device_id) do
|
||||
nil ->
|
||||
{:noreply, put_flash(socket, :error, t("Gaiia inventory item not found"))}
|
||||
{:noreply, put_flash(socket, :error, t("No linked Gaiia item found"))}
|
||||
|
||||
item ->
|
||||
case Gaiia.update_inventory_item_mapping(item, %{device_id: nil}) do
|
||||
|
|
@ -168,7 +133,7 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
|
|||
{:noreply,
|
||||
socket
|
||||
|> load_data()
|
||||
|> put_flash(:info, t("Device unlinked"))}
|
||||
|> put_flash(:info, t("Device unlinked from Gaiia"))}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, t("Failed to unlink device"))}
|
||||
|
|
@ -176,73 +141,181 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("link_site", %{"site-id" => site_id, "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, t("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, t("Site linked to Gaiia"))}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, t("Failed to link site"))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unlink_site", %{"site-id" => site_id}, socket) do
|
||||
case Gaiia.get_network_site_for_site(site_id) do
|
||||
nil ->
|
||||
{:noreply, put_flash(socket, :error, t("No linked Gaiia site found"))}
|
||||
|
||||
network_site ->
|
||||
case Gaiia.update_network_site_mapping(network_site, %{site_id: nil}) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> load_data()
|
||||
|> put_flash(:info, t("Site unlinked from Gaiia"))}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, t("Failed to unlink site"))}
|
||||
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)
|
||||
"sites" -> load_sites_with_gaiia(socket, org_id, filter)
|
||||
"devices" -> load_devices_with_gaiia(socket, org_id, filter)
|
||||
end
|
||||
end
|
||||
|
||||
defp load_network_sites(socket, org_id, filter) do
|
||||
sites =
|
||||
defp load_devices_with_gaiia(socket, org_id, filter) do
|
||||
devices =
|
||||
org_id
|
||||
|> Gaiia.list_network_sites()
|
||||
|> Devices.list_organization_devices()
|
||||
|> 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)
|
||||
inventory_items = Gaiia.list_inventory_items(org_id)
|
||||
|
||||
item_by_device =
|
||||
inventory_items
|
||||
|> Enum.filter(&(not is_nil(&1.device_id)))
|
||||
|> Map.new(&{&1.device_id, &1})
|
||||
|
||||
rows =
|
||||
Enum.map(devices, fn device ->
|
||||
%{device: device, inventory_item: Map.get(item_by_device, device.id)}
|
||||
end)
|
||||
|
||||
# Sort items with suggestions first
|
||||
sorted_sites = Enum.sort_by(sites, fn site -> if Map.has_key?(suggestions, site.gaiia_id), do: 0, else: 1 end)
|
||||
filtered = apply_device_filter(rows, filter)
|
||||
|
||||
unmapped_items_by_ip =
|
||||
inventory_items
|
||||
|> Enum.filter(&(is_nil(&1.device_id) and &1.ip_address not in [nil, ""]))
|
||||
|> Enum.group_by(& &1.ip_address)
|
||||
|
||||
suggestions =
|
||||
Enum.reduce(filtered, %{}, fn row, acc ->
|
||||
add_device_suggestion(acc, row, unmapped_items_by_ip)
|
||||
end)
|
||||
|
||||
sorted =
|
||||
Enum.sort_by(filtered, fn row ->
|
||||
cond do
|
||||
Map.has_key?(suggestions, row.device.id) -> 0
|
||||
is_nil(row.inventory_item) -> 1
|
||||
true -> 2
|
||||
end
|
||||
end)
|
||||
|
||||
socket
|
||||
|> assign(:items, sorted_sites)
|
||||
|> assign(:items, sorted)
|
||||
|> 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)
|
||||
defp load_sites_with_gaiia(socket, org_id, filter) do
|
||||
sites = Sites.list_organization_sites(org_id)
|
||||
network_sites = Gaiia.list_network_sites(org_id)
|
||||
|
||||
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)
|
||||
ns_by_site =
|
||||
network_sites
|
||||
|> Enum.filter(&(not is_nil(&1.site_id)))
|
||||
|> Map.new(&{&1.site_id, &1})
|
||||
|
||||
rows =
|
||||
Enum.map(sites, fn site ->
|
||||
%{site: site, network_site: Map.get(ns_by_site, site.id)}
|
||||
end)
|
||||
|
||||
# Sort items with suggestions first
|
||||
sorted_items = Enum.sort_by(items, fn item -> if Map.has_key?(suggestions, item.gaiia_id), do: 0, else: 1 end)
|
||||
filtered = apply_site_filter(rows, filter)
|
||||
|
||||
unmapped_ns = Enum.filter(network_sites, &is_nil(&1.site_id))
|
||||
|
||||
suggestions =
|
||||
Enum.reduce(filtered, %{}, fn row, acc ->
|
||||
add_site_suggestion(acc, row, unmapped_ns)
|
||||
end)
|
||||
|
||||
sorted =
|
||||
Enum.sort_by(filtered, fn row ->
|
||||
cond do
|
||||
Map.has_key?(suggestions, row.site.id) -> 0
|
||||
is_nil(row.network_site) -> 1
|
||||
true -> 2
|
||||
end
|
||||
end)
|
||||
|
||||
socket
|
||||
|> assign(:items, sorted_items)
|
||||
|> assign(:items, sorted)
|
||||
|> assign(:suggestions, suggestions)
|
||||
end
|
||||
|
||||
defp filter_by_mapping(items, "mapped") do
|
||||
Enum.filter(items, &mapped?/1)
|
||||
defp add_device_suggestion(acc, %{inventory_item: item}, _) when not is_nil(item), do: acc
|
||||
|
||||
defp add_device_suggestion(acc, row, unmapped_items_by_ip) do
|
||||
device_ip = to_string(row.device.ip_address)
|
||||
matches = Map.get(unmapped_items_by_ip, device_ip, [])
|
||||
|
||||
if matches == [] do
|
||||
acc
|
||||
else
|
||||
Map.put(acc, row.device.id, Enum.map(matches, &%{entity: &1, match_type: "ip", confidence: :high}))
|
||||
end
|
||||
end
|
||||
|
||||
defp filter_by_mapping(items, "unmapped") do
|
||||
Enum.reject(items, &mapped?/1)
|
||||
defp add_site_suggestion(acc, %{network_site: ns}, _) when not is_nil(ns), do: acc
|
||||
defp add_site_suggestion(acc, %{site: %{name: name}}, _) when name in [nil, ""], do: acc
|
||||
|
||||
defp add_site_suggestion(acc, row, unmapped_ns) do
|
||||
site_name_lower = String.downcase(row.site.name)
|
||||
|
||||
matches =
|
||||
Enum.filter(unmapped_ns, fn ns ->
|
||||
ns.name &&
|
||||
(String.contains?(String.downcase(ns.name), site_name_lower) or
|
||||
String.contains?(site_name_lower, String.downcase(ns.name)))
|
||||
end)
|
||||
|
||||
if matches == [] do
|
||||
acc
|
||||
else
|
||||
Map.put(acc, row.site.id, Enum.map(matches, &%{entity: &1, match_type: "name", confidence: :medium}))
|
||||
end
|
||||
end
|
||||
|
||||
defp filter_by_mapping(items, _), do: items
|
||||
defp apply_device_filter(rows, "linked"), do: Enum.filter(rows, &(not is_nil(&1.inventory_item)))
|
||||
defp apply_device_filter(rows, "unlinked"), do: Enum.filter(rows, &is_nil(&1.inventory_item))
|
||||
defp apply_device_filter(rows, _), do: rows
|
||||
|
||||
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
|
||||
defp apply_site_filter(rows, "linked"), do: Enum.filter(rows, &(not is_nil(&1.network_site)))
|
||||
defp apply_site_filter(rows, "unlinked"), do: Enum.filter(rows, &is_nil(&1.network_site))
|
||||
defp apply_site_filter(rows, _), do: rows
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@
|
|||
</.link>
|
||||
</div>
|
||||
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||
{t("Gaiia Entity Mapping")}
|
||||
{t("Link Devices & Sites to Gaiia")}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
"Link Gaiia entities to your Towerops sites and devices. Match suggestions are shown in amber."
|
||||
"For each of your monitored devices and sites, find the matching Gaiia inventory item. This enables subscriber impact data when devices go down."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -28,13 +28,13 @@
|
|||
navigate={~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"}
|
||||
class="border-b-2 border-indigo-500 px-1 pb-3 text-sm font-medium text-indigo-600 dark:text-indigo-400"
|
||||
>
|
||||
{t("Entity Mapping")}
|
||||
{t("Device & Site Linking")}
|
||||
</.link>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation"}
|
||||
class="border-b-2 border-transparent px-1 pb-3 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-white"
|
||||
>
|
||||
{t("Reconciliation")}
|
||||
{t("Comparison Report")}
|
||||
</.link>
|
||||
</nav>
|
||||
</div>
|
||||
|
|
@ -42,22 +42,6 @@
|
|||
<%!-- Tab navigation --%>
|
||||
<div class="mt-6 border-b border-gray-200 dark:border-white/10">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<.link
|
||||
id="tab-sites"
|
||||
patch={
|
||||
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping?tab=sites&filter=#{@filter}"
|
||||
}
|
||||
class={[
|
||||
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium",
|
||||
if(@tab == "sites",
|
||||
do: "border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400",
|
||||
else:
|
||||
"border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300"
|
||||
)
|
||||
]}
|
||||
>
|
||||
{t("Network Sites")}
|
||||
</.link>
|
||||
<.link
|
||||
id="tab-devices"
|
||||
patch={
|
||||
|
|
@ -72,15 +56,31 @@
|
|||
)
|
||||
]}
|
||||
>
|
||||
{t("Inventory Items")}
|
||||
{t("Devices")}
|
||||
</.link>
|
||||
<.link
|
||||
id="tab-sites"
|
||||
patch={
|
||||
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping?tab=sites&filter=#{@filter}"
|
||||
}
|
||||
class={[
|
||||
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium",
|
||||
if(@tab == "sites",
|
||||
do: "border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400",
|
||||
else:
|
||||
"border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300"
|
||||
)
|
||||
]}
|
||||
>
|
||||
{t("Sites")}
|
||||
</.link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<%!-- Filter tabs --%>
|
||||
<%!-- Filter pills --%>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<.link
|
||||
:for={f <- ~w(all mapped unmapped)}
|
||||
:for={{f, label} <- [{"all", "All"}, {"linked", "Linked"}, {"unlinked", "Unlinked"}]}
|
||||
patch={
|
||||
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping?tab=#{@tab}&filter=#{f}"
|
||||
}
|
||||
|
|
@ -93,7 +93,7 @@
|
|||
)
|
||||
]}
|
||||
>
|
||||
{String.capitalize(f)}
|
||||
{label}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
|
|
@ -109,22 +109,29 @@
|
|||
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<%= if @tab == "sites" do %>
|
||||
{t("No network sites found")}
|
||||
<% else %>
|
||||
{t("No inventory items found")}
|
||||
<%= case {@tab, @filter} do %>
|
||||
<% {"devices", "linked"} -> %>
|
||||
{t("No linked devices yet")}
|
||||
<% {"devices", "unlinked"} -> %>
|
||||
{t("All devices are linked to Gaiia")}
|
||||
<% {"sites", "linked"} -> %>
|
||||
{t("No linked sites yet")}
|
||||
<% {"sites", "unlinked"} -> %>
|
||||
{t("All sites are linked to Gaiia")}
|
||||
<% {"devices", _} -> %>
|
||||
{t("No devices in your organization")}
|
||||
<% _ -> %>
|
||||
{t("No sites in your organization")}
|
||||
<% end %>
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= case @filter do %>
|
||||
<% "mapped" -> %>
|
||||
{t("No mapped entities yet. Sync from Gaiia and link entities below.")}
|
||||
<% "unmapped" -> %>
|
||||
{t("All entities are mapped.")}
|
||||
<%= case {@tab, @filter} do %>
|
||||
<% {_, "unlinked"} -> %>
|
||||
{t("Nothing left to do here.")}
|
||||
<% {_, "linked"} -> %>
|
||||
{t("Link devices or sites using the Unlinked filter.")}
|
||||
<% _ -> %>
|
||||
{t(
|
||||
"No Gaiia data has been synced yet. Enable the Gaiia integration and run a sync."
|
||||
)}
|
||||
{t("Add devices to TowerOps to get started.")}
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -137,13 +144,7 @@
|
|||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Name")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 sm:table-cell"
|
||||
>
|
||||
{t("Gaiia ID")}
|
||||
{if @tab == "devices", do: t("Your Device"), else: t("Your Site")}
|
||||
</th>
|
||||
<%= if @tab == "devices" do %>
|
||||
<th
|
||||
|
|
@ -157,7 +158,9 @@
|
|||
scope="col"
|
||||
class="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 md:table-cell"
|
||||
>
|
||||
{if @tab == "sites", do: "Linked Site", else: "Linked Device"}
|
||||
{if @tab == "devices",
|
||||
do: t("Gaiia Inventory Item"),
|
||||
else: t("Gaiia Network Site")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
|
|
@ -168,80 +171,159 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/5 dark:bg-transparent">
|
||||
<%= for item <- @items do %>
|
||||
<%= for row <- @items do %>
|
||||
<% row_id = if @tab == "devices", do: row.device.id, else: row.site.id
|
||||
|
||||
is_linked =
|
||||
if @tab == "devices",
|
||||
do: not is_nil(row.inventory_item),
|
||||
else: not is_nil(row.network_site) %>
|
||||
<tr
|
||||
id={"row-#{item.id}"}
|
||||
id={"row-#{row_id}"}
|
||||
class={[
|
||||
"hover:bg-gray-50 dark:hover:bg-white/5",
|
||||
if(
|
||||
(@tab == "sites" and is_nil(item.site_id)) or
|
||||
(@tab == "devices" and is_nil(item.device_id)),
|
||||
do: "cursor-pointer"
|
||||
)
|
||||
if(not is_linked, do: "cursor-pointer")
|
||||
]}
|
||||
phx-click={
|
||||
if(
|
||||
(@tab == "sites" and is_nil(item.site_id)) or
|
||||
(@tab == "devices" and is_nil(item.device_id)),
|
||||
do: "start_link"
|
||||
)
|
||||
}
|
||||
phx-value-id={
|
||||
if(
|
||||
(@tab == "sites" and is_nil(item.site_id)) or
|
||||
(@tab == "devices" and is_nil(item.device_id)),
|
||||
do: item.gaiia_id
|
||||
)
|
||||
}
|
||||
phx-click={if(not is_linked, do: "start_link")}
|
||||
phx-value-id={if(not is_linked, do: row_id)}
|
||||
>
|
||||
<td class="px-4 py-4 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<%!-- Device / Site column --%>
|
||||
<td class="px-4 py-4 text-sm">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="break-all">{item.name || "Unnamed"}</span>
|
||||
<%= if Map.has_key?(@suggestions, item.gaiia_id) do %>
|
||||
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
<.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" /> Suggestion
|
||||
</span>
|
||||
<%= if @tab == "devices" do %>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<.link
|
||||
navigate={~p"/devices/#{row.device.id}"}
|
||||
class="font-medium text-gray-900 hover:text-indigo-600 dark:text-white dark:hover:text-indigo-400"
|
||||
>
|
||||
{row.device.name || row.device.ip_address || "Unnamed"}
|
||||
</.link>
|
||||
<%= if Map.has_key?(@suggestions, row.device.id) do %>
|
||||
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
<.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" />
|
||||
Match found
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= if row.device.site do %>
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{row.device.site.name}
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{row.site.name || "Unnamed"}
|
||||
</span>
|
||||
<%= if Map.has_key?(@suggestions, row.site.id) do %>
|
||||
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
<.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" />
|
||||
Match found
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<span class="block text-xs text-gray-400 dark:text-gray-500 sm:hidden">
|
||||
{item.gaiia_id}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="hidden whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400 sm:table-cell">
|
||||
{item.gaiia_id}
|
||||
</td>
|
||||
|
||||
<%!-- IP Address (devices only) --%>
|
||||
<%= if @tab == "devices" do %>
|
||||
<td class="hidden whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400 sm:table-cell">
|
||||
{item.ip_address || "-"}
|
||||
<%= if row.device.ip_address do %>
|
||||
<a
|
||||
href={"http://#{row.device.ip_address}"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-mono hover:text-indigo-600 dark:hover:text-indigo-400"
|
||||
onclick="event.stopPropagation()"
|
||||
>
|
||||
{row.device.ip_address}
|
||||
</a>
|
||||
<% else %>
|
||||
—
|
||||
<% end %>
|
||||
</td>
|
||||
<% end %>
|
||||
<td class="hidden whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400 md:table-cell">
|
||||
<%= if @tab == "sites" and item.site do %>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{item.site.name}
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if @tab == "devices" and item.device do %>
|
||||
<.link
|
||||
navigate={~p"/devices/#{item.device.id}"}
|
||||
class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
{item.device.name || item.device.ip_address}
|
||||
</.link>
|
||||
<% end %>
|
||||
<%= if (@tab == "sites" and is_nil(item.site_id)) or (@tab == "devices" and is_nil(item.device_id)) do %>
|
||||
<span class="text-gray-400 dark:text-gray-500">-</span>
|
||||
|
||||
<%!-- Gaiia Item / Network Site column --%>
|
||||
<td class="hidden px-4 py-4 text-sm md:table-cell">
|
||||
<%= if @tab == "devices" do %>
|
||||
<%= if row.inventory_item do %>
|
||||
<div>
|
||||
<%= if @gaiia_app_url do %>
|
||||
<a
|
||||
href={"#{@gaiia_app_url}/inventory/items/#{row.inventory_item.gaiia_id}"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-medium text-gray-900 hover:text-indigo-600 dark:text-white dark:hover:text-indigo-400"
|
||||
>
|
||||
{row.inventory_item.name || "Unnamed"}
|
||||
<.icon
|
||||
name="hero-arrow-top-right-on-square"
|
||||
class="inline h-3 w-3 ml-0.5 opacity-50"
|
||||
/>
|
||||
</a>
|
||||
<% else %>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{row.inventory_item.name || "Unnamed"}
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if row.inventory_item.model_name do %>
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{row.inventory_item.model_name}
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-500 dark:bg-white/10 dark:text-gray-400">
|
||||
Not linked
|
||||
</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= if row.network_site do %>
|
||||
<%= if @gaiia_app_url do %>
|
||||
<a
|
||||
href={"#{@gaiia_app_url}/network-sites/#{row.network_site.gaiia_id}"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-medium text-gray-900 hover:text-indigo-600 dark:text-white dark:hover:text-indigo-400"
|
||||
onclick="event.stopPropagation()"
|
||||
>
|
||||
{row.network_site.name || "Unnamed"}
|
||||
<.icon
|
||||
name="hero-arrow-top-right-on-square"
|
||||
class="inline h-3 w-3 ml-0.5 opacity-50"
|
||||
/>
|
||||
</a>
|
||||
<% else %>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{row.network_site.name || "Unnamed"}
|
||||
</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-500 dark:bg-white/10 dark:text-gray-400">
|
||||
Not linked
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</td>
|
||||
|
||||
<%!-- Actions column --%>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-right text-sm">
|
||||
<%= if @tab == "sites" do %>
|
||||
<%= if item.site_id do %>
|
||||
<%= if @tab == "devices" do %>
|
||||
<%= if row.inventory_item do %>
|
||||
<button
|
||||
type="button"
|
||||
id={"unlink-#{item.id}"}
|
||||
phx-click="unlink_site"
|
||||
phx-value-gaiia-id={item.gaiia_id}
|
||||
data-confirm={t("Unlink this site?")}
|
||||
id={"unlink-#{row.device.id}"}
|
||||
phx-click="unlink_device"
|
||||
phx-value-device-id={row.device.id}
|
||||
data-confirm={
|
||||
t("Remove the link between this device and its Gaiia item?")
|
||||
}
|
||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
{t("Unlink")}
|
||||
|
|
@ -249,22 +331,24 @@
|
|||
<% else %>
|
||||
<button
|
||||
type="button"
|
||||
id={"link-#{item.id}"}
|
||||
id={"link-#{row.device.id}"}
|
||||
phx-click="start_link"
|
||||
phx-value-id={item.gaiia_id}
|
||||
phx-value-id={row.device.id}
|
||||
class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
{t("Link")}
|
||||
</button>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= if item.device_id do %>
|
||||
<%= if row.network_site do %>
|
||||
<button
|
||||
type="button"
|
||||
id={"unlink-#{item.id}"}
|
||||
phx-click="unlink_device"
|
||||
phx-value-gaiia-id={item.gaiia_id}
|
||||
data-confirm={t("Unlink this device?")}
|
||||
id={"unlink-#{row.site.id}"}
|
||||
phx-click="unlink_site"
|
||||
phx-value-site-id={row.site.id}
|
||||
data-confirm={
|
||||
t("Remove the link between this site and its Gaiia network site?")
|
||||
}
|
||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
{t("Unlink")}
|
||||
|
|
@ -272,9 +356,9 @@
|
|||
<% else %>
|
||||
<button
|
||||
type="button"
|
||||
id={"link-#{item.id}"}
|
||||
id={"link-#{row.site.id}"}
|
||||
phx-click="start_link"
|
||||
phx-value-id={item.gaiia_id}
|
||||
phx-value-id={row.site.id}
|
||||
class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
{t("Link")}
|
||||
|
|
@ -283,24 +367,33 @@
|
|||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<%!-- Inline linking row --%>
|
||||
<%= if item.gaiia_id == @linking_id do %>
|
||||
<tr id={"linking-row-#{item.id}"} class="bg-indigo-50 dark:bg-indigo-900/20">
|
||||
<td colspan={if(@tab == "devices", do: "5", else: "4")} class="px-4 py-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<%= if row_id == @linking_id do %>
|
||||
<tr id={"linking-row-#{row_id}"} class="bg-indigo-50 dark:bg-indigo-900/20">
|
||||
<td colspan={if(@tab == "devices", do: "4", else: "3")} class="px-4 py-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<%= if @tab == "sites" do %>
|
||||
Search for a site to link to "{item.name}"
|
||||
<%= if @tab == "devices" do %>
|
||||
Find a Gaiia item to link to
|
||||
<span class="text-indigo-600 dark:text-indigo-400">
|
||||
{row.device.name || row.device.ip_address}
|
||||
</span>
|
||||
<% else %>
|
||||
Search for a device to link to "{item.name}"
|
||||
Find a Gaiia network site to link to
|
||||
<span class="text-indigo-600 dark:text-indigo-400">
|
||||
{row.site.name}
|
||||
</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<%!-- Show suggestions if available --%>
|
||||
<%= if suggestions = @suggestions[item.gaiia_id] do %>
|
||||
|
||||
<%!-- Suggestions --%>
|
||||
<%= if suggestions = @suggestions[row_id] do %>
|
||||
<div class="mb-3">
|
||||
<p class="mb-1 text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||
{t("Suggested matches:")}
|
||||
<.icon name="hero-light-bulb-mini" class="inline h-3 w-3 mr-0.5" />
|
||||
{t("Suggested match (same IP):")}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= for suggestion <- suggestions do %>
|
||||
|
|
@ -309,16 +402,17 @@
|
|||
phx-click={
|
||||
if(@tab == "sites", do: "link_site", else: "link_device")
|
||||
}
|
||||
phx-value-gaiia-id={item.gaiia_id}
|
||||
phx-value-site-id={
|
||||
if(@tab == "sites", do: suggestion.entity.id)
|
||||
}
|
||||
phx-value-device-id={
|
||||
if(@tab == "devices", do: suggestion.entity.id)
|
||||
}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-amber-300 bg-amber-50 px-2.5 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300 dark:hover:bg-amber-900/40"
|
||||
phx-value-device-id={if(@tab == "devices", do: row.device.id)}
|
||||
phx-value-site-id={if(@tab == "sites", do: row.site.id)}
|
||||
phx-value-gaiia-id={suggestion.entity.gaiia_id}
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2.5 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300 dark:hover:bg-amber-900/40"
|
||||
>
|
||||
{suggestion.entity.name}
|
||||
<%= if @tab == "devices" && suggestion.entity.ip_address do %>
|
||||
<span class="text-amber-600 dark:text-amber-400">
|
||||
{suggestion.entity.ip_address}
|
||||
</span>
|
||||
<% end %>
|
||||
<span class="rounded bg-amber-200 px-1 text-amber-700 dark:bg-amber-800 dark:text-amber-300">
|
||||
{suggestion.match_type}
|
||||
</span>
|
||||
|
|
@ -327,6 +421,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Search --%>
|
||||
<form id="search-form" phx-change="search" class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -335,8 +431,8 @@
|
|||
value={@search_query}
|
||||
placeholder={
|
||||
if(@tab == "sites",
|
||||
do: "Search by site name...",
|
||||
else: "Search by device name or IP..."
|
||||
do: "Search Gaiia network sites by name...",
|
||||
else: "Search Gaiia inventory by name, model, or IP..."
|
||||
)
|
||||
}
|
||||
autocomplete="off"
|
||||
|
|
@ -355,22 +451,20 @@
|
|||
phx-click={
|
||||
if(@tab == "sites", do: "link_site", else: "link_device")
|
||||
}
|
||||
phx-value-gaiia-id={item.gaiia_id}
|
||||
phx-value-site-id={if(@tab == "sites", do: result.id)}
|
||||
phx-value-device-id={if(@tab == "devices", do: result.id)}
|
||||
phx-value-device-id={if(@tab == "devices", do: @linking_id)}
|
||||
phx-value-site-id={if(@tab == "sites", do: @linking_id)}
|
||||
phx-value-gaiia-id={result.gaiia_id}
|
||||
class="flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-white/5"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{result.name || "Unnamed"}
|
||||
</span>
|
||||
<%= if @tab == "devices" do %>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{Map.get(result, :ip_address, "")}
|
||||
<%= if site = Map.get(result, :site) do %>
|
||||
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
{site.name}
|
||||
</span>
|
||||
<span class="flex items-center gap-2 text-gray-500 dark:text-gray-400">
|
||||
<%= if result.model_name do %>
|
||||
<span class="text-xs">{result.model_name}</span>
|
||||
<% end %>
|
||||
{result.ip_address}
|
||||
</span>
|
||||
<% end %>
|
||||
</button>
|
||||
|
|
@ -381,7 +475,10 @@
|
|||
|
||||
<%= if @search_query != "" and String.length(@search_query) >= 2 and @search_results == [] do %>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
No results found matching "{@search_query}"
|
||||
No Gaiia {if(@tab == "sites",
|
||||
do: "network sites",
|
||||
else: "inventory items"
|
||||
)} matching "{@search_query}"
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -389,7 +486,7 @@
|
|||
type="button"
|
||||
id="cancel-link"
|
||||
phx-click="cancel_link"
|
||||
class="self-start text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
class="flex-shrink-0 self-start text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -527,6 +527,31 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
def handle_event("save_gaiia_app_url", %{"value" => url}, socket) do
|
||||
case Map.get(socket.assigns.integrations, "gaiia") do
|
||||
nil ->
|
||||
{:noreply, socket}
|
||||
|
||||
integration ->
|
||||
updated_credentials =
|
||||
Map.put(integration.credentials, "app_url", String.trim(url))
|
||||
|
||||
case Integrations.update_integration(integration, %{credentials: updated_credentials}) do
|
||||
{:ok, _updated} ->
|
||||
integrations = load_integrations(socket.assigns.organization.id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:integrations, integrations)
|
||||
|> assign(:active_billing_provider, active_billing_provider(integrations))
|
||||
|> put_flash(:info, t("Gaiia app URL saved"))}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, t("Failed to save Gaiia app URL"))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_enabled", %{"provider" => provider}, socket) do
|
||||
case Map.get(socket.assigns.integrations, provider) do
|
||||
|
|
|
|||
|
|
@ -2144,6 +2144,28 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("Gaiia App URL")}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
"Your Gaiia app URL (e.g. https://app.gaiia.com/your-org). Used to create direct links to inventory items and network sites."
|
||||
)}
|
||||
</p>
|
||||
<div class="mt-1">
|
||||
<input
|
||||
id="gaiia-app-url"
|
||||
type="url"
|
||||
name="app_url"
|
||||
value={get_credential(@integrations["gaiia"], "app_url")}
|
||||
placeholder="https://app.gaiia.com/your-org"
|
||||
phx-blur="save_gaiia_app_url"
|
||||
class="block w-full rounded-md border-gray-300 py-1.5 text-sm text-gray-900 shadow-xs sm:leading-6 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{t("Webhook Secret")}
|
||||
|
|
|
|||
|
|
@ -574,4 +574,76 @@ defmodule Towerops.GaiiaTest do
|
|||
assert is_nil(Gaiia.get_site_subscriber_summary(site.id))
|
||||
end
|
||||
end
|
||||
|
||||
describe "search_inventory_items/2" do
|
||||
test "finds items by name (case insensitive)", %{org: org} do
|
||||
{:ok, _} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-1", name: "ePMP 3000", ip_address: "10.0.0.1"})
|
||||
{:ok, _} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-2", name: "LTU Rocket", ip_address: "10.0.0.2"})
|
||||
|
||||
results = Gaiia.search_inventory_items(org.id, "epmp")
|
||||
assert length(results) == 1
|
||||
assert hd(results).gaiia_id == "item-1"
|
||||
end
|
||||
|
||||
test "finds items by IP address", %{org: org} do
|
||||
{:ok, _} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-1", name: "Device A", ip_address: "192.168.1.50"})
|
||||
{:ok, _} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-2", name: "Device B", ip_address: "10.0.0.1"})
|
||||
|
||||
results = Gaiia.search_inventory_items(org.id, "192.168.1")
|
||||
assert length(results) == 1
|
||||
assert hd(results).gaiia_id == "item-1"
|
||||
end
|
||||
|
||||
test "finds items by model name", %{org: org} do
|
||||
{:ok, _} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-1", name: "Unit 1", model_name: "AC1000"})
|
||||
{:ok, _} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-2", name: "Unit 2", model_name: "AC2000"})
|
||||
|
||||
results = Gaiia.search_inventory_items(org.id, "AC1000")
|
||||
assert length(results) == 1
|
||||
assert hd(results).gaiia_id == "item-1"
|
||||
end
|
||||
|
||||
test "returns empty list when no match", %{org: org} do
|
||||
{:ok, _} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-1", name: "Device A"})
|
||||
|
||||
results = Gaiia.search_inventory_items(org.id, "nonexistent")
|
||||
assert results == []
|
||||
end
|
||||
|
||||
test "does not return items from other orgs", %{org: org} do
|
||||
other_user = Towerops.AccountsFixtures.user_fixture()
|
||||
other_org = Towerops.OrganizationsFixtures.organization_fixture(other_user.id)
|
||||
{:ok, _} = Gaiia.upsert_inventory_item(other_org.id, %{gaiia_id: "item-x", name: "ePMP 3000"})
|
||||
|
||||
results = Gaiia.search_inventory_items(org.id, "epmp")
|
||||
assert results == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "search_network_sites/2" do
|
||||
test "finds network sites by name (case insensitive)", %{org: org} do
|
||||
{:ok, _} = Gaiia.upsert_network_site(org.id, %{gaiia_id: "ns-1", name: "Tower North"})
|
||||
{:ok, _} = Gaiia.upsert_network_site(org.id, %{gaiia_id: "ns-2", name: "Tower South"})
|
||||
|
||||
results = Gaiia.search_network_sites(org.id, "north")
|
||||
assert length(results) == 1
|
||||
assert hd(results).gaiia_id == "ns-1"
|
||||
end
|
||||
|
||||
test "returns empty list when no match", %{org: org} do
|
||||
{:ok, _} = Gaiia.upsert_network_site(org.id, %{gaiia_id: "ns-1", name: "Tower North"})
|
||||
|
||||
results = Gaiia.search_network_sites(org.id, "nothing")
|
||||
assert results == []
|
||||
end
|
||||
|
||||
test "does not return sites from other orgs", %{org: org} do
|
||||
other_user = Towerops.AccountsFixtures.user_fixture()
|
||||
other_org = Towerops.OrganizationsFixtures.organization_fixture(other_user.id)
|
||||
{:ok, _} = Gaiia.upsert_network_site(other_org.id, %{gaiia_id: "ns-x", name: "Tower North"})
|
||||
|
||||
results = Gaiia.search_network_sites(org.id, "north")
|
||||
assert results == []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue