feat: add global search (Cmd+K) across devices, sites, accounts, alerts
- Add Towerops.Search context module with categorized search - Add GlobalSearchComponent LiveComponent with modal overlay - Add GlobalSearch JS hook for Cmd+K/Ctrl+K keyboard shortcut - Wire into authenticated layout - Keyboard navigation (up/down/enter/esc) support - DaisyUI-styled modal with categorized results (5 per category) - Searches: device name/IP, site name, account name/ID, alert message
This commit is contained in:
parent
ba97281beb
commit
723f475933
4 changed files with 307 additions and 1 deletions
|
|
@ -939,10 +939,33 @@ if (!csrfToken) {
|
|||
// Detect user's timezone
|
||||
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
||||
|
||||
// Global Search (Cmd+K / Ctrl+K) hook
|
||||
const GlobalSearch = {
|
||||
mounted() {
|
||||
this.handleKeydown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault()
|
||||
this.pushEventTo(this.el, "open", {})
|
||||
}
|
||||
if (e.key === "Escape" && this.el.querySelector("[role=dialog]")) {
|
||||
this.pushEventTo(this.el, "close", {})
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", this.handleKeydown)
|
||||
},
|
||||
updated() {
|
||||
const input = this.el.querySelector("#global-search-input") as HTMLInputElement
|
||||
if (input) input.focus()
|
||||
},
|
||||
destroyed() {
|
||||
document.removeEventListener("keydown", this.handleKeydown)
|
||||
}
|
||||
}
|
||||
|
||||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: { _csrf_token: csrfToken, timezone: userTimezone },
|
||||
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, DeviceListReorder, MikrotikPortSync },
|
||||
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, DeviceListReorder, MikrotikPortSync, GlobalSearch },
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
|
|||
93
lib/towerops/search.ex
Normal file
93
lib/towerops/search.ex
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
defmodule Towerops.Search do
|
||||
@moduledoc """
|
||||
Global search across devices, sites, accounts, and alerts.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Sites.Site
|
||||
alias Towerops.Gaiia.Account
|
||||
alias Towerops.Alerts.Alert
|
||||
|
||||
@limit 5
|
||||
|
||||
@doc """
|
||||
Searches across multiple entity types for the given organization.
|
||||
Returns a map of categorized results.
|
||||
"""
|
||||
def search(_organization_id, query) when byte_size(query) < 2, do: %{}
|
||||
|
||||
def search(organization_id, query) do
|
||||
pattern = "%#{sanitize(query)}%"
|
||||
|
||||
%{
|
||||
devices: search_devices(organization_id, pattern),
|
||||
sites: search_sites(organization_id, pattern),
|
||||
accounts: search_accounts(organization_id, pattern),
|
||||
alerts: search_alerts(organization_id, pattern)
|
||||
}
|
||||
|> Enum.reject(fn {_k, v} -> v == [] end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp search_devices(organization_id, pattern) do
|
||||
Device
|
||||
|> where([d], d.organization_id == ^organization_id)
|
||||
|> where([d], ilike(d.name, ^pattern) or ilike(d.ip_address, ^pattern))
|
||||
|> order_by([d], d.name)
|
||||
|> limit(@limit)
|
||||
|> select([d], %{id: d.id, name: d.name, ip_address: d.ip_address})
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn d ->
|
||||
%{type: :device, label: d.name, sublabel: d.ip_address, url: "/devices/#{d.id}"}
|
||||
end)
|
||||
end
|
||||
|
||||
defp search_sites(organization_id, pattern) do
|
||||
Site
|
||||
|> where([s], s.organization_id == ^organization_id)
|
||||
|> where([s], ilike(s.name, ^pattern))
|
||||
|> order_by([s], s.name)
|
||||
|> limit(@limit)
|
||||
|> select([s], %{id: s.id, name: s.name, location: s.location})
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn s ->
|
||||
%{type: :site, label: s.name, sublabel: s.location, url: "/sites/#{s.id}"}
|
||||
end)
|
||||
end
|
||||
|
||||
defp search_accounts(organization_id, pattern) do
|
||||
Account
|
||||
|> where([a], a.organization_id == ^organization_id)
|
||||
|> where([a], ilike(a.name, ^pattern) or ilike(a.readable_id, ^pattern))
|
||||
|> order_by([a], a.name)
|
||||
|> limit(@limit)
|
||||
|> select([a], %{id: a.id, name: a.name, readable_id: a.readable_id})
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn a ->
|
||||
%{type: :account, label: a.name, sublabel: a.readable_id, url: "/trace?account_id=#{a.id}"}
|
||||
end)
|
||||
end
|
||||
|
||||
defp search_alerts(organization_id, pattern) do
|
||||
Alert
|
||||
|> join(:inner, [a], d in Device, on: a.device_id == d.id)
|
||||
|> where([a, d], d.organization_id == ^organization_id)
|
||||
|> where([a, _d], ilike(a.message, ^pattern))
|
||||
|> where([a, _d], is_nil(a.resolved_at))
|
||||
|> order_by([a, _d], [desc: a.triggered_at])
|
||||
|> limit(@limit)
|
||||
|> select([a, d], %{id: a.id, message: a.message, device_name: d.name})
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn a ->
|
||||
%{type: :alert, label: a.message || "Alert", sublabel: a.device_name, url: "/alerts"}
|
||||
end)
|
||||
end
|
||||
|
||||
defp sanitize(query) do
|
||||
query
|
||||
|> String.replace("%", "\\%")
|
||||
|> String.replace("_", "\\_")
|
||||
end
|
||||
end
|
||||
|
|
@ -7,6 +7,7 @@ defmodule ToweropsWeb.Layouts do
|
|||
|
||||
alias ToweropsWeb.Components.ConsentPrompt
|
||||
alias ToweropsWeb.Components.CookieConsent
|
||||
alias ToweropsWeb.Live.Components.GlobalSearchComponent
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -516,6 +517,13 @@ defmodule ToweropsWeb.Layouts do
|
|||
policies={@policies_needing_consent}
|
||||
/>
|
||||
<% end %>
|
||||
<%= if @current_organization do %>
|
||||
<.live_component
|
||||
module={GlobalSearchComponent}
|
||||
id="global-search"
|
||||
organization_id={@current_organization.id}
|
||||
/>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
|
|
|
|||
182
lib/towerops_web/live/components/global_search_component.ex
Normal file
182
lib/towerops_web/live/components/global_search_component.ex
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
defmodule ToweropsWeb.Live.Components.GlobalSearchComponent do
|
||||
@moduledoc """
|
||||
Global search overlay (Cmd+K / Ctrl+K) that searches across
|
||||
devices, sites, accounts, and alerts.
|
||||
"""
|
||||
use ToweropsWeb, :live_component
|
||||
|
||||
alias Towerops.Search
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, query: "", results: %{}, selected_index: 0, open: false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok, assign(socket, assigns)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open", _params, socket) do
|
||||
{:noreply, assign(socket, open: true, query: "", results: %{}, selected_index: 0)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("close", _params, socket) do
|
||||
{:noreply, assign(socket, open: false, query: "", results: %{}, selected_index: 0)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"query" => query}, socket) do
|
||||
results =
|
||||
if String.length(String.trim(query)) >= 2 do
|
||||
Search.search(socket.assigns.organization_id, String.trim(query))
|
||||
else
|
||||
%{}
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, query: query, results: results, selected_index: 0)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("keydown", %{"key" => "ArrowDown"}, socket) do
|
||||
max = flat_results_count(socket.assigns.results) - 1
|
||||
idx = min(socket.assigns.selected_index + 1, max(max, 0))
|
||||
{:noreply, assign(socket, selected_index: idx)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("keydown", %{"key" => "ArrowUp"}, socket) do
|
||||
idx = max(socket.assigns.selected_index - 1, 0)
|
||||
{:noreply, assign(socket, selected_index: idx)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("keydown", %{"key" => "Enter"}, socket) do
|
||||
flat = flat_results(socket.assigns.results)
|
||||
case Enum.at(flat, socket.assigns.selected_index) do
|
||||
nil -> {:noreply, socket}
|
||||
result -> {:noreply, push_navigate(socket, to: result.url)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("keydown", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
flat = flat_results(assigns.results)
|
||||
assigns = assign(assigns, flat_results: flat)
|
||||
|
||||
~H"""
|
||||
<div id="global-search" phx-hook="GlobalSearch" phx-target={@myself}>
|
||||
<%= if @open do %>
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" role="dialog" aria-modal="true">
|
||||
<div class="fixed inset-0 bg-black/50 transition-opacity" phx-click="close" phx-target={@myself}></div>
|
||||
<div class="flex min-h-full items-start justify-center p-4 pt-[15vh]">
|
||||
<div class="relative w-full max-w-lg transform rounded-xl bg-white shadow-2xl ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10">
|
||||
<div class="flex items-center gap-3 border-b border-gray-200 px-4 dark:border-white/10">
|
||||
<.icon name="hero-magnifying-glass" class="h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
id="global-search-input"
|
||||
placeholder="Search devices, sites, accounts, alerts..."
|
||||
value={@query}
|
||||
phx-keydown="keydown"
|
||||
phx-keyup="search"
|
||||
phx-value-query={@query}
|
||||
phx-debounce="200"
|
||||
phx-target={@myself}
|
||||
autocomplete="off"
|
||||
class="flex-1 border-0 bg-transparent py-3.5 text-sm text-gray-900 placeholder:text-gray-400 focus:ring-0 dark:text-white dark:placeholder:text-gray-500"
|
||||
/>
|
||||
<kbd class="hidden sm:inline-flex items-center rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-400 dark:border-gray-600 dark:text-gray-500">esc</kbd>
|
||||
</div>
|
||||
|
||||
<div class="max-h-80 overflow-y-auto px-2 py-2">
|
||||
<%= if @query != "" and map_size(@results) == 0 do %>
|
||||
<p class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">No results found.</p>
|
||||
<% end %>
|
||||
|
||||
<%= for {category, items} <- @results do %>
|
||||
<div class="mb-2">
|
||||
<p class="px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{category_label(category)}
|
||||
</p>
|
||||
<%= for {item, idx} <- Enum.with_index(items) do %>
|
||||
<% global_idx = global_index(@results, category, idx) %>
|
||||
<.link
|
||||
navigate={item.url}
|
||||
class={[
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm cursor-pointer",
|
||||
if(global_idx == @selected_index,
|
||||
do: "bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
else: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<.icon name={category_icon(category)} class="h-4 w-4 flex-shrink-0 opacity-60" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium">{item.label}</p>
|
||||
<p :if={item.sublabel} class="truncate text-xs opacity-60">{item.sublabel}</p>
|
||||
</div>
|
||||
</.link>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @query == "" do %>
|
||||
<p class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Start typing to search...
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between border-t border-gray-200 px-4 py-2 text-xs text-gray-400 dark:border-white/10 dark:text-gray-500">
|
||||
<div class="flex gap-2">
|
||||
<span><kbd class="font-semibold">↑↓</kbd> navigate</span>
|
||||
<span><kbd class="font-semibold">↵</kbd> open</span>
|
||||
<span><kbd class="font-semibold">esc</kbd> close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp category_label(:devices), do: "Devices"
|
||||
defp category_label(:sites), do: "Sites"
|
||||
defp category_label(:accounts), do: "Accounts"
|
||||
defp category_label(:alerts), do: "Alerts"
|
||||
defp category_label(other), do: to_string(other)
|
||||
|
||||
defp category_icon(:devices), do: "hero-server"
|
||||
defp category_icon(:sites), do: "hero-building-office"
|
||||
defp category_icon(:accounts), do: "hero-user-group"
|
||||
defp category_icon(:alerts), do: "hero-bell-alert"
|
||||
defp category_icon(_), do: "hero-magnifying-glass"
|
||||
|
||||
defp flat_results(results) do
|
||||
Enum.flat_map([:devices, :sites, :accounts, :alerts], fn cat ->
|
||||
Map.get(results, cat, [])
|
||||
end)
|
||||
end
|
||||
|
||||
defp flat_results_count(results), do: length(flat_results(results))
|
||||
|
||||
defp global_index(results, category, local_idx) do
|
||||
order = [:devices, :sites, :accounts, :alerts]
|
||||
offset =
|
||||
order
|
||||
|> Enum.take_while(&(&1 != category))
|
||||
|> Enum.reduce(0, fn cat, acc -> acc + length(Map.get(results, cat, [])) end)
|
||||
|
||||
offset + local_idx
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue