diff --git a/lib/towerops/trace.ex b/lib/towerops/trace.ex index df140eea..a6543270 100644 --- a/lib/towerops/trace.ex +++ b/lib/towerops/trace.ex @@ -19,6 +19,7 @@ defmodule Towerops.Trace do alias Towerops.Preseem.Insight alias Towerops.Preseem.SubscriberMetric alias Towerops.Repo + alias Towerops.Sites.Site # --------------------------------------------------------------------------- # Search @@ -38,10 +39,11 @@ defmodule Towerops.Trace do accounts = search_accounts(organization_id, pattern) inventory = search_inventory(organization_id, pattern) + sites = search_sites(organization_id, pattern) devices = search_devices(organization_id, pattern) access_points = search_access_points(organization_id, pattern) - Enum.take(accounts ++ inventory ++ devices ++ access_points, 25) + Enum.take(accounts ++ inventory ++ sites ++ devices ++ access_points, 25) end end @@ -77,6 +79,19 @@ defmodule Towerops.Trace do end) end + defp search_sites(organization_id, pattern) do + Site + |> where(organization_id: ^organization_id) + |> where([s], ilike(s.name, ^pattern) or ilike(s.location, ^pattern) or ilike(s.address, ^pattern)) + |> limit(10) + |> Repo.all() + |> Enum.map(fn s -> + sublabel = Enum.join(Enum.reject([s.location, s.address], &is_nil/1), " ยท ") + + %{type: :site, id: s.id, label: s.name, sublabel: sublabel, data: s} + end) + end + defp search_devices(organization_id, pattern) do Device |> where(organization_id: ^organization_id) @@ -119,6 +134,7 @@ defmodule Towerops.Trace do case type do :account -> trace_from_account(organization_id, id) :inventory_item -> trace_from_inventory(organization_id, id) + :site -> trace_from_site(organization_id, id) :device -> trace_from_device(organization_id, id) :access_point -> trace_from_access_point(organization_id, id) _ -> nil @@ -166,6 +182,31 @@ defmodule Towerops.Trace do :not_found -> nil end + defp trace_from_site(organization_id, site_id) do + site = + Site + |> where(id: ^site_id, organization_id: ^organization_id) + |> Repo.one() + + if !site, do: throw(:not_found) + + devices = + Device + |> where(organization_id: ^organization_id, site_id: ^site_id) + |> preload(:site) + |> Repo.all() + + alerts = load_site_alerts(devices) + + %{ + site: site, + devices: devices, + alerts: alerts + } + catch + :not_found -> nil + end + defp trace_from_device(organization_id, device_id) do device = Device @@ -313,6 +354,19 @@ defmodule Towerops.Trace do } end + defp load_site_alerts([]), do: [] + + defp load_site_alerts(devices) do + device_ids = Enum.map(devices, & &1.id) + + Alert + |> where([a], a.device_id in ^device_ids) + |> order_by(desc: :triggered_at) + |> limit(10) + |> preload([:device, :acknowledged_by]) + |> Repo.all() + end + defp load_device_alerts(nil), do: [] defp load_device_alerts(device_id) do diff --git a/lib/towerops_web/live/trace_live/index.ex b/lib/towerops_web/live/trace_live/index.ex index 255670a3..599f4aed 100644 --- a/lib/towerops_web/live/trace_live/index.ex +++ b/lib/towerops_web/live/trace_live/index.ex @@ -140,12 +140,14 @@ defmodule ToweropsWeb.TraceLive.Index do defp type_badge_class(:account), do: "badge-primary" defp type_badge_class(:inventory_item), do: "badge-secondary" + defp type_badge_class(:site), do: "badge-warning" defp type_badge_class(:device), do: "badge-accent" defp type_badge_class(:access_point), do: "badge-info" defp type_badge_class(_), do: "badge-ghost" defp type_badge_label(:account), do: "Account" defp type_badge_label(:inventory_item), do: "Inventory" + defp type_badge_label(:site), do: "Site" defp type_badge_label(:device), do: "Device" defp type_badge_label(:access_point), do: "AP" defp type_badge_label(_), do: "Unknown" @@ -244,6 +246,7 @@ defmodule ToweropsWeb.TraceLive.Index do defp safe_type("account"), do: :account defp safe_type("inventory_item"), do: :inventory_item + defp safe_type("site"), do: :site defp safe_type("device"), do: :device defp safe_type("access_point"), do: :access_point defp safe_type(_), do: :account diff --git a/lib/towerops_web/live/trace_live/index.html.heex b/lib/towerops_web/live/trace_live/index.html.heex index 3e148b06..3076a739 100644 --- a/lib/towerops_web/live/trace_live/index.html.heex +++ b/lib/towerops_web/live/trace_live/index.html.heex @@ -5,7 +5,11 @@ > <.breadcrumb items={ [%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Trace"}] ++ - if(@trace && @trace.subscriber, do: [%{label: @trace.subscriber.name}], else: []) + cond do + @trace && @trace[:subscriber] -> [%{label: @trace.subscriber.name}] + @trace && @trace[:site] -> [%{label: @trace.site.name}] + true -> [] + end } />
<%!-- Search Bar --%> @@ -20,7 +24,7 @@ value={@query} placeholder={ t( - "Search by customer name, IP address, account ID, serial number, or device name..." + "Search by customer name, IP address, account ID, serial number, site name, or device name..." ) } class="input input-bordered w-full dark:bg-gray-800 dark:border-white/10 dark:text-white" @@ -76,12 +80,128 @@ <.icon name="hero-magnifying-glass" class="h-12 w-12 mx-auto mb-3 opacity-50" />

