diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 6a50f4aa99e..8e8c1447d22 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2349,11 +2349,6 @@ "count": 1 } }, - "src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/LicenseExpiryBanner.tsx": { "no-restricted-imports": { "count": 1 @@ -2364,14 +2359,6 @@ "count": 1 } }, - "src/components/ModelSelect/PaginatedModelSelect/PaginatedModelSelect.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx": { "max-nested-callbacks": { "count": 12 @@ -3511,17 +3498,6 @@ "count": 1 } }, - "src/components/molecules/filter.tsx": { - "local/filename-pascal-case": { - "count": 1 - }, - "no-nested-ternary": { - "count": 2 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/molecules/message_manager.tsx": { "local/filename-pascal-case": { "count": 1 @@ -4404,17 +4380,6 @@ "count": 2 } }, - "src/components/view_logs/LogsTableToolbar.tsx": { - "local/no-complex-jsx-arrow": { - "count": 1 - }, - "no-nested-ternary": { - "count": 4 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/components/view_logs/ToolsSection/FormattedToolView.tsx": { "no-restricted-imports": { "count": 1 @@ -4443,9 +4408,6 @@ "src/components/view_logs/columns.tsx": { "local/filename-pascal-case": { "count": 1 - }, - "no-restricted-imports": { - "count": 1 } }, "src/components/view_logs/index.tsx": { @@ -4454,9 +4416,6 @@ }, "no-restricted-imports": { "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/components/view_logs/log_filter_logic.tsx": { diff --git a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx b/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx deleted file mode 100644 index 7ef2e9d0def..00000000000 --- a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.test.tsx +++ /dev/null @@ -1,249 +0,0 @@ -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, 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", undefined); - }); - }); - - 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 deleted file mode 100644 index 1d19ba3255d..00000000000 --- a/ui/litellm-dashboard/src/components/KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; -import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; -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; - allFilters?: { [key: string]: string }; -} - -const SCROLL_THRESHOLD = 0.8; - -export const PaginatedKeyAliasSelect = ({ - value, - onChange, - placeholder = "Select a key alias", - style, - pageSize = 50, - allowClear = true, - disabled = false, - allFilters, -}: PaginatedKeyAliasSelectProps) => { - const [searchInput, setSearchInput] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useDebouncedState("", { - wait: DEBOUNCE_WAIT_MS, - }); - - const teamId = allFilters?.["Team ID"] || undefined; - - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteKeyAliases( - pageSize, - debouncedSearch || undefined, - teamId, - ); - - 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 ( - : "No models found"} - options={options} - optionRender={optionRender} - popupRender={(menu) => ( - <> - {menu} - {isFetchingNextPage && ( -
- -
- )} - - )} - /> - ); -}; diff --git a/ui/litellm-dashboard/src/components/common_components/FilterTeamDropdown.tsx b/ui/litellm-dashboard/src/components/common_components/FilterTeamDropdown.tsx deleted file mode 100644 index 3b756b94f12..00000000000 --- a/ui/litellm-dashboard/src/components/common_components/FilterTeamDropdown.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from "react"; -import TeamDropdown from "./team_dropdown"; -import type { FilterOptionCustomComponentProps } from "../molecules/filter"; - -const FilterTeamDropdown: React.FC = ({ value, onChange }) => ( - -); - -export default FilterTeamDropdown; diff --git a/ui/litellm-dashboard/src/components/molecules/filter.test.tsx b/ui/litellm-dashboard/src/components/molecules/filter.test.tsx deleted file mode 100644 index 66c671b12d4..00000000000 --- a/ui/litellm-dashboard/src/components/molecules/filter.test.tsx +++ /dev/null @@ -1,615 +0,0 @@ -import { screen, waitFor, within } 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 FilterComponent, { FilterOption } from "./filter"; - -describe("FilterComponent", () => { - const mockOnApplyFilters = vi.fn(); - const mockOnResetFilters = vi.fn(); - - const defaultOptions: FilterOption[] = [ - { - name: "teamId", - label: "Team ID", - options: [ - { label: "Team 1", value: "team1" }, - { label: "Team 2", value: "team2" }, - ], - }, - { - name: "status", - label: "Status", - options: [ - { label: "Active", value: "active" }, - { label: "Inactive", value: "inactive" }, - ], - }, - { - name: "userId", - label: "User ID", - }, - ]; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should render", () => { - renderWithProviders( - , - ); - expect(screen.getByRole("button", { name: "Filters" })).toBeInTheDocument(); - }); - - it("should display custom button label", () => { - renderWithProviders( - , - ); - expect(screen.getByRole("button", { name: "Custom Filters" })).toBeInTheDocument(); - }); - - it("should toggle filters visibility when filter button is clicked", async () => { - const user = userEvent.setup({ delay: null }); - renderWithProviders( - , - ); - - const filterButton = screen.getByRole("button", { name: "Filters" }); - expect(screen.queryByPlaceholderText("Enter User ID...")).not.toBeInTheDocument(); - - await user.click(filterButton); - - await waitFor(() => { - expect(screen.getByPlaceholderText("Enter User ID...")).toBeInTheDocument(); - }); - - await user.click(filterButton); - - await waitFor(() => { - expect(screen.queryByPlaceholderText("Enter User ID...")).not.toBeInTheDocument(); - }); - }); - - it("should call onResetFilters when reset button is clicked", async () => { - const user = userEvent.setup({ delay: null }); - renderWithProviders( - , - ); - - const resetButton = screen.getByRole("button", { name: "Reset Filters" }); - await user.click(resetButton); - - await waitFor(() => { - expect(mockOnResetFilters).toHaveBeenCalledTimes(1); - }); - }); - - it("renders filters in the caller-supplied order", async () => { - const user = userEvent.setup({ delay: null }); - const options: FilterOption[] = [ - { name: "model", label: "Model" }, - { name: "teamId", label: "Team ID" }, - { name: "status", label: "Status" }, - { name: "userId", label: "User ID" }, - ]; - - renderWithProviders( - , - ); - - const filterButton = screen.getByRole("button", { name: "Filters" }); - await user.click(filterButton); - - await waitFor(() => { - const labels = screen.getAllByText(/^(Team ID|Status|User ID|Model)$/); - expect(labels.map((l) => l.textContent)).toEqual(["Model", "Team ID", "Status", "User ID"]); - }); - }); - - it("should handle input filter changes", async () => { - const user = userEvent.setup({ delay: null }); - renderWithProviders( - , - ); - - const filterButton = screen.getByRole("button", { name: "Filters" }); - await user.click(filterButton); - - const userIdInput = screen.getByPlaceholderText("Enter User ID..."); - await user.type(userIdInput, "user123"); - - await waitFor(() => { - expect(mockOnApplyFilters).toHaveBeenCalledWith({ userId: "user123" }); - }); - }); - - it("should display initial values in filters", async () => { - const user = userEvent.setup({ delay: null }); - renderWithProviders( - , - ); - - const filterButton = screen.getByRole("button", { name: "Filters" }); - await user.click(filterButton); - - await waitFor(() => { - const userIdInput = screen.getByPlaceholderText("Enter User ID...") as HTMLInputElement; - expect(userIdInput.value).toBe("user123"); - }); - }); - - it("should handle select dropdown filter changes", async () => { - const user = userEvent.setup({ delay: null }); - renderWithProviders( - , - ); - - const filterButton = screen.getByRole("button", { name: "Filters" }); - await user.click(filterButton); - - const teamIdLabel = screen.getByText("Team ID"); - const teamIdSection = teamIdLabel.closest("div"); - const teamIdSelect = within(teamIdSection!).getByRole("combobox"); - - await user.click(teamIdSelect); - - await waitFor(() => { - expect(screen.getByText("Team 1")).toBeInTheDocument(); - }); - - await user.click(screen.getByText("Team 1")); - - await waitFor(() => { - expect(mockOnApplyFilters).toHaveBeenCalledWith({ teamId: "team1" }); - }); - }); - - it("should handle searchable filter with search function", async () => { - const user = userEvent.setup({ delay: null }); - const mockSearchFn = vi.fn().mockResolvedValue([ - { label: "Result 1", value: "result1" }, - { label: "Result 2", value: "result2" }, - ]); - - const options: FilterOption[] = [ - { - name: "model", - label: "Model", - isSearchable: true, - searchFn: mockSearchFn, - }, - ]; - - renderWithProviders( - , - ); - - const filterButton = screen.getByRole("button", { name: "Filters" }); - await user.click(filterButton); - - await waitFor(() => { - expect(mockSearchFn).toHaveBeenCalledWith(""); - }); - - const modelLabel = screen.getByText("Model"); - const modelSection = modelLabel.closest("div"); - const modelSelect = within(modelSection!).getByRole("combobox"); - await user.click(modelSelect); - - await waitFor(() => { - expect(screen.getByText("Result 1")).toBeInTheDocument(); - expect(screen.getByText("Result 2")).toBeInTheDocument(); - }); - }); - - it("should debounce search input for searchable filters", async () => { - const user = userEvent.setup({ delay: null }); - const mockSearchFn = vi.fn().mockResolvedValue([{ label: "Result", value: "result" }]); - - const options: FilterOption[] = [ - { - name: "model", - label: "Model", - isSearchable: true, - searchFn: mockSearchFn, - }, - ]; - - renderWithProviders( - , - ); - - const filterButton = screen.getByRole("button", { name: "Filters" }); - await user.click(filterButton); - - await waitFor(() => { - expect(mockSearchFn).toHaveBeenCalledWith(""); - }); - - vi.clearAllMocks(); - - const modelLabel = screen.getByText("Model"); - const modelSection = modelLabel.closest("div"); - const modelSelect = within(modelSection!).getByRole("combobox"); - await user.click(modelSelect); - await user.type(modelSelect, "test"); - - expect(mockSearchFn).not.toHaveBeenCalled(); - - await waitFor( - () => { - expect(mockSearchFn).toHaveBeenCalledWith("test"); - }, - { timeout: 500 }, - ); - }); - - it("should show loading state when searching", async () => { - const user = userEvent.setup({ delay: null }); - let resolveSearch: (value: Array<{ label: string; value: string }>) => void; - const mockSearchFn = vi.fn().mockImplementation( - () => - new Promise>((resolve) => { - resolveSearch = resolve; - }), - ); - - const options: FilterOption[] = [ - { - name: "model", - label: "Model", - isSearchable: true, - searchFn: mockSearchFn, - }, - ]; - - renderWithProviders( - , - ); - - const filterButton = screen.getByRole("button", { name: "Filters" }); - await user.click(filterButton); - - await waitFor(() => { - expect(mockSearchFn).toHaveBeenCalledWith(""); - }); - - const modelLabel = screen.getByText("Model"); - const modelSection = modelLabel.closest("div"); - const modelSelect = within(modelSection!).getByRole("combobox"); - await user.click(modelSelect); - await user.type(modelSelect, "test"); - - await waitFor( - () => { - expect(screen.getByText("Loading...")).toBeInTheDocument(); - }, - { timeout: 500 }, - ); - - resolveSearch!([{ label: "Result", value: "result" }]); - - await waitFor(() => { - expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); - }); - }); - - it("shows a loading state (not an empty list) while a searchable filter's data is still loading", async () => { - const user = userEvent.setup({ delay: null }); - const mockSearchFn = vi.fn().mockResolvedValue([]); - - const options: FilterOption[] = [ - { - name: "model", - label: "Model", - isSearchable: true, - loading: true, - searchFn: mockSearchFn, - }, - ]; - - renderWithProviders( - , - ); - - await user.click(screen.getByRole("button", { name: "Filters" })); - - const modelLabel = screen.getByText("Model"); - const modelSelect = within(modelLabel.closest("div")!).getByRole("combobox"); - await user.click(modelSelect); - - await waitFor(() => { - expect(screen.getByText("Loading...")).toBeInTheDocument(); - }); - expect(screen.queryByText("No results found")).not.toBeInTheDocument(); - // It must not cache an empty initial-options list while the source is still loading. - expect(mockSearchFn).not.toHaveBeenCalled(); - }); - - it("loads initial options once a searchable filter's data finishes loading", async () => { - const user = userEvent.setup({ delay: null }); - const mockSearchFn = vi.fn().mockResolvedValue([{ label: "Team A", value: "team-a" }]); - const baseOption: FilterOption = { name: "model", label: "Model", isSearchable: true, searchFn: mockSearchFn }; - - const { rerender } = renderWithProviders( - , - ); - - await user.click(screen.getByRole("button", { name: "Filters" })); - expect(mockSearchFn).not.toHaveBeenCalled(); - - rerender( - , - ); - - await waitFor(() => { - expect(mockSearchFn).toHaveBeenCalledWith(""); - }); - }); - - it("should handle search errors gracefully", async () => { - const user = userEvent.setup({ delay: null }); - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const mockSearchFn = vi.fn().mockRejectedValue(new Error("Search failed")); - - const options: FilterOption[] = [ - { - name: "model", - label: "Model", - isSearchable: true, - searchFn: mockSearchFn, - }, - ]; - - renderWithProviders( - , - ); - - const filterButton = screen.getByRole("button", { name: "Filters" }); - await user.click(filterButton); - - await waitFor(() => { - expect(mockSearchFn).toHaveBeenCalledWith(""); - }); - - const modelLabel = screen.getByText("Model"); - const modelSection = modelLabel.closest("div"); - const modelSelect = within(modelSection!).getByRole("combobox"); - await user.click(modelSelect); - await user.type(modelSelect, "test"); - - await waitFor( - () => { - expect(consoleErrorSpy).toHaveBeenCalledWith("Error searching:", expect.any(Error)); - expect(screen.getByText("No results found")).toBeInTheDocument(); - }, - { timeout: 500 }, - ); - - consoleErrorSpy.mockRestore(); - }); - - it("should load initial options when dropdown opens for searchable filter", async () => { - const user = userEvent.setup({ delay: null }); - const mockSearchFn = vi.fn().mockResolvedValue([{ label: "Initial Result", value: "initial" }]); - - const options: FilterOption[] = [ - { - name: "model", - label: "Model", - isSearchable: true, - searchFn: mockSearchFn, - }, - ]; - - renderWithProviders( - , - ); - - const filterButton = screen.getByRole("button", { name: "Filters" }); - await user.click(filterButton); - - await waitFor(() => { - expect(mockSearchFn).toHaveBeenCalledWith(""); - }); - - vi.clearAllMocks(); - - const modelLabel = screen.getByText("Model"); - const modelSection = modelLabel.closest("div"); - const modelSelect = within(modelSection!).getByRole("combobox"); - await user.click(modelSelect); - - await waitFor(() => { - expect(screen.getByText("Initial Result")).toBeInTheDocument(); - }); - }); - - it("renders caller-supplied options that match no predefined filter name (LIT-3151)", async () => { - const user = userEvent.setup({ delay: null }); - const options: FilterOption[] = [ - { - name: "unknownFilter", - label: "Unknown Filter", - }, - ]; - - renderWithProviders( - , - ); - - const filterButton = screen.getByRole("button", { name: "Filters" }); - await user.click(filterButton); - - await waitFor(() => { - expect(screen.getByText("Unknown Filter")).toBeInTheDocument(); - expect(screen.getByPlaceholderText("Enter Unknown Filter...")).toBeInTheDocument(); - }); - }); - - it("renders every Tool Policies filter when none match a predefined name (LIT-3151)", async () => { - const user = userEvent.setup({ delay: null }); - const options: FilterOption[] = [ - { name: "Input Policy", label: "Input Policy", options: [{ label: "Trusted", value: "trusted" }] }, - { name: "Output Policy", label: "Output Policy", options: [{ label: "Blocked", value: "blocked" }] }, - { name: "Team Name", label: "Team Name", options: [] }, - { name: "Key Name", label: "Key Name", options: [] }, - ]; - - renderWithProviders( - , - ); - - await user.click(screen.getByRole("button", { name: "Filters" })); - - await waitFor(() => { - const labels = screen.getAllByText(/^(Input Policy|Output Policy|Team Name|Key Name)$/); - expect(labels.map((l) => l.textContent)).toEqual(["Input Policy", "Output Policy", "Team Name", "Key Name"]); - }); - }); - - it("should call onApplyFilters with updated values when multiple filters change", async () => { - const user = userEvent.setup({ delay: null }); - renderWithProviders( - , - ); - - const filterButton = screen.getByRole("button", { name: "Filters" }); - await user.click(filterButton); - - const userIdInput = screen.getByPlaceholderText("Enter User ID..."); - await user.type(userIdInput, "user123"); - - await waitFor(() => { - expect(mockOnApplyFilters).toHaveBeenCalledWith({ userId: "user123" }); - }); - - const teamIdLabel = screen.getByText("Team ID"); - const teamIdSection = teamIdLabel.closest("div"); - const teamIdSelect = within(teamIdSection!).getByRole("combobox"); - await user.click(teamIdSelect); - - await waitFor(() => { - expect(screen.getByText("Team 1")).toBeInTheDocument(); - }); - - await user.click(screen.getByText("Team 1")); - - await waitFor(() => { - expect(mockOnApplyFilters).toHaveBeenCalledWith({ - userId: "user123", - teamId: "team1", - }); - }); - }); - - it("cancels a pending debounced search when the component unmounts mid-type", async () => { - const user = userEvent.setup({ delay: null }); - const mockSearchFn = vi.fn().mockResolvedValue([{ label: "Result", value: "result" }]); - - const options: FilterOption[] = [ - { - name: "model", - label: "Model", - isSearchable: true, - searchFn: mockSearchFn, - }, - ]; - - const { unmount } = renderWithProviders( - , - ); - - await user.click(screen.getByRole("button", { name: "Filters" })); - - await waitFor(() => { - expect(mockSearchFn).toHaveBeenCalledWith(""); - }); - - vi.clearAllMocks(); - - const modelLabel = screen.getByText("Model"); - const modelSelect = within(modelLabel.closest("div")!).getByRole("combobox"); - await user.click(modelSelect); - await user.type(modelSelect, "test"); - - expect(mockSearchFn).not.toHaveBeenCalled(); - - unmount(); - - await new Promise((resolve) => setTimeout(resolve, 400)); - expect(mockSearchFn).not.toHaveBeenCalled(); - }); - - it("should reset all filter values when reset button is clicked", async () => { - const user = userEvent.setup({ delay: null }); - renderWithProviders( - , - ); - - const filterButton = screen.getByRole("button", { name: "Filters" }); - await user.click(filterButton); - - await waitFor(() => { - const userIdInput = screen.getByPlaceholderText("Enter User ID...") as HTMLInputElement; - expect(userIdInput.value).toBe("user123"); - }); - - const resetButton = screen.getByRole("button", { name: "Reset Filters" }); - await user.click(resetButton); - - await waitFor(() => { - const userIdInput = screen.getByPlaceholderText("Enter User ID...") as HTMLInputElement; - expect(userIdInput.value).toBe(""); - }); - }); -}); diff --git a/ui/litellm-dashboard/src/components/molecules/filter.tsx b/ui/litellm-dashboard/src/components/molecules/filter.tsx deleted file mode 100644 index 8218de41a19..00000000000 --- a/ui/litellm-dashboard/src/components/molecules/filter.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; -import { FilterIcon } from "@heroicons/react/outline"; -import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; -import { Button, Input, Select } from "antd"; -import React, { useCallback, useEffect, useState } from "react"; - -export interface FilterOptionCustomComponentProps { - value?: string; - onChange: (value: string) => void; - placeholder?: string; - allFilters?: { [key: string]: string }; -} - -export interface FilterOption { - name: string; - label?: string; - isSearchable?: boolean; - searchFn?: (searchText: string) => Promise>; - options?: Array<{ label: string; value: string }>; - customComponent?: React.ComponentType; - loading?: boolean; -} - -interface FilterValues { - [key: string]: string; -} - -interface FilterComponentProps { - options: FilterOption[]; - onApplyFilters: (filters: FilterValues) => void; - initialValues?: FilterValues; - buttonLabel?: string; - onResetFilters: () => void; -} - -const FilterComponent: React.FC = ({ - options, - onApplyFilters, - onResetFilters, - initialValues = {}, - buttonLabel = "Filters", -}) => { - const [showFilters, setShowFilters] = useState(false); - const [tempValues, setTempValues] = useState(initialValues); - const [searchOptionsMap, setSearchOptionsMap] = useState<{ - [key: string]: Array<{ label: string; value: string }>; - }>({}); - const [searchLoadingMap, setSearchLoadingMap] = useState<{ - [key: string]: boolean; - }>({}); - const [searchInputValueMap, setSearchInputValueMap] = useState<{ - [key: string]: string; - }>({}); - const [initialOptionsLoaded, setInitialOptionsLoaded] = useState<{ - [key: string]: boolean; - }>({}); - - const debouncedSearch = useDebouncedCallback( - async (value: string, option: FilterOption) => { - if (!option.isSearchable || !option.searchFn) return; - - setSearchLoadingMap((prev) => ({ ...prev, [option.name]: true })); - try { - const results = await option.searchFn(value); - setSearchOptionsMap((prev) => ({ ...prev, [option.name]: results })); - } catch (error) { - console.error("Error searching:", error); - setSearchOptionsMap((prev) => ({ ...prev, [option.name]: [] })); - } finally { - setSearchLoadingMap((prev) => ({ ...prev, [option.name]: false })); - } - }, - { wait: DEBOUNCE_WAIT_MS }, - ); - - // Load initial options for searchable filters - const loadInitialOptions = useCallback( - async (option: FilterOption) => { - if (!option.isSearchable || !option.searchFn || option.loading || initialOptionsLoaded[option.name]) return; - - setSearchLoadingMap((prev) => ({ ...prev, [option.name]: true })); - setInitialOptionsLoaded((prev) => ({ ...prev, [option.name]: true })); - - try { - // Load initial options with empty search to get some default results - const results = await option.searchFn(""); - setSearchOptionsMap((prev) => ({ ...prev, [option.name]: results })); - } catch (error) { - console.error("Error loading initial options:", error); - setSearchOptionsMap((prev) => ({ ...prev, [option.name]: [] })); - } finally { - setSearchLoadingMap((prev) => ({ ...prev, [option.name]: false })); - } - }, - [initialOptionsLoaded], - ); - - // Load initial options when filters are shown - useEffect(() => { - if (showFilters) { - options.forEach((option) => { - if (option.isSearchable && !initialOptionsLoaded[option.name]) { - loadInitialOptions(option); - } - }); - } - }, [showFilters, options, loadInitialOptions, initialOptionsLoaded]); - - const handleFilterChange = (name: string, value: string) => { - const newValues = { - ...tempValues, - [name]: value, - }; - setTempValues(newValues); - onApplyFilters(newValues); - }; - - const resetFilters = () => { - const emptyValues: FilterValues = {}; - options.forEach((option) => { - emptyValues[option.name] = ""; - }); - setTempValues(emptyValues); - onResetFilters(); - }; - - // Handle dropdown open to load initial options - const handleDropdownVisibleChange = (open: boolean, option: FilterOption) => { - if (open && option.isSearchable && !initialOptionsLoaded[option.name]) { - loadInitialOptions(option); - } - }; - - return ( -
-
- - -
- - {showFilters && ( -
- {options.map((option) => { - const isOptionLoading = searchLoadingMap[option.name] || option.loading; - return ( -
- - {option.isSearchable ? ( - handleFilterChange(option.name, value)} - allowClear - > - {option.options.map((opt) => ( - - {opt.label} - - ))} - - ) : option.customComponent ? ( - (() => { - const CustomComponent = option.customComponent; - return ( - handleFilterChange(option.name, value ?? "")} - placeholder={`Select ${option.label || option.name}...`} - allFilters={tempValues} - /> - ); - })() - ) : ( - handleFilterChange(option.name, e.target.value)} - allowClear - /> - )} -
- ); - })} -
- )} -
- ); -}; - -export default FilterComponent; diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx new file mode 100644 index 00000000000..cfabeeab362 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.test.tsx @@ -0,0 +1,165 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { PaginatedSearchSelect } from "./PaginatedSearchSelect"; +import type { SearchSelectOption } from "./SearchSelect"; + +const OPTIONS: SearchSelectOption[] = [ + { label: "alias-alpha", value: "alias-alpha" }, + { label: "alias-beta", value: "alias-beta" }, + { label: "gamma-key", value: "gamma-key" }, +]; + +function renderSelect(overrides: Partial> = {}) { + const props: React.ComponentProps = { + options: OPTIONS, + onValueChange: vi.fn(), + onSearchChange: vi.fn(), + onLoadMore: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +function setListMetrics(list: HTMLElement, metrics: { scrollTop: number; clientHeight: number; scrollHeight: number }) { + Object.defineProperty(list, "scrollTop", { value: metrics.scrollTop, configurable: true }); + Object.defineProperty(list, "clientHeight", { value: metrics.clientHeight, configurable: true }); + Object.defineProperty(list, "scrollHeight", { value: metrics.scrollHeight, configurable: true }); +} + +describe("PaginatedSearchSelect", () => { + it("reports the typed query to the server instead of filtering locally", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + renderSelect({ onSearchChange }); + + const input = screen.getByRole("combobox"); + await user.click(input); + await user.type(input, "gamma"); + + await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("gamma")); + + expect(await screen.findByText("alias-alpha")).toBeInTheDocument(); + }); + + it("does not re-query the server when an item is selected", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + + function Controlled() { + const [value, setValue] = useState(""); + return ( + + ); + } + render(); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("alias-beta")); + + expect(screen.getByRole("combobox")).toHaveValue("alias-beta"); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(onSearchChange).not.toHaveBeenCalled(); + }); + + it("still reports a cleared input so the unfiltered page comes back", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + renderSelect({ onSearchChange, value: "alias-alpha" }); + + await user.click(document.querySelector('[data-slot="combobox-clear"]') as HTMLElement); + + await waitFor(() => expect(onSearchChange).toHaveBeenCalledWith("")); + }); + + it("requests the next page once the list is scrolled near the bottom", async () => { + const user = userEvent.setup(); + const onLoadMore = vi.fn(); + renderSelect({ onLoadMore, hasNextPage: true }); + + await user.click(screen.getByRole("combobox")); + const list = await screen.findByTestId("paginated-search-select-list"); + + setListMetrics(list, { scrollTop: 0, clientHeight: 100, scrollHeight: 1000 }); + fireEvent.scroll(list); + expect(onLoadMore).not.toHaveBeenCalled(); + + setListMetrics(list, { scrollTop: 850, clientHeight: 100, scrollHeight: 1000 }); + fireEvent.scroll(list); + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); + + it("does not request more pages when there is no next page or one is already in flight", async () => { + const user = userEvent.setup(); + const onLoadMore = vi.fn(); + const { unmount } = render( + , + ); + await user.click(screen.getByRole("combobox")); + let list = await screen.findByTestId("paginated-search-select-list"); + setListMetrics(list, { scrollTop: 900, clientHeight: 100, scrollHeight: 1000 }); + fireEvent.scroll(list); + expect(onLoadMore).not.toHaveBeenCalled(); + unmount(); + + renderSelect({ onLoadMore, hasNextPage: true, isFetchingNextPage: true }); + await user.click(screen.getByRole("combobox")); + list = await screen.findByTestId("paginated-search-select-list"); + setListMetrics(list, { scrollTop: 900, clientHeight: 100, scrollHeight: 1000 }); + fireEvent.scroll(list); + expect(onLoadMore).not.toHaveBeenCalled(); + }); + + it("keeps showing a selected value that is absent from the current page of options", () => { + renderSelect({ options: [], value: "alias-from-an-earlier-page" }); + + expect(screen.getByRole("combobox")).toHaveValue("alias-from-an-earlier-page"); + }); + + it("reports the selected option's value", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + renderSelect({ onValueChange }); + + await user.click(screen.getByRole("combobox")); + await user.click(await screen.findByText("alias-beta")); + + expect(onValueChange).toHaveBeenCalledWith("alias-beta"); + }); + + it("surfaces loading and fetching-more affordances", async () => { + const user = userEvent.setup(); + const { unmount } = render( + , + ); + await user.click(screen.getByRole("combobox")); + expect(await screen.findByText("Loading key aliases…")).toBeInTheDocument(); + unmount(); + + renderSelect({ isFetchingNextPage: true }); + await user.click(screen.getByRole("combobox")); + expect(await screen.findByTestId("paginated-search-select-loading-more")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx new file mode 100644 index 00000000000..b59cd1263ea --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/PaginatedSearchSelect.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; +import { Loader2 } from "lucide-react"; +import { useMemo, type UIEvent } from "react"; + +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; + +import type { SearchSelectOption } from "./SearchSelect"; + +const SCROLL_THRESHOLD = 0.8; + +const SEARCH_REASONS: ReadonlySet = new Set(["input-change", "input-clear", "clear-press"]); + +interface PaginatedSearchSelectProps { + options: SearchSelectOption[]; + value?: string; + onValueChange: (value: string) => void; + onSearchChange: (query: string) => void; + onLoadMore: () => void; + hasNextPage?: boolean; + isLoading?: boolean; + isFetchingNextPage?: boolean; + placeholder?: string; + emptyText?: string; + loadingText?: string; + disabled?: boolean; + className?: string; +} + +export function PaginatedSearchSelect({ + options, + value, + onValueChange, + onSearchChange, + onLoadMore, + hasNextPage = false, + isLoading = false, + isFetchingNextPage = false, + placeholder = "Search…", + emptyText = "No results", + loadingText = "Loading…", + disabled = false, + className, +}: PaginatedSearchSelectProps) { + const selected = useMemo(() => { + if (value === undefined || value === "") return null; + return options.find((option) => option.value === value) ?? { label: value, value }; + }, [options, value]); + + const items = useMemo(() => { + if (selected === null) return options; + if (options.some((option) => option.value === selected.value)) return options; + return [selected, ...options]; + }, [options, selected]); + + const debouncedSearch = useDebouncedCallback(onSearchChange, { wait: DEBOUNCE_WAIT_MS }); + + const handleInputValueChange = (next: string, reason: string) => { + if (!SEARCH_REASONS.has(reason)) return; + debouncedSearch(next); + }; + + const handleScroll = (event: UIEvent) => { + const target = event.currentTarget; + if (target.scrollHeight === 0) return; + const ratio = (target.scrollTop + target.clientHeight) / target.scrollHeight; + if (ratio >= SCROLL_THRESHOLD && hasNextPage && !isFetchingNextPage) { + onLoadMore(); + } + }; + + return ( + onValueChange(item?.value ?? "")} + onInputValueChange={(next, eventDetails) => handleInputValueChange(next, eventDetails.reason)} + isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value} + itemToStringLabel={(item: SearchSelectOption) => item.label} + filter={null} + disabled={disabled} + > + + + {isLoading ? loadingText : emptyText} + + {(item: SearchSelectOption) => ( + + + {item.label} + {item.sublabel != null && item.sublabel !== "" && ( + {item.sublabel} + )} + + + )} + + {isFetchingNextPage && ( +
+ +
+ )} +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx index f65ff2cc6ca..a522e75a4af 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx @@ -1,14 +1,18 @@ +"use client"; + import moment from "moment"; -import { useEffect, useRef, useState } from "react"; -import { SyncOutlined } from "@ant-design/icons"; -import { Button, Switch } from "antd"; +import { CalendarDays } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; + import { QUICK_SELECT_OPTIONS } from "./constants"; import { getTimeRangeDisplay } from "./logs_utils"; -import type { PaginatedResponse } from "./log_filter_logic"; interface LogsTableToolbarProps { - searchTerm: string; - onSearchChange: (value: string) => void; startTime: string; onStartTimeChange: (value: string) => void; endTime: string; @@ -19,18 +23,11 @@ interface LogsTableToolbarProps { onSelectedTimeIntervalChange: (value: { value: number; unit: string }) => void; isLiveTail: boolean; onIsLiveTailChange: (value: boolean) => void; - currentPage: number; - onCurrentPageChange: (updater: number | ((prev: number) => number)) => void; - pageSize: number; - isLoading: boolean; - isButtonLoading: boolean; - onRefetch: () => void; - filteredLogs: PaginatedResponse; + onResetToFirstPage: () => void; + onResetFilters: () => void; } export function LogsTableToolbar({ - searchTerm, - onSearchChange, startTime, onStartTimeChange, endTime, @@ -41,26 +38,23 @@ export function LogsTableToolbar({ onSelectedTimeIntervalChange, isLiveTail, onIsLiveTailChange, - currentPage, - onCurrentPageChange, - pageSize, - isLoading, - isButtonLoading, - onRefetch, - filteredLogs, + onResetToFirstPage, + onResetFilters, }: LogsTableToolbarProps) { const [quickSelectOpen, setQuickSelectOpen] = useState(false); - const quickSelectRef = useRef(null); - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (quickSelectRef.current && !quickSelectRef.current.contains(event.target as Node)) { - setQuickSelectOpen(false); - } - } - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); + const applyQuickSelect = (option: { label: string; value: number; unit: string }) => { + onResetToFirstPage(); + onEndTimeChange(moment().format("YYYY-MM-DDTHH:mm")); + onStartTimeChange( + moment() + .subtract(option.value, option.unit as moment.unitOfTime.DurationConstructor) + .format("YYYY-MM-DDTHH:mm"), + ); + onSelectedTimeIntervalChange({ value: option.value, unit: option.unit }); + onIsCustomDateChange(false); + setQuickSelectOpen(false); + }; const selectedOption = QUICK_SELECT_OPTIONS.find( (option) => option.value === selectedTimeInterval.value && option.unit === selectedTimeInterval.unit, @@ -68,178 +62,83 @@ export function LogsTableToolbar({ const displayLabel = isCustomDate ? getTimeRangeDisplay(isCustomDate, startTime, endTime) : selectedOption?.label; return ( - <> -
-
-
-
- onSearchChange(e.target.value)} - /> - - - -
- -
-
- - - {quickSelectOpen && ( -
-
- {QUICK_SELECT_OPTIONS.map((option) => ( - - ))} -
- -
-
- )} -
- -
- Live Tail - -
- +
+ + + + {displayLabel} + + } + /> + +
+ {QUICK_SELECT_OPTIONS.map((option) => ( -
- - {isCustomDate && ( -
-
- { - onStartTimeChange(e.target.value); - onCurrentPageChange(1); - }} - className="px-3 py-2 border rounded-md text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - /> -
- to -
- { - onEndTimeChange(e.target.value); - onCurrentPageChange(1); - }} - className="px-3 py-2 border rounded-md text-sm focus:outline-hidden focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - /> -
-
- )} -
- -
- + - -
+ Custom Range +
-
-
- {isLiveTail && currentPage === 1 && ( -
-
- Auto-refreshing every 15 seconds -
- + + + + {isCustomDate && ( +
+ { + onStartTimeChange(event.target.value); + onResetToFirstPage(); + }} + /> + to + { + onEndTimeChange(event.target.value); + onResetToFirstPage(); + }} + />
)} - + +
+ Live Tail + +
+ + +
+ ); +} + +export function LiveTailBanner({ onStop }: { onStop: () => void }) { + return ( +
+ Auto-refreshing every 15 seconds + +
); } diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx new file mode 100644 index 00000000000..0e6e60ad05d --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -0,0 +1,91 @@ +import { screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; +import { LOG_FILTER_IDS } from "./log_filter_logic"; +import { RequestLogsFilters } from "./RequestLogsFilters"; + +vi.mock("@/app/(dashboard)/hooks/keys/useKeyAliases", () => ({ + useInfiniteKeyAliases: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({ + useInfiniteModelInfo: vi.fn(), +})); + +vi.mock("../networking", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, allEndUsersCall: vi.fn().mockResolvedValue([]) }; +}); + +import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; +import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; + +const emptyInfiniteQuery = { + data: { pages: [], pageParams: [] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, +}; + +function renderFilters(filters: Record = {}) { + const set = vi.fn(); + renderWithProviders( + filters[id]} set={set} teams={[]} accessToken="test-token" />, + ); + return { set }; +} + +describe("RequestLogsFilters", () => { + beforeEach(() => { + vi.clearAllMocks(); + testQueryClient.clear(); + vi.mocked(useInfiniteKeyAliases).mockReturnValue( + emptyInfiniteQuery as unknown as ReturnType, + ); + vi.mocked(useInfiniteModelInfo).mockReturnValue( + emptyInfiniteQuery as unknown as ReturnType, + ); + }); + + it("renders every backend-supported filter field", async () => { + renderFilters(); + + for (const label of [ + "Team ID", + "Status", + "Key Alias", + "End User", + "Error Code", + "Error Message", + "Key Hash", + "Session ID", + "Model", + "Public model / search tool", + ]) { + expect(await screen.findByText(label)).toBeInTheDocument(); + } + }); + + it("scopes the Key Alias lookup to the selected team", async () => { + renderFilters({ [LOG_FILTER_IDS.TEAM_ID]: "team-42" }); + + await waitFor(() => expect(useInfiniteKeyAliases).toHaveBeenCalled()); + expect(useInfiniteKeyAliases).toHaveBeenCalledWith(50, undefined, "team-42"); + }); + + it("leaves the Key Alias lookup unscoped when no team is selected", async () => { + renderFilters(); + + await waitFor(() => expect(useInfiniteKeyAliases).toHaveBeenCalled()); + expect(useInfiniteKeyAliases).toHaveBeenCalledWith(50, undefined, undefined); + }); + + it("does not leak the team scope into the Model lookup", async () => { + renderFilters({ [LOG_FILTER_IDS.TEAM_ID]: "team-42" }); + + await waitFor(() => expect(useInfiniteModelInfo).toHaveBeenCalled()); + expect(useInfiniteModelInfo).toHaveBeenCalledWith(50, undefined); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx new file mode 100644 index 00000000000..ec20f4d0e47 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; + +import { useInfiniteKeyAliases } from "@/app/(dashboard)/hooks/keys/useKeyAliases"; +import { useInfiniteModelInfo } from "@/app/(dashboard)/hooks/models/useModels"; +import { DataTableFilterField } from "@/components/shared/DataTable"; +import { PaginatedSearchSelect } from "@/components/shared/PaginatedSearchSelect"; +import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +import type { Team } from "../key_team_helpers/key_list"; +import { allEndUsersCall } from "../networking"; +import { ERROR_CODE_OPTIONS } from "./constants"; +import { LOG_FILTER_IDS } from "./log_filter_logic"; + +const ALL_VALUE = "all"; +const PAGE_SIZE = 50; + +const asString = (value: unknown): string => (typeof value === "string" ? value : ""); +const emptyToUndefined = (value: string): string | undefined => (value === "" ? undefined : value); + +function TeamFilterField({ + value, + onChange, + teams, +}: { + value: string; + onChange: (value: string | undefined) => void; + teams: Team[]; +}) { + const options = useMemo( + () => + teams.map((team) => ({ + label: team.team_alias || team.team_id, + value: team.team_id, + sublabel: team.team_id, + })), + [teams], + ); + + return ( + + onChange(emptyToUndefined(next))} + placeholder="Search or select a team" + emptyText="No teams found" + /> + + ); +} + +function KeyAliasFilterField({ + value, + onChange, + teamId, +}: { + value: string; + onChange: (value: string | undefined) => void; + teamId: string; +}) { + const [search, setSearch] = useState(""); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteKeyAliases( + PAGE_SIZE, + emptyToUndefined(search), + emptyToUndefined(teamId), + ); + + const options = useMemo(() => { + const seen = new Set(); + return (data?.pages ?? []).flatMap((page) => + page.aliases.flatMap((alias) => { + if (!alias || seen.has(alias)) return []; + seen.add(alias); + return [{ label: alias, value: alias }]; + }), + ); + }, [data]); + + return ( + + onChange(emptyToUndefined(next))} + onSearchChange={setSearch} + onLoadMore={() => void fetchNextPage()} + hasNextPage={hasNextPage} + isLoading={isLoading} + isFetchingNextPage={isFetchingNextPage} + placeholder="Search a key alias" + emptyText="No key aliases found" + /> + + ); +} + +function ModelFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) { + const [search, setSearch] = useState(""); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteModelInfo( + PAGE_SIZE, + emptyToUndefined(search), + ); + + const options = useMemo(() => { + const seen = new Set(); + return (data?.pages ?? []).flatMap((page) => + page.data.flatMap((model) => { + const modelId = model.model_info?.id ?? ""; + const modelName = model.model_name ?? ""; + if (!modelId || seen.has(modelId)) return []; + seen.add(modelId); + return [{ label: modelName || modelId, value: modelId, sublabel: `Model ID: ${modelId}` }]; + }), + ); + }, [data]); + + return ( + + onChange(emptyToUndefined(next))} + onSearchChange={setSearch} + onLoadMore={() => void fetchNextPage()} + hasNextPage={hasNextPage} + isLoading={isLoading} + isFetchingNextPage={isFetchingNextPage} + placeholder="Search a model" + emptyText="No models found" + /> + + ); +} + +function EndUserFilterField({ + value, + onChange, + accessToken, +}: { + value: string; + onChange: (value: string | undefined) => void; + accessToken: string; +}) { + const { data } = useQuery({ + queryKey: ["logFilterEndUsers", accessToken], + queryFn: async () => { + const endUsers = await allEndUsersCall(accessToken); + return (endUsers ?? []).flatMap((endUser: { user_id?: string }) => + typeof endUser.user_id === "string" ? [endUser.user_id] : [], + ); + }, + enabled: accessToken !== "", + }); + + const options = useMemo( + () => (data ?? []).map((userId) => ({ label: userId, value: userId })), + [data], + ); + + return ( + + onChange(emptyToUndefined(next))} + placeholder="Search an end user" + emptyText="No end users found" + /> + + ); +} + +function ErrorCodeFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) { + const [query, setQuery] = useState(""); + + const options = useMemo(() => { + const trimmed = query.trim(); + const lowered = trimmed.toLowerCase(); + const matches = ERROR_CODE_OPTIONS.filter((option) => option.label.toLowerCase().includes(lowered)); + if (trimmed === "" || ERROR_CODE_OPTIONS.some((option) => option.value === trimmed)) return matches; + return [...matches, { label: `Use custom code: ${trimmed}`, value: trimmed }]; + }, [query]); + + const selected = useMemo(() => { + if (value === "") return null; + return ERROR_CODE_OPTIONS.find((option) => option.value === value) ?? { label: value, value }; + }, [value]); + + const items = useMemo(() => { + if (selected === null) return options; + if (options.some((option) => option.value === selected.value)) return options; + return [selected, ...options]; + }, [options, selected]); + + return ( + + onChange(emptyToUndefined(item?.value ?? ""))} + onInputValueChange={setQuery} + isItemEqualToValue={(a: SearchSelectOption, b: SearchSelectOption) => a.value === b.value} + itemToStringLabel={(item: SearchSelectOption) => item.label} + filter={null} + > + + + No error codes found + + {(item: SearchSelectOption) => ( + + {item.label} + + )} + + + + + ); +} + +interface RequestLogsFiltersProps { + get: (columnId: string) => unknown; + set: (columnId: string, value: unknown) => void; + teams: Team[]; + accessToken: string; +} + +export function RequestLogsFilters({ get, set, teams, accessToken }: RequestLogsFiltersProps) { + const valueOf = (id: string): string => asString(get(id)); + const setter = (id: string) => (next: string | undefined) => set(id, next); + + return ( + <> + + + + + + + + + + + + + + set(LOG_FILTER_IDS.ERROR_MESSAGE, emptyToUndefined(event.target.value))} + placeholder="Enter error message…" + /> + + + + set(LOG_FILTER_IDS.KEY_HASH, emptyToUndefined(event.target.value))} + placeholder="Enter key hash…" + /> + + + + set(LOG_FILTER_IDS.SESSION_ID, emptyToUndefined(event.target.value))} + placeholder="Enter session ID…" + /> + + + + + + set(LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL, emptyToUndefined(event.target.value))} + placeholder="Enter public model or search tool…" + /> + + + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx new file mode 100644 index 00000000000..9469c273d20 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx @@ -0,0 +1,202 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import moment from "moment"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; +import type { LogEntry } from "./columns"; +import RequestLogsPanel from "./RequestLogsPanel"; + +vi.mock("../networking", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + uiSpendLogsCall: vi.fn(), + keyInfoV1Call: vi.fn().mockResolvedValue({ info: {} }), + allEndUsersCall: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("@/components/key_team_helpers/filter_helpers", () => ({ + fetchAllTeams: vi.fn().mockResolvedValue([]), +})); + +vi.mock("./LogDetailsDrawer", () => ({ + LogDetailsDrawer: function LogDetailsDrawerMock({ open }: { open: boolean }) { + return
{open ? "open" : "closed"}
; + }, +})); + +import { uiSpendLogsCall } from "../networking"; + +const logEntry = (overrides: Partial): LogEntry => ({ + request_id: "req-1", + api_key: "key-1", + team_id: "team-1", + model: "gpt-4o", + model_id: "model-1", + call_type: "acompletion", + spend: 0.01, + total_tokens: 10, + prompt_tokens: 5, + completion_tokens: 5, + startTime: "2026-07-07T09:50:13Z", + endTime: "2026-07-07T09:50:14Z", + cache_hit: "false", + messages: [], + response: {}, + ...overrides, +}); + +const respondWith = (data: LogEntry[]) => + vi.mocked(uiSpendLogsCall).mockResolvedValue({ + data, + total: data.length, + page: 1, + page_size: 50, + total_pages: 1, + }); + +const defaultProps = { + accessToken: "test-token", + token: "test-token", + userRole: "Admin", + userID: "user-1", + isActive: true, +}; + +const row = (requestId: string) => document.querySelector(`[data-row-id="${requestId}"]`); +const lastCall = () => vi.mocked(uiSpendLogsCall).mock.calls.at(-1)?.[0]; + +describe("RequestLogsPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + testQueryClient.clear(); + respondWith([]); + }); + + describe("multi-call session collapsing", () => { + const sessionRows = [ + logEntry({ request_id: "req-mcp", call_type: "call_mcp_tool", session_id: "sess-1", session_total_count: 3 }), + logEntry({ request_id: "req-llm", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }), + logEntry({ request_id: "req-llm-2", call_type: "acompletion", session_id: "sess-1", session_total_count: 3 }), + ]; + + it("collapses a multi-call session to a single representative row", async () => { + respondWith(sessionRows); + renderWithProviders(); + + await waitFor(() => expect(row("req-mcp") ?? row("req-llm") ?? row("req-llm-2")).not.toBeNull()); + + const rendered = ["req-mcp", "req-llm", "req-llm-2"].filter((id) => row(id) !== null); + expect(rendered).toHaveLength(1); + }); + + it("prefers an LLM call over an MCP call as the session's representative", async () => { + respondWith(sessionRows); + renderWithProviders(); + + await waitFor(() => expect(row("req-llm")).not.toBeNull()); + expect(row("req-mcp")).toBeNull(); + }); + + it("shows the session's call count and composition on the representative row", async () => { + respondWith(sessionRows); + renderWithProviders(); + + await waitFor(() => expect(row("req-llm")).not.toBeNull()); + expect(within(row("req-llm") as HTMLElement).getByText("3")).toBeInTheDocument(); + }); + + it("leaves single-call rows untouched", async () => { + respondWith([ + logEntry({ request_id: "req-solo-a", session_id: "sess-a", session_total_count: 1 }), + logEntry({ request_id: "req-solo-b" }), + ]); + renderWithProviders(); + + await waitFor(() => expect(row("req-solo-a")).not.toBeNull()); + expect(row("req-solo-b")).not.toBeNull(); + }); + }); + + describe("client-side search", () => { + it("narrows the visible rows without refetching", async () => { + const user = userEvent.setup(); + respondWith([ + logEntry({ request_id: "req-alpha", model: "gpt-4o" }), + logEntry({ request_id: "req-beta", model: "claude-opus" }), + ]); + renderWithProviders(); + + await waitFor(() => expect(row("req-alpha")).not.toBeNull()); + const callsBefore = vi.mocked(uiSpendLogsCall).mock.calls.length; + + await user.type(screen.getByTestId("datatable-search"), "alpha"); + + await waitFor(() => expect(row("req-beta")).toBeNull()); + expect(row("req-alpha")).not.toBeNull(); + expect(vi.mocked(uiSpendLogsCall).mock.calls.length).toBe(callsBefore); + }); + }); + + describe("time range", () => { + it("requests a ~15 minute window when Last 15 Minutes is picked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + await user.click(screen.getByRole("button", { name: /Last 24 Hours/i })); + await user.click(await screen.findByRole("button", { name: "Last 15 Minutes" })); + + await waitFor(() => { + const call = lastCall(); + if (!call) throw new Error("no call"); + const diff = moment + .utc(call.end_date, "YYYY-MM-DD HH:mm:ss") + .diff(moment.utc(call.start_date, "YYYY-MM-DD HH:mm:ss"), "seconds"); + expect(diff).toBeGreaterThanOrEqual(15 * 60); + expect(diff).toBeLessThanOrEqual(16 * 60); + }); + }); + + it("restores the default 24 hour window when filters are reset", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + await user.click(screen.getByRole("button", { name: /Last 24 Hours/i })); + await user.click(await screen.findByRole("button", { name: "Last 15 Minutes" })); + await waitFor(() => expect(screen.getByRole("button", { name: /Last 15 Minutes/i })).toBeInTheDocument()); + + await user.click(screen.getByRole("button", { name: "Reset Filters" })); + + expect(await screen.findByRole("button", { name: /Last 24 Hours/i })).toBeInTheDocument(); + + await user.click(screen.getByTestId("datatable-refresh")); + + await waitFor(() => { + const call = lastCall(); + if (!call) throw new Error("no call"); + const diff = moment + .utc(call.end_date, "YYYY-MM-DD HH:mm:ss") + .diff(moment.utc(call.start_date, "YYYY-MM-DD HH:mm:ss"), "seconds"); + expect(diff).toBeGreaterThanOrEqual(24 * 60 * 60); + }); + }); + }); + + describe("live tail", () => { + it("shows the auto-refresh banner on the first page and hides it once stopped", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + expect(await screen.findByText("Auto-refreshing every 15 seconds")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Stop" })); + + expect(screen.queryByText("Auto-refreshing every 15 seconds")).toBeNull(); + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx new file mode 100644 index 00000000000..05044dda791 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; +import moment from "moment"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { internalUserRoles } from "../../utils/roles"; +import type { KeyResponse } from "../key_team_helpers/key_list"; +import { keyInfoV1Call } from "../networking"; +import KeyInfoView from "../templates/key_info_view"; +import type { LogEntry } from "./columns"; +import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants"; +import { DEFAULT_LOGS_SORTING, useLogFilterLogic } from "./log_filter_logic"; +import { LogDetailsDrawer } from "./LogDetailsDrawer"; +import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar"; +import { RequestLogsTable } from "./RequestLogsTable"; + +const PAGE_SIZE = 50; +const DEFAULT_INTERVAL = { value: 24, unit: "hours" }; + +interface RequestLogsPanelProps { + accessToken: string; + token: string; + userRole: string; + userID: string; + isActive: boolean; +} + +interface SessionComposition { + llm: number; + agent: number; + mcp: number; +} + +export default function RequestLogsPanel({ accessToken, token, userRole, userID, isActive }: RequestLogsPanelProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE }); + const [sorting, setSorting] = useState(DEFAULT_LOGS_SORTING); + const [columnFilters, setColumnFilters] = useState([]); + + const [startTime, setStartTime] = useState(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm")); + const [endTime, setEndTime] = useState(moment().format("YYYY-MM-DDTHH:mm")); + const [isCustomDate, setIsCustomDate] = useState(false); + const [selectedTimeInterval, setSelectedTimeInterval] = useState<{ value: number; unit: string }>(DEFAULT_INTERVAL); + + const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState(null); + const [selectedLog, setSelectedLog] = useState(null); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [selectedSessionId, setSelectedSessionId] = useState(null); + + const [isLiveTail, setIsLiveTail] = useState(() => { + const storedValue = sessionStorage.getItem("isLiveTail"); + return storedValue !== null ? JSON.parse(storedValue) : true; + }); + + useEffect(() => { + sessionStorage.setItem("isLiveTail", JSON.stringify(isLiveTail)); + }, [isLiveTail]); + + const filterByCurrentUser = internalUserRoles.includes(userRole); + + const { logsQuery, filteredLogs, allTeams } = useLogFilterLogic({ + accessToken, + token, + userRole, + userID, + columnFilters, + filterByCurrentUser, + activeTab: isActive ? "request logs" : "inactive", + isLiveTail, + startTime, + endTime, + pagination, + isCustomDate, + sorting, + }); + + const keyInfoQueryOptions: UseQueryOptions = { + queryKey: ["requestLogsKeyInfo", selectedKeyIdInfoView, accessToken], + queryFn: async () => { + if (selectedKeyIdInfoView === null) return null; + const keyData = await keyInfoV1Call(accessToken, selectedKeyIdInfoView); + return { + ...keyData["info"], + token: selectedKeyIdInfoView, + api_key: selectedKeyIdInfoView, + }; + }, + enabled: selectedKeyIdInfoView !== null, + }; + + const { data: selectedKeyInfo } = useQuery(keyInfoQueryOptions); + + const rows = useMemo(() => { + const searchedLogs = filteredLogs.data.filter((log) => { + if (!searchTerm) return true; + return ( + log.request_id.includes(searchTerm) || + log.model.includes(searchTerm) || + (log.user !== undefined && log.user.includes(searchTerm)) + ); + }); + + const sessionCompositionById = searchedLogs.reduce>((acc, log) => { + if (!log.session_id) return acc; + if (!acc[log.session_id]) { + acc[log.session_id] = { llm: 0, agent: 0, mcp: 0 }; + } + if (MCP_CALL_TYPES.includes(log.call_type)) { + acc[log.session_id].mcp += 1; + } else if (AGENT_CALL_TYPES.includes(log.call_type)) { + acc[log.session_id].agent += 1; + } else { + acc[log.session_id].llm += 1; + } + return acc; + }, {}); + + const sessionRepresentativeMap = new Map(); + for (const log of searchedLogs) { + if (!log.session_id || (log.session_total_count || 1) <= 1) continue; + const isMcp = MCP_CALL_TYPES.includes(log.call_type); + const existing = sessionRepresentativeMap.get(log.session_id); + if (!existing || (existing.isMcp && !isMcp)) { + sessionRepresentativeMap.set(log.session_id, { requestId: log.request_id, isMcp }); + } + } + + return searchedLogs + .map((log) => { + const sessionComposition = log.session_id ? sessionCompositionById[log.session_id] : undefined; + return { + ...log, + session_llm_count: sessionComposition?.llm ?? undefined, + session_mcp_count: sessionComposition?.mcp ?? undefined, + session_agent_count: sessionComposition?.agent ?? undefined, + }; + }) + .filter((log) => { + if (!log.session_id || (log.session_total_count || 1) <= 1) return true; + return sessionRepresentativeMap.get(log.session_id)?.requestId === log.request_id; + }); + }, [filteredLogs.data, searchTerm]); + + const handleSortingChange = useCallback>((updaterOrValue) => { + setSorting(updaterOrValue); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + }, []); + + const handleColumnFiltersChange = useCallback>((updaterOrValue) => { + setColumnFilters(updaterOrValue); + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + }, []); + + const resetToFirstPage = useCallback(() => { + setPagination((previous) => ({ ...previous, pageIndex: 0 })); + }, []); + + const handleResetFilters = useCallback(() => { + setColumnFilters([]); + setSearchTerm(""); + setStartTime(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm")); + setEndTime(moment().format("YYYY-MM-DDTHH:mm")); + setIsCustomDate(false); + setSelectedTimeInterval(DEFAULT_INTERVAL); + resetToFirstPage(); + }, [resetToFirstPage]); + + const handleRowClick = useCallback((log: LogEntry) => { + const isMultiCallSession = log.session_id !== undefined && (log.session_total_count || 1) > 1; + setSelectedSessionId(isMultiCallSession ? log.session_id ?? null : null); + setSelectedLog(log); + setIsDrawerOpen(true); + }, []); + + const handleSessionClick = useCallback( + (sessionId: string) => { + if (!sessionId) return; + const log = rows.find((candidate) => candidate.session_id === sessionId) ?? null; + setSelectedSessionId(sessionId); + setSelectedLog(log); + setIsDrawerOpen(true); + }, + [rows], + ); + + const handleKeyHashClick = useCallback((keyHash: string) => { + setSelectedKeyIdInfoView(keyHash); + }, []); + + if (selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView) { + return ( + setSelectedKeyIdInfoView(null)} + backButtonText="Back to Logs" + /> + ); + } + + return ( + <> +
+

Request Logs

+
+ + {isLiveTail && pagination.pageIndex === 0 && setIsLiveTail(false)} />} + + void logsQuery.refetch()} + onRowClick={handleRowClick} + onKeyHashClick={handleKeyHashClick} + onSessionClick={handleSessionClick} + teams={allTeams ?? []} + accessToken={accessToken} + toolbarChildren={ + + } + /> + + { + setIsDrawerOpen(false); + setSelectedSessionId(null); + }} + logEntry={selectedLog} + sessionId={selectedSessionId} + accessToken={accessToken} + allLogs={rows} + onSelectLog={setSelectedLog} + startTime={moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")} + /> + + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx new file mode 100644 index 00000000000..2b2b8448604 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTable.tsx @@ -0,0 +1,131 @@ +"use client"; + +import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; +import { ScrollText } from "lucide-react"; +import { useMemo, useState, type ReactNode } from "react"; + +import { DataTable, DataTableFilterDrawer, DataTableToolbar } from "@/components/shared/DataTable"; + +import type { Team } from "../key_team_helpers/key_list"; +import type { LogEntry } from "./columns"; +import { LOG_FILTER_LABELS } from "./log_filter_logic"; +import { RequestLogsFilters } from "./RequestLogsFilters"; +import { getRequestLogsTableColumns } from "./RequestLogsTableColumns"; + +interface RequestLogsTableProps { + data: LogEntry[]; + rowCount: number; + isLoading: boolean; + isRefreshing: boolean; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + sorting: SortingState; + onSortingChange: OnChangeFn; + columnFilters: ColumnFiltersState; + onColumnFiltersChange: OnChangeFn; + searchValue: string; + onSearchChange: (value: string) => void; + onRefresh: () => void; + onRowClick: (log: LogEntry) => void; + onKeyHashClick: (keyHash: string) => void; + onSessionClick: (sessionId: string) => void; + teams: Team[]; + accessToken: string; + toolbarChildren?: ReactNode; +} + +function RequestLogsEmptyState({ filtered }: { filtered: boolean }) { + return ( +
+
+ +
+
{filtered ? "No matching requests" : "No requests yet"}
+
+ {filtered + ? "No requests match your filters for this time range." + : "Requests proxied through LiteLLM will appear here."} +
+
+ ); +} + +export function RequestLogsTable({ + data, + rowCount, + isLoading, + isRefreshing, + pagination, + onPaginationChange, + sorting, + onSortingChange, + columnFilters, + onColumnFiltersChange, + searchValue, + onSearchChange, + onRefresh, + onRowClick, + onKeyHashClick, + onSessionClick, + teams, + accessToken, + toolbarChildren, +}: RequestLogsTableProps) { + const [filtersOpen, setFiltersOpen] = useState(false); + + const columns = useMemo(() => { + const deps = { onKeyHashClick, onSessionClick }; + return getRequestLogsTableColumns(deps); + }, [onKeyHashClick, onSessionClick]); + + const isFiltered = columnFilters.length > 0 || searchValue !== ""; + + return ( + row.request_id} + sortingMode="server" + sorting={sorting} + onSortingChange={onSortingChange} + paginationMode="server" + pagination={pagination} + onPaginationChange={onPaginationChange} + rowCount={rowCount} + filterMode="server" + columnFilters={columnFilters} + onColumnFiltersChange={onColumnFiltersChange} + isLoading={isLoading} + loadingMessage="Loading request logs…" + noDataMessage={} + size="compact" + onRowClick={onRowClick} + toolbar={(table) => ( + <> + setFiltersOpen(true)} + filterLabels={LOG_FILTER_LABELS} + showViewOptions={false} + > + {toolbarChildren} + + + {({ get, set }) => } + + + )} + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx new file mode 100644 index 00000000000..2fdc8455ca1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.test.tsx @@ -0,0 +1,136 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { DataTable } from "@/components/shared/DataTable"; + +import type { LogEntry } from "./columns"; +import { getRequestLogsTableColumns } from "./RequestLogsTableColumns"; + +const logEntry = (overrides: Partial): LogEntry => ({ + request_id: "req-1", + api_key: "key-1", + team_id: "team-1", + model: "gpt-4o", + model_id: "model-1", + call_type: "acompletion", + spend: 0, + total_tokens: 10, + prompt_tokens: 5, + completion_tokens: 5, + startTime: "2026-07-07T09:50:13Z", + endTime: "2026-07-07T09:50:14Z", + cache_hit: "false", + messages: [], + response: {}, + ...overrides, +}); + +const noopDeps = { onKeyHashClick: vi.fn(), onSessionClick: vi.fn() }; + +function renderRows(rows: LogEntry[], deps = noopDeps) { + render( + row.request_id} + size="compact" + />, + ); +} + +describe("Cost column", () => { + it("renders '-' for zero spend with no tooltip, so hovering never shows a contradictory $0", async () => { + const user = userEvent.setup(); + renderRows([logEntry({ request_id: "req-zero", spend: 0 })]); + + for (const dash of screen.getAllByText("-")) { + await user.hover(dash); + } + expect(screen.queryByText("$0")).not.toBeInTheDocument(); + }); + + it("shows the full-precision raw value in the tooltip for a real spend", async () => { + const user = userEvent.setup(); + renderRows([logEntry({ request_id: "req-spend", spend: 0.00012345678 })]); + + await user.hover(screen.getByText("$0.000123")); + expect(await screen.findByText("$0.00012345678")).toBeInTheDocument(); + }); + + it("shows the summed session total, not the representative call's spend, for a multi-round session", () => { + renderRows([ + logEntry({ + request_id: "req-session", + spend: 0.01, + session_id: "sess-1", + session_total_count: 3, + session_total_spend: 0.06, + }), + ]); + + expect(screen.getByText("$0.060000")).toBeInTheDocument(); + expect(screen.queryByText("$0.010000")).not.toBeInTheDocument(); + expect(screen.getByText("session total")).toBeInTheDocument(); + }); +}); + +describe("row action cells", () => { + it("reports the key hash through the injected dependency rather than a row field", async () => { + const user = userEvent.setup(); + const deps = { onKeyHashClick: vi.fn(), onSessionClick: vi.fn() }; + renderRows([logEntry({ request_id: "req-key", metadata: { user_api_key: "sk-hash-9" } })], deps); + + await user.click(screen.getByText("sk-hash-9")); + expect(deps.onKeyHashClick).toHaveBeenCalledWith("sk-hash-9"); + }); + + it("reports the session id from the session cell", async () => { + const user = userEvent.setup(); + const deps = { onKeyHashClick: vi.fn(), onSessionClick: vi.fn() }; + renderRows([logEntry({ request_id: "req-sess", session_id: "sess-42" })], deps); + + await user.click(screen.getByText("sess-42")); + expect(deps.onSessionClick).toHaveBeenCalledWith("sess-42"); + }); +}); + +describe("sortable headers", () => { + it("exposes sort controls only for the backend-sortable fields", () => { + renderRows([logEntry({})]); + + for (const field of ["startTime", "spend", "request_duration_ms", "ttft_ms", "model", "total_tokens"]) { + expect(screen.getByTestId(`sort-trigger-${field}`)).toBeInTheDocument(); + } + for (const field of ["request_id", "session_id", "status", "type", "end_user"]) { + expect(screen.queryByTestId(`sort-trigger-${field}`)).toBeNull(); + } + }); +}); + +describe("TTFT column", () => { + it("renders '-' when the completion start equals the end time, since TTFT is meaningless there", () => { + renderRows([ + logEntry({ + request_id: "req-nonstream", + endTime: "2026-07-07T09:50:14Z", + completionStartTime: "2026-07-07T09:50:14Z", + }), + ]); + + expect(screen.queryByText("1.00")).not.toBeInTheDocument(); + }); + + it("renders seconds when streaming produced a real first token", () => { + renderRows([ + logEntry({ + request_id: "req-stream", + startTime: "2026-07-07T09:50:13Z", + endTime: "2026-07-07T09:50:16Z", + completionStartTime: "2026-07-07T09:50:14Z", + }), + ]); + + expect(screen.getByText("1.00")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx new file mode 100644 index 00000000000..4e5a83ac7dd --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsTableColumns.tsx @@ -0,0 +1,317 @@ +"use client"; + +import type { ColumnDef } from "@tanstack/react-table"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { CellTooltip, DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells"; +import { getSpendString } from "@/utils/dataUtils"; + +import { getProviderLogoAndName } from "../provider_info_helpers"; +import type { LogEntry } from "./columns"; +import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants"; +import { AgentBadge, AgentIcon, LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges"; + +export interface RequestLogsTableColumnsDeps { + onKeyHashClick: (keyHash: string) => void; + onSessionClick: (sessionId: string) => void; +} + +const readMetaString = (metadata: Record | undefined, key: string): string | undefined => { + const value = metadata?.[key]; + return typeof value === "string" && value !== "" ? value : undefined; +}; + +const readMcpLogoUrl = (metadata: Record | undefined): string | undefined => { + const mcpMetadata = metadata?.["mcp_tool_call_metadata"]; + if (typeof mcpMetadata !== "object" || mcpMetadata === null) return undefined; + const url = (mcpMetadata as Record)["mcp_server_logo_url"]; + return typeof url === "string" && url !== "" ? url : undefined; +}; + +const getLogoUrl = (row: LogEntry, provider: string): string => + readMcpLogoUrl(row.metadata) ?? (provider ? getProviderLogoAndName(provider).logo : ""); + +function TruncatedText({ value }: { value: string | undefined }) { + const display = value ?? "-"; + return {display}} />; +} + +export const getRequestLogsTableColumns = ({ + onKeyHashClick, + onSessionClick, +}: RequestLogsTableColumnsDeps): ColumnDef[] => [ + { + id: "startTime", + accessorKey: "startTime", + header: ({ column }) => , + size: 200, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "type", + header: "Type", + size: 90, + enableSorting: false, + meta: { skeleton: "badge" }, + cell: ({ row }) => { + const log = row.original; + const sessionCount = log.session_total_count || 1; + const isMcp = MCP_CALL_TYPES.includes(log.call_type); + const isAgent = AGENT_CALL_TYPES.includes(log.call_type); + const sessionLlmCount = log.session_llm_count ?? (isMcp || isAgent ? 0 : sessionCount); + const sessionAgentCount = log.session_agent_count ?? (isAgent ? sessionCount : 0); + const sessionMcpCount = log.session_mcp_count ?? (isMcp ? sessionCount : 0); + + if (isMcp) return ; + if (isAgent && sessionCount <= 1) return ; + if (sessionCount <= 1) return ; + + const sessionTypeBadge = ( + + + {sessionCount} + {sessionAgentCount > 0 && ( + <> + · + + + )} + {sessionMcpCount > 0 && ( + <> + · + + + )} + + ); + + const tooltipParts = [ + sessionLlmCount > 0 && `${sessionLlmCount} LLM`, + sessionAgentCount > 0 && `${sessionAgentCount} Agent`, + sessionMcpCount > 0 && `${sessionMcpCount} MCP`, + ].filter(Boolean); + return ; + }, + }, + { + id: "status", + header: "Status", + size: 100, + enableSorting: false, + meta: { skeleton: "badge" }, + cell: ({ row }) => { + const status = readMetaString(row.original.metadata, "status") ?? "Success"; + const isSuccess = status.toLowerCase() !== "failure"; + return ; + }, + }, + { + id: "session_id", + accessorKey: "session_id", + header: "Session ID", + size: 120, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "request_id", + accessorKey: "request_id", + header: "Request ID", + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "spend", + accessorKey: "spend", + header: ({ column }) => , + size: 110, + enableSorting: true, + meta: { numeric: true, skeleton: "twoLine" }, + cell: ({ row }) => { + const log = row.original; + const mcpCount = log.mcp_tool_call_count || 0; + const mcpSpend = log.mcp_tool_call_spend || 0; + const isMultiCallSession = (log.session_total_count || 1) > 1; + const spend = isMultiCallSession && log.session_total_spend != null ? log.session_total_spend : log.spend; + const money = ( + + + + ); + + return ( +
+ {spend ? : money} + {isMultiCallSession && session total} + {mcpCount > 0 && mcpSpend > 0 && ( + + incl. {getSpendString(mcpSpend)} from {mcpCount} MCP + + )} +
+ ); + }, + }, + { + id: "request_duration_ms", + accessorKey: "request_duration_ms", + header: ({ column }) => , + enableSorting: true, + meta: { numeric: true }, + cell: ({ row }) => { + const ms = row.original.request_duration_ms; + if (ms == null) return -; + return ( + {(ms / 1000).toFixed(2)}} + /> + ); + }, + }, + { + id: "ttft_ms", + accessorKey: "completionStartTime", + header: ({ column }) => , + enableSorting: true, + meta: { numeric: true }, + cell: ({ row }) => { + const log = row.original; + const completionStartTime = log.completionStartTime; + if (!completionStartTime) return -; + if (completionStartTime === log.endTime) return -; + const ttftMs = new Date(completionStartTime).getTime() - new Date(log.startTime).getTime(); + if (ttftMs <= 0) return -; + return ( + {(ttftMs / 1000).toFixed(2)}} + /> + ); + }, + }, + { + id: "team_alias", + header: "Team Name", + size: 150, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "key_hash", + header: "Key Hash", + size: 110, + enableSorting: false, + cell: ({ row }) => ( + + ), + }, + { + id: "key_alias", + header: "Key Alias", + size: 150, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "model", + accessorKey: "model", + header: ({ column }) => , + size: 200, + enableSorting: true, + cell: ({ row }) => { + const log = row.original; + const provider = log.custom_llm_provider; + const modelName = log.model ?? ""; + return ( +
+ {provider && ( + { + event.currentTarget.style.display = "none"; + }} + /> + )} + {modelName}} /> +
+ ); + }, + }, + { + id: "total_tokens", + accessorKey: "total_tokens", + header: ({ column }) => , + size: 140, + enableSorting: true, + meta: { numeric: true }, + cell: ({ row }) => { + const log = row.original; + return ( + + {String(log.total_tokens || "0")} + + ({String(log.prompt_tokens || "0")}+{String(log.completion_tokens || "0")}) + + + ); + }, + }, + { + id: "user", + accessorKey: "user", + header: "Internal User", + size: 150, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "end_user", + accessorKey: "end_user", + header: "End User", + size: 140, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "request_tags", + accessorKey: "request_tags", + header: "Tags", + size: 150, + enableSorting: false, + meta: { skeleton: "chips" }, + cell: ({ row }) => { + const tags = row.original.request_tags; + if (!tags || Object.keys(tags).length === 0) return "-"; + + const tagEntries = Object.entries(tags); + const [firstTagKey, firstTagValue] = tagEntries[0]; + const remainingCount = tagEntries.length - 1; + + return ( +
+ + {tagEntries.map(([key, value]) => ( + + {key}: {String(value)} + + ))} +
+ } + trigger={ + + {firstTagKey}: {String(firstTagValue)} + {remainingCount > 0 && ` +${remainingCount}`} + + } + /> +
+ ); + }, + }, +]; diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx deleted file mode 100644 index c3d9bfdd0a5..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it } from "vitest"; - -import { createColumns, type LogEntry } from "./columns"; -import { DataTable } from "./table"; - -const logEntry = (overrides: Partial): LogEntry => ({ - request_id: "req-1", - api_key: "key-1", - team_id: "team-1", - model: "gpt-4o", - model_id: "model-1", - call_type: "acompletion", - spend: 0, - total_tokens: 10, - prompt_tokens: 5, - completion_tokens: 5, - startTime: "2026-07-07T09:50:13Z", - endTime: "2026-07-07T09:50:14Z", - cache_hit: "false", - messages: [], - response: {}, - ...overrides, -}); - -describe("Cost column", () => { - it("renders '-' for zero spend with no tooltip, so hovering never shows a contradictory $0", async () => { - const user = userEvent.setup(); - render( - r.request_id} - />, - ); - for (const dash of screen.getAllByText("-")) { - await user.hover(dash); - } - expect(screen.queryByText("$0")).not.toBeInTheDocument(); - }); - - it("shows the full-precision raw value in the tooltip for a real spend", async () => { - const user = userEvent.setup(); - render( - r.request_id} - />, - ); - const formatted = screen.getByText("$0.000123"); - await user.hover(formatted); - expect(await screen.findByText("$0.00012345678")).toBeInTheDocument(); - }); - - it("shows the summed session total, not the representative call's spend, for a multi-round session", () => { - const overrides: Partial = { - request_id: "req-session", - spend: 0.01, - session_id: "sess-1", - session_total_count: 3, - session_total_spend: 0.06, - }; - render( r.request_id} />); - expect(screen.getByText("$0.060000")).toBeInTheDocument(); - expect(screen.queryByText("$0.010000")).not.toBeInTheDocument(); - expect(screen.getByText("session total")).toBeInTheDocument(); - }); -}); diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index d3ba90d4e2d..a4b8015892a 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -1,13 +1,3 @@ -import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells"; -import { getSpendString } from "@/utils/dataUtils"; -import type { ColumnDef } from "@tanstack/react-table"; -import { Tooltip } from "antd"; -import React from "react"; -import { getProviderLogoAndName } from "../provider_info_helpers"; -import { TableHeaderSortDropdown } from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown"; -import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants"; -import { AgentBadge, AgentIcon, LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges"; - /** API sort field mapping for /spend/logs/ui endpoint */ export const LOGS_SORT_FIELD_MAP = { startTime: "startTime", @@ -20,22 +10,6 @@ export const LOGS_SORT_FIELD_MAP = { export type LogsSortField = keyof typeof LOGS_SORT_FIELD_MAP; -export interface LogsSortProps { - sortBy: LogsSortField; - sortOrder: "asc" | "desc"; - onSortChange: (sortBy: LogsSortField, sortOrder: "asc" | "desc") => void; -} - -// Helper to get the appropriate logo URL -const getLogoUrl = (row: LogEntry, provider: string) => { - // Check if mcp_tool_call_metadata exists and contains mcp_server_logo_url - if (row.metadata?.mcp_tool_call_metadata?.mcp_server_logo_url) { - return row.metadata.mcp_tool_call_metadata.mcp_server_logo_url; - } - // Fall back to default provider logo - return provider ? getProviderLogoAndName(provider).logo : ""; -}; - export type LogEntry = { request_id: string; api_key: string; @@ -72,470 +46,4 @@ export type LogEntry = { session_llm_count?: number; session_mcp_count?: number; session_agent_count?: number; - onKeyHashClick?: (keyHash: string) => void; - onSessionClick?: (sessionId: string) => void; -}; - -const SortableHeader = ({ - label, - field, - sortBy, - sortOrder, - onSortChange, -}: { - label: string; - field: LogsSortField; - sortBy: LogsSortField; - sortOrder: "asc" | "desc"; - onSortChange: (sortBy: LogsSortField, sortOrder: "asc" | "desc") => void; -}) => ( -
- {label} - { - if (newState === false) { - onSortChange("startTime", "desc"); - } else { - onSortChange(field, newState); - } - }} - /> -
-); - -export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] => [ - { - header: sortProps - ? () => ( - - ) - : "Time", - accessorKey: "startTime", - size: 200, - cell: (info: any) => , - }, - { - header: "Type", - id: "type", - size: 90, - cell: (info: any) => { - const row = info.row.original; - const sessionCount = row.session_total_count || 1; - const isMcp = MCP_CALL_TYPES.includes(row.call_type); - const isAgent = AGENT_CALL_TYPES.includes(row.call_type); - const sessionLlmCount = row.session_llm_count ?? (isMcp || isAgent ? 0 : sessionCount); - const sessionAgentCount = row.session_agent_count ?? (isAgent ? sessionCount : 0); - const sessionMcpCount = row.session_mcp_count ?? (isMcp ? sessionCount : 0); - - if (isMcp) return ; - if (isAgent && sessionCount <= 1) return ; - if (sessionCount <= 1) return ; - - // Multi-call session — show total count, plus Agent/MCP indicators when mixed. - const sessionTypeBadge = ( - - - {sessionCount} - {sessionAgentCount > 0 && ( - <> - · - - - )} - {sessionMcpCount > 0 && ( - <> - · - - - )} - - ); - - const tooltipParts = [ - sessionLlmCount > 0 && `${sessionLlmCount} LLM`, - sessionAgentCount > 0 && `${sessionAgentCount} Agent`, - sessionMcpCount > 0 && `${sessionMcpCount} MCP`, - ].filter(Boolean); - return {sessionTypeBadge}; - }, - }, - { - header: "Status", - accessorKey: "metadata.status", - size: 100, - cell: (info: any) => { - const status = info.getValue() || "Success"; - const isSuccess = status.toLowerCase() !== "failure"; - return ; - }, - }, - { - header: "Session ID", - accessorKey: "session_id", - size: 120, - cell: (info: any) => , - }, - - { - header: "Request ID", - accessorKey: "request_id", - cell: (info: any) => , - }, - { - header: sortProps - ? () => ( - - ) - : "Cost", - accessorKey: "spend", - size: 110, - meta: { numeric: true }, - cell: (info: any) => { - const row = info.row.original; - const mcpCount = row.mcp_tool_call_count || 0; - const mcpSpend = row.mcp_tool_call_spend || 0; - const isMultiCallSession = (row.session_total_count || 1) > 1; - const spend = isMultiCallSession && row.session_total_spend != null ? row.session_total_spend : info.getValue(); - - return ( -
- - - - - - {isMultiCallSession && session total} - {mcpCount > 0 && mcpSpend > 0 && ( - - incl. {getSpendString(mcpSpend)} from {mcpCount} MCP - - )} -
- ); - }, - }, - { - header: sortProps - ? () => ( - - ) - : "Duration (s)", - accessorKey: "request_duration_ms", - meta: { numeric: true }, - cell: (info: any) => { - const ms = info.getValue(); - if (ms == null) return -; - const seconds = (ms / 1000).toFixed(2); - return ( - - {seconds} - - ); - }, - }, - { - header: sortProps - ? () => ( - - ) - : "TTFT (s)", - accessorKey: "completionStartTime", - meta: { numeric: true }, - cell: (info: any) => { - const row = info.row.original; - const completionStartTime = info.getValue(); - if (!completionStartTime) return -; - // For non-streaming, completionStartTime == endTime so TTFT is not meaningful - if (completionStartTime === row.endTime) return -; - const ttftMs = new Date(completionStartTime).getTime() - new Date(row.startTime).getTime(); - if (ttftMs <= 0) return -; - const ttftSeconds = (ttftMs / 1000).toFixed(2); - return ( - - {ttftSeconds} - - ); - }, - }, - { - header: "Team Name", - accessorKey: "metadata.user_api_key_team_alias", - size: 150, - cell: (info: any) => ( - - {String(info.getValue() || "-")} - - ), - }, - { - header: "Key Hash", - accessorKey: "metadata.user_api_key", - size: 110, - cell: (info: any) => , - }, - { - header: "Key Alias", - accessorKey: "metadata.user_api_key_alias", - size: 150, - cell: (info: any) => ( - - {String(info.getValue() || "-")} - - ), - }, - { - header: sortProps - ? () => ( - - ) - : "Model", - accessorKey: "model", - size: 200, - cell: (info: any) => { - const row = info.row.original; - const provider = row.custom_llm_provider; - const modelName = String(info.getValue() || ""); - return ( -
- {provider && ( - { - const target = e.target as HTMLImageElement; - target.style.display = "none"; - }} - /> - )} - - {modelName} - -
- ); - }, - }, - { - header: sortProps - ? () => ( - - ) - : "Tokens", - accessorKey: "total_tokens", - size: 140, - meta: { numeric: true }, - cell: (info: any) => { - const row = info.row.original; - return ( - - {String(row.total_tokens || "0")} - - ({String(row.prompt_tokens || "0")}+{String(row.completion_tokens || "0")}) - - - ); - }, - }, - { - header: "Internal User", - accessorKey: "user", - size: 150, - cell: (info: any) => ( - - {String(info.getValue() || "-")} - - ), - }, - { - header: "End User", - accessorKey: "end_user", - size: 140, - cell: (info: any) => ( - - {String(info.getValue() || "-")} - - ), - }, - - { - header: "Tags", - accessorKey: "request_tags", - size: 150, - cell: (info: any) => { - const tags = info.getValue(); - if (!tags || Object.keys(tags).length === 0) return "-"; - - const tagEntries = Object.entries(tags); - const firstTag = tagEntries[0]; - const remainingTags = tagEntries.slice(1); - - return ( -
- - {tagEntries.map(([key, value]) => ( - - {key}: {String(value)} - - ))} -
- } - > - - {firstTag[0]}: {String(firstTag[1])} - {remainingTags.length > 0 && ` +${remainingTags.length}`} - - -
- ); - }, - }, -]; - -/** Default columns without sort (for backward compatibility) */ -export const columns = createColumns(); - -const formatMessage = (message: any): string => { - if (!message) return "N/A"; - if (typeof message === "string") return message; - if (typeof message === "object") { - // Handle the {text, type} object specifically - if (message.text) return message.text; - if (message.content) return message.content; - return JSON.stringify(message); - } - return String(message); -}; - -// Add this new component for displaying request/response with copy buttons -export const RequestResponsePanel = ({ request, response }: { request: any; response: any }) => { - const requestStr = typeof request === "object" ? JSON.stringify(request, null, 2) : String(request || "{}"); - const responseStr = typeof response === "object" ? JSON.stringify(response, null, 2) : String(response || "{}"); - - const copyToClipboard = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - } catch (err) { - console.error("Failed to copy text: ", err); - } - }; - - return ( -
-
-
-

Request

- -
-
{requestStr}
-
- -
-
-

Response

- -
-
-          {responseStr}
-        
-
-
- ); -}; - -// New component for collapsible JSON display -const CollapsibleJsonCell = ({ jsonData }: { jsonData: any }) => { - const [isExpanded, setIsExpanded] = React.useState(false); - const jsonString = JSON.stringify(jsonData, null, 2); - - if (!jsonData || Object.keys(jsonData).length === 0) { - return -; - } - - return ( -
- - {isExpanded && ( -
{jsonString}
- )} -
- ); }; diff --git a/ui/litellm-dashboard/src/components/view_logs/filter_options.ts b/ui/litellm-dashboard/src/components/view_logs/filter_options.ts deleted file mode 100644 index 90e27b6144d..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/filter_options.ts +++ /dev/null @@ -1,82 +0,0 @@ -import FilterTeamDropdown from "../common_components/FilterTeamDropdown"; -import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect"; -import { PaginatedModelSelect } from "../ModelSelect/PaginatedModelSelect/PaginatedModelSelect"; -import { FilterOption } from "../molecules/filter"; -import { allEndUsersCall } from "../networking"; -import { ERROR_CODE_OPTIONS } from "./constants"; -import { FILTER_KEYS } from "./log_filter_logic"; - -export function getLogFilterOptions(accessToken: string): FilterOption[] { - return [ - { - name: "Team ID", - label: "Team ID", - customComponent: FilterTeamDropdown, - }, - { - name: "Status", - label: "Status", - isSearchable: false, - options: [ - { label: "Success", value: "success" }, - { label: "Failure", value: "failure" }, - ], - }, - { - name: "Key Alias", - label: "Key Alias", - customComponent: PaginatedKeyAliasSelect, - }, - { - name: "End User", - label: "End User", - isSearchable: true, - searchFn: async (searchText: string) => { - const data = await allEndUsersCall(accessToken); - const users = data?.map((u: any) => u.user_id) || []; - const filtered = users.filter((u: string) => u.toLowerCase().includes(searchText.toLowerCase())); - return filtered.map((u: string) => ({ label: u, value: u })); - }, - }, - { - name: "Error Code", - label: "Error Code", - isSearchable: true, - searchFn: async (searchText: string) => { - if (!searchText) return ERROR_CODE_OPTIONS; - const lower = searchText.toLowerCase(); - const filtered = ERROR_CODE_OPTIONS.filter((opt) => opt.label.toLowerCase().includes(lower)); - const isExactValue = ERROR_CODE_OPTIONS.some((opt) => opt.value === searchText.trim()); - if (!isExactValue && searchText.trim()) { - filtered.push({ label: `Use custom code: ${searchText.trim()}`, value: searchText.trim() }); - } - return filtered; - }, - }, - { - name: "Error Message", - label: "Error Message", - isSearchable: false, - }, - { - name: "Key Hash", - label: "Key Hash", - isSearchable: false, - }, - { - name: FILTER_KEYS.SESSION_ID, - label: "Session ID", - isSearchable: false, - }, - { - name: "Model", - label: "Model", - customComponent: PaginatedModelSelect, - }, - { - name: FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL, - label: "Public model / search tool", - isSearchable: false, - }, - ]; -} diff --git a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx b/ui/litellm-dashboard/src/components/view_logs/index.test.tsx index 6f1c4126282..b2e77ec7fd5 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.test.tsx @@ -1,106 +1,60 @@ -import { screen, waitFor } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import moment from "moment"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import SpendLogsTable from "./index"; import { renderWithProviders } from "../../../tests/test-utils"; -import { uiSpendLogsCall } from "../networking"; -import { useLogFilterLogic } from "./log_filter_logic"; -const mockHandleFilterResetFromHook = vi.fn(); -vi.mock("./log_filter_logic", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useLogFilterLogic: vi.fn(() => ({ - logsQuery: { isLoading: false, isFetching: false, refetch: vi.fn() }, - filteredLogs: { data: [], total: 0, page: 1, page_size: 50, total_pages: 1 }, - allTeams: [], - handleFilterChange: vi.fn(), - handleFilterReset: mockHandleFilterResetFromHook, - })), - }; -}); - -vi.mock("../networking", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - uiSpendLogsCall: vi.fn().mockResolvedValue({ - data: [], - total: 0, - page: 1, - page_size: 50, - total_pages: 0, - }), - keyListCall: vi.fn().mockResolvedValue({ keys: [] }), - keyInfoV1Call: vi.fn().mockResolvedValue({ info: {} }), - allEndUsersCall: vi.fn().mockResolvedValue([]), - }; -}); - -vi.mock("../key_team_helpers/filter_helpers", () => ({ - fetchAllTeams: vi.fn().mockResolvedValue([]), +vi.mock("./RequestLogsPanel", () => ({ + default: function RequestLogsPanelMock({ isActive }: { isActive: boolean }) { + return
{isActive ? "active" : "inactive"}
; + }, })); +vi.mock("./AuditLogsPanel", () => ({ + default: function AuditLogsPanelMock({ isActive }: { isActive: boolean }) { + return
{isActive ? "active" : "inactive"}
; + }, +})); + +vi.mock("../DeletedKeysPage/DeletedKeysPage", () => ({ + default: function DeletedKeysPageMock() { + return
; + }, +})); + +vi.mock("../DeletedTeamsPage/DeletedTeamsPage", () => ({ + default: function DeletedTeamsPageMock() { + return
; + }, +})); + +const defaultProps = { + accessToken: "test-token", + token: "test-token", + userRole: "Admin", + userID: "user-1", + premiumUser: false, +}; + describe("SpendLogsTable", () => { - const defaultProps = { - accessToken: "test-token", - token: "test-token", - userRole: "Admin", - userID: "user-1", - premiumUser: false, - }; + it("renders the four log tabs", () => { + renderWithProviders(); - beforeEach(() => { - vi.clearAllMocks(); - // Clear sessionStorage to avoid isLiveTail state from previous tests - sessionStorage.clear(); + for (const label of ["Request Logs", "Audit Logs", "Deleted Keys", "Deleted Teams"]) { + expect(screen.getByRole("tab", { name: label })).toBeInTheDocument(); + } }); - it("should call handleFilterResetFromHook when Reset Filters is clicked", async () => { + it("marks only the visible tab's panel active so background tabs do not query", async () => { const user = userEvent.setup(); renderWithProviders(); - const resetButton = screen.getByRole("button", { name: "Reset Filters" }); - await user.click(resetButton); + expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("active"); - await waitFor(() => { - expect(mockHandleFilterResetFromHook).toHaveBeenCalledTimes(1); - }); - }); + await user.click(screen.getByRole("tab", { name: "Audit Logs" })); - it("should reset custom date range to default when Reset Filters is clicked", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - // Open the time range quick select dropdown (button shows current range like "Last 24 Hours") - const quickSelectButton = screen.getByRole("button", { - name: /Last 24 Hours|Last 15 Minutes|Last Hour|Last 4 Hours|Last 7 Days/i, - }); - await user.click(quickSelectButton); - - // Click "Custom Range" to enable custom date selection - const customRangeButton = await screen.findByRole("button", { name: "Custom Range" }); - await user.click(customRangeButton); - - // Custom date inputs should now be visible (start and end datetime-local inputs) - const datetimeInputs = document.querySelectorAll('input[type="datetime-local"]'); - expect(datetimeInputs.length).toBeGreaterThanOrEqual(2); - - // Click Reset Filters - this should reset the custom date range and hide custom inputs - const resetButton = screen.getByRole("button", { name: "Reset Filters" }); - await user.click(resetButton); - - await waitFor(() => { - expect(mockHandleFilterResetFromHook).toHaveBeenCalled(); - }); - - // After reset, custom date inputs should be hidden (isCustomDate reset to false) - await waitFor(() => { - const inputsAfterReset = document.querySelectorAll('input[type="datetime-local"]'); - expect(inputsAfterReset.length).toBe(0); - }); + expect(await screen.findByTestId("audit-logs-panel")).toHaveTextContent("active"); + expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive"); }); describe("auth-not-ready guard", () => { @@ -108,73 +62,14 @@ describe("SpendLogsTable", () => { renderWithProviders(); expect(document.querySelector(".ant-spin")).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Reset Filters" })).not.toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Request Logs" })).not.toBeInTheDocument(); }); - it("renders the table (no spinner) once all credentials are present", () => { + it("renders the tabs (no spinner) once all credentials are present", () => { renderWithProviders(); expect(document.querySelector(".ant-spin")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument(); - }); - }); - - describe("Quick Select time range", () => { - // uiSpendLogsCall fires from the real useLogFilterLogic query, so restore it here. - beforeEach(async () => { - const actual = await vi.importActual("./log_filter_logic"); - vi.mocked(useLogFilterLogic).mockImplementation(actual.useLogFilterLogic); - }); - - const waitForWindowSeconds = async (minMinutes: number) => { - let diff = -1; - await waitFor(() => { - const lastCall = vi.mocked(uiSpendLogsCall).mock.calls.at(-1)?.[0]; - if (!lastCall) throw new Error("uiSpendLogsCall was not called"); - diff = moment - .utc(lastCall.end_date, "YYYY-MM-DD HH:mm:ss") - .diff(moment.utc(lastCall.start_date, "YYYY-MM-DD HH:mm:ss"), "seconds"); - // start_date is rounded down to the minute boundary, end_date is the - // current wall-clock at queryFn time. The dropped sub-minute fraction - // on start_date can push the diff up to (minMinutes+1)*60 seconds - // exactly (e.g. click at HH:MM:59.9 → start floors to HH:MM:00 and - // queryFn fires just past HH:(MM+1):00), so allow equality on the - // upper bound. - expect(diff).toBeGreaterThanOrEqual(minMinutes * 60); - expect(diff).toBeLessThanOrEqual((minMinutes + 1) * 60); - }); - return diff; - }; - - it("should pass a ~1-minute window to uiSpendLogsCall when 'Last Minute' is selected", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByRole("button", { name: /Last 24 Hours/i })); - await user.click(await screen.findByRole("button", { name: "Last Minute" })); - - await waitForWindowSeconds(1); - }); - - it("should pass a ~15-minute window to uiSpendLogsCall when 'Last 15 Minutes' is selected", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByRole("button", { name: /Last 24 Hours/i })); - await user.click(await screen.findByRole("button", { name: "Last 15 Minutes" })); - - await waitForWindowSeconds(15); - }); - - it("should update the time-range button label to 'Last Minute' after selecting it", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - await user.click(screen.getByRole("button", { name: /Last 24 Hours/i })); - await user.click(await screen.findByRole("button", { name: "Last Minute" })); - - expect(screen.getByRole("button", { name: "Last Minute" })).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /Last 24 Hours/i })).not.toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Request Logs" })).toBeInTheDocument(); }); }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index aa8077a02e9..8e7423e3fae 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -1,21 +1,9 @@ -import moment from "moment"; -import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; +import { useState } from "react"; import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; -import { internalUserRoles } from "../../utils/roles"; import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage"; import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage"; -import { KeyResponse } from "../key_team_helpers/key_list"; -import FilterComponent from "../molecules/filter"; -import { keyInfoV1Call } from "../networking"; -import KeyInfoView from "../templates/key_info_view"; import AuditLogsPanel from "./AuditLogsPanel"; -import { createColumns, LogEntry, type LogsSortField } from "./columns"; -import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants"; -import { getLogFilterOptions } from "./filter_options"; -import { useLogFilterLogic, defaultFilters, type LogFilterState } from "./log_filter_logic"; -import { LogDetailsDrawer } from "./LogDetailsDrawer"; -import { LogsTableToolbar } from "./LogsTableToolbar"; -import { DataTable } from "./table"; +import RequestLogsPanel from "./RequestLogsPanel"; import { AntDLoadingSpinner } from "../ui/AntDLoadingSpinner"; interface SpendLogsTableProps { @@ -27,190 +15,8 @@ interface SpendLogsTableProps { } export default function SpendLogsTable({ accessToken, token, userRole, userID, premiumUser }: SpendLogsTableProps) { - const [searchTerm, setSearchTerm] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [pageSize] = useState(50); - - // New state variables for Start and End Time - const [startTime, setStartTime] = useState(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm")); - const [endTime, setEndTime] = useState(moment().format("YYYY-MM-DDTHH:mm")); - - const [isCustomDate, setIsCustomDate] = useState(false); - const [filters, setFilters] = useState(defaultFilters); - const [selectedKeyInfo, setSelectedKeyInfo] = useState(null); - const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState(null); - const [filterByCurrentUser, setFilterByCurrentUser] = useState(userRole && internalUserRoles.includes(userRole)); const [activeTab, setActiveTab] = useState("request logs"); - const [selectedLog, setSelectedLog] = useState(null); - const [isDrawerOpen, setIsDrawerOpen] = useState(false); - const [selectedSessionId, setSelectedSessionId] = useState(null); - - const [sortBy, setSortBy] = useState("startTime"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); - - const [selectedTimeInterval, setSelectedTimeInterval] = useState<{ value: number; unit: string }>({ - value: 24, - unit: "hours", - }); - - const [isLiveTail, setIsLiveTail] = useState(() => { - const storedValue = sessionStorage.getItem("isLiveTail"); - // default to true if nothing is stored - return storedValue !== null ? JSON.parse(storedValue) : true; - }); - - useEffect(() => { - sessionStorage.setItem("isLiveTail", JSON.stringify(isLiveTail)); - }, [isLiveTail]); - - useEffect(() => { - const fetchKeyInfo = async () => { - if (selectedKeyIdInfoView && accessToken) { - const keyData = await keyInfoV1Call(accessToken, selectedKeyIdInfoView); - - const keyResponse: KeyResponse = { - ...keyData["info"], - token: selectedKeyIdInfoView, - api_key: selectedKeyIdInfoView, - }; - setSelectedKeyInfo(keyResponse); - } - }; - fetchKeyInfo(); - }, [selectedKeyIdInfoView, accessToken]); - - useEffect(() => { - if (userRole && internalUserRoles.includes(userRole)) { - setFilterByCurrentUser(true); - } - }, [userRole]); - - const { - logsQuery, - filteredLogs, - allTeams, - handleFilterChange, - handleFilterReset: handleFilterResetFromHook, - } = useLogFilterLogic({ - accessToken, - token, - userRole, - userID, - filters, - setFilters, - filterByCurrentUser: !!filterByCurrentUser, - activeTab, - isLiveTail, - startTime, - endTime, - pageSize, - isCustomDate, - setCurrentPage, - sortBy, - sortOrder, - currentPage, - }); - - const handleFilterReset = useCallback(() => { - handleFilterResetFromHook(); - setStartTime(moment().subtract(24, "hours").format("YYYY-MM-DDTHH:mm")); - setEndTime(moment().format("YYYY-MM-DDTHH:mm")); - setIsCustomDate(false); - setSelectedTimeInterval({ value: 24, unit: "hours" }); - setCurrentPage(1); - }, [handleFilterResetFromHook]); - - const handleSortChange = useCallback((newSortBy: LogsSortField, newSortOrder: "asc" | "desc") => { - setSortBy(newSortBy); - setSortOrder(newSortOrder); - setCurrentPage(1); - }, []); - - const columns = useMemo( - () => createColumns({ sortBy, sortOrder, onSortChange: handleSortChange }), - [sortBy, sortOrder, handleSortChange], - ); - - const filteredData = useMemo(() => { - const searchedLogs = filteredLogs.data.filter((log) => { - const matchesSearch = - !searchTerm || - log.request_id.includes(searchTerm) || - log.model.includes(searchTerm) || - (log.user && log.user.includes(searchTerm)); - - // No need for additional filtering since we're now handling this in the API call - return matchesSearch; - }); - - const sessionCompositionById = searchedLogs.reduce>( - (acc, log) => { - if (!log.session_id) return acc; - if (!acc[log.session_id]) { - acc[log.session_id] = { llm: 0, agent: 0, mcp: 0 }; - } - if (MCP_CALL_TYPES.includes(log.call_type)) { - acc[log.session_id].mcp += 1; - } else if (AGENT_CALL_TYPES.includes(log.call_type)) { - acc[log.session_id].agent += 1; - } else { - acc[log.session_id].llm += 1; - } - return acc; - }, - {}, - ); - - // Build a single-pass map of session_id → representative request_id. - // Prefers an LLM row over an MCP row as the representative. - const sessionRepresentativeMap = new Map(); - for (const log of searchedLogs) { - if (!log.session_id || (log.session_total_count || 1) <= 1) continue; - const isMcp = MCP_CALL_TYPES.includes(log.call_type); - const existing = sessionRepresentativeMap.get(log.session_id); - if (!existing || (existing.isMcp && !isMcp)) { - sessionRepresentativeMap.set(log.session_id, { requestId: log.request_id, isMcp }); - } - } - - return ( - searchedLogs - .map((log) => { - const sessionComposition = log.session_id ? sessionCompositionById[log.session_id] : undefined; - return { - ...log, - request_duration_ms: log.request_duration_ms, - session_llm_count: sessionComposition?.llm ?? undefined, - session_mcp_count: sessionComposition?.mcp ?? undefined, - session_agent_count: sessionComposition?.agent ?? undefined, - onKeyHashClick: (keyHash: string) => setSelectedKeyIdInfoView(keyHash), - onSessionClick: (sessionId: string) => { - if (sessionId) { - setSelectedSessionId(sessionId); - setSelectedLog(log); - setIsDrawerOpen(true); - } - }, - }; - }) - // Deduplicate multi-call sessions using the pre-built map (O(1) per row). - .filter((log) => { - if (!log.session_id || (log.session_total_count || 1) <= 1) return true; - return sessionRepresentativeMap.get(log.session_id)?.requestId === log.request_id; - }) - ); - }, [filteredLogs.data, searchTerm]); - - // Keep the Fetch button busy until the table has actually committed the new - // rows. `keepPreviousData` leaves logsQuery.isLoading false on refetch, so - // without this the button clears while stale rows are still on screen. - const deferredData = useDeferredValue(filteredData); - const isStale = deferredData !== filteredData; - const isButtonLoading = logsQuery.isFetching || isStale; - const isRefiltering = logsQuery.isPlaceholderData; - const isLogsLoading = logsQuery.isLoading || isRefiltering; - if (!accessToken || !token || !userRole || !userID) { return (
@@ -219,20 +25,6 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p ); } - const handleRowClick = (log: LogEntry) => { - // Multi-call session row: open in the same right-side drawer (session mode) - if (log.session_id && (log.session_total_count || 1) > 1) { - setSelectedSessionId(log.session_id); - setSelectedLog(log); - setIsDrawerOpen(true); - return; - } - // Single-call row: open the detail drawer - setSelectedSessionId(null); - setSelectedLog(log); - setIsDrawerOpen(true); - }; - return (
setActiveTab(index === 0 ? "request logs" : "audit logs")}> @@ -244,56 +36,13 @@ export default function SpendLogsTable({ accessToken, token, userRole, userID, p -
-

Request Logs

-
- {selectedKeyInfo && selectedKeyIdInfoView && selectedKeyInfo.api_key === selectedKeyIdInfoView ? ( - setSelectedKeyIdInfoView(null)} - backButtonText="Back to Logs" - /> - ) : ( - <> - -
- logsQuery.refetch()} - filteredLogs={filteredLogs} - /> - row.request_id} - onRowClick={handleRowClick} - isLoading={isLogsLoading} - /> -
- - )} +
- - {/* Log Details Drawer */} - { - setIsDrawerOpen(false); - setSelectedSessionId(null); - }} - logEntry={selectedLog} - sessionId={selectedSessionId} - accessToken={accessToken} - allLogs={filteredData} - onSelectLog={setSelectedLog} - startTime={moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")} - />
); } diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index ef550baea91..9f8f2fbe542 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -1,14 +1,16 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act, renderHook, waitFor } from "@testing-library/react"; -import React, { ReactNode, useState } from "react"; +import type { ColumnFiltersState, PaginationState, SortingState } from "@tanstack/react-table"; +import { renderHook, waitFor } from "@testing-library/react"; +import moment from "moment"; +import React, { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { LogsSortField } from "./columns"; import { - defaultFilters, + DEFAULT_LOGS_SORTING, + getFilterValue, getLiveTailRefetchInterval, LIVE_TAIL_INTERVAL_MS, + LOG_FILTER_IDS, useLogFilterLogic, - type LogFilterState, type PaginatedResponse, } from "./log_filter_logic"; @@ -30,31 +32,33 @@ const emptyResponse: PaginatedResponse = { total_pages: 0, }; +const FIRST_PAGE: PaginationState = { pageIndex: 0, pageSize: 50 }; + const defaultProps = { accessToken: "test-token" as string | null, token: "test-token" as string | null, userRole: "Admin" as string | null, userID: "user-1" as string | null, + columnFilters: [] as ColumnFiltersState, filterByCurrentUser: false, activeTab: "request logs", isLiveTail: false, startTime: "2025-01-01T00:00:00", endTime: "2025-01-01T23:59:59", + pagination: FIRST_PAGE, isCustomDate: true, - sortBy: "startTime" as LogsSortField, - sortOrder: "desc" as "asc" | "desc", - currentPage: 1, + sorting: DEFAULT_LOGS_SORTING, }; -type HookOverrides = Partial[0], "filters" | "setFilters">>; +type HookOverrides = Partial[0]>; + +const lastCallParams = () => vi.mocked(uiSpendLogsCall).mock.calls.at(-1)?.[0]; describe("useLogFilterLogic", () => { let queryClient: QueryClient; beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); vi.clearAllMocks(); vi.mocked(uiSpendLogsCall).mockResolvedValue(emptyResponse); }); @@ -63,602 +67,169 @@ describe("useLogFilterLogic", () => { React.createElement(QueryClientProvider, { client: queryClient }, children); function renderFilterHook(overrides: HookOverrides = {}) { - const setCurrentPage = overrides.setCurrentPage ?? vi.fn(); - const rendered = renderHook( - () => { - const [filters, setFilters] = useState(defaultFilters); - const hook = useLogFilterLogic({ - ...defaultProps, - ...overrides, - filters, - setFilters, - setCurrentPage, - }); - return { ...hook, filters, setFilters }; - }, - { wrapper }, - ); - return { ...rendered, setCurrentPage }; + return renderHook(() => useLogFilterLogic({ ...defaultProps, ...overrides }), { wrapper }); } - describe("return shape", () => { - it("exposes filteredLogs, allTeams, handleFilterChange, handleFilterReset", () => { - const { result } = renderFilterHook(); - - expect(result.current.filteredLogs).toBeDefined(); - expect(result.current).toHaveProperty("allTeams"); - expect(result.current.handleFilterChange).toBeInstanceOf(Function); - expect(result.current.handleFilterReset).toBeInstanceOf(Function); - }); - }); - - describe("handleFilterReset", () => { - it("restores filters to defaults after changes", () => { - const { result } = renderFilterHook(); - - act(() => { - result.current.handleFilterChange({ "Team ID": "team-1", Status: "success" }); - }); - - expect(result.current.filters["Team ID"]).toBe("team-1"); - expect(result.current.filters["Status"]).toBe("success"); - - act(() => { - result.current.handleFilterReset(); - }); - - expect(result.current.filters["Team ID"]).toBe(""); - expect(result.current.filters["Status"]).toBe(""); - }); - - it("calls setCurrentPage(1)", () => { - const setCurrentPage = vi.fn(); - const { result } = renderFilterHook({ setCurrentPage }); - - act(() => { - result.current.handleFilterReset(); - }); - - expect(setCurrentPage).toHaveBeenCalledWith(1); - }); - - it("triggers a fetch with all filter params undefined", async () => { - vi.mocked(uiSpendLogsCall).mockResolvedValue(emptyResponse); - const { result } = renderFilterHook(); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); - - act(() => { - result.current.handleFilterReset(); - }); - - await waitFor( - () => { - expect(uiSpendLogsCall).toHaveBeenLastCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ - team_id: undefined, - api_key: undefined, - request_id: undefined, - user_id: undefined, - end_user: undefined, - status_filter: undefined, - model_id: undefined, - key_alias: undefined, - error_code: undefined, - error_message: undefined, - }), - }), - ); - }, - { timeout: 500 }, - ); - }); - }); - - describe("handleFilterChange", () => { - it("calls setCurrentPage(1) when filters change", () => { - const setCurrentPage = vi.fn(); - const { result } = renderFilterHook({ setCurrentPage }); - - act(() => { - result.current.handleFilterChange({ "Team ID": "team-1" }); - }); - - expect(setCurrentPage).toHaveBeenCalledWith(1); - }); - - it("merges partial updates without clobbering other filter keys", () => { - const { result } = renderFilterHook(); - - act(() => { - result.current.handleFilterChange({ "Team ID": "team-a" }); - }); - expect(result.current.filters["Team ID"]).toBe("team-a"); - - act(() => { - result.current.handleFilterChange({ Model: "gpt-4" }); - }); - - expect(result.current.filters["Team ID"]).toBe("team-a"); - expect(result.current.filters["Model"]).toBe("gpt-4"); - }); - - it("does not call setCurrentPage when filters are identical", async () => { - const setCurrentPage = vi.fn(); - const { result } = renderFilterHook({ setCurrentPage }); - - act(() => { - result.current.handleFilterChange({ "Team ID": "team-1" }); - }); - - await waitFor(() => expect(setCurrentPage).toHaveBeenCalledTimes(1), { timeout: 500 }); - - setCurrentPage.mockClear(); - - await act(async () => { - result.current.handleFilterChange({ "Team ID": "team-1" }); - await new Promise((resolve) => setTimeout(resolve, 350)); - }); - - expect(setCurrentPage).not.toHaveBeenCalled(); - }); - }); - - describe("query params — filter keys", () => { - const filterCases: Array<{ - filterKey: keyof LogFilterState; - paramName: string; - value: string; - }> = [ - { filterKey: "Team ID", paramName: "team_id", value: "team-a" }, - { filterKey: "Key Hash", paramName: "api_key", value: "key-x" }, - { filterKey: "Request ID", paramName: "request_id", value: "req-xyz" }, - { filterKey: "Session ID", paramName: "session_id", value: "sess-42" }, - { filterKey: "User ID", paramName: "user_id", value: "user-123" }, - { filterKey: "End User", paramName: "end_user", value: "user-a" }, - { filterKey: "Status", paramName: "status_filter", value: "error" }, - { filterKey: "Model", paramName: "model_id", value: "gpt-4" }, - { filterKey: "Public model / search tool", paramName: "model", value: "tavily-marketing" }, - { filterKey: "Error Code", paramName: "error_code", value: "429" }, - { filterKey: "Error Message", paramName: "error_message", value: "rate limit exceeded" }, + describe("column filters map onto backend query params", () => { + const cases: ReadonlyArray<{ id: string; value: string; param: string }> = [ + { id: LOG_FILTER_IDS.KEY_HASH, value: "sk-hash-1", param: "api_key" }, + { id: LOG_FILTER_IDS.TEAM_ID, value: "team-1", param: "team_id" }, + { id: LOG_FILTER_IDS.REQUEST_ID, value: "req-1", param: "request_id" }, + { id: LOG_FILTER_IDS.SESSION_ID, value: "sess-1", param: "session_id" }, + { id: LOG_FILTER_IDS.END_USER, value: "end-user-1", param: "end_user" }, + { id: LOG_FILTER_IDS.STATUS, value: "failure", param: "status_filter" }, + { id: LOG_FILTER_IDS.MODEL_ID, value: "model-uuid-1", param: "model_id" }, + { id: LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL, value: "gpt-4o", param: "model" }, + { id: LOG_FILTER_IDS.KEY_ALIAS, value: "alias-1", param: "key_alias" }, + { id: LOG_FILTER_IDS.ERROR_CODE, value: "429", param: "error_code" }, + { id: LOG_FILTER_IDS.ERROR_MESSAGE, value: "rate limited", param: "error_message" }, + { id: LOG_FILTER_IDS.USER_ID, value: "user-9", param: "user_id" }, ]; - it.each(filterCases)( - "forwards $filterKey as params.$paramName to uiSpendLogsCall", - async ({ filterKey, paramName, value }) => { - const { result } = renderFilterHook(); + it.each(cases)("sends $id as $param", async ({ id, value, param }) => { + renderFilterHook({ columnFilters: [{ id, value }] }); - act(() => { - result.current.handleFilterChange({ [filterKey]: value } as Partial); - }); + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + expect(lastCallParams()?.params).toMatchObject({ [param]: value }); + }); - await waitFor( - () => { - expect(uiSpendLogsCall).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ [paramName]: value }), - }), - ); - }, - { timeout: 500 }, - ); - }, - ); - }); - - describe("query params — date & sort", () => { - it("passes start_date, end_date, sort_by, and sort_order to uiSpendLogsCall", async () => { - const { result } = renderFilterHook({ - startTime: "2025-01-15T00:00:00Z", - endTime: "2025-01-15T23:59:59Z", - isCustomDate: true, - sortBy: "spend" as LogsSortField, - sortOrder: "asc", + it("omits params for filters that are absent, blank, or whitespace-only", async () => { + renderFilterHook({ + columnFilters: [ + { id: LOG_FILTER_IDS.TEAM_ID, value: " " }, + { id: LOG_FILTER_IDS.KEY_HASH, value: "" }, + ], }); - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor( - () => { - expect(uiSpendLogsCall).toHaveBeenCalledWith( - expect.objectContaining({ - start_date: "2025-01-15 00:00:00", - end_date: "2025-01-15 23:59:59", - params: expect.objectContaining({ - sort_by: "spend", - sort_order: "asc", - }), - }), - ); - }, - { timeout: 500 }, - ); + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + const params = lastCallParams()?.params; + expect(params?.team_id).toBeUndefined(); + expect(params?.api_key).toBeUndefined(); + expect(params?.error_code).toBeUndefined(); }); }); - describe("debounce", () => { - it("calls uiSpendLogsCall after the debounce elapses for text filters", async () => { - const { result } = renderFilterHook(); + describe("paging, dates, and sort", () => { + it("sends a 1-based page derived from pageIndex", async () => { + renderFilterHook({ pagination: { pageIndex: 2, pageSize: 25 } }); - act(() => { - result.current.handleFilterChange({ "Key Hash": "hash-1" }); - }); - - await waitFor( - () => - expect(uiSpendLogsCall).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ api_key: "hash-1" }), - }), - ), - { timeout: 500 }, - ); + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + expect(lastCallParams()).toMatchObject({ page: 3, page_size: 25 }); }); - it("does not call uiSpendLogsCall with a text filter before the debounce elapses", async () => { - const { result } = renderFilterHook(); + it("passes start_date, end_date, sort_by, and sort_order", async () => { + renderFilterHook({ sorting: [{ id: "spend", desc: false }] }); - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); - vi.mocked(uiSpendLogsCall).mockClear(); - - act(() => { - result.current.handleFilterChange({ "Key Hash": "hash-1" }); - }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(uiSpendLogsCall).not.toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ api_key: "hash-1" }), - }), - ); - - await waitFor( - () => - expect(uiSpendLogsCall).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ api_key: "hash-1" }), - }), - ), - { timeout: 500 }, - ); + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + const call = lastCallParams(); + expect(call?.start_date).toBe(moment(defaultProps.startTime).utc().format("YYYY-MM-DD HH:mm:ss")); + expect(call?.end_date).toBe(moment(defaultProps.endTime).utc().format("YYYY-MM-DD HH:mm:ss")); + expect(call?.params).toMatchObject({ sort_by: "spend", sort_order: "asc" }); }); - it("applies dropdown filter changes without waiting for the debounce", async () => { - const { result } = renderFilterHook(); + it("falls back to the default sort when the sorting state is empty", async () => { + renderFilterHook({ sorting: [] }); - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); - vi.mocked(uiSpendLogsCall).mockClear(); - - act(() => { - result.current.handleFilterChange({ "Team ID": "team-instant" }); - }); - - await waitFor( - () => - expect(uiSpendLogsCall).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ team_id: "team-instant" }), - }), - ), - { timeout: 100 }, - ); + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + expect(lastCallParams()?.params).toMatchObject({ sort_by: "startTime", sort_order: "desc" }); }); - // Guards the TEXT_FILTER_KEYS fix: this free-text filter must debounce, not fire per keystroke. - it("debounces the 'Public model / search tool' text filter", async () => { - const { result } = renderFilterHook(); + it("ignores a sort id the backend does not support", async () => { + renderFilterHook({ sorting: [{ id: "request_id", desc: false }] as SortingState }); - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); - vi.mocked(uiSpendLogsCall).mockClear(); - - act(() => { - result.current.handleFilterChange({ "Public model / search tool": "tavily-marketing" }); - }); - - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(uiSpendLogsCall).not.toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ model: "tavily-marketing" }), - }), - ); - - await waitFor( - () => - expect(uiSpendLogsCall).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ model: "tavily-marketing" }), - }), - ), - { timeout: 500 }, - ); - }); - }); - - describe("handleFilterReset", () => { - it("flushes the text-filter debounce so a pending typed value is not sent", async () => { - const { result } = renderFilterHook(); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); - vi.mocked(uiSpendLogsCall).mockClear(); - - act(() => { - result.current.handleFilterChange({ "Key Hash": "pending-hash" }); - }); - - act(() => { - result.current.handleFilterReset(); - }); - - await new Promise((resolve) => setTimeout(resolve, 400)); - - for (const call of vi.mocked(uiSpendLogsCall).mock.calls) { - expect(call[0].params?.api_key).toBeUndefined(); - } - }); - }); - - describe("backend filtered logs", () => { - it("returns the query payload as filteredLogs when backend filters are active", async () => { - const backendLog = { request_id: "backend-req" }; - vi.mocked(uiSpendLogsCall).mockResolvedValue({ - data: [backendLog], - total: 1, - page: 1, - page_size: 50, - total_pages: 1, - } as PaginatedResponse); - - const { result } = renderFilterHook(); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor( - () => { - expect(result.current.filteredLogs.data).toHaveLength(1); - expect(result.current.filteredLogs.data[0].request_id).toBe("backend-req"); - }, - { timeout: 500 }, - ); - }); - - it("returns empty data when the API returns an empty payload", async () => { - vi.mocked(uiSpendLogsCall).mockResolvedValue(emptyResponse); - const { result } = renderFilterHook(); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); - - expect(result.current.filteredLogs.data).toHaveLength(0); + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + expect(lastCallParams()?.params).toMatchObject({ sort_by: "startTime" }); }); }); describe("refetch triggers", () => { - it("refetches when sortBy changes", async () => { - const { rerender } = renderHook( - (props: { sortBy: LogsSortField }) => { - const [filters, setFilters] = useState(defaultFilters); - return useLogFilterLogic({ - ...defaultProps, - filters, - setFilters, - setCurrentPage: vi.fn(), - sortBy: props.sortBy, - }); - }, - { wrapper, initialProps: { sortBy: "startTime" } }, - ); + it.each([ + ["sorting", { sorting: [{ id: "spend", desc: true }] as SortingState }], + ["pagination", { pagination: { pageIndex: 1, pageSize: 50 } }], + ["startTime", { startTime: "2025-02-02T00:00:00" }], + ["columnFilters", { columnFilters: [{ id: LOG_FILTER_IDS.TEAM_ID, value: "team-2" }] }], + ])("refetches when %s changes", async (_label, nextProps) => { + const { rerender } = renderHook((props: HookOverrides) => useLogFilterLogic({ ...defaultProps, ...props }), { + wrapper, + initialProps: {}, + }); - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); - - rerender({ sortBy: "spend" }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 }); - expect(uiSpendLogsCall).toHaveBeenLastCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ sort_by: "spend" }), - }), - ); - }); - - it("refetches when sortOrder changes", async () => { - const { rerender } = renderHook( - (props: { sortOrder: "asc" | "desc" }) => { - const [filters, setFilters] = useState(defaultFilters); - return useLogFilterLogic({ - ...defaultProps, - filters, - setFilters, - setCurrentPage: vi.fn(), - sortOrder: props.sortOrder, - }); - }, - { wrapper, initialProps: { sortOrder: "desc" } }, - ); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); - - rerender({ sortOrder: "asc" }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 }); - expect(uiSpendLogsCall).toHaveBeenLastCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ sort_order: "asc" }), - }), - ); - }); - - it("refetches when currentPage changes", async () => { - const { rerender } = renderHook( - (props: { currentPage: number }) => { - const [filters, setFilters] = useState(defaultFilters); - return useLogFilterLogic({ - ...defaultProps, - filters, - setFilters, - setCurrentPage: vi.fn(), - currentPage: props.currentPage, - }); - }, - { wrapper, initialProps: { currentPage: 1 } }, - ); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); - - rerender({ currentPage: 2 }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 }); - expect(uiSpendLogsCall).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })); - }); - - it("refetches when startTime changes", async () => { - const { rerender } = renderHook( - (props: { startTime: string }) => { - const [filters, setFilters] = useState(defaultFilters); - return useLogFilterLogic({ - ...defaultProps, - filters, - setFilters, - setCurrentPage: vi.fn(), - startTime: props.startTime, - }); - }, - { wrapper, initialProps: { startTime: "2025-01-01T00:00:00Z" } }, - ); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); - - rerender({ startTime: "2025-01-02T00:00:00Z" }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 }); - expect(uiSpendLogsCall).toHaveBeenLastCalledWith(expect.objectContaining({ start_date: "2025-01-02 00:00:00" })); - }); - - it("refetches with a different end_date when isCustomDate toggles", async () => { - const customEndTime = "2025-01-15T23:59:59Z"; - const customEndFormatted = "2025-01-15 23:59:59"; - - const { rerender } = renderHook( - (props: { isCustomDate: boolean }) => { - const [filters, setFilters] = useState(defaultFilters); - return useLogFilterLogic({ - ...defaultProps, - endTime: customEndTime, - filters, - setFilters, - setCurrentPage: vi.fn(), - isCustomDate: props.isCustomDate, - }); - }, - { wrapper, initialProps: { isCustomDate: false } }, - ); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); - const firstEndDate = vi.mocked(uiSpendLogsCall).mock.calls[0][0].end_date; - expect(firstEndDate).not.toBe(customEndFormatted); - - rerender({ isCustomDate: true }); - - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 }); - expect(vi.mocked(uiSpendLogsCall).mock.calls[1][0].end_date).toBe(customEndFormatted); + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1)); + rerender(nextProps); + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2)); }); }); describe("query enablement", () => { - const nullCredentialCases: Array<{ name: string; override: HookOverrides }> = [ - { name: "accessToken", override: { accessToken: null } }, - { name: "token", override: { token: null } }, - { name: "userRole", override: { userRole: null } }, - { name: "userID", override: { userID: null } }, - ]; - - it.each(nullCredentialCases)("does not call uiSpendLogsCall when $name is null", async ({ override }) => { - const { result } = renderFilterHook(override); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await new Promise((resolve) => setTimeout(resolve, 350)); + it("does not query when the request logs tab is inactive", async () => { + renderFilterHook({ activeTab: "audit logs" }); + await new Promise((resolve) => setTimeout(resolve, 50)); expect(uiSpendLogsCall).not.toHaveBeenCalled(); }); - it("does not call uiSpendLogsCall when activeTab is not 'request logs'", async () => { - const { result } = renderFilterHook({ activeTab: "audit logs" }); - - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await new Promise((resolve) => setTimeout(resolve, 350)); + it("does not query when credentials are missing", async () => { + renderFilterHook({ accessToken: null }); + await new Promise((resolve) => setTimeout(resolve, 50)); expect(uiSpendLogsCall).not.toHaveBeenCalled(); }); }); describe("filterByCurrentUser", () => { - it("sends user_id: userID when the User ID filter is blank", async () => { - const { result } = renderFilterHook({ + it("scopes to the current user when no explicit user filter is set", async () => { + renderFilterHook({ filterByCurrentUser: true }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + expect(lastCallParams()?.params).toMatchObject({ user_id: "user-1" }); + }); + + it("lets an explicit user filter win over the current-user scope", async () => { + renderFilterHook({ filterByCurrentUser: true, - userID: "me-123", + columnFilters: [{ id: LOG_FILTER_IDS.USER_ID, value: "someone-else" }], }); - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - - await waitFor( - () => { - expect(uiSpendLogsCall).toHaveBeenCalledWith( - expect.objectContaining({ - params: expect.objectContaining({ user_id: "me-123" }), - }), - ); - }, - { timeout: 500 }, - ); + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + expect(lastCallParams()?.params).toMatchObject({ user_id: "someone-else" }); }); }); - describe("error handling", () => { - it("does not crash when uiSpendLogsCall throws", async () => { - vi.mocked(uiSpendLogsCall).mockRejectedValue(new Error("Network error")); - const { result } = renderFilterHook(); + it("returns an empty payload and does not crash when the call fails", async () => { + vi.mocked(uiSpendLogsCall).mockRejectedValue(new Error("boom")); + const { result } = renderFilterHook(); - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); + expect(result.current.filteredLogs.data).toEqual([]); + expect(result.current.filteredLogs.total).toBe(0); + }); +}); - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); +describe("getFilterValue", () => { + it("trims values and treats blank ones as absent", () => { + const filters: ColumnFiltersState = [ + { id: "team_id", value: " team-1 " }, + { id: "key_hash", value: " " }, + { id: "status", value: 42 }, + ]; - expect(result.current.filteredLogs).toBeDefined(); - expect(result.current.filteredLogs.data).toEqual([]); - }); + expect(getFilterValue(filters, "team_id")).toBe("team-1"); + expect(getFilterValue(filters, "key_hash")).toBeUndefined(); + expect(getFilterValue(filters, "status")).toBeUndefined(); + expect(getFilterValue(filters, "missing")).toBeUndefined(); }); }); describe("getLiveTailRefetchInterval", () => { - it("polls every 15s when live tail is on and on page 1", () => { - expect(getLiveTailRefetchInterval(true, 1)).toBe(LIVE_TAIL_INTERVAL_MS); + it("polls every 15s when live tail is on and on the first page", () => { + expect(getLiveTailRefetchInterval(true, 0)).toBe(LIVE_TAIL_INTERVAL_MS); }); it("does not poll when live tail is off", () => { - expect(getLiveTailRefetchInterval(false, 1)).toBe(false); + expect(getLiveTailRefetchInterval(false, 0)).toBe(false); }); - it("does not poll when not on page 1, even with live tail on", () => { - expect(getLiveTailRefetchInterval(true, 2)).toBe(false); + it("does not poll past the first page, even with live tail on", () => { + expect(getLiveTailRefetchInterval(true, 1)).toBe(false); }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index caa9d8d9361..ae229056fa3 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -1,13 +1,11 @@ import moment from "moment"; -import { useEffect, useMemo, useState } from "react"; -import { useDebouncer } from "@tanstack/react-pacer/debouncer"; -import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; +import { keepPreviousData, useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import type { ColumnFiltersState, PaginationState, SortingState } from "@tanstack/react-table"; import { uiSpendLogsCall } from "../networking"; import { Team } from "../key_team_helpers/key_list"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { fetchAllTeams } from "../../components/key_team_helpers/filter_helpers"; import { defaultPageSize } from "../constants"; -import type { LogEntry, LogsSortField } from "./columns"; +import { LOGS_SORT_FIELD_MAP, type LogEntry, type LogsSortField } from "./columns"; export interface PaginatedResponse { data: LogEntry[]; @@ -18,54 +16,48 @@ export interface PaginatedResponse { total_is_capped?: boolean; } -/** Spend log `model` column (LLM public model name or `search_tool_name` for /search). */ -export const FILTER_KEYS = { - TEAM_ID: "Team ID", - KEY_HASH: "Key Hash", - REQUEST_ID: "Request ID", - SESSION_ID: "Session ID", - MODEL: "Model", - /** Exact match on LiteLLM_SpendLogs.model — use for search tools and public model names. */ - PUBLIC_MODEL_OR_SEARCH_TOOL: "Public model / search tool", - USER_ID: "User ID", - END_USER: "End User", - STATUS: "Status", - KEY_ALIAS: "Key Alias", - ERROR_CODE: "Error Code", - ERROR_MESSAGE: "Error Message", +export const LOG_FILTER_IDS = { + TEAM_ID: "team_id", + STATUS: "status", + KEY_ALIAS: "key_alias", + END_USER: "end_user", + ERROR_CODE: "error_code", + ERROR_MESSAGE: "error_message", + KEY_HASH: "key_hash", + SESSION_ID: "session_id", + MODEL_ID: "model_id", + PUBLIC_MODEL_OR_SEARCH_TOOL: "model", + REQUEST_ID: "request_id", + USER_ID: "user_id", } as const; -export type FilterKey = keyof typeof FILTER_KEYS; -export type LogFilterState = Record<(typeof FILTER_KEYS)[FilterKey], string>; +export const LOG_FILTER_LABELS: Record = { + [LOG_FILTER_IDS.TEAM_ID]: "Team ID", + [LOG_FILTER_IDS.STATUS]: "Status", + [LOG_FILTER_IDS.KEY_ALIAS]: "Key Alias", + [LOG_FILTER_IDS.END_USER]: "End User", + [LOG_FILTER_IDS.ERROR_CODE]: "Error Code", + [LOG_FILTER_IDS.ERROR_MESSAGE]: "Error Message", + [LOG_FILTER_IDS.KEY_HASH]: "Key Hash", + [LOG_FILTER_IDS.SESSION_ID]: "Session ID", + [LOG_FILTER_IDS.MODEL_ID]: "Model", + [LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "Public model / search tool", +}; -// Keys whose UI is a free-form text input; only these need debouncing. -const TEXT_FILTER_KEYS: readonly (keyof LogFilterState)[] = [ - FILTER_KEYS.KEY_HASH, - FILTER_KEYS.ERROR_MESSAGE, - FILTER_KEYS.REQUEST_ID, - FILTER_KEYS.SESSION_ID, - FILTER_KEYS.USER_ID, - FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL, -]; - -// Live-tail polls every 15s, but only on page 1 (newest) while live tail is on. export const LIVE_TAIL_INTERVAL_MS = 15000; -export const getLiveTailRefetchInterval = (isLiveTail: boolean, currentPage: number): number | false => - isLiveTail && currentPage === 1 ? LIVE_TAIL_INTERVAL_MS : false; -export const defaultFilters: LogFilterState = { - [FILTER_KEYS.TEAM_ID]: "", - [FILTER_KEYS.KEY_HASH]: "", - [FILTER_KEYS.REQUEST_ID]: "", - [FILTER_KEYS.SESSION_ID]: "", - [FILTER_KEYS.MODEL]: "", - [FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "", - [FILTER_KEYS.USER_ID]: "", - [FILTER_KEYS.END_USER]: "", - [FILTER_KEYS.STATUS]: "", - [FILTER_KEYS.KEY_ALIAS]: "", - [FILTER_KEYS.ERROR_CODE]: "", - [FILTER_KEYS.ERROR_MESSAGE]: "", +export const getLiveTailRefetchInterval = (isLiveTail: boolean, pageIndex: number): number | false => + isLiveTail && pageIndex === 0 ? LIVE_TAIL_INTERVAL_MS : false; + +export const DEFAULT_LOGS_SORTING: SortingState = [{ id: "startTime", desc: true }]; + +const isSortField = (id: string): id is LogsSortField => Object.hasOwn(LOGS_SORT_FIELD_MAP, id); + +export const getFilterValue = (columnFilters: ColumnFiltersState, columnId: string): string | undefined => { + const entry = columnFilters.find((filter) => filter.id === columnId); + if (typeof entry?.value !== "string") return undefined; + const trimmed = entry.value.trim(); + return trimmed === "" ? undefined : trimmed; }; export function useLogFilterLogic({ @@ -73,63 +65,45 @@ export function useLogFilterLogic({ token, userRole, userID, - filters, - setFilters, + columnFilters, filterByCurrentUser, activeTab, isLiveTail, startTime, endTime, - pageSize = defaultPageSize, + pagination, isCustomDate, - setCurrentPage, - sortBy = "startTime", - sortOrder = "desc", - currentPage = 1, + sorting, }: { accessToken: string | null; token: string | null; userRole: string | null; userID: string | null; - filters: LogFilterState; - setFilters: React.Dispatch>; + columnFilters: ColumnFiltersState; filterByCurrentUser: boolean | null; activeTab: string; isLiveTail: boolean; startTime: string; endTime: string; - pageSize?: number; + pagination: PaginationState; isCustomDate: boolean; - setCurrentPage: (page: number) => void; - sortBy?: LogsSortField; - sortOrder?: "asc" | "desc"; - currentPage?: number; + sorting: SortingState; }) { - const [debouncedFilters, setDebouncedFilters] = useState(filters); - const debouncer = useDebouncer(setDebouncedFilters, { wait: DEBOUNCE_WAIT_MS }); - useEffect(() => { - debouncer.maybeExecute(filters); - }, [filters, debouncer]); + const pageSize = pagination.pageSize || defaultPageSize; + const activeSort = sorting[0] ?? DEFAULT_LOGS_SORTING[0]; + const sortBy: LogsSortField = isSortField(activeSort.id) ? activeSort.id : "startTime"; + const sortOrder: "asc" | "desc" = activeSort.desc ? "desc" : "asc"; - // Live values for dropdown keys, debounced for text keys. - const effectiveFilters = useMemo(() => { - const merged = { ...filters }; - for (const k of TEXT_FILTER_KEYS) { - merged[k] = debouncedFilters[k]; - } - return merged; - }, [filters, debouncedFilters]); - - const logsQuery = useQuery({ + const logsQueryOptions: UseQueryOptions = { queryKey: [ "logs", "table", - currentPage, + pagination.pageIndex, pageSize, startTime, endTime, isCustomDate, - effectiveFilters, + columnFilters, filterByCurrentUser ? userID : null, sortBy, sortOrder, @@ -150,38 +124,39 @@ export function useLogFilterLogic({ ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") : moment().utc().format("YYYY-MM-DD HH:mm:ss"); - const response = await uiSpendLogsCall({ + const userIdFilter = getFilterValue(columnFilters, LOG_FILTER_IDS.USER_ID); + + return await uiSpendLogsCall({ accessToken, start_date: formattedStartTime, end_date: formattedEndTime, - page: currentPage, + page: pagination.pageIndex + 1, page_size: pageSize, params: { - api_key: effectiveFilters[FILTER_KEYS.KEY_HASH] || undefined, - team_id: effectiveFilters[FILTER_KEYS.TEAM_ID] || undefined, - request_id: effectiveFilters[FILTER_KEYS.REQUEST_ID] || undefined, - session_id: effectiveFilters[FILTER_KEYS.SESSION_ID] || undefined, - user_id: effectiveFilters[FILTER_KEYS.USER_ID] || (filterByCurrentUser ? userID ?? undefined : undefined), - end_user: effectiveFilters[FILTER_KEYS.END_USER] || undefined, - status_filter: effectiveFilters[FILTER_KEYS.STATUS] || undefined, - model_id: effectiveFilters[FILTER_KEYS.MODEL] || undefined, - model: effectiveFilters[FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL] || undefined, - key_alias: effectiveFilters[FILTER_KEYS.KEY_ALIAS] || undefined, - error_code: effectiveFilters[FILTER_KEYS.ERROR_CODE] || undefined, - error_message: effectiveFilters[FILTER_KEYS.ERROR_MESSAGE] || undefined, + api_key: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_HASH), + team_id: getFilterValue(columnFilters, LOG_FILTER_IDS.TEAM_ID), + request_id: getFilterValue(columnFilters, LOG_FILTER_IDS.REQUEST_ID), + session_id: getFilterValue(columnFilters, LOG_FILTER_IDS.SESSION_ID), + user_id: userIdFilter ?? (filterByCurrentUser ? userID ?? undefined : undefined), + end_user: getFilterValue(columnFilters, LOG_FILTER_IDS.END_USER), + status_filter: getFilterValue(columnFilters, LOG_FILTER_IDS.STATUS), + model_id: getFilterValue(columnFilters, LOG_FILTER_IDS.MODEL_ID), + model: getFilterValue(columnFilters, LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL), + key_alias: getFilterValue(columnFilters, LOG_FILTER_IDS.KEY_ALIAS), + error_code: getFilterValue(columnFilters, LOG_FILTER_IDS.ERROR_CODE), + error_message: getFilterValue(columnFilters, LOG_FILTER_IDS.ERROR_MESSAGE), sort_by: sortBy, sort_order: sortOrder, }, }); - - return response; }, enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs", - refetchInterval: getLiveTailRefetchInterval(isLiveTail, currentPage), + refetchInterval: getLiveTailRefetchInterval(isLiveTail, pagination.pageIndex), placeholderData: keepPreviousData, - // Only live-tail-poll while the tab is visible. refetchIntervalInBackground: false, - }); + }; + + const logsQuery = useQuery(logsQueryOptions); const filteredLogs: PaginatedResponse = logsQuery.data ?? { data: [], @@ -191,7 +166,7 @@ export function useLogFilterLogic({ total_pages: 0, }; - const { data: allTeams } = useQuery({ + const allTeamsQueryOptions: UseQueryOptions = { queryKey: ["allTeamsForLogFilters", accessToken], queryFn: async () => { if (!accessToken) return []; @@ -199,34 +174,13 @@ export function useLogFilterLogic({ return teamsData || []; }, enabled: !!accessToken, - }); - - const handleFilterChange = (newFilters: Partial) => { - setFilters((prev) => { - const updatedFilters = { ...prev, ...newFilters }; - for (const key of Object.keys(defaultFilters) as Array) { - if (!(key in updatedFilters)) { - updatedFilters[key] = defaultFilters[key]; - } - } - if (JSON.stringify(updatedFilters) !== JSON.stringify(prev)) { - setCurrentPage(1); - } - return updatedFilters as LogFilterState; - }); }; - const handleFilterReset = () => { - setFilters(defaultFilters); - setDebouncedFilters(defaultFilters); - setCurrentPage(1); - }; + const { data: allTeams } = useQuery(allTeamsQueryOptions); return { logsQuery, filteredLogs, allTeams, - handleFilterChange, - handleFilterReset, }; }