ux: overhaul Preseem devices page to match Gaiia mapping UX

- Replace filter tabs with pill buttons (All/Unmatched/Matched)
- Add IP-based match suggestions when AP IP matches a TowerOps device
- Show "Match found" badge on rows with suggestions; sort suggestions first
- Show suggestion chips in inline linking row for one-click linking
- Make unlinked AP rows clickable to open linking panel
- Make IP addresses clickable http links
- Improve linking row layout and descriptive copy
- Replace bare dash with "Not linked" pill in Linked Device column
This commit is contained in:
Graham McIntire 2026-03-13 11:38:45 -05:00
parent 88ee55d603
commit 412fc3c626
No known key found for this signature in database
4 changed files with 382 additions and 190 deletions

View file

@ -1,3 +1,13 @@
2026-03-13
ux: overhaul Preseem devices page to match Gaiia mapping UX
- Replace filter tabs with pill buttons (All/Unmatched/Matched)
- Add IP-based match suggestions: detect when a Preseem AP IP matches a TowerOps device IP
- Show "Match found" badge on rows with suggestions; smart sort (suggestions first)
- Show suggestion chips in inline linking row for one-click linking
- Make unlinked AP rows clickable to open linking panel
- Make IP addresses clickable http links
Files: preseem_devices_live.ex, preseem_devices_live.html.heex, preseem_devices_live_test.exs
2026-03-12
fix: comprehensive mobile responsive audit and alignment improvements
- Devices index: restructured header for 390px (tabs on second row, icon-only buttons)

View file

@ -18,6 +18,7 @@ defmodule ToweropsWeb.Org.PreseemDevicesLive do
|> assign(:linking_ap_id, nil)
|> assign(:device_search, "")
|> assign(:search_results, [])
|> assign(:suggestions, %{})
|> load_access_points()}
end
@ -146,6 +147,48 @@ defmodule ToweropsWeb.Org.PreseemDevicesLive do
access_points = Repo.preload(access_points, :device)
assign(socket, :access_points, access_points)
suggestions = compute_suggestions(access_points, org_id)
sorted =
Enum.sort_by(access_points, fn ap ->
cond do
Map.has_key?(suggestions, ap.id) -> 0
is_nil(ap.device_id) -> 1
true -> 2
end
end)
socket
|> assign(:access_points, sorted)
|> assign(:suggestions, suggestions)
end
defp compute_suggestions(access_points, org_id) do
unmatched_with_ip =
Enum.filter(access_points, &(is_nil(&1.device_id) and &1.ip_address not in [nil, ""]))
if unmatched_with_ip == [] do
%{}
else
linked_device_ids =
access_points
|> Enum.filter(&(not is_nil(&1.device_id)))
|> MapSet.new(& &1.device_id)
unlinked_by_ip =
org_id
|> Devices.list_organization_devices()
|> Enum.filter(&(not MapSet.member?(linked_device_ids, &1.id) and &1.ip_address not in [nil, ""]))
|> Enum.group_by(&to_string(&1.ip_address))
Enum.reduce(unmatched_with_ip, %{}, &add_ap_suggestion(&1, &2, unlinked_by_ip))
end
end
defp add_ap_suggestion(ap, acc, unlinked_by_ip) do
case Map.get(unlinked_by_ip, ap.ip_address, []) do
[] -> acc
matches -> Map.put(acc, ap.id, Enum.map(matches, &%{device: &1, match_type: "ip"}))
end
end
end

View file

