From efc6e7a3abb0510ddd2d4b53743ee38dcf072129 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 31 Jan 2026 10:31:37 -0600 Subject: [PATCH] paginate discovered devices --- CLAUDE.md | 164 ++++++++++++++++++ .../user_settings_html/edit.html.heex | 1 - lib/towerops_web/live/device_live/index.ex | 46 ++++- .../live/device_live/index.html.heex | 90 ++++++++++ 4 files changed, 297 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f00ddb3e..d7b4c91a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -503,6 +503,170 @@ Available at `/docs/api` (adapted from Tailwind UI Protocol template). - Certificates: `kubectl describe certificate towerops-net-cert -n towerops` - Pods: `kubectl describe pod -n towerops -l app=towerops` +## Common Patterns + +### Pagination in LiveView + +Simple offset-based pagination pattern for lists. Example from `DeviceLive.Index` (discovered devices tab): + +**1. Add pagination to socket assigns in `handle_params` or `apply_action`:** + +```elixir +def handle_params(params, _url, socket) do + tab = Map.get(params, "tab", "existing") + {:noreply, apply_action(socket, socket.assigns.live_action, params, tab)} +end + +defp apply_action(socket, :index, params, "discovered") do + organization = socket.assigns.current_scope.organization + + # Get page from params, default to 1 + page = params |> Map.get("page", "1") |> String.to_integer() + per_page = 20 + + # Fetch all items (or use a paginated query for large datasets) + all_items = MyContext.list_items(organization.id) + total_count = length(all_items) + total_pages = ceil(total_count / per_page) + + # Ensure page is within valid range + page = max(1, min(page, max(1, total_pages))) + + # Slice items for current page + offset = (page - 1) * per_page + items = Enum.slice(all_items, offset, per_page) + + socket + |> assign(:items, items) + |> assign(:pagination, %{ + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages + }) +end +``` + +**2. Add pagination helper function to LiveView module:** + +```elixir +# Generate pagination range with ellipsis for large page counts +# Shows: [1] ... [4] [5] [6] ... [20] when on page 5 of 20 +defp pagination_range(current_page, total_pages) do + cond do + total_pages <= 7 -> + # Show all pages if 7 or fewer + Enum.to_list(1..total_pages) + + current_page <= 4 -> + # Near the start + [1, 2, 3, 4, 5, :ellipsis, total_pages] + + current_page >= total_pages - 3 -> + # Near the end + [1, :ellipsis, total_pages - 4, total_pages - 3, total_pages - 2, total_pages - 1, total_pages] + + true -> + # In the middle + [1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages] + end +end +``` + +**3. Add pagination controls to template:** + +```heex +<%= if @pagination.total_pages > 1 do %> +
+ +
+ <.link + :if={@pagination.page > 1} + patch={~p"/path?page=#{@pagination.page - 1}"} + class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" + > + Previous + + <.link + :if={@pagination.page < @pagination.total_pages} + patch={~p"/path?page=#{@pagination.page + 1}"} + class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" + > + Next + +
+ + + +
+<% end %> +``` + +**Notes**: +- Use `patch` instead of `navigate` to keep LiveView mounted and avoid full page reload +- Preserve other query params when changing pages (e.g., `~p"/path?tab=#{@tab}&page=#{page}"`) +- For very large datasets (>10,000 items), use database-level pagination with `limit/offset` instead of in-memory slicing +- The `pagination_range/2` function creates smart ellipsis to avoid showing too many page numbers +- Mobile shows simple Previous/Next, desktop shows full pagination controls + ## Testing Patterns ### SNMP Mocking with Mox diff --git a/lib/towerops_web/controllers/user_settings_html/edit.html.heex b/lib/towerops_web/controllers/user_settings_html/edit.html.heex index b4155e26..9525ec62 100644 --- a/lib/towerops_web/controllers/user_settings_html/edit.html.heex +++ b/lib/towerops_web/controllers/user_settings_html/edit.html.heex @@ -142,5 +142,4 @@ - diff --git a/lib/towerops_web/live/device_live/index.ex b/lib/towerops_web/live/device_live/index.ex index 5dce64c1..c4e8765c 100644 --- a/lib/towerops_web/live/device_live/index.ex +++ b/lib/towerops_web/live/device_live/index.ex @@ -35,21 +35,39 @@ defmodule ToweropsWeb.DeviceLive.Index do {:noreply, apply_action(socket, socket.assigns.live_action, params, tab)} end - defp apply_action(socket, :index, _params, tab) do + defp apply_action(socket, :index, params, tab) do organization = socket.assigns.current_scope.organization case tab do "discovered" -> - discovered = Snmp.list_discovered_devices_for_organization(organization.id) + page = params |> Map.get("page", "1") |> String.to_integer() + per_page = 20 + + all_discovered = Snmp.list_discovered_devices_for_organization(organization.id) + total_count = length(all_discovered) + total_pages = ceil(total_count / per_page) + + # Ensure page is within valid range + page = max(1, min(page, max(1, total_pages))) + + offset = (page - 1) * per_page + discovered_devices = Enum.slice(all_discovered, offset, per_page) socket |> assign(:active_tab, "discovered") - |> assign(:discovered_devices, discovered) + |> assign(:discovered_devices, discovered_devices) + |> assign(:pagination, %{ + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages + }) _ -> socket |> assign(:active_tab, "existing") |> assign(:discovered_devices, []) + |> assign(:pagination, nil) end end @@ -233,4 +251,26 @@ defmodule ToweropsWeb.DeviceLive.Index do defp device_type_label(type) do type |> to_string() |> String.capitalize() end + + # Generate pagination range with ellipsis for large page counts + # Shows: [1] ... [4] [5] [6] ... [20] when on page 5 of 20 + defp pagination_range(current_page, total_pages) do + cond do + total_pages <= 7 -> + # Show all pages if 7 or fewer + Enum.to_list(1..total_pages) + + current_page <= 4 -> + # Near the start + [1, 2, 3, 4, 5, :ellipsis, total_pages] + + current_page >= total_pages - 3 -> + # Near the end + [1, :ellipsis, total_pages - 4, total_pages - 3, total_pages - 2, total_pages - 1, total_pages] + + true -> + # In the middle + [1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages] + end + end end diff --git a/lib/towerops_web/live/device_live/index.html.heex b/lib/towerops_web/live/device_live/index.html.heex index 5efdd65b..6fbad8b4 100644 --- a/lib/towerops_web/live/device_live/index.html.heex +++ b/lib/towerops_web/live/device_live/index.html.heex @@ -368,6 +368,13 @@

<% else %> +
+ Showing {(@pagination.page - 1) * @pagination.per_page + 1}-{min( + @pagination.page * @pagination.per_page, + @pagination.total_count + )} of {@pagination.total_count} discovered devices +
+ <.table id="discovered-devices" rows={@discovered_devices} @@ -472,6 +479,89 @@ + + <%= if @pagination.total_pages > 1 do %> +
+
+ <.link + :if={@pagination.page > 1} + patch={~p"/devices?tab=discovered&page=#{@pagination.page - 1}"} + class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" + > + Previous + + <.link + :if={@pagination.page < @pagination.total_pages} + patch={~p"/devices?tab=discovered&page=#{@pagination.page + 1}"} + class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" + > + Next + +
+ +
+ <% end %> <% end %> <% end %>