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." + )}

@@ -35,56 +37,23 @@ - <%!-- Filter tabs --%> -
- + <%!-- Filter pills --%> +
+ <.link + :for={{f, label} <- [{"all", "All"}, {"unmatched", "Unmatched"}, {"matched", "Matched"}]} + id={"filter-#{f}"} + patch={~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices?filter=#{f}"} + class={[ + "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: + "bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-white/5 dark:text-gray-400 dark:hover:bg-white/10" + ) + ]} + > + {label} +
<%!-- 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")} - - - {t("Preseem ID")} + {t("Access Point")} <%= for ap <- @access_points do %> - - - {ap.name || "Unnamed"} - - {ap.preseem_id} - - - {humanize_confidence(ap.match_confidence)} - - - - {ap.preseem_id} - - - {ap.ip_address || "-"} - - - - {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 %> - - - <%!-- Inline linking row --%> - <%= if ap.id == @linking_ap_id do %> - - -
-
-

- 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}" -

+ + +
+
+ + {ap.name || "Unnamed"} + + <%= if Map.has_key?(@suggestions, ap.id) do %> + + <.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" /> Match found + <% end %>
- + + {ap.preseem_id} + + + {humanize_confidence(ap.match_confidence)} +
+ + <%= if ap.ip_address do %> + + {ap.ip_address} + + <% else %> + — + <% end %> + + + + {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" + onclick="event.stopPropagation()" + > + {ap.device.name || ap.device.ip_address} + + <% else %> + + Not linked + + <% end %> + + + <%= if ap.device do %> + + <% else %> + + <% end %> + - <% end %> + <%!-- Inline linking row --%> + <%= if ap.id == @linking_ap_id do %> + + +
+
+

+ Find a TowerOps device to link to + {ap.name} +

+ + <%!-- Suggestions --%> + <%= if suggestions = @suggestions[ap.id] do %> +
+

+ <.icon name="hero-light-bulb-mini" class="mr-0.5 inline h-3 w-3" /> + {t("Suggested match (same IP):")} +

+
+ <%= for suggestion <- suggestions do %> + + <% end %> +
+
+ <% end %> + + <%!-- Search --%> +
+ +
+ + <%= 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 %> <% 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 index 63efd1a0..abb33147 100644 --- a/test/towerops_web/live/org/preseem_devices_live_test.exs +++ b/test/towerops_web/live/org/preseem_devices_live_test.exs @@ -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})