diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 14d0028f..f3e4755c 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -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)
diff --git a/lib/towerops_web/live/org/preseem_devices_live.ex b/lib/towerops_web/live/org/preseem_devices_live.ex
index 53ab17dc..87d66bf4 100644
--- a/lib/towerops_web/live/org/preseem_devices_live.ex
+++ b/lib/towerops_web/live/org/preseem_devices_live.ex
@@ -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
diff --git a/lib/towerops_web/live/org/preseem_devices_live.html.heex b/lib/towerops_web/live/org/preseem_devices_live.html.heex
index edcbb62a..95ee933c 100644
--- a/lib/towerops_web/live/org/preseem_devices_live.html.heex
+++ b/lib/towerops_web/live/org/preseem_devices_live.html.heex
@@ -13,7 +13,9 @@
{t("Preseem Devices")}
- {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."
+ )}