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:
parent
0bdec8653f
commit
cc3ca1c7de
4 changed files with 262 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
} />
|
||||
<div class="space-y-6">
|
||||
<%!-- 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" />
|
||||
<p class="text-lg font-medium">No results found</p>
|
||||
<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>
|
||||
</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 --%>
|
||||
<div :if={@trace} class="space-y-4">
|
||||
<div :if={@trace && @trace_type != :site} class="space-y-4">
|
||||
<%!-- Known Issues Callout --%>
|
||||
<.issues_callout trace={@trace} />
|
||||
|
||||
|
|
@ -554,7 +674,7 @@
|
|||
</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
|
||||
{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>
|
||||
</div>
|
||||
|
|
|
|||
79
test/towerops/trace_test.exs
Normal file
79
test/towerops/trace_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue