diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex
index e09215a1..6132a016 100644
--- a/lib/towerops/gaiia.ex
+++ b/lib/towerops/gaiia.ex
@@ -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
diff --git a/lib/towerops/workers/gaiia_insight_worker.ex b/lib/towerops/workers/gaiia_insight_worker.ex
index 5173bbd1..e9d30f5f 100644
--- a/lib/towerops/workers/gaiia_insight_worker.ex
+++ b/lib/towerops/workers/gaiia_insight_worker.ex
@@ -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",
diff --git a/lib/towerops_web/live/insights_live/index.html.heex b/lib/towerops_web/live/insights_live/index.html.heex
index aa5889d1..5491ab01 100644
--- a/lib/towerops_web/live/insights_live/index.html.heex
+++ b/lib/towerops_web/live/insights_live/index.html.heex
@@ -311,7 +311,7 @@
<.icon name="hero-light-bulb" class="h-3.5 w-3.5 inline" />
- {t("Recommended actions")}
+ {t("To fix this:")}
<.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
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")}
@@ -339,7 +340,7 @@
<.icon name="hero-light-bulb" class="h-3.5 w-3.5 inline" />
- {t("Recommended actions")}
+ {t("To fix this:")}
<.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")}
@@ -358,7 +360,7 @@
<.icon name="hero-light-bulb" class="h-3.5 w-3.5 inline" />
- {t("Recommended actions")}
+ {t("To fix this:")}
<.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")}
diff --git a/lib/towerops_web/live/org/gaiia_mapping_live.ex b/lib/towerops_web/live/org/gaiia_mapping_live.ex
index d4227b25..d80e2425 100644
--- a/lib/towerops_web/live/org/gaiia_mapping_live.ex
+++ b/lib/towerops_web/live/org/gaiia_mapping_live.ex
@@ -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
diff --git a/lib/towerops_web/live/org/gaiia_mapping_live.html.heex b/lib/towerops_web/live/org/gaiia_mapping_live.html.heex
index 3891f93b..9f0b26df 100644
--- a/lib/towerops_web/live/org/gaiia_mapping_live.html.heex
+++ b/lib/towerops_web/live/org/gaiia_mapping_live.html.heex
@@ -12,11 +12,11 @@
- {t("Gaiia Entity Mapping")}
+ {t("Link Devices & Sites to Gaiia")}
{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."
)}
@@ -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
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")}
@@ -42,22 +42,6 @@
<%!-- Tab navigation --%>
- <.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
id="tab-devices"
patch={
@@ -72,15 +56,31 @@
)
]}
>
- {t("Inventory Items")}
+ {t("Devices")}
+
+ <.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")}
- <%!-- Filter tabs --%>
+ <%!-- Filter pills --%>
<.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}
@@ -109,22 +109,29 @@
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
/>
- <%= 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 %>
- <%= 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 %>
@@ -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")}
-
-
- {t("Gaiia ID")}
+ {if @tab == "devices", do: t("Your Device"), else: t("Your Site")}
<%= if @tab == "devices" do %>
- {if @tab == "sites", do: "Linked Site", else: "Linked Device"}
+ {if @tab == "devices",
+ do: t("Gaiia Inventory Item"),
+ else: t("Gaiia Network Site")}
- <%= 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) %>
-
+ <%!-- Device / Site column --%>
+
-
{item.name || "Unnamed"}
- <%= if Map.has_key?(@suggestions, item.gaiia_id) do %>
-
- <.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" /> Suggestion
-
+ <%= if @tab == "devices" do %>
+
+
+ <.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"}
+
+ <%= if Map.has_key?(@suggestions, row.device.id) do %>
+
+ <.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" />
+ Match found
+
+ <% end %>
+
+ <%= if row.device.site do %>
+
+ {row.device.site.name}
+
+ <% end %>
+
+ <% else %>
+
+
+
+ {row.site.name || "Unnamed"}
+
+ <%= if Map.has_key?(@suggestions, row.site.id) do %>
+
+ <.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" />
+ Match found
+
+ <% end %>
+
+
<% end %>
-
- {item.gaiia_id}
-
-
- {item.gaiia_id}
-
+
+ <%!-- IP Address (devices only) --%>
<%= if @tab == "devices" do %>
- {item.ip_address || "-"}
+ <%= if row.device.ip_address do %>
+
+ {row.device.ip_address}
+
+ <% else %>
+ —
+ <% end %>
<% end %>
-
- <%= if @tab == "sites" and item.site do %>
-
- {item.site.name}
-
- <% 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}
-
- <% end %>
- <%= if (@tab == "sites" and is_nil(item.site_id)) or (@tab == "devices" and is_nil(item.device_id)) do %>
- -
+
+ <%!-- Gaiia Item / Network Site column --%>
+
+ <%= if @tab == "devices" do %>
+ <%= if row.inventory_item do %>
+
+ <% else %>
+
+ Not linked
+
+ <% end %>
+ <% else %>
+ <%= if row.network_site do %>
+ <%= if @gaiia_app_url do %>
+
+ {row.network_site.name || "Unnamed"}
+ <.icon
+ name="hero-arrow-top-right-on-square"
+ class="inline h-3 w-3 ml-0.5 opacity-50"
+ />
+
+ <% else %>
+
+ {row.network_site.name || "Unnamed"}
+
+ <% end %>
+ <% else %>
+
+ Not linked
+
+ <% end %>
<% end %>
+
+ <%!-- Actions column --%>
- <%= if @tab == "sites" do %>
- <%= if item.site_id do %>
+ <%= if @tab == "devices" do %>
+ <%= if row.inventory_item do %>
{t("Unlink")}
@@ -249,22 +331,24 @@
<% else %>
{t("Link")}
<% end %>
<% else %>
- <%= if item.device_id do %>
+ <%= if row.network_site do %>
{t("Unlink")}
@@ -272,9 +356,9 @@
<% else %>
{t("Link")}
@@ -283,24 +367,33 @@
<% end %>
+
<%!-- Inline linking row --%>
- <%= if item.gaiia_id == @linking_id do %>
-
-
-
+ <%= if row_id == @linking_id do %>
+
+
+
- <%= if @tab == "sites" do %>
- Search for a site to link to "{item.name}"
+ <%= if @tab == "devices" do %>
+ Find a Gaiia item to link to
+
+ {row.device.name || row.device.ip_address}
+
<% else %>
- Search for a device to link to "{item.name}"
+ Find a Gaiia network site to link to
+
+ {row.site.name}
+
<% end %>
- <%!-- Show suggestions if available --%>
- <%= if suggestions = @suggestions[item.gaiia_id] do %>
+
+ <%!-- Suggestions --%>
+ <%= if suggestions = @suggestions[row_id] do %>
- {t("Suggested matches:")}
+ <.icon name="hero-light-bulb-mini" class="inline h-3 w-3 mr-0.5" />
+ {t("Suggested match (same IP):")}
<%= 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 %>
+
+ {suggestion.entity.ip_address}
+
+ <% end %>
{suggestion.match_type}
@@ -327,6 +421,8 @@
<% end %>
+
+ <%!-- Search --%>
@@ -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")}
diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex
index 213cad37..b636023d 100644
--- a/lib/towerops_web/live/org/settings_live.ex
+++ b/lib/towerops_web/live/org/settings_live.ex
@@ -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
diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex
index 9796f377..74b88ad8 100644
--- a/lib/towerops_web/live/org/settings_live.html.heex
+++ b/lib/towerops_web/live/org/settings_live.html.heex
@@ -2144,6 +2144,28 @@
+
+
+ {t("Gaiia App URL")}
+
+
+ {t(
+ "Your Gaiia app URL (e.g. https://app.gaiia.com/your-org). Used to create direct links to inventory items and network sites."
+ )}
+
+
+
+
+
+
{t("Webhook Secret")}
diff --git a/test/towerops/gaiia_test.exs b/test/towerops/gaiia_test.exs
index ef9ac31b..388c7e8a 100644
--- a/test/towerops/gaiia_test.exs
+++ b/test/towerops/gaiia_test.exs
@@ -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