mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-14 08:26:09 +00:00
Show predefined error codes in UI with user adjustable fallback
This commit is contained in:
@@ -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(
|
||||
<FilterComponent
|
||||
options={defaultOptions}
|
||||
onApplyFilters={mockOnApplyFilters}
|
||||
onResetFilters={mockOnResetFilters}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FilterComponent
|
||||
options={defaultOptions}
|
||||
@@ -78,7 +104,7 @@ describe("FilterComponent", () => {
|
||||
});
|
||||
|
||||
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(
|
||||
<FilterComponent
|
||||
options={defaultOptions}
|
||||
@@ -124,4 +150,378 @@ describe("FilterComponent", () => {
|
||||
expect(mockOnApplyFilters).toHaveBeenCalledWith({ userId: "user123" });
|
||||
});
|
||||
});
|
||||
|
||||
it("should display initial values in filters", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderWithProviders(
|
||||
<FilterComponent
|
||||
options={defaultOptions}
|
||||
onApplyFilters={mockOnApplyFilters}
|
||||
onResetFilters={mockOnResetFilters}
|
||||
initialValues={{ teamId: "team1", userId: "user123" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FilterComponent
|
||||
options={defaultOptions}
|
||||
onApplyFilters={mockOnApplyFilters}
|
||||
onResetFilters={mockOnResetFilters}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FilterComponent
|
||||
options={options}
|
||||
onApplyFilters={mockOnApplyFilters}
|
||||
onResetFilters={mockOnResetFilters}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FilterComponent
|
||||
options={options}
|
||||
onApplyFilters={mockOnApplyFilters}
|
||||
onResetFilters={mockOnResetFilters}
|
||||
/>,
|
||||
);
|
||||
|
||||
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<Array<{ label: string; value: string }>>((resolve) => {
|
||||
resolveSearch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const options: FilterOption[] = [
|
||||
{
|
||||
name: "model",
|
||||
label: "Model",
|
||||
isSearchable: true,
|
||||
searchFn: mockSearchFn,
|
||||
},
|
||||
];
|
||||
|
||||
renderWithProviders(
|
||||
<FilterComponent
|
||||
options={options}
|
||||
onApplyFilters={mockOnApplyFilters}
|
||||
onResetFilters={mockOnResetFilters}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FilterComponent
|
||||
options={options}
|
||||
onApplyFilters={mockOnApplyFilters}
|
||||
onResetFilters={mockOnResetFilters}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FilterComponent
|
||||
options={options}
|
||||
onApplyFilters={mockOnApplyFilters}
|
||||
onResetFilters={mockOnResetFilters}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FilterComponent
|
||||
options={options}
|
||||
onApplyFilters={mockOnApplyFilters}
|
||||
onResetFilters={mockOnResetFilters}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FilterComponent
|
||||
options={defaultOptions}
|
||||
onApplyFilters={mockOnApplyFilters}
|
||||
onResetFilters={mockOnResetFilters}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<FilterComponent
|
||||
options={defaultOptions}
|
||||
onApplyFilters={mockOnApplyFilters}
|
||||
onResetFilters={mockOnResetFilters}
|
||||
initialValues={{ teamId: "team1", userId: "user123" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,7 +164,7 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
|
||||
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,
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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<string>();
|
||||
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 && (
|
||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border p-2 z-50">
|
||||
<div className="space-y-1">
|
||||
{quickSelectOptions.map((option) => (
|
||||
{QUICK_SELECT_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${displayLabel === option.label ? "bg-blue-50 text-blue-600" : ""
|
||||
|
||||
Reference in New Issue
Block a user