add preseem devices management page with manual linking

This commit is contained in:
Graham McIntire 2026-02-12 18:12:27 -06:00
parent 59cf53144f
commit b9db1d2224
No known key found for this signature in database
7 changed files with 752 additions and 0 deletions

View file

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

View file

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

View file

@ -78,6 +78,13 @@
{String.capitalize(integration.last_sync_status)}
</span>
<% 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 →
</.link>
</div>
<% end %>
</div>

View file

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

View file

@ -0,0 +1,273 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<div class="mb-4">
<.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
</.link>
</div>
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
Preseem Devices
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Manage device matching between Preseem access points and your monitored devices.
</p>
</div>
<%!-- Filter tabs --%>
<div class="mt-6 border-b border-gray-200 dark:border-white/10">
<nav class="-mb-px flex space-x-8">
<.link
id="filter-all"
patch={~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices?filter=all"}
class={[
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium",
if(@filter == "all",
do: "border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400",
else:
"border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300"
)
]}
>
All
</.link>
<.link
id="filter-unmatched"
patch={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices?filter=unmatched"
}
class={[
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium",
if(@filter == "unmatched",
do: "border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400",
else:
"border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300"
)
]}
>
Unmatched
</.link>
<.link
id="filter-matched"
patch={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices?filter=matched"
}
class={[
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium",
if(@filter == "matched",
do: "border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400",
else:
"border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300"
)
]}
>
Matched
</.link>
</nav>
</div>
<%!-- Access points table --%>
<div class="mt-6">
<%= if @access_points == [] do %>
<div
id="empty-state"
class="rounded-lg border-2 border-dashed border-gray-300 p-12 text-center dark:border-gray-700"
>
<.icon name="hero-signal" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
No access points found
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<%= 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 %>
</p>
</div>
<% else %>
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-white/5">
<tr>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
Name
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
Preseem ID
</th>
<th
scope="col"
class="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 sm:table-cell"
>
IP Address
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
Status
</th>
<th
scope="col"
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
Linked Device
</th>
<th
scope="col"
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
Actions
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/5 dark:bg-transparent">
<tr
:for={ap <- @access_points}
id={"ap-row-#{ap.id}"}
class="hover:bg-gray-50 dark:hover:bg-white/5"
>
<td class="whitespace-nowrap px-4 py-4 text-sm font-medium text-gray-900 dark:text-white">
{ap.name || "Unnamed"}
</td>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
{ap.preseem_id}
</td>
<td class="hidden whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400 sm:table-cell">
{ap.ip_address || "-"}
</td>
<td class="whitespace-nowrap px-4 py-4 text-sm">
<span class={[
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
match_confidence_classes(ap.match_confidence)
]}>
{humanize_confidence(ap.match_confidence)}
</span>
</td>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
<%= 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}
</.link>
<% else %>
<span class="text-gray-400 dark:text-gray-500">-</span>
<% end %>
</td>
<td class="whitespace-nowrap px-4 py-4 text-right text-sm">
<%= if ap.device do %>
<button
type="button"
id={"unlink-ap-#{ap.id}"}
phx-click="unlink_device"
phx-value-ap-id={ap.id}
data-confirm="Unlink this device from the Preseem access point?"
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
>
Unlink
</button>
<% else %>
<button
type="button"
id={"link-ap-#{ap.id}"}
phx-click="start_link"
phx-value-ap-id={ap.id}
class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
>
Link
</button>
<% end %>
</td>
</tr>
<%!-- Inline linking row --%>
<%= for ap <- @access_points, ap.id == @linking_ap_id do %>
<tr id={"linking-row-#{ap.id}"} class="bg-indigo-50 dark:bg-indigo-900/20">
<td colspan="6" class="px-4 py-4">
<div class="flex items-center gap-4">
<div class="flex-1">
<p class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
Search for a device to link to "{ap.name}"
</p>
<form id="device-search-form" phx-change="search_devices" class="flex gap-2">
<input
type="text"
id="device-search-input"
name="query"
value={@device_search}
placeholder="Search by device name or IP..."
autocomplete="off"
phx-debounce="300"
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-white/5 dark:text-white"
/>
</form>
<%= if @search_results != [] do %>
<div class="mt-2 max-h-48 overflow-y-auto rounded-md border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800">
<ul class="divide-y divide-gray-200 dark:divide-white/5">
<li :for={device <- @search_results}>
<button
type="button"
id={"select-device-#{device.id}"}
phx-click="link_device"
phx-value-ap-id={ap.id}
phx-value-device-id={device.id}
class="flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-white/5"
>
<span class="font-medium text-gray-900 dark:text-white">
{device.name || "Unnamed"}
</span>
<span class="text-gray-500 dark:text-gray-400">
{device.ip_address}
<%= if device.site do %>
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">
{device.site.name}
</span>
<% end %>
</span>
</button>
</li>
</ul>
</div>
<% end %>
<%= if @device_search != "" and String.length(@device_search) >= 2 and @search_results == [] do %>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
No devices found matching "{@device_search}"
</p>
<% end %>
</div>
<button
type="button"
id="cancel-link"
phx-click="cancel_link"
class="self-start text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
Cancel
</button>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</Layouts.authenticated>

View file

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

View file

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