From b9db1d2224be8efee8877cb103db0ae0174f9e4d Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 18:12:27 -0600 Subject: [PATCH] add preseem devices management page with manual linking --- lib/towerops/devices.ex | 17 + lib/towerops/preseem.ex | 9 + .../live/org/integrations_live.html.heex | 7 + .../live/org/preseem_devices_live.ex | 150 +++++++++ .../live/org/preseem_devices_live.html.heex | 273 ++++++++++++++++ lib/towerops_web/router.ex | 1 + .../live/org/preseem_devices_live_test.exs | 295 ++++++++++++++++++ 7 files changed, 752 insertions(+) create mode 100644 lib/towerops_web/live/org/preseem_devices_live.ex create mode 100644 lib/towerops_web/live/org/preseem_devices_live.html.heex create mode 100644 test/towerops_web/live/org/preseem_devices_live_test.exs diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index 55578d68..3939f7dc 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -116,6 +116,23 @@ defmodule Towerops.Devices do ) end + @doc """ + Search devices by name or IP address for an organization. + Returns up to 10 results. Requires at least 2 characters. + """ + @spec search_devices(String.t(), String.t()) :: [DeviceSchema.t()] + def search_devices(organization_id, query) do + search_term = "%#{query}%" + + DeviceSchema + |> where(organization_id: ^organization_id) + |> where([d], ilike(d.name, ^search_term) or ilike(d.ip_address, ^search_term)) + |> order_by(:name) + |> limit(10) + |> Repo.all() + |> Repo.preload(:site) + end + @doc """ Returns a map of device status counts for an organization. diff --git a/lib/towerops/preseem.ex b/lib/towerops/preseem.ex index c6ee02fb..52cee112 100644 --- a/lib/towerops/preseem.ex +++ b/lib/towerops/preseem.ex @@ -31,6 +31,15 @@ defmodule Towerops.Preseem do |> Repo.all() end + @doc "List matched Preseem APs for an organization (everything except unmatched/ambiguous)." + def list_matched_access_points(organization_id) do + AccessPoint + |> where(organization_id: ^organization_id) + |> where([ap], ap.match_confidence not in ["unmatched", "ambiguous"]) + |> order_by(:name) + |> Repo.all() + end + @doc "List recent subscriber metrics for a Preseem AP. Default limit 100." def list_subscriber_metrics(access_point_id, opts \\ []) do limit = Keyword.get(opts, :limit, 100) diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex index 765a03a8..fb556ead 100644 --- a/lib/towerops_web/live/org/integrations_live.html.heex +++ b/lib/towerops_web/live/org/integrations_live.html.heex @@ -78,6 +78,13 @@ {String.capitalize(integration.last_sync_status)} <% end %> + + <.link + navigate={~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices"} + class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + Manage Devices → + <% end %> diff --git a/lib/towerops_web/live/org/preseem_devices_live.ex b/lib/towerops_web/live/org/preseem_devices_live.ex new file mode 100644 index 00000000..048a3ef3 --- /dev/null +++ b/lib/towerops_web/live/org/preseem_devices_live.ex @@ -0,0 +1,150 @@ +defmodule ToweropsWeb.Org.PreseemDevicesLive do + @moduledoc false + use ToweropsWeb, :live_view + + alias Towerops.Devices + alias Towerops.Preseem + alias Towerops.Repo + + @impl true + def mount(_params, _session, socket) do + org = socket.assigns.current_scope.organization + + {:ok, + socket + |> assign(:organization, org) + |> assign(:filter, "all") + |> assign(:linking_ap_id, nil) + |> assign(:device_search, "") + |> assign(:search_results, []) + |> load_access_points()} + end + + @impl true + def handle_params(params, _url, socket) do + filter = + case params["filter"] do + f when f in ~w(all unmatched matched) -> f + _ -> "all" + end + + {:noreply, socket |> assign(:filter, filter) |> load_access_points()} + end + + @impl true + def handle_event("filter", %{"filter" => filter}, socket) do + org = socket.assigns.organization + + {:noreply, + push_patch(socket, + to: ~p"/orgs/#{org.slug}/settings/integrations/preseem/devices?filter=#{filter}" + )} + end + + @impl true + def handle_event("start_link", %{"ap-id" => ap_id}, socket) do + {:noreply, + socket + |> assign(:linking_ap_id, ap_id) + |> assign(:device_search, "") + |> assign(:search_results, [])} + end + + @impl true + def handle_event("cancel_link", _params, socket) do + {:noreply, + socket + |> assign(:linking_ap_id, nil) + |> assign(:device_search, "") + |> assign(:search_results, [])} + end + + @impl true + def handle_event("search_devices", %{"query" => query}, socket) do + org = socket.assigns.organization + + results = + if String.length(query) >= 2 do + Devices.search_devices(org.id, query) + else + [] + end + + {:noreply, socket |> assign(:device_search, query) |> assign(:search_results, results)} + end + + @impl true + def handle_event("link_device", %{"ap-id" => ap_id, "device-id" => device_id}, socket) do + case Preseem.link_access_point(ap_id, device_id) do + {:ok, _} -> + {:noreply, + socket + |> assign(:linking_ap_id, nil) + |> assign(:device_search, "") + |> assign(:search_results, []) + |> load_access_points() + |> put_flash(:info, "Device linked successfully")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to link device")} + end + end + + @impl true + def handle_event("unlink_device", %{"ap-id" => ap_id}, socket) do + case Preseem.unlink_access_point(ap_id) do + {:ok, _} -> + {:noreply, + socket + |> load_access_points() + |> put_flash(:info, "Device unlinked")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to unlink device")} + end + end + + defp match_confidence_classes(confidence) do + case confidence do + "unmatched" -> + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" + + "ambiguous" -> + "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400" + + "manual" -> + "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" + + _ -> + "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" + end + end + + defp humanize_confidence(confidence) do + case confidence do + "auto_mac" -> "Auto (MAC)" + "auto_ip" -> "Auto (IP)" + "auto_hostname" -> "Auto (Hostname)" + "manual" -> "Manual" + "ambiguous" -> "Ambiguous" + "unmatched" -> "Unmatched" + other -> String.capitalize(other || "unknown") + end + end + + defp load_access_points(socket) do + org_id = socket.assigns.organization.id + filter = socket.assigns.filter + + access_points = + case filter do + "unmatched" -> Preseem.list_unmatched_access_points(org_id) + "matched" -> Preseem.list_matched_access_points(org_id) + _ -> Preseem.list_access_points(org_id) + end + + access_points = Repo.preload(access_points, :device) + + assign(socket, :access_points, access_points) + end +end diff --git a/lib/towerops_web/live/org/preseem_devices_live.html.heex b/lib/towerops_web/live/org/preseem_devices_live.html.heex new file mode 100644 index 00000000..27a4e49b --- /dev/null +++ b/lib/towerops_web/live/org/preseem_devices_live.html.heex @@ -0,0 +1,273 @@ + +
+
+ <.link + navigate={~p"/orgs/#{@organization.slug}/settings/integrations"} + class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white" + > + <.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Integrations + +
+

+ Preseem Devices +

+

+ Manage device matching between Preseem access points and your monitored devices. +

+
+ + <%!-- Filter tabs --%> +
+ +
+ + <%!-- Access points table --%> +
+ <%= if @access_points == [] do %> +
+ <.icon name="hero-signal" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" /> +

+ No access points found +

+

+ <%= case @filter do %> + <% "unmatched" -> %> + All access points are matched. Nice! + <% "matched" -> %> + No matched access points yet. Sync from Preseem and link devices. + <% _ -> %> + No Preseem access points have been synced yet. Enable the integration and run a sync. + <% end %> +

+
+ <% else %> +
+ + + + + + + + + + + + + + + + + + + + + <%!-- Inline linking row --%> + <%= for ap <- @access_points, ap.id == @linking_ap_id do %> + + + + <% end %> + +
+ Name + + Preseem ID + + Status + + Linked Device + + Actions +
+ {ap.name || "Unnamed"} + + {ap.preseem_id} + + + {humanize_confidence(ap.match_confidence)} + + + <%= if ap.device do %> + <.link + navigate={~p"/devices/#{ap.device.id}"} + class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300" + > + {ap.device.name || ap.device.ip_address} + + <% else %> + - + <% end %> + + <%= if ap.device do %> + + <% else %> + + <% end %> +
+
+
+

+ Search for a device to link to "{ap.name}" +

+
+ +
+ + <%= if @search_results != [] do %> +
+
    +
  • + +
  • +
+
+ <% end %> + + <%= if @device_search != "" and String.length(@device_search) >= 2 and @search_results == [] do %> +

+ No devices found matching "{@device_search}" +

+ <% end %> +
+ +
+
+
+ <% end %> +
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index cd77ce0c..af453f29 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -310,6 +310,7 @@ defmodule ToweropsWeb.Router do live "/settings", Org.SettingsLive, :index live "/settings/integrations", Org.IntegrationsLive, :index + live "/settings/integrations/preseem/devices", Org.PreseemDevicesLive, :index end end diff --git a/test/towerops_web/live/org/preseem_devices_live_test.exs b/test/towerops_web/live/org/preseem_devices_live_test.exs new file mode 100644 index 00000000..542e72f1 --- /dev/null +++ b/test/towerops_web/live/org/preseem_devices_live_test.exs @@ -0,0 +1,295 @@ +defmodule ToweropsWeb.Org.PreseemDevicesLiveTest do + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.AccessPoint + alias Towerops.Repo + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{user: user, organization: org} + end + + defp insert_access_point!(org, attrs) do + default = %{ + organization_id: org.id, + preseem_id: "ap-#{System.unique_integer([:positive])}", + name: "Test AP" + } + + {:ok, ap} = + %AccessPoint{} + |> AccessPoint.changeset(Map.merge(default, attrs)) + |> Repo.insert() + + ap + end + + describe "index" do + test "renders page with no access points", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert html =~ "Preseem Devices" + end + + test "shows empty state message when no APs exist", %{conn: conn, user: user, organization: org} do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert has_element?(view, "#empty-state") + end + + test "shows access points in table", %{conn: conn, user: user, organization: org} do + insert_access_point!(org, %{ + name: "Tower 1 AP", + preseem_id: "preseem-123", + ip_address: "10.0.0.1", + mac_address: "AA:BB:CC:DD:EE:FF" + }) + + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert html =~ "Tower 1 AP" + assert html =~ "preseem-123" + assert html =~ "10.0.0.1" + end + + test "shows match confidence for each AP", %{conn: conn, user: user, organization: org} do + insert_access_point!(org, %{name: "Unmatched AP", match_confidence: "unmatched"}) + + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert html =~ "Unmatched" + end + + test "shows linked device name for matched APs", %{conn: conn, user: user, organization: org} do + device = device_fixture(%{organization_id: org.id, name: "My Router"}) + + insert_access_point!(org, %{ + name: "Matched AP", + device_id: device.id, + match_confidence: "manual" + }) + + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert html =~ "My Router" + end + end + + describe "filtering" do + setup %{organization: org} do + unmatched = insert_access_point!(org, %{name: "Unmatched AP", match_confidence: "unmatched"}) + device = device_fixture(%{organization_id: org.id, name: "Linked Device"}) + + matched = + insert_access_point!(org, %{ + name: "Matched AP", + device_id: device.id, + match_confidence: "auto_mac" + }) + + %{unmatched: unmatched, matched: matched, device: device} + end + + test "shows all APs by default", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert html =~ "Unmatched AP" + assert html =~ "Matched AP" + end + + test "filters to unmatched only", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices?filter=unmatched") + + assert html =~ "Unmatched AP" + refute html =~ "Matched AP" + end + + test "filters to matched only", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices?filter=matched") + + refute html =~ "Unmatched AP" + assert html =~ "Matched AP" + end + + test "clicking filter tab patches URL", %{conn: conn, user: user, organization: org} do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + view |> element("#filter-unmatched") |> render_click() + + assert_patch(view, ~p"/orgs/#{org.slug}/settings/integrations/preseem/devices?filter=unmatched") + end + end + + describe "linking" do + test "shows link button for unmatched APs", %{conn: conn, user: user, organization: org} do + ap = insert_access_point!(org, %{name: "Unlinked AP", match_confidence: "unmatched"}) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert has_element?(view, "#link-ap-#{ap.id}") + end + + test "clicking link opens search UI", %{conn: conn, user: user, organization: org} do + ap = insert_access_point!(org, %{name: "Unlinked AP", match_confidence: "unmatched"}) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + view |> element("#link-ap-#{ap.id}") |> render_click() + + assert has_element?(view, "#device-search-input") + end + + test "searching devices returns results", %{conn: conn, user: user, organization: org} do + device = device_fixture(%{organization_id: org.id, name: "My Tower Router"}) + ap = insert_access_point!(org, %{name: "Unlinked AP", match_confidence: "unmatched"}) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + view |> element("#link-ap-#{ap.id}") |> render_click() + + html = + view + |> element("#device-search-form") + |> render_change(%{query: "Tower"}) + + assert html =~ "My Tower Router" + + # Verify the device fixture was created correctly + assert device.name == "My Tower Router" + end + + test "can link a device to an AP", %{conn: conn, user: user, organization: org} do + device = device_fixture(%{organization_id: org.id, name: "Link Target"}) + ap = insert_access_point!(org, %{name: "Unlinked AP", match_confidence: "unmatched"}) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + # Open linking UI + view |> element("#link-ap-#{ap.id}") |> render_click() + + # Search for device + view + |> element("#device-search-form") + |> render_change(%{query: "Link Target"}) + + # Click to link + html = + view + |> element("#select-device-#{device.id}") + |> render_click() + + assert html =~ "Device linked successfully" + + # Verify the AP is now linked in the database + updated_ap = Repo.get!(AccessPoint, ap.id) + assert updated_ap.device_id == device.id + assert updated_ap.match_confidence == "manual" + end + + test "cancel closes linking UI", %{conn: conn, user: user, organization: org} do + ap = insert_access_point!(org, %{name: "AP to Cancel", match_confidence: "unmatched"}) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + # Open linking UI + view |> element("#link-ap-#{ap.id}") |> render_click() + assert has_element?(view, "#device-search-input") + + # Cancel + view |> element("#cancel-link") |> render_click() + refute has_element?(view, "#device-search-input") + end + end + + describe "unlinking" do + test "shows unlink button for matched APs", %{conn: conn, user: user, organization: org} do + device = device_fixture(%{organization_id: org.id}) + + ap = + insert_access_point!(org, %{ + name: "Linked AP", + device_id: device.id, + match_confidence: "manual" + }) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert has_element?(view, "#unlink-ap-#{ap.id}") + end + + test "can unlink a device from an AP", %{conn: conn, user: user, organization: org} do + device = device_fixture(%{organization_id: org.id}) + + ap = + insert_access_point!(org, %{ + name: "Linked AP", + device_id: device.id, + match_confidence: "manual" + }) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + html = view |> element("#unlink-ap-#{ap.id}") |> render_click() + assert html =~ "Device unlinked" + + # Verify the AP is now unlinked in the database + updated_ap = Repo.get!(AccessPoint, ap.id) + assert is_nil(updated_ap.device_id) + assert updated_ap.match_confidence == "unmatched" + end + end +end