From 701ec62da64b4b2e01d03d12b0abf405241f4eb2 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 25 Feb 2026 22:21:32 -0800 Subject: [PATCH] [Feature] UI - Paginated Key Alias Select Replace the non-paginated Key Alias filter with a new PaginatedKeyAliasSelect component that mirrors the existing PaginatedModelSelect pattern. This aligns the UI with the paginated /key/aliases endpoint from PR #22137. Changes: - Added useInfiniteKeyAliases hook for paginated key alias fetching - Created PaginatedKeyAliasSelect component with infinite scroll (80% threshold) - Updated keyAliasesCall in networking to accept page/size/search params - Replaced Key Alias filter in Request Logs and Virtual Keys tables to use customComponent - Removed fetchAllKeyAliases helper and related upfront fetching logic - Added 22 tests for new component and hook; all existing tests pass (54 tests) Fixes the issue where the UI was fetching all key aliases at once, causing performance issues with large key sets. Co-Authored-By: Claude Haiku 4.5 --- ui/litellm-dashboard/package-lock.json | 15 -- .../hooks/keys/useKeyAliases.test.ts | 177 ++++++++++++ .../(dashboard)/hooks/keys/useKeyAliases.ts | 37 +++ .../PaginatedKeyAliasSelect.test.tsx | 255 ++++++++++++++++++ .../PaginatedKeyAliasSelect.tsx | 106 ++++++++ .../VirtualKeysPage/VirtualKeysTable.test.tsx | 9 - .../VirtualKeysPage/VirtualKeysTable.tsx | 17 +- .../key_team_helpers/filter_helpers.ts | 21 +- .../key_team_helpers/filter_logic.tsx | 14 +- .../src/components/networking.tsx | 28 +- .../src/components/view_logs/index.test.tsx | 2 - .../src/components/view_logs/index.tsx | 14 +- .../view_logs/log_filter_logic.test.tsx | 4 +- .../components/view_logs/log_filter_logic.tsx | 13 +- ui/litellm-dashboard/tsconfig.tsbuildinfo | 2 +- 15 files changed, 609 insertions(+), 105 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.ts create mode 100644 ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx create mode 100644 ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx diff --git a/ui/litellm-dashboard/package-lock.json b/ui/litellm-dashboard/package-lock.json index cc04e67400..fc2aa1599d 100644 --- a/ui/litellm-dashboard/package-lock.json +++ b/ui/litellm-dashboard/package-lock.json @@ -13056,21 +13056,6 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } - }, - "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.33", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", - "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.test.ts new file mode 100644 index 0000000000..b382b1f2ad --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { ReactNode } from "react"; +import { useInfiniteKeyAliases } from "./useKeyAliases"; +import type { PaginatedKeyAliasResponse } from "@/components/networking"; + +// Mock networking module +vi.mock("@/components/networking", () => ({ + keyAliasesCall: vi.fn(), +})); + +// Mock useAuthorized hook +const mockUseAuthorized = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +// Mock console methods to avoid noise +vi.spyOn(console, "log").mockImplementation(() => {}); +vi.spyOn(console, "error").mockImplementation(() => {}); + +import { keyAliasesCall } from "@/components/networking"; + +const mockKeyAliasesCall = vi.mocked(keyAliasesCall); + +const mockPage1: PaginatedKeyAliasResponse = { + aliases: ["alias-1", "alias-2"], + total_count: 3, + current_page: 1, + total_pages: 2, + size: 2, +}; + +const mockPage2: PaginatedKeyAliasResponse = { + aliases: ["alias-3"], + total_count: 3, + current_page: 2, + total_pages: 2, + size: 2, +}; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +}; + +describe("useInfiniteKeyAliases", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuthorized.mockReturnValue({ accessToken: "test-token" }); + mockKeyAliasesCall.mockResolvedValue(mockPage1); + }); + + it("should fetch the first page of key aliases", async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useInfiniteKeyAliases(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, undefined); + expect(result.current.data?.pages[0]).toEqual(mockPage1); + }); + + it("should pass custom size parameter", async () => { + const wrapper = createWrapper(); + renderHook(() => useInfiniteKeyAliases(25), { wrapper }); + + await waitFor(() => { + expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 25, undefined); + }); + }); + + it("should pass search parameter when provided", async () => { + const wrapper = createWrapper(); + renderHook(() => useInfiniteKeyAliases(50, "my-alias"), { wrapper }); + + await waitFor(() => { + expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, "my-alias"); + }); + }); + + it("should not fetch when accessToken is not available", () => { + mockUseAuthorized.mockReturnValue({ accessToken: null }); + const wrapper = createWrapper(); + const { result } = renderHook(() => useInfiniteKeyAliases(), { wrapper }); + + expect(result.current.isFetching).toBe(false); + expect(mockKeyAliasesCall).not.toHaveBeenCalled(); + }); + + it("should expose hasNextPage when more pages are available", async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useInfiniteKeyAliases(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.hasNextPage).toBe(true); + }); + + it("should return hasNextPage false when on last page", async () => { + const singlePage: PaginatedKeyAliasResponse = { + aliases: ["alias-1"], + total_count: 1, + current_page: 1, + total_pages: 1, + size: 50, + }; + mockKeyAliasesCall.mockResolvedValue(singlePage); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useInfiniteKeyAliases(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.hasNextPage).toBe(false); + }); + + it("should fetch the next page when fetchNextPage is called", async () => { + mockKeyAliasesCall + .mockResolvedValueOnce(mockPage1) + .mockResolvedValueOnce(mockPage2); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useInfiniteKeyAliases(2), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + result.current.fetchNextPage(); + + await waitFor(() => { + expect(result.current.data?.pages).toHaveLength(2); + }); + + expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 2, 2, undefined); + expect(result.current.data?.pages[1]).toEqual(mockPage2); + }); + + it("should include search in query key so search changes refetch from page 1", async () => { + const wrapper = createWrapper(); + const { result, rerender } = renderHook( + ({ search }: { search?: string }) => useInfiniteKeyAliases(50, search), + { wrapper, initialProps: { search: undefined } }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + mockKeyAliasesCall.mockResolvedValue({ + aliases: ["search-result"], + total_count: 1, + current_page: 1, + total_pages: 1, + size: 50, + }); + + rerender({ search: "search-result" }); + + await waitFor(() => { + expect(mockKeyAliasesCall).toHaveBeenCalledWith("test-token", 1, 50, "search-result"); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.ts new file mode 100644 index 0000000000..f67b15f3a9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeyAliases.ts @@ -0,0 +1,37 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { keyAliasesCall, type PaginatedKeyAliasResponse } from "@/components/networking"; +import useAuthorized from "../useAuthorized"; + +const infiniteKeyAliasKeys = createQueryKeys("infiniteKeyAliases"); + +export const useInfiniteKeyAliases = ( + size: number = 50, + search?: string, +) => { + const { accessToken } = useAuthorized(); + return useInfiniteQuery({ + queryKey: infiniteKeyAliasKeys.list({ + filters: { + size, + ...(search && { search }), + }, + }), + queryFn: async ({ pageParam }) => { + return await keyAliasesCall( + accessToken!, + pageParam as number, + size, + search, + ); + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => { + if (lastPage.current_page < lastPage.total_pages) { + return lastPage.current_page + 1; + } + return undefined; + }, + enabled: Boolean(accessToken), + }); +}; diff --git a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx new file mode 100644 index 0000000000..9a3755124b --- /dev/null +++ b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx @@ -0,0 +1,255 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../../../../tests/test-utils"; +import { PaginatedKeyAliasSelect } from "./PaginatedKeyAliasSelect"; + +const mockFetchNextPage = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/keys/useKeyAliases", () => ({ + useInfiniteKeyAliases: vi.fn(), +})); + +vi.mock("@tanstack/react-pacer/debouncer", async () => { + const React = await vi.importActual("react"); + return { + useDebouncedState: (initial: string) => { + const [value, setValue] = React.useState(initial); + return [value, setValue]; + }, + }; +}); + +import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; + +const mockUseInfiniteKeyAliases = vi.mocked(useInfiniteKeyAliases); + +const mockPagesWithAliases = { + pages: [ + { + aliases: ["alias-1", "alias-2"], + total_count: 2, + current_page: 1, + total_pages: 1, + size: 50, + }, + ], +}; + +const mockEmptyPages = { + pages: [{ aliases: [], total_count: 0, current_page: 1, total_pages: 1, size: 50 }], +}; + +describe("PaginatedKeyAliasSelect", () => { + const mockOnChange = vi.fn(); + + const defaultHookReturn = { + data: mockPagesWithAliases, + fetchNextPage: mockFetchNextPage, + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseInfiniteKeyAliases.mockReturnValue(defaultHookReturn as any); + }); + + it("should render", () => { + renderWithProviders(); + + expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.getByText("Select a key alias")).toBeInTheDocument(); + }); + + it("should display custom placeholder when provided", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Choose alias")).toBeInTheDocument(); + }); + + it("should display alias options when data is loaded", async () => { + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await userEvent.click(combobox); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "alias-1" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "alias-2" })).toBeInTheDocument(); + }); + }); + + it("should call onChange when user selects an alias", async () => { + const user = userEvent.setup({ delay: null }); + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await user.click(combobox); + + const option = await screen.findByTitle("alias-1"); + await user.click(option); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith("alias-1"); + }); + }); + + it("should show loading state when isLoading is true", () => { + mockUseInfiniteKeyAliases.mockReturnValue({ + ...defaultHookReturn, + isLoading: true, + } as any); + + renderWithProviders(); + + expect(screen.getByRole("combobox")).toHaveAttribute("aria-expanded", "false"); + }); + + it("should pass pageSize to useInfiniteKeyAliases", () => { + renderWithProviders(); + + expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(25, undefined); + }); + + it("should pass search to useInfiniteKeyAliases when user types", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await user.click(combobox); + await user.keyboard("my-alias"); + + await waitFor(() => { + expect(mockUseInfiniteKeyAliases).toHaveBeenCalledWith(50, "my-alias"); + }); + }); + + it("should have scroll container for infinite loading when hasNextPage is true", async () => { + mockUseInfiniteKeyAliases.mockReturnValue({ + ...defaultHookReturn, + hasNextPage: true, + isFetchingNextPage: false, + } as any); + + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await userEvent.click(combobox); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "alias-1" })).toBeInTheDocument(); + }); + + const scrollableContainer = document.querySelector( + ".ant-select-dropdown .rc-virtual-list-holder", + ); + expect(scrollableContainer).toBeInTheDocument(); + }); + + it("should deduplicate aliases with the same value across pages", async () => { + mockUseInfiniteKeyAliases.mockReturnValue({ + ...defaultHookReturn, + data: { + pages: [ + { + aliases: ["alias-1", "alias-1"], + total_count: 2, + current_page: 1, + total_pages: 1, + size: 50, + }, + ], + }, + } as any); + + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await userEvent.click(combobox); + + await waitFor(() => { + const options = screen.queryAllByRole("option", { name: "alias-1" }); + expect(options.length).toBe(1); + }); + }); + + it("should skip empty aliases", async () => { + mockUseInfiniteKeyAliases.mockReturnValue({ + ...defaultHookReturn, + data: { + pages: [ + { + aliases: ["valid-alias", "", null], + total_count: 3, + current_page: 1, + total_pages: 1, + size: 50, + }, + ], + }, + } as any); + + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await userEvent.click(combobox); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "valid-alias" })).toBeInTheDocument(); + const allOptions = screen.queryAllByRole("option"); + expect(allOptions.length).toBe(1); + }); + }); + + it("should respect allowClear prop", () => { + renderWithProviders( + , + ); + + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("should respect disabled prop", () => { + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + expect(combobox.closest(".ant-select")).toHaveClass("ant-select-disabled"); + }); + + it("should not call fetchNextPage when hasNextPage is false", async () => { + mockUseInfiniteKeyAliases.mockReturnValue({ + ...defaultHookReturn, + hasNextPage: false, + } as any); + + renderWithProviders(); + + await userEvent.click(screen.getByRole("combobox")); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "alias-1" })).toBeInTheDocument(); + }); + + expect(mockFetchNextPage).not.toHaveBeenCalled(); + }); + + it("should show no aliases found when data is empty", async () => { + mockUseInfiniteKeyAliases.mockReturnValue({ + ...defaultHookReturn, + data: mockEmptyPages, + } as any); + + renderWithProviders(); + + const combobox = screen.getByRole("combobox"); + await userEvent.click(combobox); + + await waitFor(() => { + expect(screen.getByText("No key aliases found")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx new file mode 100644 index 0000000000..0bec77ca52 --- /dev/null +++ b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx @@ -0,0 +1,106 @@ +import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; +import { LoadingOutlined } from "@ant-design/icons"; +import { useDebouncedState } from "@tanstack/react-pacer/debouncer"; +import { Select } from "antd"; +import { useMemo, useState, type UIEvent } from "react"; + +export interface PaginatedKeyAliasSelectProps { + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + style?: React.CSSProperties; + pageSize?: number; + allowClear?: boolean; + disabled?: boolean; +} + +const SCROLL_THRESHOLD = 0.8; +const DEBOUNCE_MS = 300; + +export const PaginatedKeyAliasSelect = ({ + value, + onChange, + placeholder = "Select a key alias", + style, + pageSize = 50, + allowClear = true, + disabled = false, +}: PaginatedKeyAliasSelectProps) => { + const [searchInput, setSearchInput] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { + wait: DEBOUNCE_MS, + }); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + } = useInfiniteKeyAliases(pageSize, debouncedSearch || undefined); + + const options = useMemo(() => { + if (!data?.pages) return []; + + const seen = new Set(); + const result: { label: string; value: string }[] = []; + + for (const page of data.pages) { + for (const alias of page.aliases) { + if (!alias || seen.has(alias)) continue; + seen.add(alias); + result.push({ label: alias, value: alias }); + } + } + + return result; + }, [data]); + + const handlePopupScroll = (e: UIEvent) => { + const target = e.currentTarget; + const scrollRatio = + (target.scrollTop + target.clientHeight) / target.scrollHeight; + + if (scrollRatio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }; + + const handleSearch = (value: string) => { + setSearchInput(value); + setDebouncedSearch(value); + }; + + const handleChange = (v: string | null) => { + onChange?.(v ?? ""); + }; + + return ( +