From eef80535ec80a282aa210939dbdec5f7dada92c3 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Wed, 23 Sep 2026 18:25:50 -0700 Subject: [PATCH] feat(ui): configure prompt caching request rows per page (#42842) * feat(ui): configure prompt caching request rows per page * style(ui): match prompt caching pagination arrows --- ...tCachingRequestsTable.integration.test.tsx | 109 +++++++++++++++--- .../PromptCachingRequestsTable.tsx | 70 ++++++++--- 2 files changed, 143 insertions(+), 36 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.integration.test.tsx index e7594a18105..d1e8520015a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.integration.test.tsx @@ -1,5 +1,15 @@ import { Profiler } from "react"; -import { act, fireEvent, renderWithProviders, screen, testQueryClient, waitFor, within } from "@/../tests/test-utils"; +import userEvent from "@testing-library/user-event"; +import { + act, + chooseSelectOption, + fireEvent, + renderWithProviders, + screen, + testQueryClient, + waitFor, + within, +} from "@/../tests/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { components } from "@/lib/http/schema"; @@ -23,8 +33,13 @@ const request = (overrides: Partial = {}): CacheRequest => ({ net_savings: -0.0075, ...overrides, }); -const response = (requests: CacheRequest[], nextCursor: RequestsResponse["next_cursor"] = null) => { - const body: RequestsResponse = { requests, has_more: nextCursor !== null, next_cursor: nextCursor, page_size: 10 }; +const response = (requests: CacheRequest[], nextCursor: RequestsResponse["next_cursor"] = null, pageSize = 10) => { + const body: RequestsResponse = { + requests, + has_more: nextCursor !== null, + next_cursor: nextCursor, + page_size: pageSize, + }; return Response.json(body); }; const lastQuery = () => new URL(String(fetchMock.mock.calls.at(-1)?.[0]), "http://localhost").searchParams; @@ -100,18 +115,76 @@ describe("PromptCachingRequestsTable", () => { const table = await screen.findByRole("table", { name: "Prompt caching requests" }); expect(within(table).getAllByRole("link")).toHaveLength(10); expect(within(table).queryByRole("link", { name: "request-11" })).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Go to next page" })); await screen.findByRole("link", { name: "request-11" }); expect(within(screen.getByRole("table", { name: "Prompt caching requests" })).getAllByRole("link")).toHaveLength(1); - expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); - fireEvent.click(screen.getByRole("button", { name: "Previous" })); + expect(screen.getByRole("button", { name: "Go to next page" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Go to previous page" })); await screen.findByRole("link", { name: "request-1" }); expect(within(screen.getByRole("table", { name: "Prompt caching requests" })).getAllByRole("link")).toHaveLength( 10, ); - expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Go to previous page" })).toBeDisabled(); }); + it.each([25, 50, 100])( + "restarts at page one with %i rows and retains the size across navigation and filters", + async (pageSize) => { + const user = userEvent.setup(); + const rows = Array.from({ length: 101 }, (_, index) => request({ request_id: `request-${index + 1}` })); + fetchMock.mockImplementation(async (input) => { + const query = new URL(String(input), "http://localhost").searchParams; + const start = rows.findIndex((row) => row.request_id === query.get("cursor_request_id")) + 1; + const size = Number(query.get("page_size")); + const end = start + size; + const page = rows.slice(start, end); + const last = page.at(-1); + return response( + page, + end < rows.length && last ? { start_time: last.start_time, request_id: last.request_id } : null, + size, + ); + }); + renderWithProviders(); + await screen.findByRole("link", { name: "request-1" }); + expect(screen.getByRole("combobox", { name: "Rows per page" })).toHaveTextContent("10"); + fireEvent.click(screen.getByRole("button", { name: "Go to next page" })); + await screen.findByRole("link", { name: "request-11" }); + + await chooseSelectOption(user, screen.getByRole("combobox", { name: "Rows per page" }), String(pageSize)); + await screen.findByRole("link", { name: "request-1" }); + expect(within(screen.getByRole("table", { name: "Prompt caching requests" })).getAllByRole("link")).toHaveLength( + pageSize, + ); + expect(lastQuery().get("page_size")).toBe(String(pageSize)); + expect(lastQuery().has("cursor_request_id")).toBe(false); + expect(lastQuery().has("cursor_start_time")).toBe(false); + expect(screen.getByText("Page 1")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Go to previous page" })).toBeDisabled(); + + fireEvent.click(screen.getByRole("button", { name: "Go to next page" })); + await screen.findByRole("link", { name: `request-${pageSize + 1}` }); + expect(lastQuery().get("page_size")).toBe(String(pageSize)); + expect(lastQuery().get("cursor_request_id")).toBe(`request-${pageSize}`); + expect(screen.getByText("Page 2")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Go to previous page" })); + await screen.findByRole("link", { name: "request-1" }); + expect(within(screen.getByRole("table", { name: "Prompt caching requests" })).getAllByRole("link")).toHaveLength( + pageSize, + ); + + fireEvent.click(screen.getByRole("button", { name: "Go to next page" })); + await screen.findByRole("link", { name: `request-${pageSize + 1}` }); + fireEvent.click(screen.getByRole("tab", { name: "Cache hits" })); + await screen.findByRole("link", { name: "request-1" }); + expect(lastQuery().get("filter")).toBe("hits"); + expect(lastQuery().get("page_size")).toBe(String(pageSize)); + expect(lastQuery().has("cursor_request_id")).toBe(false); + expect(screen.getByText("Page 1")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Rows per page" })).toHaveTextContent(String(pageSize)); + }, + ); + it("forwards complete server cursors, goes back to prior cursors, and clears them for each caching filter", async () => { fetchMock.mockImplementation(async (input) => { const query = new URL(String(input), "http://localhost").searchParams; @@ -130,33 +203,33 @@ describe("PromptCachingRequestsTable", () => { }); renderWithProviders(); await screen.findByRole("link", { name: "all-1" }); - expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Go to previous page" })).toBeDisabled(); expect(lastQuery().has("page")).toBe(false); expect(lastQuery().has("cursor_request_id")).toBe(false); - fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Go to next page" })); await screen.findByRole("link", { name: "all-2" }); expect(screen.getByText("Page 2")).toBeInTheDocument(); expect(lastQuery().get("cursor_start_time")).toBe(firstCursor.start_time); expect(lastQuery().get("cursor_request_id")).toBe(firstCursor.request_id); - fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Go to next page" })); await screen.findByRole("link", { name: "all-3" }); expect(screen.getByText("Page 3")).toBeInTheDocument(); expect(lastQuery().get("cursor_start_time")).toBe(secondCursor.start_time); expect(lastQuery().get("cursor_request_id")).toBe(secondCursor.request_id); - expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Go to next page" })).toBeDisabled(); await testQueryClient.invalidateQueries({ refetchType: "none" }); - fireEvent.click(screen.getByRole("button", { name: "Previous" })); + fireEvent.click(screen.getByRole("button", { name: "Go to previous page" })); await screen.findByRole("link", { name: "all-2" }); await waitFor(() => expect(lastQuery().get("cursor_request_id")).toBe(firstCursor.request_id)); expect(lastQuery().get("cursor_start_time")).toBe(firstCursor.start_time); expect(screen.getByText("Page 2")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Previous" })); + fireEvent.click(screen.getByRole("button", { name: "Go to previous page" })); await screen.findByRole("link", { name: "all-1" }); await waitFor(() => expect(lastQuery().has("cursor_request_id")).toBe(false)); expect(lastQuery().has("cursor_start_time")).toBe(false); - fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Go to next page" })); await screen.findByRole("link", { name: "all-2" }); fireEvent.click(screen.getByRole("tab", { name: "LiteLLM injected" })); @@ -166,7 +239,7 @@ describe("PromptCachingRequestsTable", () => { expect(lastQuery().has("cursor_request_id")).toBe(false); expect(lastQuery().has("cursor_start_time")).toBe(false); - fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Go to next page" })); await screen.findByRole("link", { name: "injected-2" }); fireEvent.click(screen.getByRole("tab", { name: "Cache hits" })); await screen.findByRole("link", { name: "hits-1" }); @@ -203,7 +276,7 @@ describe("PromptCachingRequestsTable", () => { ); const { rerender } = renderWithProviders(tree("token-a", dates)); await screen.findByRole("link", { name: "old-first" }); - fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole("button", { name: "Go to next page" })); await screen.findByRole("link", { name: "old-second" }); const pending = Promise.withResolvers(); @@ -253,7 +326,7 @@ describe("PromptCachingRequestsTable", () => { expect(screen.getByRole("link", { name: "current-hit" })).toBeInTheDocument(); expect(screen.queryByRole("link", { name: "stale-all" })).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Go to next page" })).toBeDisabled(); }); it("offers retry after a failed read and shows the empty state after it succeeds", async () => { @@ -265,7 +338,7 @@ describe("PromptCachingRequestsTable", () => { fireEvent.click(screen.getByRole("button", { name: "Retry" })); expect(await screen.findByText("No matching prompt caching requests in this range")).toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Go to next page" })).toBeDisabled(); expect(fetchMock).toHaveBeenCalledTimes(2); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.tsx index 140c11d2318..ba9cfd8ca22 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingRequestsTable.tsx @@ -1,12 +1,14 @@ "use client"; import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { ChevronLeft, ChevronRight } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; import { apiClient } from "@/components/networking"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { LOG_ID_QUERY_PARAM } from "@/components/view_logs/logDetailRouting"; @@ -18,6 +20,7 @@ import { benchmarksWindow as activityWindow } from "./useAutoRouterBenchmarks"; import type { DateRange } from "./useDailyActivityRange"; const REQUESTS_PATH = "/cost_optimization/prompt_caching/requests"; +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; type RequestsEndpoint = paths[typeof REQUESTS_PATH]["get"]; type RequestsResponse = RequestsEndpoint["responses"][200]["content"]["application/json"]; type RequestsQuery = NonNullable; @@ -31,10 +34,11 @@ interface PromptCachingRequestsTableProps { export default function PromptCachingRequestsTable({ accessToken, dateValue }: PromptCachingRequestsTableProps) { const [filter, setFilter] = useState("all"); + const [pageSize, setPageSize] = useState(10); const window = activityWindow(dateValue, new Date()); const startDate = window.start_date ? `${window.start_date}T00:00:00.000Z` : ""; const endDate = window.end_date ? `${window.end_date}T23:59:59.999Z` : ""; - const scope = JSON.stringify([accessToken, startDate, endDate, filter]); + const scope = JSON.stringify([accessToken, startDate, endDate, filter, pageSize]); const [pagination, setPagination] = useState<{ scope: string; cursors: readonly RequestCursor[] }>({ scope, cursors: [null], @@ -52,7 +56,7 @@ export default function PromptCachingRequestsTable({ accessToken, dateValue }: P start_date: startDate, end_date: endDate, filter, - page_size: 10, + page_size: pageSize, cursor_start_time: cursor?.start_time, cursor_request_id: cursor?.request_id, }; @@ -161,22 +165,52 @@ export default function PromptCachingRequestsTable({ accessToken, dateValue }: P )} -
- - Page {page} - +
+
+ Rows per page + +
+
+ Page {page} +
+ + +
+
)}