From 04c33b0719cb0736fadfefe4de82771c584e6567 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 1 Feb 2026 13:27:56 -0600 Subject: [PATCH] paginate audit logs --- CLAUDE.md | 180 +++++------------- .../components/core_components.ex | 147 ++++++++++++++ lib/towerops_web/live/admin/dashboard_live.ex | 38 +++- .../live/admin/dashboard_live.html.heex | 11 +- 4 files changed, 238 insertions(+), 138 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 93603864..654195fc 100644 --- a/CLAUDE.md +++ b/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,136 +528,62 @@ defp apply_action(socket, :index, params, "discovered") do 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 - }) + {:noreply, + 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:** +**2. Use the `<.pagination>` component in your 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 - -
+ +
+ <%= for item <- @items do %> + + <% end %> +
- - -
-<% end %> + +<.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 diff --git a/lib/towerops_web/components/core_components.ex b/lib/towerops_web/components/core_components.ex index d7acde65..388e2ad4 100644 --- a/lib/towerops_web/components/core_components.ex +++ b/lib/towerops_web/components/core_components.ex @@ -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 %> +
+ +
+ <.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 + :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 + +
+ + +
+ <% 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. """ diff --git a/lib/towerops_web/live/admin/dashboard_live.ex b/lib/towerops_web/live/admin/dashboard_live.ex index 53420988..05a7d0db 100644 --- a/lib/towerops_web/live/admin/dashboard_live.ex +++ b/lib/towerops_web/live/admin/dashboard_live.ex @@ -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 diff --git a/lib/towerops_web/live/admin/dashboard_live.html.heex b/lib/towerops_web/live/admin/dashboard_live.html.heex index ef28de37..227d3e6a 100644 --- a/lib/towerops_web/live/admin/dashboard_live.html.heex +++ b/lib/towerops_web/live/admin/dashboard_live.html.heex @@ -60,8 +60,14 @@
-
+

Recent Audit Logs

+ <.link + navigate={~p"/admin/audit"} + class="text-sm text-blue-600 dark:text-blue-400 hover:underline" + > + View all → +
<.table id="audit-logs" rows={@recent_logs}> <:col :let={log} label="Action">{log.action} @@ -71,6 +77,9 @@ {ToweropsWeb.TimeHelpers.format_iso8601(log.inserted_at, @timezone)} +
+ <.pagination meta={@pagination} path={~p"/admin"} /> +