paginate discovered devices

This commit is contained in:
Graham McIntire 2026-01-31 10:31:37 -06:00
parent 30f6f41c4d
commit efc6e7a3ab
4 changed files with 297 additions and 4 deletions

164
CLAUDE.md
View file

@ -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 %>
<div class="mt-6 flex items-center justify-between border-t border-gray-200 dark:border-white/10 pt-6">
<!-- Mobile: Previous/Next only -->
<div class="flex flex-1 justify-between sm:hidden">
<.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>
<.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
</.link>
</div>
<!-- Desktop: Full pagination with page numbers -->
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700 dark:text-gray-400">
Showing
<span class="font-medium">{(@pagination.page - 1) * @pagination.per_page + 1}</span>
to
<span class="font-medium">{min(@pagination.page * @pagination.per_page, @pagination.total_count)}</span>
of
<span class="font-medium">{@pagination.total_count}</span>
results
</p>
</div>
<div>
<nav class="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
<!-- Previous button -->
<.link
:if={@pagination.page > 1}
patch={~p"/path?page=#{@pagination.page - 1}"}
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:ring-white/10 dark:hover:bg-gray-800"
>
<span class="sr-only">Previous</span>
<.icon name="hero-chevron-left" class="h-5 w-5" />
</.link>
<!-- Page numbers with ellipsis -->
<%= for page_num <- pagination_range(@pagination.page, @pagination.total_pages) do %>
<%= if page_num == :ellipsis do %>
<span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300 dark:text-gray-400 dark:ring-white/10">
...
</span>
<% else %>
<.link
patch={~p"/path?page=#{page_num}"}
class={[
"relative inline-flex items-center px-4 py-2 text-sm font-semibold ring-1 ring-inset ring-gray-300 dark:ring-white/10",
if @pagination.page == page_num do
"z-10 bg-blue-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
else
"text-gray-900 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
end
]}
>
{page_num}
</.link>
<% end %>
<% end %>
<!-- Next button -->
<.link
:if={@pagination.page < @pagination.total_pages}
patch={~p"/path?page=#{@pagination.page + 1}"}
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:ring-white/10 dark:hover:bg-gray-800"
>
<span class="sr-only">Next</span>
<.icon name="hero-chevron-right" class="h-5 w-5" />
</.link>
</nav>
</div>
</div>
</div>
<% 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

View file

@ -142,5 +142,4 @@
</.link>
</div>
</div>
</Layouts.authenticated>

View file

@ -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

View file

@ -368,6 +368,13 @@
</p>
</div>
<% else %>
<div class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Showing {(@pagination.page - 1) * @pagination.per_page + 1}-{min(
@pagination.page * @pagination.per_page,
@pagination.total_count
)} of {@pagination.total_count} discovered devices
</div>
<.table
id="discovered-devices"
rows={@discovered_devices}
@ -472,6 +479,89 @@
</.button>
</:col>
</.table>
<%= if @pagination.total_pages > 1 do %>
<div class="mt-6 flex items-center justify-between border-t border-gray-200 dark:border-white/10 pt-6">
<div class="flex flex-1 justify-between sm:hidden">
<.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>
<.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
</.link>
</div>
<div class="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700 dark:text-gray-400">
Showing
<span class="font-medium">
{(@pagination.page - 1) * @pagination.per_page + 1}
</span>
to
<span class="font-medium">
{min(@pagination.page * @pagination.per_page, @pagination.total_count)}
</span>
of <span class="font-medium">{@pagination.total_count}</span>
results
</p>
</div>
<div>
<nav
class="isolate inline-flex -space-x-px rounded-md shadow-sm"
aria-label="Pagination"
>
<.link
:if={@pagination.page > 1}
patch={~p"/devices?tab=discovered&page=#{@pagination.page - 1}"}
class="relative inline-flex items-center rounded-l-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:ring-white/10 dark:hover:bg-gray-800"
>
<span class="sr-only">Previous</span>
<.icon name="hero-chevron-left" class="h-5 w-5" />
</.link>
<%= for page_num <- pagination_range(@pagination.page, @pagination.total_pages) do %>
<%= if page_num == :ellipsis do %>
<span class="relative inline-flex items-center px-4 py-2 text-sm font-semibold text-gray-700 ring-1 ring-inset ring-gray-300 dark:text-gray-400 dark:ring-white/10">
...
</span>
<% else %>
<.link
patch={~p"/devices?tab=discovered&page=#{page_num}"}
class={[
"relative inline-flex items-center px-4 py-2 text-sm font-semibold ring-1 ring-inset ring-gray-300 dark:ring-white/10",
if @pagination.page == page_num do
"z-10 bg-blue-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
else
"text-gray-900 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
end
]}
>
{page_num}
</.link>
<% end %>
<% end %>
<.link
:if={@pagination.page < @pagination.total_pages}
patch={~p"/devices?tab=discovered&page=#{@pagination.page + 1}"}
class="relative inline-flex items-center rounded-r-md px-2 py-2 text-gray-400 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:ring-white/10 dark:hover:bg-gray-800"
>
<span class="sr-only">Next</span>
<.icon name="hero-chevron-right" class="h-5 w-5" />
</.link>
</nav>
</div>
</div>
</div>
<% end %>
<% end %>
<% end %>
</Layouts.authenticated>