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