feat: add site search to trace

Trace can now search sites by name, location, or address. Selecting
a site shows all devices at that site with status and recent alerts.
This commit is contained in:
Graham McIntire 2026-03-06 15:37:23 -06:00
parent 0bdec8653f
commit cc3ca1c7de
No known key found for this signature in database
4 changed files with 262 additions and 6 deletions

View file

@ -19,6 +19,7 @@ defmodule Towerops.Trace do
alias Towerops.Preseem.Insight alias Towerops.Preseem.Insight
alias Towerops.Preseem.SubscriberMetric alias Towerops.Preseem.SubscriberMetric
alias Towerops.Repo alias Towerops.Repo
alias Towerops.Sites.Site
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Search # Search
@ -38,10 +39,11 @@ defmodule Towerops.Trace do
accounts = search_accounts(organization_id, pattern) accounts = search_accounts(organization_id, pattern)
inventory = search_inventory(organization_id, pattern) inventory = search_inventory(organization_id, pattern)
sites = search_sites(organization_id, pattern)
devices = search_devices(organization_id, pattern) devices = search_devices(organization_id, pattern)
access_points = search_access_points(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
end end
@ -77,6 +79,19 @@ defmodule Towerops.Trace do
end) end)
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 defp search_devices(organization_id, pattern) do
Device Device
|> where(organization_id: ^organization_id) |> where(organization_id: ^organization_id)
@ -119,6 +134,7 @@ defmodule Towerops.Trace do
case type do case type do
:account -> trace_from_account(organization_id, id) :account -> trace_from_account(organization_id, id)
:inventory_item -> trace_from_inventory(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) :device -> trace_from_device(organization_id, id)
:access_point -> trace_from_access_point(organization_id, id) :access_point -> trace_from_access_point(organization_id, id)
_ -> nil _ -> nil
@ -166,6 +182,31 @@ defmodule Towerops.Trace do
:not_found -> nil :not_found -> nil
end 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 defp trace_from_device(organization_id, device_id) do
device = device =
Device Device
@ -313,6 +354,19 @@ defmodule Towerops.Trace do
} }
end 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(nil), do: []
defp load_device_alerts(device_id) do defp load_device_alerts(device_id) do

View file

@ -140,12 +140,14 @@ defmodule ToweropsWeb.TraceLive.Index do
defp type_badge_class(:account), do: "badge-primary" defp type_badge_class(:account), do: "badge-primary"
defp type_badge_class(:inventory_item), do: "badge-secondary" 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(:device), do: "badge-accent"
defp type_badge_class(:access_point), do: "badge-info" defp type_badge_class(:access_point), do: "badge-info"
defp type_badge_class(_), do: "badge-ghost" defp type_badge_class(_), do: "badge-ghost"
defp type_badge_label(:account), do: "Account" defp type_badge_label(:account), do: "Account"
defp type_badge_label(:inventory_item), do: "Inventory" 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(:device), do: "Device"
defp type_badge_label(:access_point), do: "AP" defp type_badge_label(:access_point), do: "AP"
defp type_badge_label(_), do: "Unknown" defp type_badge_label(_), do: "Unknown"
@ -244,6 +246,7 @@ defmodule ToweropsWeb.TraceLive.Index do
defp safe_type("account"), do: :account defp safe_type("account"), do: :account
defp safe_type("inventory_item"), do: :inventory_item defp safe_type("inventory_item"), do: :inventory_item
defp safe_type("site"), do: :site
defp safe_type("device"), do: :device defp safe_type("device"), do: :device
defp safe_type("access_point"), do: :access_point defp safe_type("access_point"), do: :access_point
defp safe_type(_), do: :account defp safe_type(_), do: :account

View file

@ -5,7 +5,11 @@
> >
<.breadcrumb items={ <.breadcrumb items={
[%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Trace"}] ++ [%{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
} /> } />
<div class="space-y-6"> <div class="space-y-6">
<%!-- Search Bar --%> <%!-- Search Bar --%>
@ -20,7 +24,7 @@
value={@query} value={@query}
placeholder={ placeholder={
t( 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" 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" /> <.icon name="hero-magnifying-glass" class="h-12 w-12 mx-auto mb-3 opacity-50" />
<p class="text-lg font-medium">No results found</p> <p class="text-lg font-medium">No results found</p>
<p class="text-sm"> <p class="text-sm">
{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")}
</p> </p>
</div> </div>
<%!-- Site Trace View --%>
<div :if={@trace && @trace_type == :site} class="space-y-4">
<%!-- Site Info --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<.icon name="hero-building-office-2" class="h-5 w-5 text-gray-400" />
{@trace.site.name}
</h3>
<div class="mt-2 space-y-1 text-sm text-gray-600 dark:text-gray-400">
<p :if={@trace.site.location}>
<span class="font-medium text-gray-500 dark:text-gray-400">Location:</span>
{@trace.site.location}
</p>
<p :if={@trace.site.address}>
<span class="font-medium text-gray-500 dark:text-gray-400">Address:</span>
{@trace.site.address}
</p>
<p>
<span class="font-medium text-gray-500 dark:text-gray-400">Devices:</span>
{length(@trace.devices)}
</p>
</div>
</div>
</div>
<%!-- Devices at Site --%>
<div
:if={length(@trace.devices) > 0}
class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900"
>
<div class="card-body p-4 sm:p-6">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
{t("Devices")}
</h3>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr class="text-xs text-gray-500 dark:text-gray-400 uppercase">
<th>{t("Name")}</th>
<th>{t("IP Address")}</th>
<th>{t("Status")}</th>
</tr>
</thead>
<tbody>
<tr
:for={device <- @trace.devices}
class="hover:bg-base-200/50 dark:hover:bg-white/5"
>
<td>
<.link
navigate={~p"/devices/#{device.id}"}
class="font-medium text-blue-600 dark:text-blue-400 hover:underline"
>
{device.name || device.ip_address}
</.link>
</td>
<td class="font-mono text-sm">{device.ip_address}</td>
<td>
<span class="flex items-center gap-1.5">
<span class={"h-2 w-2 rounded-full #{device_status_dot(device.status)}"}>
</span>
<span class={device_status_color(device.status)}>
{device.status || "unknown"}
</span>
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<%!-- Recent Alerts --%>
<div
:if={@trace.alerts != []}
class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900"
>
<div class="card-body p-4 sm:p-6">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-3">
{t("Recent Alerts")}
</h3>
<div class="space-y-2">
<div
:for={alert <- @trace.alerts}
class="flex items-start gap-3 p-2 rounded-lg hover:bg-base-200/50 dark:hover:bg-white/5"
>
<span class={"mt-1.5 h-2 w-2 rounded-full flex-shrink-0 #{alert_dot(alert)}"}>
</span>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{format_alert_type(alert.alert_type)}
</span>
<span :if={alert.device} class="text-xs text-gray-500 dark:text-gray-400">
{alert.device.name}
</span>
<span
:if={alert.resolved_at}
class="badge badge-xs badge-success"
>
{t("Resolved")}
</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 truncate">
{alert.message}
</p>
<span class="text-xs text-gray-400 dark:text-gray-500">
{format_relative_time(alert.triggered_at)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<%!-- Trace View --%> <%!-- Trace View --%>
<div :if={@trace} class="space-y-4"> <div :if={@trace && @trace_type != :site} class="space-y-4">
<%!-- Known Issues Callout --%> <%!-- Known Issues Callout --%>
<.issues_callout trace={@trace} /> <.issues_callout trace={@trace} />
@ -554,7 +674,7 @@
</h2> </h2>
<p class="text-gray-500 dark:text-gray-400 max-w-md mx-auto"> <p class="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
{t( {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."
)} )}
</p> </p>
</div> </div>

View file

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