paginate discovered devices
This commit is contained in:
parent
30f6f41c4d
commit
efc6e7a3ab
4 changed files with 297 additions and 4 deletions
164
CLAUDE.md
164
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 %>
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -142,5 +142,4 @@
|
|||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Layouts.authenticated>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue