add preseem devices management page with manual linking
This commit is contained in:
parent
59cf53144f
commit
b9db1d2224
7 changed files with 752 additions and 0 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
150
lib/towerops_web/live/org/preseem_devices_live.ex
Normal file
150
lib/towerops_web/live/org/preseem_devices_live.ex
Normal 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
|
||||
273
lib/towerops_web/live/org/preseem_devices_live.html.heex
Normal file
273
lib/towerops_web/live/org/preseem_devices_live.html.heex
Normal 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>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
295
test/towerops_web/live/org/preseem_devices_live_test.exs
Normal file
295
test/towerops_web/live/org/preseem_devices_live_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue