paginate audit logs
This commit is contained in:
parent
7232dab601
commit
04c33b0719
4 changed files with 238 additions and 138 deletions
164
CLAUDE.md
164
CLAUDE.md
|
|
@ -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>
|
||||
<!-- Your list/table here -->
|
||||
<div id="items">
|
||||
<%= for item <- @items do %>
|
||||
<!-- item rendering -->
|
||||
<% end %>
|
||||
</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 %>
|
||||
<!-- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue