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:
Graham McIntire 2026-03-12 13:04:17 -05:00
parent 1b8fb606a5
commit 58181923f8
No known key found for this signature in database
8 changed files with 565 additions and 251 deletions

View file

@ -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

View file

@ -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",

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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")}

View file

@ -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