diff --git a/assets/js/app.ts b/assets/js/app.ts index fe72ce52..0006246d 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -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 diff --git a/lib/towerops/search.ex b/lib/towerops/search.ex new file mode 100644 index 00000000..56a4ffeb --- /dev/null +++ b/lib/towerops/search.ex @@ -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 diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index 848293fd..d4575f30 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -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 diff --git a/lib/towerops_web/live/components/global_search_component.ex b/lib/towerops_web/live/components/global_search_component.ex new file mode 100644 index 00000000..55bcccb3 --- /dev/null +++ b/lib/towerops_web/live/components/global_search_component.ex @@ -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""" + + """ + 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