diff --git a/ui/litellm-dashboard/src/components/molecules/filter.test.tsx b/ui/litellm-dashboard/src/components/molecules/filter.test.tsx index bd06d110e9..1a90c4a069 100644 --- a/ui/litellm-dashboard/src/components/molecules/filter.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/filter.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor } from "@testing-library/react"; +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"; @@ -58,8 +58,34 @@ describe("FilterComponent", () => { 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(); + const user = userEvent.setup({ delay: null }); renderWithProviders( { }); it("should render filters in correct order", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); const options: FilterOption[] = [ { name: "model", label: "Model" }, { name: "teamId", label: "Team ID" }, @@ -105,7 +131,7 @@ describe("FilterComponent", () => { }); it("should handle input filter changes", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); renderWithProviders( { 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("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("should not render filters that are not in orderedFilters list", 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.queryByText("Unknown Filter")).not.toBeInTheDocument(); + }); + }); + + 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("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 index 6c46392410..a3a12fdf75 100644 --- a/ui/litellm-dashboard/src/components/molecules/filter.tsx +++ b/ui/litellm-dashboard/src/components/molecules/filter.tsx @@ -164,7 +164,7 @@ const FilterComponent: React.FC = ({ placeholder={`Search ${option.label || option.name}...`} value={tempValues[option.name] || undefined} onChange={(value) => handleFilterChange(option.name, value)} - onDropdownVisibleChange={(open) => handleDropdownVisibleChange(open, option)} + onOpenChange={(open) => handleDropdownVisibleChange(open, option)} onSearch={(value) => { setSearchInputValueMap((prev) => ({ ...prev, diff --git a/ui/litellm-dashboard/src/components/view_logs/constants.ts b/ui/litellm-dashboard/src/components/view_logs/constants.ts new file mode 100644 index 0000000000..84862fa463 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/constants.ts @@ -0,0 +1,21 @@ +export const ERROR_CODE_OPTIONS: { label: string; value: string }[] = [ + { label: "400 - Bad Request", value: "400" }, + { label: "401 - Invalid Authentication", value: "401" }, + { label: "403 - Permission Denied", value: "403" }, + { label: "404 - Not Found", value: "404" }, + { label: "408 - Request Timeout", value: "408" }, + { label: "422 - Unprocessable Entity", value: "422" }, + { label: "429 - Rate Limited", value: "429" }, + { label: "500 - Internal Server Error", value: "500" }, + { label: "502 - Bad Gateway", value: "502" }, + { label: "503 - Service Unavailable", value: "503" }, + { label: "529 - Overloaded", value: "529" }, +]; + +export const QUICK_SELECT_OPTIONS: { label: string; value: number; unit: string }[] = [ + { label: "Last 15 Minutes", value: 15, unit: "minutes" }, + { label: "Last Hour", value: 1, unit: "hours" }, + { label: "Last 4 Hours", value: 4, unit: "hours" }, + { label: "Last 24 Hours", value: 24, unit: "hours" }, + { label: "Last 7 Days", value: 7, unit: "days" }, +]; diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 87e11e00c7..b791663cee 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -25,6 +25,7 @@ import { ErrorViewer } from "./ErrorViewer"; import { useLogFilterLogic } from "./log_filter_logic"; import { getTimeRangeDisplay } from "./logs_utils"; import { prefetchLogDetails } from "./prefetch"; +import { ERROR_CODE_OPTIONS, QUICK_SELECT_OPTIONS } from "./constants"; import { RequestResponsePanel } from "./RequestResponsePanel"; import { SessionView } from "./SessionView"; import SpendLogsSettingsModal from "./SpendLogsSettingsModal/SpendLogsSettingsModal"; @@ -372,24 +373,6 @@ export default function SpendLogsTable({ setSelectedLog(log); }; - // Function to extract unique error codes from logs - const extractErrorCodes = (logs: LogEntry[], searchText: string = "") => { - const errorCodes = new Set(); - logs.forEach((log) => { - const metadata = log.metadata || {}; - if (metadata.status === "failure" && metadata.error_information) { - const errorCode = metadata.error_information.error_code; - if (errorCode && (!searchText || errorCode.toLowerCase().includes(searchText.toLowerCase()))) { - errorCodes.add(errorCode); - } - } - }); - return Array.from(errorCodes).map((code) => ({ - label: code, - value: code, - })); - }; - const logFilterOptions: FilterOption[] = [ { name: "Team ID", @@ -455,7 +438,14 @@ export default function SpendLogsTable({ label: "Error Code", isSearchable: true, searchFn: async (searchText: string) => { - return extractErrorCodes(logsData.data, searchText); + 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; }, }, { @@ -492,15 +482,7 @@ export default function SpendLogsTable({ return unit; }; - const quickSelectOptions = [ - { label: "Last 15 Minutes", value: 15, unit: "minutes" }, - { label: "Last Hour", value: 1, unit: "hours" }, - { label: "Last 4 Hours", value: 4, unit: "hours" }, - { label: "Last 24 Hours", value: 24, unit: "hours" }, - { label: "Last 7 Days", value: 7, unit: "days" }, - ]; - - const selectedOption = quickSelectOptions.find( + const selectedOption = QUICK_SELECT_OPTIONS.find( (option) => option.value === selectedTimeInterval.value && option.unit === selectedTimeInterval.unit, ); @@ -617,7 +599,7 @@ export default function SpendLogsTable({ {quickSelectOpen && ( - {quickSelectOptions.map((option) => ( + {QUICK_SELECT_OPTIONS.map((option) => (