paginate audit logs

This commit is contained in:
Graham McIntire 2026-02-01 13:27:56 -06:00
parent 7232dab601
commit 04c33b0719
No known key found for this signature in database
4 changed files with 238 additions and 138 deletions

164
CLAUDE.md
View file

@ -507,25 +507,17 @@ Available at `/docs/api` (adapted from Tailwind UI Protocol template).
### Pagination in LiveView
Simple offset-based pagination pattern for lists. Example from `DeviceLive.Index` (discovered devices tab):
Simple offset-based pagination using the reusable `<.pagination>` component from `CoreComponents`.
**1. Add pagination to socket assigns in `handle_params` or `apply_action`:**
**1. Add pagination metadata 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)
all_items = MyContext.list_items(socket.assigns.current_scope.organization.id)
total_count = length(all_items)
total_pages = ceil(total_count / per_page)
@ -536,6 +528,7 @@ defp apply_action(socket, :index, params, "discovered") do
offset = (page - 1) * per_page
items = Enum.slice(all_items, offset, per_page)
{:noreply,
socket
|> assign(:items, items)
|> assign(:pagination, %{
@ -543,129 +536,54 @@ defp apply_action(socket, :index, params, "discovered") do
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:**
**2. Use the `<.pagination>` component in your 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 %>
<!-- Your list/table here -->
<div id="items">
<%= for item <- @items do %>
<!-- item rendering -->
<% end %>
</div>
<!-- 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 %>
<!-- Pagination component -->
<.pagination meta={@pagination} path={~p"/your-path"} />
```
**Preserving query parameters:**
If you need to preserve other query params (like tabs or filters), pass them via the `params` attribute:
```heex
<.pagination
meta={@pagination}
path={~p"/devices"}
params={%{"tab" => @current_tab, "filter" => @filter}}
/>
```
**Component Reference:**
The `<.pagination>` component (defined in `CoreComponents`) accepts:
- `meta` (required) - Map with `:page`, `:per_page`, `:total_count`, `:total_pages`
- `path` (required) - Base path for pagination links (e.g., `~p"/admin"`)
- `params` (optional) - Additional query params to preserve (default: `%{}`)
**Features:**
- Mobile: Simple Previous/Next buttons
- Desktop: Full page numbers with smart ellipsis (shows [1] ... [4] [5] [6] ... [20])
- Automatically builds pagination URLs preserving query params
- Only renders if `total_pages > 1`
- Uses `patch` navigation to avoid full page reload
**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
- The component handles URL building automatically - just provide the base path and params
- See `lib/towerops_web/live/admin/dashboard_live.ex` for a complete example
## Testing Patterns

View file

@ -597,6 +597,153 @@ defmodule ToweropsWeb.CoreComponents do
)
end
@doc """
Renders pagination controls with page numbers and ellipsis.
Supports both mobile (Previous/Next only) and desktop (full page numbers) views.
## Examples
<.pagination
meta={@pagination}
path={~p"/admin"}
/>
<.pagination
meta={@pagination}
path={~p"/devices"}
params={%{"tab" => @current_tab}}
/>
"""
attr :meta, :map, required: true, doc: "Pagination metadata with :page, :per_page, :total_count, :total_pages"
attr :path, :string, required: true, doc: "Base path for pagination links"
attr :params, :map, default: %{}, doc: "Additional query params to preserve"
def pagination(assigns) do
~H"""
<%= if @meta.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={@meta.page > 1}
patch={build_pagination_path(@path, @params, @meta.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={@meta.page < @meta.total_pages}
patch={build_pagination_path(@path, @params, @meta.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">{(@meta.page - 1) * @meta.per_page + 1}</span>
to
<span class="font-medium">{min(@meta.page * @meta.per_page, @meta.total_count)}</span>
of <span class="font-medium">{@meta.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={@meta.page > 1}
patch={build_pagination_path(@path, @params, @meta.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(@meta.page, @meta.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={build_pagination_path(@path, @params, 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 @meta.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={@meta.page < @meta.total_pages}
patch={build_pagination_path(@path, @params, @meta.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
# 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
# Build pagination path with preserved query params
defp build_pagination_path(base_path, params, page) do
query_params = Map.put(params, "page", page)
query_string = URI.encode_query(query_params)
if query_string == "" do
base_path
else
"#{base_path}?#{query_string}"
end
end
@doc """
Translates an error message using gettext.
"""

View file

@ -8,16 +8,42 @@ defmodule ToweropsWeb.Admin.DashboardLive do
@impl true
def mount(_params, _session, socket) do
user_count = Admin.count_users()
org_count = Admin.count_organizations()
recent_logs = Admin.list_audit_logs(limit: 10)
{:ok,
socket
|> assign(:page_title, "Admin Dashboard")
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:timezone, socket.assigns.current_scope.timezone)}
end
@impl true
def handle_params(params, _url, socket) do
page = params |> Map.get("page", "1") |> String.to_integer()
per_page = 10
user_count = Admin.count_users()
org_count = Admin.count_organizations()
# Fetch all recent logs (limited to last 100 for performance)
all_logs = Admin.list_audit_logs(limit: 100)
total_count = length(all_logs)
total_pages = ceil(total_count / per_page)
# Ensure page is within valid range
page = max(1, min(page, max(1, total_pages)))
# Slice logs for current page
offset = (page - 1) * per_page
recent_logs = Enum.slice(all_logs, offset, per_page)
{:noreply,
socket
|> assign(:user_count, user_count)
|> assign(:org_count, org_count)
|> assign(:recent_logs, recent_logs)}
|> assign(:recent_logs, recent_logs)
|> assign(:pagination, %{
page: page,
per_page: per_page,
total_count: total_count,
total_pages: total_pages
})}
end
end

View file

@ -60,8 +60,14 @@
</div>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Audit Logs</h2>
<.link
navigate={~p"/admin/audit"}
class="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View all →
</.link>
</div>
<.table id="audit-logs" rows={@recent_logs}>
<:col :let={log} label="Action">{log.action}</:col>
@ -71,6 +77,9 @@
{ToweropsWeb.TimeHelpers.format_iso8601(log.inserted_at, @timezone)}
</:col>
</.table>
<div class="px-4 pb-3">
<.pagination meta={@pagination} path={~p"/admin"} />
</div>
</div>
</div>
</Layouts.admin>