From b139b4eb73206deee1e5ce1ad6e5977a68c87f7e Mon Sep 17 00:00:00 2001 From: erwill2 Date: Mon, 13 Jul 2026 22:33:20 -0500 Subject: [PATCH] feat(admin): paginate audit and request logs Add shared, filter-preserving pagination controls and bounded page parsing for both admin log views. Clamp empty and overflow pages, preserve request-log page state during event refresh, and cover the helper plus LiveView navigation regressions. --- .../components/pages/audit_logs/components.ex | 24 +++ .../pages/request_logs/presentation.ex | 29 ++- .../live/admin/log_pagination.ex | 194 ++++++++++++++++++ .../live/admin/pages/audit_logs_live.ex | 62 ++++-- .../live/admin/pages/request_logs_live.ex | 123 +++++++---- .../live/admin/log_pagination_test.exs | 56 +++++ .../live/admin/pages/audit_logs_live_test.exs | 54 +++++ .../admin/pages/request_logs_live_test.exs | 87 ++++++++ 8 files changed, 573 insertions(+), 56 deletions(-) create mode 100644 lib/codex_pooler_web/live/admin/log_pagination.ex create mode 100644 test/codex_pooler_web/live/admin/log_pagination_test.exs diff --git a/lib/codex_pooler_web/live/admin/components/pages/audit_logs/components.ex b/lib/codex_pooler_web/live/admin/components/pages/audit_logs/components.ex index f4bdb942..40e11f96 100644 --- a/lib/codex_pooler_web/live/admin/components/pages/audit_logs/components.ex +++ b/lib/codex_pooler_web/live/admin/components/pages/audit_logs/components.ex @@ -4,6 +4,7 @@ defmodule CodexPoolerWeb.Admin.AuditLogsComponents do use CodexPoolerWeb, :html alias CodexPoolerWeb.Admin.AuditLogsComponents.Filters + alias CodexPoolerWeb.Admin.LogPagination import CodexPoolerWeb.Admin.AuditLogsComponents.Presentation, only: [ @@ -23,6 +24,7 @@ defmodule CodexPoolerWeb.Admin.AuditLogsComponents do defdelegate audit_log_filters(assigns), to: Filters attr :audit_logs, :map, required: true + attr :current_params, :map, required: true attr :datetime_preferences, :map, required: true def audit_logs_table(assigns) do @@ -31,6 +33,17 @@ defmodule CodexPoolerWeb.Admin.AuditLogsComponents do id="admin-audit-logs" class="min-w-0 rounded-box border border-base-300 bg-base-100 shadow-sm" > + + + + """ end diff --git a/lib/codex_pooler_web/live/admin/components/pages/request_logs/presentation.ex b/lib/codex_pooler_web/live/admin/components/pages/request_logs/presentation.ex index 490d230e..00d666ef 100644 --- a/lib/codex_pooler_web/live/admin/components/pages/request_logs/presentation.ex +++ b/lib/codex_pooler_web/live/admin/components/pages/request_logs/presentation.ex @@ -5,6 +5,7 @@ defmodule CodexPoolerWeb.Admin.RequestLogsPresentation do alias CodexPoolerWeb.Admin.BadgeComponents, as: AdminBadges alias CodexPoolerWeb.Admin.Components, as: AdminComponents + alias CodexPoolerWeb.Admin.LogPagination alias CodexPoolerWeb.Admin.RequestLogsPresentation.Usage import CodexPoolerWeb.Admin.RequestLogsDisplay, @@ -35,6 +36,7 @@ defmodule CodexPoolerWeb.Admin.RequestLogsPresentation do ] attr :request_logs, :map, required: true + attr :current_params, :map, required: true attr :datetime_preferences, :map, required: true def request_logs_table(assigns) do @@ -43,7 +45,8 @@ defmodule CodexPoolerWeb.Admin.RequestLogsPresentation do id="admin-request-logs" class={[ "min-w-0", - @request_logs.items != [] && "rounded-box border border-base-300 bg-base-100 shadow-sm" + (@request_logs.items != [] or @request_logs.total > 0) && + "rounded-box border border-base-300 bg-base-100 shadow-sm" ]} > + + + + """ end diff --git a/lib/codex_pooler_web/live/admin/log_pagination.ex b/lib/codex_pooler_web/live/admin/log_pagination.ex new file mode 100644 index 00000000..84b326db --- /dev/null +++ b/lib/codex_pooler_web/live/admin/log_pagination.ex @@ -0,0 +1,194 @@ +defmodule CodexPoolerWeb.Admin.LogPagination do + @moduledoc false + + use CodexPoolerWeb, :html + + @max_page 10_000 + + @type page_error :: %{required(:field) => :page, required(:message) => String.t()} + @type page_projection :: %{ + required(:total) => non_neg_integer(), + required(:limit) => pos_integer(), + required(:offset) => non_neg_integer() + } + + attr :page, :map, required: true + attr :base_path, :string, required: true + attr :current_params, :map, required: true + attr :id_prefix, :string, required: true + attr :range_id, :string, required: true + attr :range_role, :string, required: true + attr :label, :string, required: true + attr :placement, :atom, required: true, values: [:top, :bottom] + attr :show_border, :boolean, default: true + + def controls(assigns) do + assigns = assign(assigns, metadata(assigns.page)) + + ~H""" +
0} + class={[ + "border-base-300/70 py-3", + @show_border && @placement == :top && "border-b px-3", + @show_border && @placement == :bottom && "border-t px-3", + !@show_border && "px-3" + ]} + > + +
+ """ + end + + attr :id, :string, required: true + attr :label, :string, required: true + attr :enabled, :boolean, required: true + attr :path, :string, required: true + + defp pagination_link(assigns) do + ~H""" + <.link + :if={@enabled} + id={@id} + data-role="pagination-link" + patch={@path} + class="btn btn-sm join-item" + > + {@label} + + + {@label} + + """ + end + + @spec parse_page(map()) :: {pos_integer(), page_error() | nil} + def parse_page(params) when is_map(params) do + params + |> Map.get("page") + |> normalize_page_value() + |> parse_page_value() + end + + @spec offset(pos_integer(), pos_integer()) :: non_neg_integer() + def offset(page, page_size) when page > 0 and page_size > 0, do: (page - 1) * page_size + + @spec clamp_page(pos_integer(), page_projection()) :: pos_integer() + def clamp_page(page, %{total: total, limit: limit}) + when is_integer(page) and page > 0 and is_integer(total) and total >= 0 and + is_integer(limit) and limit > 0 do + min(page, total_pages(total, limit)) + end + + @spec put_page(map(), integer()) :: map() + def put_page(params, page) when is_map(params) and page <= 1 do + params + |> stringify_keys() + |> Map.delete("page") + end + + def put_page(params, page) when is_map(params) do + params + |> stringify_keys() + |> Map.put("page", Integer.to_string(page)) + end + + @spec metadata(page_projection()) :: map() + def metadata(%{total: total, limit: limit, offset: offset}) + when is_integer(total) and total >= 0 and is_integer(limit) and limit > 0 and + is_integer(offset) and offset >= 0 do + current_page = div(offset, limit) + 1 + + %{ + current_page: current_page, + total_pages: total_pages(total, limit), + previous_page: current_page - 1, + next_page: current_page + 1, + has_previous_page: offset > 0, + has_next_page: offset + limit < total, + range: range(total, limit, offset) + } + end + + @spec path(String.t(), map(), integer()) :: String.t() + def path(base_path, params, page) when is_binary(base_path) and is_map(params) do + query = + params + |> put_page(page) + |> Enum.reject(fn {_key, value} -> is_nil(value) or value == "" end) + |> Enum.sort_by(fn {key, _value} -> key end) + |> URI.encode_query() + + if query == "", do: base_path, else: base_path <> "?" <> query + end + + defp normalize_page_value(nil), do: nil + defp normalize_page_value(value) when is_binary(value), do: String.trim(value) + defp normalize_page_value(value) when is_integer(value), do: Integer.to_string(value) + defp normalize_page_value(_value), do: :invalid + + defp parse_page_value(value) when value in [nil, ""], do: {1, nil} + + defp parse_page_value(value) when is_binary(value) do + case Integer.parse(value) do + {page, ""} when page > 0 and page <= @max_page -> {page, nil} + _other -> invalid_page() + end + end + + defp parse_page_value(_value), do: invalid_page() + + defp invalid_page, + do: {1, %{field: :page, message: "Page must be an integer between 1 and 10,000"}} + + defp stringify_keys(params), + do: Map.new(params, fn {key, value} -> {to_string(key), value} end) + + defp total_pages(total, limit), do: max(div(total + limit - 1, limit), 1) + + defp range(0, _limit, _offset), do: "Showing 0 of 0" + + defp range(total, limit, offset) do + first = offset + 1 + last = min(offset + limit, total) + "Showing #{first}-#{last} of #{total}" + end + + defp pagination_label(label, :top), do: "#{label} pagination (top)" + defp pagination_label(label, :bottom), do: "#{label} pagination" +end diff --git a/lib/codex_pooler_web/live/admin/pages/audit_logs_live.ex b/lib/codex_pooler_web/live/admin/pages/audit_logs_live.ex index 00bb39e2..29c03d9c 100644 --- a/lib/codex_pooler_web/live/admin/pages/audit_logs_live.ex +++ b/lib/codex_pooler_web/live/admin/pages/audit_logs_live.ex @@ -4,6 +4,7 @@ defmodule CodexPoolerWeb.Admin.AuditLogsLive do alias CodexPooler.Audit alias CodexPooler.Pools alias CodexPoolerWeb.Admin.Components, as: AdminComponents + alias CodexPoolerWeb.Admin.LogPagination alias CodexPoolerWeb.Admin.PoolFilterComponents alias CodexPoolerWeb.DateTimeDisplay @@ -22,6 +23,7 @@ defmodule CodexPoolerWeb.Admin.AuditLogsLive do pools: [], selected_pool: nil, audit_logs: empty_audit_logs(), + current_params: %{}, selected_audit_event: nil, filter_form: to_form(%{}, as: :filters), filter_values: %{}, @@ -104,7 +106,11 @@ defmodule CodexPoolerWeb.Admin.AuditLogsLive do pool_filter_options={@pool_filter_options} /> - <.audit_logs_table audit_logs={@audit_logs} datetime_preferences={@datetime_preferences} /> + <.audit_logs_table + audit_logs={@audit_logs} + current_params={@current_params} + datetime_preferences={@datetime_preferences} + /> @@ -121,29 +127,57 @@ defmodule CodexPoolerWeb.Admin.AuditLogsLive do pools = Pools.list_log_filter_pools(socket.assigns.current_scope) {selected_pool, pool_error} = select_pool(pools, params["pool_id"]) {filters, form_values, filter_errors} = parse_filters(params, selected_pool) - filter_errors = Enum.reject([pool_error | filter_errors], &is_nil/1) + {page, page_error} = LogPagination.parse_page(params) + filter_errors = Enum.reject([pool_error, page_error | filter_errors], &is_nil/1) + offset = LogPagination.offset(page, @page_size) audit_logs = if selected_pool do - Audit.list_events(selected_pool, limit: @page_size, filters: filters) + Audit.list_events(selected_pool, limit: @page_size, offset: offset, filters: filters) else Audit.list_events_for_scope(socket.assigns.current_scope, limit: @page_size, + offset: offset, filters: filters ) end - assign(socket, - pools: pools, - selected_pool: selected_pool, - audit_logs: audit_logs, - selected_audit_event: - selected_audit_event(socket.assigns.selected_audit_event, audit_logs.items), - filter_form: to_form(form_values, as: :filters, errors: form_errors(filter_errors)), - filter_values: form_values, - filter_errors: filter_errors, - pool_filter_options: PoolFilterComponents.pool_filter_options(pools) - ) + case LogPagination.clamp_page(page, audit_logs) do + ^page -> + assign(socket, + pools: pools, + selected_pool: selected_pool, + audit_logs: audit_logs, + current_params: + params + |> normalize_query_params() + |> LogPagination.put_page(page), + selected_audit_event: + selected_audit_event(socket.assigns.selected_audit_event, audit_logs.items), + filter_form: to_form(form_values, as: :filters, errors: form_errors(filter_errors)), + filter_values: form_values, + filter_errors: filter_errors, + pool_filter_options: PoolFilterComponents.pool_filter_options(pools) + ) + + clamped_page -> + push_patch(socket, + to: + LogPagination.path( + "/admin/audit-logs", + normalize_query_params(params), + clamped_page + ) + ) + end + end + + defp normalize_query_params(params) do + params + |> Map.new(fn {key, value} -> {to_string(key), value} end) + |> Map.take(~w(pool_id outcome actor_type actor action target date_from date_to page)) + |> Enum.reject(fn {_key, value} -> is_nil(value) or value == "" end) + |> Map.new() end defp parse_filters(params, selected_pool) do diff --git a/lib/codex_pooler_web/live/admin/pages/request_logs_live.ex b/lib/codex_pooler_web/live/admin/pages/request_logs_live.ex index 384af7fd..470c9e5c 100644 --- a/lib/codex_pooler_web/live/admin/pages/request_logs_live.ex +++ b/lib/codex_pooler_web/live/admin/pages/request_logs_live.ex @@ -7,6 +7,7 @@ defmodule CodexPoolerWeb.Admin.RequestLogsLive do alias CodexPooler.Upstreams alias CodexPooler.Upstreams.Assignments, as: UpstreamAssignments alias CodexPoolerWeb.Admin.Components, as: AdminComponents + alias CodexPoolerWeb.Admin.LogPagination alias CodexPoolerWeb.Admin.PoolEventSubscriptions alias CodexPoolerWeb.Admin.PoolFilterComponents alias CodexPoolerWeb.Admin.RequestLogDetailDrawer @@ -34,6 +35,7 @@ defmodule CodexPoolerWeb.Admin.RequestLogsLive do selected_pool: nil, request_logs: empty_request_logs(), current_params: %{}, + current_page: 1, filter_form: to_form(%{}, as: :filters), filter_values: %{}, filter_errors: [], @@ -238,6 +240,7 @@ defmodule CodexPoolerWeb.Admin.RequestLogsLive do <.request_logs_table request_logs={@request_logs} + current_params={@current_params} datetime_preferences={@datetime_preferences} /> @@ -317,36 +320,54 @@ defmodule CodexPoolerWeb.Admin.RequestLogsLive do {filters, form_values, filter_errors} = RequestLogFilterForm.parse_filters(params, selected_pool, visible_upstream_identity_ids) - filter_errors = Enum.reject([pool_error | filter_errors], &is_nil/1) + {page, page_error} = LogPagination.parse_page(params) + filter_errors = Enum.reject([pool_error, page_error | filter_errors], &is_nil/1) visible_pool_ids = pool_ids(pools) - request_logs = request_logs(selected_pool, filters, visible_pool_ids) + request_logs = request_logs(selected_pool, filters, visible_pool_ids, page) model_filter_models = request_log_models(selected_pool, visible_pool_ids) - socket - |> cancel_request_logs_reload_timer() - |> maybe_subscribe_pool_events(pools, selected_pool) - |> assign( - pools: pools, - selected_pool: selected_pool, - request_logs: request_logs, - current_params: params, - filter_form: - to_form(form_values, - as: :filters, - errors: RequestLogFilterForm.form_errors(filter_errors) - ), - filter_values: form_values, - filter_errors: filter_errors, - pool_filter_options: PoolFilterComponents.pool_filter_options(pools), - model_filter_options: model_filter_options(model_filter_models, form_values["model"]), - upstream_account_options: upstream_account_options(upstream_filter_identities), - visible_pool_ids: visible_pool_ids, - request_log_filters: filters, - request_logs_loaded?: true - ) - |> assign_selected_request_log(params) - |> maybe_clear_missing_selected_request_log() - |> notify_request_logs_reload(reload_stage, started_at) + case LogPagination.clamp_page(page, request_logs) do + ^page -> + socket + |> cancel_request_logs_reload_timer() + |> maybe_subscribe_pool_events(pools, selected_pool) + |> assign( + pools: pools, + selected_pool: selected_pool, + request_logs: request_logs, + current_params: + params + |> normalize_request_log_query_params() + |> LogPagination.put_page(page), + current_page: page, + filter_form: + to_form(form_values, + as: :filters, + errors: RequestLogFilterForm.form_errors(filter_errors) + ), + filter_values: form_values, + filter_errors: filter_errors, + pool_filter_options: PoolFilterComponents.pool_filter_options(pools), + model_filter_options: model_filter_options(model_filter_models, form_values["model"]), + upstream_account_options: upstream_account_options(upstream_filter_identities), + visible_pool_ids: visible_pool_ids, + request_log_filters: filters, + request_logs_loaded?: true + ) + |> assign_selected_request_log(params) + |> maybe_clear_missing_selected_request_log() + |> notify_request_logs_reload(reload_stage, started_at) + + clamped_page -> + push_patch(socket, + to: + LogPagination.path( + "/admin/request-logs", + normalize_request_log_query_params(params), + clamped_page + ) + ) + end end defp refresh_request_logs_from_events(socket) do @@ -354,19 +375,33 @@ defmodule CodexPoolerWeb.Admin.RequestLogsLive do selected_pool = socket.assigns.selected_pool filters = socket.assigns.request_log_filters visible_pool_ids = socket.assigns.visible_pool_ids - request_logs = request_logs(selected_pool, filters, visible_pool_ids) + page = socket.assigns.current_page + request_logs = request_logs(selected_pool, filters, visible_pool_ids, page) model_filter_models = request_log_models(selected_pool, visible_pool_ids) - socket - |> cancel_request_logs_reload_timer() - |> assign( - request_logs: request_logs, - model_filter_options: - model_filter_options(model_filter_models, socket.assigns.filter_values["model"]) - ) - |> assign_selected_request_log(socket.assigns.current_params) - |> maybe_clear_missing_selected_request_log() - |> notify_request_logs_reload(:event_refresh, started_at) + case LogPagination.clamp_page(page, request_logs) do + ^page -> + socket + |> cancel_request_logs_reload_timer() + |> assign( + request_logs: request_logs, + model_filter_options: + model_filter_options(model_filter_models, socket.assigns.filter_values["model"]) + ) + |> assign_selected_request_log(socket.assigns.current_params) + |> maybe_clear_missing_selected_request_log() + |> notify_request_logs_reload(:event_refresh, started_at) + + clamped_page -> + push_patch(socket, + to: + LogPagination.path( + "/admin/request-logs", + socket.assigns.current_params, + clamped_page + ) + ) + end end defp assign_selected_request_log(socket, params) do @@ -426,13 +461,19 @@ defmodule CodexPoolerWeb.Admin.RequestLogsLive do |> Map.new() end - defp request_logs(selected_pool, filters, _visible_pool_ids) when not is_nil(selected_pool) do - Accounting.list_request_logs(selected_pool, limit: @page_size, filters: filters) + defp request_logs(selected_pool, filters, _visible_pool_ids, page) + when not is_nil(selected_pool) do + Accounting.list_request_logs(selected_pool, + limit: @page_size, + offset: LogPagination.offset(page, @page_size), + filters: filters + ) end - defp request_logs(_selected_pool, filters, visible_pool_ids) do + defp request_logs(_selected_pool, filters, visible_pool_ids, page) do Accounting.list_request_logs(nil, limit: @page_size, + offset: LogPagination.offset(page, @page_size), filters: filters, visible_pool_ids: visible_pool_ids ) diff --git a/test/codex_pooler_web/live/admin/log_pagination_test.exs b/test/codex_pooler_web/live/admin/log_pagination_test.exs new file mode 100644 index 00000000..ca3ccc2e --- /dev/null +++ b/test/codex_pooler_web/live/admin/log_pagination_test.exs @@ -0,0 +1,56 @@ +defmodule CodexPoolerWeb.Admin.LogPaginationTest do + use ExUnit.Case, async: true + + alias CodexPoolerWeb.Admin.LogPagination + + describe "parse_page/1" do + test "defaults missing and blank pages to the first page" do + assert {1, nil} = LogPagination.parse_page(%{}) + assert {1, nil} = LogPagination.parse_page(%{"page" => " "}) + end + + test "accepts trimmed positive integers" do + assert {3, nil} = LogPagination.parse_page(%{"page" => " 3 "}) + assert {4, nil} = LogPagination.parse_page(%{"page" => 4}) + end + + test "rejects non-positive, partial, and structured values without raising" do + for value <- ["0", "-1", "2x", "10001", %{"nested" => "1"}] do + assert {1, %{field: :page, message: "Page must be an integer between 1 and 10,000"}} = + LogPagination.parse_page(%{"page" => value}) + end + end + end + + test "computes offsets, bounds, navigation state, and visible ranges" do + assert LogPagination.offset(3, 50) == 100 + assert LogPagination.clamp_page(99, %{total: 51, limit: 50, offset: 4_900}) == 2 + assert LogPagination.clamp_page(7, %{total: 0, limit: 50, offset: 300}) == 1 + + assert %{ + current_page: 2, + total_pages: 2, + previous_page: 1, + next_page: 3, + has_previous_page: true, + has_next_page: false, + range: "Showing 51-51 of 51" + } = LogPagination.metadata(%{total: 51, limit: 50, offset: 50}) + + assert %{current_page: 1, total_pages: 1, range: "Showing 0 of 0"} = + LogPagination.metadata(%{total: 0, limit: 50, offset: 0}) + end + + test "builds deterministic paths while removing first-page and blank parameters" do + params = %{"model" => "gpt-5", "page" => "8", "status" => "", pool_id: "pool-1"} + + assert LogPagination.path("/admin/request-logs", params, 2) == + "/admin/request-logs?model=gpt-5&page=2&pool_id=pool-1" + + assert LogPagination.path("/admin/request-logs", params, 1) == + "/admin/request-logs?model=gpt-5&pool_id=pool-1" + + assert LogPagination.path("/admin/audit-logs", %{"page" => "3"}, 1) == + "/admin/audit-logs" + end +end diff --git a/test/codex_pooler_web/live/admin/pages/audit_logs_live_test.exs b/test/codex_pooler_web/live/admin/pages/audit_logs_live_test.exs index 857b8d1a..76eec0f1 100644 --- a/test/codex_pooler_web/live/admin/pages/audit_logs_live_test.exs +++ b/test/codex_pooler_web/live/admin/pages/audit_logs_live_test.exs @@ -703,6 +703,60 @@ defmodule CodexPoolerWeb.Admin.AuditLogsLiveTest do assert has_element?(owner_view, "#audit-log-row-#{nilified_event.id}", "Pool deleted") end + test "paginates audit logs, preserves filters, and clamps overflow pages", %{conn: conn} do + now = DateTime.utc_now() |> DateTime.truncate(:microsecond) + + for index <- 1..52 do + %AuditEvent{ + occurred_at: DateTime.add(now, -index, :second), + actor_type: "system", + action: "pool.update", + target_type: "pool", + target_id: Ecto.UUID.generate(), + outcome: "success", + details: %{"index" => index} + } + |> Repo.insert!() + end + + {:ok, view, _html} = live(conn, ~p"/admin/audit-logs?action=pool.update") + + assert has_element?(view, "#admin-audit-logs-pagination-top", "Page 1 of 2") + assert has_element?(view, "#admin-audit-logs-pagination-bottom", "Page 1 of 2") + assert has_element?(view, "#admin-audit-logs-range-top", "Showing 1-50 of 52") + assert has_element?(view, "#admin-audit-logs-range-bottom", "Showing 1-50 of 52") + assert has_element?(view, "#admin-audit-logs-pagination-top-prev.btn-disabled") + refute has_element?(view, "#admin-audit-logs-pagination-top-next.btn-disabled") + + view + |> element("#admin-audit-logs-pagination-top-next") + |> render_click() + + assert_patch(view, "/admin/audit-logs?action=pool.update&page=2") + assert has_element?(view, "#admin-audit-logs-pagination-bottom", "Page 2 of 2") + assert has_element?(view, "#admin-audit-logs-range-bottom", "Showing 51-52 of 52") + refute has_element?(view, "#admin-audit-logs-pagination-bottom-prev.btn-disabled") + assert has_element?(view, "#admin-audit-logs-pagination-bottom-next.btn-disabled") + + view + |> element("#admin-audit-logs-pagination-bottom-prev") + |> render_click() + + assert_patch(view, "/admin/audit-logs?action=pool.update") + + assert {:error, {:live_redirect, %{to: overflow_to}}} = + live(conn, ~p"/admin/audit-logs?action=pool.update&page=99") + + assert overflow_to == "/admin/audit-logs?action=pool.update&page=2" + end + + test "clamps an empty audit-log page to page one", %{conn: conn} do + assert {:error, {:live_redirect, %{to: overflow_to}}} = + live(conn, ~p"/admin/audit-logs?action=auth.logout&page=4") + + assert overflow_to == "/admin/audit-logs?action=auth.logout" + end + defp set_datetime_preferences!(user, attrs) do {1, _rows} = from(operator in User, where: operator.id == ^user.id) diff --git a/test/codex_pooler_web/live/admin/pages/request_logs_live_test.exs b/test/codex_pooler_web/live/admin/pages/request_logs_live_test.exs index f9a9d363..5f93f2a6 100644 --- a/test/codex_pooler_web/live/admin/pages/request_logs_live_test.exs +++ b/test/codex_pooler_web/live/admin/pages/request_logs_live_test.exs @@ -308,6 +308,14 @@ defmodule CodexPoolerWeb.Admin.RequestLogsLiveTest do assert has_element?(view, "#request-logs-table") assert has_element?(view, "#mobile-request-logs-table") assert has_element?(view, "#mobile-request-logs-table-body") + assert has_element?(view, "#admin-request-logs-pagination-top", "Page 1 of 1") + assert has_element?(view, "#admin-request-logs-pagination", "Page 1 of 1") + assert has_element?(view, "#admin-request-logs-range-top", "Showing") + assert has_element?(view, "#admin-request-logs-range", "Showing") + assert has_element?(view, "#admin-request-logs-pagination-top-prev.btn-disabled") + assert has_element?(view, "#admin-request-logs-pagination-prev.btn-disabled") + assert has_element?(view, "#admin-request-logs-pagination-top-next.btn-disabled") + assert has_element?(view, "#admin-request-logs-pagination-next.btn-disabled") assert has_element?(view, "#admin-request-logs", "Usage") assert has_element?(view, "#admin-request-logs", "$0.12") assert has_element?(view, "#request-log-row-#{request.id}", "Admin key") @@ -2092,6 +2100,84 @@ defmodule CodexPoolerWeb.Admin.RequestLogsLiveTest do ) end + test "paginates request logs, preserves filters, and clamps overflow pages", + %{conn: conn, scope: scope} do + {:ok, pool} = Pools.create_pool(scope, %{slug: "paginate-logs", name: "Paginate Logs"}) + reload_ref = attach_request_log_reload_telemetry() + now = DateTime.utc_now() |> DateTime.truncate(:microsecond) + + logs = + for index <- 1..51 do + request_log_fixture(pool, %{ + correlation_id: "req-paginate-#{index}", + requested_model: "gpt-paginate", + admitted_at: DateTime.add(now, -index, :second) + }).request + end + + newest = List.first(logs) + oldest = List.last(logs) + + {:ok, view, _html} = + live(conn, ~p"/admin/request-logs?pool_id=#{pool.id}&model=gpt-paginate") + + assert_request_log_reload(reload_ref, :initial_load, :selected_pool) + assert has_element?(view, "#admin-request-logs-pagination-top", "Page 1 of 2") + assert has_element?(view, "#admin-request-logs-pagination", "Page 1 of 2") + assert has_element?(view, "#admin-request-logs-range-top", "Showing 1-50 of 51") + assert has_element?(view, "#admin-request-logs-range", "Showing 1-50 of 51") + assert has_element?(view, "#request-log-row-#{newest.id}") + refute has_element?(view, "#request-log-row-#{oldest.id}") + assert has_element?(view, "#admin-request-logs-pagination-top-prev.btn-disabled") + refute has_element?(view, "#admin-request-logs-pagination-top-next.btn-disabled") + + view + |> element("#admin-request-logs-pagination-top-next") + |> render_click() + + assert_patch(view, ~p"/admin/request-logs?model=gpt-paginate&page=2&pool_id=#{pool.id}") + assert_request_log_reload(reload_ref, :filter_patch, :selected_pool) + assert has_element?(view, "#admin-request-logs-pagination", "Page 2 of 2") + assert has_element?(view, "#admin-request-logs-range", "Showing 51-51 of 51") + assert has_element?(view, "#request-log-row-#{oldest.id}") + refute has_element?(view, "#request-log-row-#{newest.id}") + refute has_element?(view, "#admin-request-logs-pagination-prev.btn-disabled") + assert has_element?(view, "#admin-request-logs-pagination-next.btn-disabled") + + refreshed_request = + request_log_fixture(pool, %{ + correlation_id: "req-paginate-refresh", + requested_model: "gpt-paginate", + admitted_at: DateTime.add(now, 1, :second) + }).request + + assert {:ok, _event} = + Events.broadcast_request_logs(pool.id, "request_log_created", %{ + request_id: refreshed_request.id, + status: refreshed_request.status + }) + + assert_request_log_reload(reload_ref, :event_refresh, :selected_pool) + assert has_element?(view, "#admin-request-logs-pagination", "Page 2 of 2") + assert has_element?(view, "#admin-request-logs-range", "Showing 51-52 of 52") + refute has_element?(view, "#request-log-row-#{refreshed_request.id}") + + assert {:error, {:live_redirect, %{to: overflow_to}}} = + live(conn, ~p"/admin/request-logs?pool_id=#{pool.id}&model=gpt-paginate&page=99") + + assert overflow_to == + ~p"/admin/request-logs?model=gpt-paginate&page=2&pool_id=#{pool.id}" + end + + test "clamps an empty request-log page to page one", %{conn: conn, scope: scope} do + {:ok, pool} = Pools.create_pool(scope, %{slug: "empty-page-logs", name: "Empty Page Logs"}) + + assert {:error, {:live_redirect, %{to: overflow_to}}} = + live(conn, ~p"/admin/request-logs?pool_id=#{pool.id}&status=failed&page=7") + + assert overflow_to == ~p"/admin/request-logs?pool_id=#{pool.id}&status=failed" + end + test "missing request timestamp keeps not recorded display", %{scope: scope} do {:ok, pool} = Pools.create_pool(scope, %{slug: "missing-time-logs", name: "Missing Time Logs"}) @@ -2108,6 +2194,7 @@ defmodule CodexPoolerWeb.Admin.RequestLogsLiveTest do html = render_component(&RequestLogsPresentation.request_logs_table/1, request_logs: request_logs, + current_params: %{}, datetime_preferences: %{datetime_format: "default", timezone: "Etc/UTC"} )