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
+
+
+
+
+
+
+
+ Showing
+ {(@pagination.page - 1) * @pagination.per_page + 1}
+ to
+ {min(@pagination.page * @pagination.per_page, @pagination.total_count)}
+ of
+ {@pagination.total_count}
+ results
+
+
+
+
+
+
+
+<% 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
+
+
+
+
+
+ Showing
+
+ {(@pagination.page - 1) * @pagination.per_page + 1}
+
+ to
+
+ {min(@pagination.page * @pagination.per_page, @pagination.total_count)}
+
+ of {@pagination.total_count}
+ results
+
+
+
+
+
+
+
+ <% end %>
<% end %>
<% end %>