No results found

- {t("Try searching by customer name, IP address, account ID, or device name")} + {t("Try searching by customer name, IP address, account ID, site name, or device name")}

+ <%!-- Site Trace View --%> +
+ <%!-- Site Info --%> +
+
+

+ <.icon name="hero-building-office-2" class="h-5 w-5 text-gray-400" /> + {@trace.site.name} +

+
+

+ Location: + {@trace.site.location} +

+

+ Address: + {@trace.site.address} +

+

+ Devices: + {length(@trace.devices)} +

+
+
+
+ <%!-- Devices at Site --%> +
0} + class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900" + > +
+

+ {t("Devices")} +

+
+ + + + + + + + + + + + + + + +
{t("Name")}{t("IP Address")}{t("Status")}
+ <.link + navigate={~p"/devices/#{device.id}"} + class="font-medium text-blue-600 dark:text-blue-400 hover:underline" + > + {device.name || device.ip_address} + + {device.ip_address} + + + + + {device.status || "unknown"} + + +
+
+
+
+ <%!-- Recent Alerts --%> +
+
+

+ {t("Recent Alerts")} +

+
+
+ + +
+
+ + {format_alert_type(alert.alert_type)} + + + {alert.device.name} + + + {t("Resolved")} + +
+

+ {alert.message} +

+ + {format_relative_time(alert.triggered_at)} + +
+
+
+
+
+
+ <%!-- Trace View --%> -
+
<%!-- Known Issues Callout --%> <.issues_callout trace={@trace} /> @@ -554,7 +674,7 @@

{t( - "Search for a customer by name, IP address, account ID, or device name to see their full network status at a glance." + "Search for a customer by name, IP address, account ID, site name, or device name to see their full network status at a glance." )}

diff --git a/test/towerops/trace_test.exs b/test/towerops/trace_test.exs new file mode 100644 index 00000000..cb94b4c2 --- /dev/null +++ b/test/towerops/trace_test.exs @@ -0,0 +1,79 @@ +defmodule Towerops.TraceTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Trace + + setup do + user = user_fixture() + {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test ISP"}, user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Tower Alpha", + organization_id: organization.id, + location: "Downtown" + }) + + {:ok, device} = + Towerops.Devices.create_device(%{ + name: "Router-1", + ip_address: "10.0.0.1", + site_id: site.id, + organization_id: organization.id, + snmp_enabled: true + }) + + %{organization: organization, site: site, device: device} + end + + describe "search/2" do + test "finds sites by name", %{organization: organization} do + results = Trace.search(organization.id, "Tower") + + site_results = Enum.filter(results, &(&1.type == :site)) + assert length(site_results) == 1 + assert hd(site_results).label == "Tower Alpha" + end + + test "finds sites by location", %{organization: organization} do + results = Trace.search(organization.id, "Downtown") + + site_results = Enum.filter(results, &(&1.type == :site)) + assert length(site_results) == 1 + end + + test "finds devices by name", %{organization: organization} do + results = Trace.search(organization.id, "Router") + + device_results = Enum.filter(results, &(&1.type == :device)) + assert length(device_results) == 1 + assert hd(device_results).label == "Router-1" + end + + test "returns empty list for short queries", %{organization: organization} do + assert Trace.search(organization.id, "a") == [] + end + end + + describe "assemble_trace/3" do + test "assembles trace from site with its devices", %{ + organization: organization, + site: site, + device: device + } do + trace = Trace.assemble_trace(organization.id, :site, site.id) + + assert trace + assert trace.site.id == site.id + assert trace.site.name == "Tower Alpha" + assert length(trace.devices) == 1 + assert hd(trace.devices).id == device.id + end + + test "returns nil for non-existent site", %{organization: organization} do + assert Trace.assemble_trace(organization.id, :site, Ecto.UUID.generate()) == nil + end + end +end