diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index b06448912d0..93f860333d8 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -4,6 +4,7 @@ import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; import { vi, it, expect, beforeEach, describe, Mock, MockedFunction } from "vitest"; import { chooseSelectOption, renderWithProviders } from "../../../tests/test-utils"; import { VirtualKeysTable } from "./VirtualKeysTable"; +import { KEY_TABLE_SORT_FIELDS } from "./keyTableColumns"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; import { useKeyInfo } from "@/app/(dashboard)/hooks/keys/useKeyInfo"; import { KeysResponse, useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; @@ -176,8 +177,10 @@ const keysResult = (keys: KeyResponse[], data: Partial = {}, extra const openFilters = () => fireEvent.click(screen.getByRole("button", { name: "Filters" })); -const lastKeyParam = (onUrlUpdate: Mock) => - onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("key"); +const lastSearchParam = (onUrlUpdate: Mock, name: string) => + onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get(name); + +const lastKeyParam = (onUrlUpdate: Mock) => lastSearchParam(onUrlUpdate, "key"); beforeEach(() => { vi.clearAllMocks(); @@ -510,7 +513,8 @@ describe("server-side filtering – the LIT-4080 regression guard", () => { }); it("drops the filter from the useKeys query when it is cleared", async () => { - renderWithProviders(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); openFilters(); const userIdInput = await screen.findByPlaceholderText(/Enter User ID/); @@ -520,6 +524,12 @@ describe("server-side filtering – the LIT-4080 regression guard", () => { await waitFor(() => { expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" })); }); + // Let the filter reach the URL before clearing it: NuqsTestingAdapter runs + // resetUrlUpdateQueueOnMount on every render, so a still-queued write can be + // aborted by the re-render its own predecessor triggers. + await waitFor(() => { + expect(lastSearchParam(onUrlUpdate, "filter_user")).toBe("user-42"); + }); fireEvent.click(screen.getByTestId("datatable-clear-filters")); @@ -638,3 +648,183 @@ describe("Status column reflects blocked / expiry / scim metadata", () => { }); }); }); + +describe("table state lives in the URL so it survives leaving and returning to the page", () => { + it("restores the search term, sort and pagination from the URL on mount", async () => { + renderWithProviders(, { + searchParams: { key_search: "prod", sort_by: "spend", sort_order: "asc", page: "3", page_size: "25" }, + }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith( + 3, + 25, + expect.objectContaining({ selectedKeyAlias: "prod", sortBy: "spend", sortOrder: "asc" }), + ); + }); + expect(screen.getByPlaceholderText(/Search by key alias/)).toHaveValue("prod"); + }); + + it("restores the drawer filters from the URL on mount", async () => { + renderWithProviders(, { searchParams: { filter_team: "team-1", filter_user: "user-42" } }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith( + 1, + 50, + expect.objectContaining({ teamID: "team-1", userID: "user-42" }), + ); + }); + expect(screen.getByTestId("filter-chip-team_id")).toHaveTextContent("Test Team"); + }); + + it("writes the search term to the URL", async () => { + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); + + fireEvent.change(screen.getByPlaceholderText(/Search by key alias/), { target: { value: "prod" } }); + + await waitFor(() => { + expect(lastSearchParam(onUrlUpdate, "key_search")).toBe("prod"); + }); + }); + + it("writes the sort field and direction to the URL", async () => { + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); + + fireEvent.click(screen.getByRole("button", { name: "Key" })); + + await waitFor(() => { + expect(lastSearchParam(onUrlUpdate, "sort_by")).toBe("key_alias"); + }); + expect(lastSearchParam(onUrlUpdate, "sort_order")).toBe("asc"); + }); + + it("writes an applied drawer filter to the URL and clears it again", async () => { + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); + + openFilters(); + fireEvent.change(await screen.findByPlaceholderText(/Enter User ID/), { target: { value: "user-42" } }); + fireEvent.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => { + expect(lastSearchParam(onUrlUpdate, "filter_user")).toBe("user-42"); + }); + + fireEvent.click(screen.getByTestId("datatable-clear-filters")); + + await waitFor(() => { + expect(lastSearchParam(onUrlUpdate, "filter_user")).toBeNull(); + }); + expect(screen.queryByTestId("filter-chip-user_id")).not.toBeInTheDocument(); + }); + + it("returns to page 1 when the search term changes", async () => { + const onUrlUpdate = vi.fn(); + renderWithProviders(, { searchParams: { page: "3" }, onUrlUpdate }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith(3, 50, expect.anything()); + }); + + fireEvent.change(screen.getByPlaceholderText(/Search by key alias/), { target: { value: "prod" } }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ selectedKeyAlias: "prod" })); + }); + await waitFor(() => { + expect(lastSearchParam(onUrlUpdate, "page")).toBeNull(); + }); + }); + + it("leaves the create-key deep link's team_id alone instead of filtering the list with it", async () => { + renderWithProviders(, { searchParams: { create: "true", team_id: "team-1" } }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ teamID: undefined })); + }); + expect(screen.queryByTestId("filter-chip-team_id")).not.toBeInTheDocument(); + }); + + it.each([ + ["0", 1], + ["-3", 1], + ])("clamps a hand-edited page of %s up to the first page", async (page, expected) => { + renderWithProviders(, { searchParams: { page } }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith(expected, 50, expect.anything()); + }); + }); + + it.each([ + ["0", 1], + ["1000", 100], + ])("clamps a hand-edited page_size of %s into the range /key/list accepts", async (pageSize, expected) => { + renderWithProviders(, { searchParams: { page_size: pageSize } }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith(1, expected, expect.anything()); + }); + }); + + it("trims whitespace off a filter that arrived from the URL", async () => { + renderWithProviders(, { searchParams: { filter_user: " user-42 " } }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ userID: "user-42" })); + }); + }); + + it("falls back to the default sort when the URL names a column the table cannot sort by", async () => { + renderWithProviders(, { searchParams: { sort_by: "totally_unknown_field" } }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith( + 1, + 50, + expect.objectContaining({ sortBy: "created_at", sortOrder: "desc" }), + ); + }); + expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); + }); + + it.each(KEY_TABLE_SORT_FIELDS)("round-trips a %s sort from the URL", async (field) => { + renderWithProviders(, { searchParams: { sort_by: field, sort_order: "asc" } }); + + await waitFor(() => { + expect(mockUseKeys).toHaveBeenLastCalledWith(1, 50, expect.objectContaining({ sortBy: field, sortOrder: "asc" })); + }); + }); + + it("clears sort_by from the URL when the Spend / Budget sort is reset", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { onUrlUpdate }); + + await chooseSelectOption(user, screen.getByTestId("sort-trigger-spend"), "Spend ascending", "menuitem"); + await waitFor(() => { + expect(lastSearchParam(onUrlUpdate, "sort_by")).toBe("spend"); + }); + + await chooseSelectOption(user, screen.getByTestId("sort-trigger-spend"), "Reset", "menuitem"); + + await waitFor(() => { + expect(lastSearchParam(onUrlUpdate, "sort_by")).toBeNull(); + }); + expect(lastSearchParam(onUrlUpdate, "sort_order")).toBeNull(); + }); + + it("drops the search param back out of the URL when the search box is cleared", async () => { + const onUrlUpdate = vi.fn(); + renderWithProviders(, { searchParams: { key_search: "prod" }, onUrlUpdate }); + + fireEvent.change(screen.getByPlaceholderText(/Search by key alias/), { target: { value: "" } }); + + await waitFor(() => { + expect(lastSearchParam(onUrlUpdate, "key_search")).toBeNull(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index 7dd25ca1fd0..8ec8b6d0c3f 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -15,34 +15,65 @@ import { SearchSelect } from "@/components/shared/SearchSelect"; import { PageHeader } from "@/components/shared/PageHeader"; import { Input } from "@/components/ui/input"; import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; -import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; +import { ColumnFiltersState, functionalUpdate, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; import { KeyRound } from "lucide-react"; -import { parseAsString, useQueryState } from "nuqs"; +import { createParser, parseAsInteger, parseAsString, parseAsStringLiteral, useQueryState, useQueryStates } from "nuqs"; import React, { useCallback, useMemo, useState } from "react"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; import KeyInfoView from "../templates/key_info_view"; -import { getKeyTableColumns, KEY_TABLE_HIDDEN_COLUMNS } from "./keyTableColumns"; +import { getKeyTableColumns, KEY_TABLE_HIDDEN_COLUMNS, KEY_TABLE_SORT_FIELDS } from "./keyTableColumns"; interface VirtualKeysTableProps { headerActions?: React.ReactNode; } -const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; +const FILTER_COLUMNS = ["team_id", "org_id", "user_id", "key_hash"] as const; +type FilterColumn = (typeof FILTER_COLUMNS)[number]; -const toSortOrder = (sorting: SortingState): "asc" | "desc" | undefined => { - const active = sorting[0]; - if (!active) return undefined; - return active.desc ? "desc" : "asc"; -}; - -const FILTER_LABELS: Record = { +const FILTER_LABELS: Record = { team_id: "Team", org_id: "Organization", user_id: "User ID", key_hash: "Key ID", }; +const DEFAULT_SORT_BY = "created_at"; +const DEFAULT_SORT_ORDER = "desc"; +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 100; +const MAX_PAGE = 100_000; + +const boundedInteger = (min: number, max: number, fallback: number) => + createParser({ + parse: (value: string) => { + const parsed = parseAsInteger.parse(value); + return parsed === null ? null : Math.min(Math.max(parsed, min), max); + }, + serialize: String, + }).withDefault(fallback); + +// The filters carry a prefix because /api-keys also takes team_id, key_alias and key_type +// as create-key prefills; an unprefixed filter would hijack those deep links. +const TABLE_STATE = { + key_search: parseAsString.withDefault(""), + sort_by: parseAsString.withDefault(DEFAULT_SORT_BY), + sort_order: parseAsStringLiteral(["asc", "desc"] as const).withDefault(DEFAULT_SORT_ORDER), + page: boundedInteger(1, MAX_PAGE, 1), + page_size: boundedInteger(1, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE), + filter_team: parseAsString.withDefault(""), + filter_org: parseAsString.withDefault(""), + filter_user: parseAsString.withDefault(""), + filter_key_id: parseAsString.withDefault(""), +}; + +const toSortOrder = (active: SortingState[number]): "asc" | "desc" => (active.desc ? "desc" : "asc"); + +const filterValue = (filters: ColumnFiltersState, column: FilterColumn): string | null => { + const value = filters.find((filter) => filter.id === column)?.value; + return (typeof value === "string" ? value.trim() : "") || null; +}; + export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { const { data: fetchedOrganizations } = useOrganizations(); const organizations = useMemo(() => fetchedOrganizations ?? [], [fetchedOrganizations]); @@ -50,32 +81,48 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { const allTeams = useMemo(() => fetchedTeams ?? [], [fetchedTeams]); const [selectedKeyId, setSelectedKeyId] = useQueryState("key", parseAsString.withOptions({ history: "push" })); - const [sorting, setSorting] = useState(DEFAULT_SORTING); - const [tablePagination, setTablePagination] = useState({ pageIndex: 0, pageSize: 50 }); - const [columnFilters, setColumnFilters] = useState([]); + const [tableState, setTableState] = useQueryStates(TABLE_STATE); const [filtersOpen, setFiltersOpen] = useState(false); - const [searchInput, setSearchInput] = useState(""); + const searchInput = tableState.key_search; const [searchQuery] = useDebouncedValue(searchInput, { wait: DEBOUNCE_WAIT_MS }); - const getFilterValue = useCallback( - (columnId: string): string | undefined => { - const entry = columnFilters.find((filter) => filter.id === columnId); - return typeof entry?.value === "string" && entry.value.trim() ? entry.value.trim() : undefined; - }, - [columnFilters], + // A hand-edited sort_by the table cannot sort by would 400 at /key/list and leave the page loading. + const sortBy = KEY_TABLE_SORT_FIELDS.includes(tableState.sort_by) ? tableState.sort_by : DEFAULT_SORT_BY; + const sorting = useMemo( + () => [{ id: sortBy, desc: tableState.sort_order === "desc" }], + [sortBy, tableState.sort_order], + ); + const tablePagination = useMemo( + () => ({ pageIndex: tableState.page - 1, pageSize: tableState.page_size }), + [tableState.page, tableState.page_size], + ); + const { filter_team, filter_org, filter_user, filter_key_id } = tableState; + const appliedFilters = useMemo( + () => ({ + team_id: filter_team.trim(), + org_id: filter_org.trim(), + user_id: filter_user.trim(), + key_hash: filter_key_id.trim(), + }), + [filter_team, filter_org, filter_user, filter_key_id], + ); + const columnFilters = useMemo( + () => + FILTER_COLUMNS.filter((column) => appliedFilters[column]).map((column) => ({ + id: column, + value: appliedFilters[column], + })), + [appliedFilters], ); - const sortBy = sorting[0]?.id; - const sortOrder = toSortOrder(sorting); - const keyListOptions = { - teamID: getFilterValue("team_id"), - organizationID: getFilterValue("org_id"), + teamID: appliedFilters.team_id || undefined, + organizationID: appliedFilters.org_id || undefined, selectedKeyAlias: searchQuery.trim() || undefined, - userID: getFilterValue("user_id"), - keyHash: getFilterValue("key_hash"), + userID: appliedFilters.user_id || undefined, + keyHash: appliedFilters.key_hash || undefined, sortBy, - sortOrder, + sortOrder: tableState.sort_order, expand: "user", }; @@ -89,20 +136,47 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { const keyList = useMemo(() => keys?.keys ?? [], [keys]); const rowCount = keys?.total_count ?? 0; - const handleSearchChange = useCallback((value: string) => { - setSearchInput(value); - setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); - }, []); + const handleSearchChange = useCallback( + (value: string) => { + void setTableState({ key_search: value || null, page: null }); + }, + [setTableState], + ); - const handleSortingChange = useCallback>((updaterOrValue) => { - setSorting(updaterOrValue); - setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); - }, []); + const handleSortingChange = useCallback>( + (updaterOrValue) => { + const active = functionalUpdate(updaterOrValue, sorting)[0]; + void setTableState({ + sort_by: active?.id ?? null, + sort_order: active ? toSortOrder(active) : null, + page: null, + }); + }, + [sorting, setTableState], + ); - const handleColumnFiltersChange = useCallback>((updaterOrValue) => { - setColumnFilters(updaterOrValue); - setTablePagination((prev) => ({ ...prev, pageIndex: 0 })); - }, []); + const handleColumnFiltersChange = useCallback>( + (updaterOrValue) => { + const next = functionalUpdate(updaterOrValue, columnFilters); + const nextFilters = { + filter_team: filterValue(next, "team_id"), + filter_org: filterValue(next, "org_id"), + filter_user: filterValue(next, "user_id"), + filter_key_id: filterValue(next, "key_hash"), + page: null, + }; + void setTableState(nextFilters); + }, + [columnFilters, setTableState], + ); + + const handlePaginationChange = useCallback>( + (updaterOrValue) => { + const next = functionalUpdate(updaterOrValue, tablePagination); + void setTableState({ page: next.pageIndex + 1, page_size: next.pageSize }); + }, + [tablePagination, setTableState], + ); const columns = useMemo( () => getKeyTableColumns({ allTeams, organizations, onSelectKey: (key) => void setSelectedKeyId(key.token) }), @@ -199,7 +273,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { onSortingChange={handleSortingChange} paginationMode="server" pagination={tablePagination} - onPaginationChange={setTablePagination} + onPaginationChange={handlePaginationChange} rowCount={rowCount} filterMode="server" columnFilters={columnFilters} diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx index 51b8b734b4c..0865fe76519 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/keyTableColumns.tsx @@ -32,6 +32,14 @@ const SPEND_BUDGET_SORT_FIELDS: DataTableSortField[] = [ { id: "max_budget", label: "Budget" }, ]; +export const KEY_TABLE_SORT_FIELDS: readonly string[] = [ + "key_alias", + "token", + "created_at", + "updated_at", + ...SPEND_BUDGET_SORT_FIELDS.map((field) => field.id), +]; + const getKeyStatus = (key: KeyResponse): KeyStatus => { if (key.blocked === true) { const isScimBlocked = (key.metadata as Record | null | undefined)?.scim_blocked === true;