@ -13,7 +13,9 @@
{t("Preseem Devices")}
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Manage device matching between Preseem access points and your monitored devices.")}
{t(
"For each Preseem access point, find and link the matching TowerOps device. This enables subscriber impact data when access points go down."
)}
</p>
</div>
@ -35,56 +37,23 @@
</nav>
</div>
<%!-- Filter tabs --%>
<div class="mt-6 border-b border-gray-200 dark:border-white/10">
<nav class="-mb-px flex space-x-8">
<%!-- Filter pills --%>
<div class="mt-4 flex items-center gap-2">
<.link
id="filter-all"
patch={~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices?filter=all"}
:for={{f, label} <- [{"all", "All"}, {"unmatched", "Unmatched"}, {"matched", "Matched"}]}
id={"filter-#{f}"}
patch={~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices?filter=#{f}"}
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",
"rounded-full px-3 py-1 text-xs font-medium",
if(@filter == f,
do: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300",
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"
"bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-white/5 dark:text-gray-400 dark:hover:bg-white/10"
)
]}
>
{t("All")}
{label}
</.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"
)
]}
>
{t("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"
)
]}
>
{t("Matched")}
</.link>
</nav>
</div>
<%!-- Access points table --%>
@ -120,13 +89,7 @@
scope="col"
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
>
{t("Name")}
</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"
>
{t("Preseem ID")}
{t("Access Point")}
</th>
<th
scope="col"
@ -158,11 +121,26 @@
<%= for ap <- @access_points do %>
<tr
id={"ap-row-#{ap.id}"}
class="hover:bg-gray-50 dark:hover:bg-white/5"
class={[
"hover:bg-gray-50 dark:hover:bg-white/5",
if(is_nil(ap.device), do: "cursor-pointer")
]}
phx-click={if(is_nil(ap.device), do: "start_link")}
phx-value-ap-id={if(is_nil(ap.device), do: ap.id)}
>
<td class="px-4 py-4 text-sm font-medium text-gray-900 dark:text-white">
<span class="break-all">{ap.name || "Unnamed"}</span>
<span class="block text-xs text-gray-400 dark:text-gray-500 sm:hidden">
<td class="px-4 py-4 text-sm">
<div>
<div class="flex flex-wrap items-center gap-2">
<span class="break-all font-medium text-gray-900 dark:text-white">
{ap.name || "Unnamed"}
</span>
<%= if Map.has_key?(@suggestions, ap.id) do %>
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
<.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" /> Match found
</span>
<% end %>
</div>
<span class="mt-0.5 block text-xs text-gray-400 dark:text-gray-500 sm:hidden">
{ap.preseem_id}
</span>
<span class={[
@ -171,12 +149,22 @@
]}>
{humanize_confidence(ap.match_confidence)}
</span>
</td>
<td class="hidden whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400 sm:table-cell">
{ap.preseem_id}
</div>
</td>
<td class="hidden whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400 md:table-cell">
{ap.ip_address || "-"}
<%= if ap.ip_address do %>
<a
href={"http://#{ap.ip_address}"}
target="_blank"
rel="noopener noreferrer"
class="font-mono hover:text-indigo-600 dark:hover:text-indigo-400"
onclick="event.stopPropagation()"
>
{ap.ip_address}
</a>
<% else %>
<% end %>
</td>
<td class="hidden whitespace-nowrap px-4 py-4 text-sm sm:table-cell">
<span class={[
@ -191,11 +179,14 @@
<.link
navigate={~p"/devices/#{ap.device.id}"}
class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
onclick="event.stopPropagation()"
>
{ap.device.name || ap.device.ip_address}
</.link>
<% else %>
<span class="text-gray-400 dark:text-gray-500">-</span>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-500 dark:bg-white/10 dark:text-gray-400">
Not linked
</span>
<% end %>
</td>
<td class="whitespace-nowrap px-4 py-4 text-right text-sm">
@ -207,6 +198,7 @@
phx-value-ap-id={ap.id}
data-confirm={t("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"
onclick="event.stopPropagation()"
>
{t("Unlink")}
</button>
@ -217,6 +209,7 @@
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"
onclick="event.stopPropagation()"
>
{t("Link")}
</button>
@ -226,13 +219,47 @@
<%!-- Inline linking row --%>
<%= if 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">
<td colspan="5" class="px-4 py-4">
<div class="flex items-start 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}"
Find a TowerOps device to link to
<span class="text-indigo-600 dark:text-indigo-400">{ap.name}</span>
</p>
<form id="device-search-form" phx-change="search_devices" class="flex gap-2">
<%!-- Suggestions --%>
<%= if suggestions = @suggestions[ap.id] do %>
<div class="mb-3">
<p class="mb-1 text-xs font-medium text-amber-700 dark:text-amber-400">
<.icon name="hero-light-bulb-mini" class="mr-0.5 inline h-3 w-3" />
{t("Suggested match (same IP):")}
</p>
<div class="flex flex-wrap gap-2">
<%= for suggestion <- suggestions do %>
<button
type="button"
phx-click="link_device"
phx-value-ap-id={ap.id}
phx-value-device-id={suggestion.device.id}
class="inline-flex items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2.5 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300 dark:hover:bg-amber-900/40"
>
{suggestion.device.name || suggestion.device.ip_address ||
"Unnamed"}
<span class="rounded bg-amber-200 px-1 text-amber-700 dark:bg-amber-800 dark:text-amber-300">
{suggestion.match_type}
</span>
</button>
<% end %>
</div>
</div>
<% end %>
<%!-- Search --%>
<form
id="device-search-form"
phx-change="search_devices"
class="flex gap-2"
>
<input
type="text"
id="device-search-input"
@ -260,10 +287,10 @@
<span class="font-medium text-gray-900 dark:text-white">
{device.name || "Unnamed"}
</span>
<span class="text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-2 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">
<span class="text-xs text-gray-400 dark:text-gray-500">
{device.site.name}
</span>
<% end %>
@ -284,7 +311,7 @@
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"
class="flex-shrink-0 self-start text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
{t("Cancel")}
</button>

View file

@ -249,6 +249,118 @@ defmodule ToweropsWeb.Org.PreseemDevicesLiveTest do
end
end
describe "suggestions" do
test "shows match found badge when AP IP matches a TowerOps device", %{
conn: conn,
user: user,
organization: org
} do
insert_access_point!(org, %{
name: "AP with IP",
ip_address: "10.0.0.5",
match_confidence: "unmatched"
})
device_fixture(%{organization_id: org.id, name: "Matching Device", ip_address: "10.0.0.5"})
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices")
assert html =~ "Match found"
end
test "shows suggestion chip in linking row when IP matches", %{
conn: conn,
user: user,
organization: org
} do
ap =
insert_access_point!(org, %{
name: "AP with IP",
ip_address: "10.0.0.5",
match_confidence: "unmatched"
})
device_fixture(%{organization_id: org.id, name: "Matching Device", ip_address: "10.0.0.5"})
{: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 = render(view)
assert html =~ "Matching Device"
assert html =~ "Suggested match"
end
test "APs with suggestions sort before unmatched APs without suggestions", %{
conn: conn,
user: user,
organization: org
} do
insert_access_point!(org, %{
name: "No Match AP",
ip_address: "10.0.0.99",
match_confidence: "unmatched"
})
ap_with_suggestion =
insert_access_point!(org, %{
name: "AP with Suggestion",
ip_address: "10.0.0.5",
match_confidence: "unmatched"
})
device_fixture(%{organization_id: org.id, name: "Matching Device", ip_address: "10.0.0.5"})
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices")
suggestion_pos = html |> :binary.match(ap_with_suggestion.name) |> elem(0)
no_match_pos = html |> :binary.match("No Match AP") |> elem(0)
assert suggestion_pos < no_match_pos
end
test "does not suggest a device already linked to another AP", %{
conn: conn,
user: user,
organization: org
} do
already_linked_device =
device_fixture(%{
organization_id: org.id,
name: "Already Linked",
ip_address: "10.0.0.5"
})
insert_access_point!(org, %{
name: "Other AP",
ip_address: "10.0.0.5",
device_id: already_linked_device.id,
match_confidence: "manual"
})
insert_access_point!(org, %{
name: "Target AP",
ip_address: "10.0.0.5",
match_confidence: "unmatched"
})
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices")
refute html =~ "Match found"
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})