From adc2859f0ab43550f07af671d3492860c577494d Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 13 Feb 2026 11:33:41 -0800 Subject: [PATCH 1/2] sorting spend logs in ui --- .../ui_unit_tests/log_filter_logic.test.tsx | 87 --- .../src/components/networking.tsx | 90 ++- .../src/components/view_logs/columns.tsx | 85 ++- .../src/components/view_logs/index.tsx | 96 +-- .../view_logs/log_filter_logic.test.tsx | 682 ++++++++++++++++++ .../components/view_logs/log_filter_logic.tsx | 56 +- 6 files changed, 910 insertions(+), 186 deletions(-) delete mode 100644 tests/proxy_admin_ui_tests/ui_unit_tests/log_filter_logic.test.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx diff --git a/tests/proxy_admin_ui_tests/ui_unit_tests/log_filter_logic.test.tsx b/tests/proxy_admin_ui_tests/ui_unit_tests/log_filter_logic.test.tsx deleted file mode 100644 index 81a627c40d2..00000000000 --- a/tests/proxy_admin_ui_tests/ui_unit_tests/log_filter_logic.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { uiSpendLogsCall } from '../../../ui/litellm-dashboard/src/components/networking'; - -// Mock the networking module -jest.mock('../../../ui/litellm-dashboard/src/components/networking', () => ({ - uiSpendLogsCall: jest.fn(), -})); - -const mockUiSpendLogsCall = uiSpendLogsCall as jest.MockedFunction; - -describe('Key Alias Filtering Integration Test', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should call API with correct key_alias parameter', async () => { - // Mock API response with both success and failure logs - const mockResponse = { - data: [ - { request_id: 'req-1', status: 'success', metadata: { user_api_key_alias: 'test-key' } }, - { request_id: 'req-2', status: 'failure', metadata: { user_api_key_alias: 'test-key' } } - ], - total: 2, - page: 1, - page_size: 50, - total_pages: 1 - }; - - mockUiSpendLogsCall.mockResolvedValueOnce(mockResponse); - - // Simulate the API call that would happen when filtering by key alias - const result = await uiSpendLogsCall( - 'test-token', - undefined, - undefined, - undefined, - '2024-01-15 09:00:00', - '2024-01-15 11:00:00', - 1, - 50, - undefined, - undefined, - undefined, - undefined, - 'test-key-alias' // key_alias - this is the fix - ); - - // Verify the API was called correctly - expect(mockUiSpendLogsCall).toHaveBeenCalledWith( - 'test-token', - undefined, - undefined, - undefined, - '2024-01-15 09:00:00', - '2024-01-15 11:00:00', - 1, - 50, - undefined, - undefined, - undefined, - undefined, - 'test-key-alias' // The key assertion - this parameter should be passed through - ); - - // Verify response contains both success and failure logs - expect(result.data).toHaveLength(2); - expect(result.data[0].status).toBe('success'); - expect(result.data[1].status).toBe('failure'); - }); - - it('should pass undefined for empty key alias', async () => { - mockUiSpendLogsCall.mockResolvedValueOnce({ data: [], total: 0, page: 1, page_size: 50, total_pages: 0 }); - - await uiSpendLogsCall( - 'test-token', undefined, undefined, undefined, - '2024-01-15 09:00:00', '2024-01-15 11:00:00', - 1, 50, undefined, undefined, undefined, undefined, - undefined // Empty string should become undefined - ); - - expect(mockUiSpendLogsCall).toHaveBeenCalledWith( - 'test-token', undefined, undefined, undefined, - '2024-01-15 09:00:00', '2024-01-15 11:00:00', - 1, 50, undefined, undefined, undefined, undefined, - undefined // Should be undefined for empty key alias - ); - }); -}); \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 945ef02dd88..cd5feed9d79 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2581,46 +2581,66 @@ export const userSpendLogsCall = async ( } }; -export const uiSpendLogsCall = async ( - accessToken: string, - api_key?: string, - team_id?: string, - request_id?: string, - start_date?: string, - end_date?: string, - page?: number, - page_size?: number, - user_id?: string, - end_user?: string, - status_filter?: string, - model?: string, - modelId?: string, - keyAlias?: string, - error_code?: string, - error_message?: string, -) => { +/** + * Optional query params for /spend/logs/ui - matches backend spend_management_endpoints.py + */ +export interface UiSpendLogsParams { + api_key?: string; + team_id?: string; + request_id?: string; + user_id?: string; + end_user?: string; + status_filter?: string; + /** Filter by model name (e.g. "gpt-4") */ + model?: string; + /** Filter by model ID (litellm model deployment id) */ + model_id?: string; + key_alias?: string; + error_code?: string; + error_message?: string; + sort_by?: string; + sort_order?: "asc" | "desc"; + min_spend?: number; + max_spend?: number; +} + +export interface UiSpendLogsCallOptions { + accessToken: string; + start_date: string; + end_date: string; + page?: number; + page_size?: number; + params?: UiSpendLogsParams; +} + +export const uiSpendLogsCall = async ({ + accessToken, + start_date, + end_date, + page = 1, + page_size = 50, + params = {}, +}: UiSpendLogsCallOptions) => { try { // Construct base URL let url = proxyBaseUrl ? `${proxyBaseUrl}/spend/logs/ui` : `/spend/logs/ui`; - // Add query parameters if they exist const queryParams = new URLSearchParams(); - if (api_key) queryParams.append("api_key", api_key); - if (team_id) queryParams.append("team_id", team_id); - if (request_id) queryParams.append("request_id", request_id); - if (start_date) queryParams.append("start_date", start_date); - if (end_date) queryParams.append("end_date", end_date); - if (page) queryParams.append("page", page.toString()); - if (page_size) queryParams.append("page_size", page_size.toString()); - if (user_id) queryParams.append("user_id", user_id); - if (end_user) queryParams.append("end_user", end_user); - if (status_filter) queryParams.append("status_filter", status_filter); - if (model) queryParams.append("model", model); - if (modelId) queryParams.append("model_id", modelId); - if (keyAlias) queryParams.append("key_alias", keyAlias); - if (error_code) queryParams.append("error_code", error_code); - if (error_message) queryParams.append("error_message", error_message); - // Append query parameters to URL if any exist + queryParams.append("start_date", start_date); + queryParams.append("end_date", end_date); + queryParams.append("page", page.toString()); + queryParams.append("page_size", page_size.toString()); + + // Add optional params only when explicitly provided + for (const [key, value] of Object.entries(params)) { + if (value == null) continue; + if (key === "min_spend" || key === "max_spend") { + queryParams.append(key, value.toString()); + } else if (key === "sort_order" || value !== "") { + queryParams.append(key, String(value)); + } + } + const queryString = queryParams.toString(); if (queryString) { url += `?${queryString}`; diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index 452d31aed51..526a111e06f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -4,10 +4,26 @@ import { Badge, Button } from "@tremor/react"; import { Tooltip } from "antd"; import React, { useState } from "react"; import { getProviderLogoAndName } from "../provider_info_helpers"; +import { TableHeaderSortDropdown } from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown"; import { TimeCell } from "./time_cell"; import { MCP_CALL_TYPES } from "./constants"; import { LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges"; +/** API sort field mapping for /spend/logs/ui endpoint */ +export const LOGS_SORT_FIELD_MAP = { + startTime: "startTime", + spend: "spend", + total_tokens: "total_tokens", +} as const; + +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 @@ -56,9 +72,47 @@ export type LogEntry = { onSessionClick?: (sessionId: string) => void; }; -export const columns: ColumnDef[] = [ +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: "Time", + header: sortProps + ? () => ( + + ) + : "Time", accessorKey: "startTime", cell: (info: any) => , }, @@ -145,7 +199,17 @@ export const columns: ColumnDef[] = [ ), }, { - header: "Cost", + header: sortProps + ? () => ( + + ) + : "Cost", accessorKey: "spend", cell: (info: any) => { const row = info.row.original; @@ -240,7 +304,17 @@ export const columns: ColumnDef[] = [ }, }, { - header: "Tokens", + header: sortProps + ? () => ( + + ) + : "Tokens", accessorKey: "total_tokens", cell: (info: any) => { const row = info.row.original; @@ -308,6 +382,9 @@ export const columns: ColumnDef[] = [ }, ]; +/** 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; diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 0214a6ecf87..89a00fbb4cd 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -1,11 +1,10 @@ -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; import moment from "moment"; -import { useCallback, useEffect, useRef, useState } from "react"; - +import { useCallback, useDeferredValue, useEffect, useRef, useState } from "react"; import GuardrailViewer from "@/components/view_logs/GuardrailViewer/GuardrailViewer"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { truncateString } from "@/utils/textUtils"; -import { SettingOutlined } from "@ant-design/icons"; +import { SettingOutlined, SyncOutlined } from "@ant-design/icons"; import { Row } from "@tanstack/react-table"; import { Switch, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; import { Button, Tooltip } from "antd"; @@ -20,7 +19,7 @@ import FilterComponent, { FilterOption } from "../molecules/filter"; import { allEndUsersCall, keyInfoV1Call, keyListCall, uiSpendLogsCall } from "../networking"; import KeyInfoView from "../templates/key_info_view"; import AuditLogs from "./audit_logs"; -import { columns, LogEntry } from "./columns"; +import { createColumns, LogEntry, type LogsSortField } from "./columns"; import { ConfigInfoMessage } from "./ConfigInfoMessage"; import { ERROR_CODE_OPTIONS, MCP_CALL_TYPES, QUICK_SELECT_OPTIONS } from "./constants"; import { CostBreakdownViewer } from "./CostBreakdownViewer"; @@ -77,7 +76,7 @@ export default function SpendLogsTable({ const [tempKeyHash, setTempKeyHash] = useState(""); const [selectedTeamId, setSelectedTeamId] = useState(""); const [selectedKeyHash, setSelectedKeyHash] = useState(""); - const [selectedModel, setSelectedModel] = useState(""); + const [selectedModelId, setSelectedModelId] = useState(""); const [selectedKeyInfo, setSelectedKeyInfo] = useState(null); const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState(null); const [selectedStatus, setSelectedStatus] = useState(""); @@ -90,6 +89,9 @@ export default function SpendLogsTable({ const [selectedSessionId, setSelectedSessionId] = useState(null); const [isSpendLogsSettingsModalVisible, setIsSpendLogsSettingsModalVisible] = useState(false); + const [sortBy, setSortBy] = useState("startTime"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + const queryClient = useQueryClient(); const [isLiveTail, setIsLiveTail] = useState(() => { @@ -168,7 +170,9 @@ export default function SpendLogsTable({ selectedKeyHash, filterByCurrentUser ? userID : null, selectedStatus, - selectedModel, + selectedModelId, + sortBy, + sortOrder, ], queryFn: async () => { if (!accessToken || !token || !userRole || !userID) { @@ -189,29 +193,38 @@ export default function SpendLogsTable({ // Get base response from API // NOTE: We only fetch the list of logs here (lightweight). // Log details (messages/response) are fetched on-demand when user clicks a row. - const response = await uiSpendLogsCall( + const response = await uiSpendLogsCall({ accessToken, - selectedKeyHash || undefined, - selectedTeamId || undefined, - undefined, - formattedStartTime, - formattedEndTime, - currentPage, - pageSize, - filterByCurrentUser ? userID : undefined, - selectedEndUser, - selectedStatus, - undefined, - selectedModel || undefined, - ); + start_date: formattedStartTime, + end_date: formattedEndTime, + page: currentPage, + page_size: pageSize, + params: { + api_key: selectedKeyHash || undefined, + team_id: selectedTeamId || undefined, + user_id: filterByCurrentUser ? userID ?? undefined : undefined, + end_user: selectedEndUser || undefined, + status_filter: selectedStatus || undefined, + model_id: selectedModelId || undefined, + sort_by: sortBy, + sort_order: sortOrder, + }, + }); return response; }, enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs", refetchInterval: isLiveTail && currentPage === 1 ? 15000 : false, + placeholderData: keepPreviousData, refetchIntervalInBackground: true, }); + // Defer the transition from "Fetching" to "Fetch" so the button stays loading until + // the table has rendered with the new data (avoids the visual gap where the button + // exits loading state before the table updates) + const isFetchingDeferred = useDeferredValue(logs.isFetching); + const isButtonLoading = logs.isFetching || isFetchingDeferred; + const logsData = logs.data || { data: [], total: 0, @@ -237,6 +250,9 @@ export default function SpendLogsTable({ setCurrentPage, userID, userRole, + sortBy, + sortOrder, + currentPage, }); const fetchKeyHashForAlias = useCallback( @@ -268,7 +284,7 @@ export default function SpendLogsTable({ setSelectedTeamId(""); } setSelectedStatus(filters["Status"] || ""); - setSelectedModel(filters["Model"] || ""); + setSelectedModelId(filters["Model"] || ""); setSelectedEndUser(filters["End User"] || ""); if (filters["Key Hash"]) { @@ -596,26 +612,15 @@ export default function SpendLogsTable({ - + {isButtonLoading ? "Fetching" : "Fetch"} + {isCustomDate && ( @@ -694,9 +699,18 @@ export default function SpendLogsTable({ )} { + setSortBy(newSortBy); + setSortOrder(newSortOrder); + setCurrentPage(1); + }, + })} data={filteredData} onRowClick={handleRowClick} + isLoading={logs.isLoading} /> 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 new file mode 100644 index 00000000000..da4822d0189 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -0,0 +1,682 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import React, { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PaginatedResponse } from "."; +import type { LogEntry, LogsSortField } from "./columns"; +import { useLogFilterLogic } from "./log_filter_logic"; + +vi.mock("../networking", () => ({ + uiSpendLogsCall: vi.fn(), +})); + +vi.mock("@/components/key_team_helpers/filter_helpers", () => ({ + fetchAllKeyAliases: vi.fn().mockResolvedValue([]), + fetchAllTeams: vi.fn().mockResolvedValue([]), +})); + +import { uiSpendLogsCall } from "../networking"; + +const createLogEntry = (overrides: Partial = {}): LogEntry => +({ + request_id: "req-1", + api_key: "key-1", + team_id: "team-1", + model: "gpt-4", + model_id: "gpt-4", + call_type: "chat", + spend: 0, + total_tokens: 0, + prompt_tokens: 0, + completion_tokens: 0, + startTime: "2025-01-01T00:00:00Z", + endTime: "2025-01-01T00:01:00Z", + cache_hit: "miss", + messages: [], + response: {}, + metadata: {}, + request_tags: {}, + ...overrides, +} as LogEntry); + +const createPaginatedResponse = (data: LogEntry[]): PaginatedResponse => ({ + data, + total: data.length, + page: 1, + page_size: 50, + total_pages: 1, +}); + +const defaultProps = { + logs: createPaginatedResponse([]), + accessToken: "test-token", + startTime: "2025-01-01T00:00:00", + endTime: "2025-01-01T23:59:59", + isCustomDate: true, + setCurrentPage: vi.fn(), + userID: "user-1", + userRole: "Admin", +}; + +describe("useLogFilterLogic", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + vi.clearAllMocks(); + vi.mocked(uiSpendLogsCall).mockResolvedValue({ + data: [], + total: 0, + page: 1, + page_size: 50, + total_pages: 0, + }); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("should return filters, filteredLogs, allKeyAliases, allTeams, handleFilterChange, and handleFilterReset", () => { + const { result } = renderHook( + () => + useLogFilterLogic({ + ...defaultProps, + logs: createPaginatedResponse([createLogEntry()]), + }), + { wrapper }, + ); + + expect(result.current.filters).toBeDefined(); + expect(result.current.filteredLogs).toBeDefined(); + expect(result.current.allKeyAliases).toBeDefined(); + expect(result.current).toHaveProperty("allTeams"); + expect(result.current.handleFilterChange).toBeDefined(); + expect(result.current.handleFilterReset).toBeDefined(); + }); + + it("should initialize filters with all keys empty", () => { + const { result } = renderHook(() => useLogFilterLogic(defaultProps), { wrapper }); + + const filters = result.current.filters; + expect(filters["Team ID"]).toBe(""); + expect(filters["Key Hash"]).toBe(""); + expect(filters["Request ID"]).toBe(""); + expect(filters["Model"]).toBe(""); + expect(filters["User ID"]).toBe(""); + expect(filters["End User"]).toBe(""); + expect(filters["Status"]).toBe(""); + expect(filters["Key Alias"]).toBe(""); + expect(filters["Error Code"]).toBe(""); + expect(filters["Error Message"]).toBe(""); + }); + + it("should return all logs when no filters are applied", () => { + const logs = createPaginatedResponse([ + createLogEntry({ request_id: "req-1" }), + createLogEntry({ request_id: "req-2" }), + ]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + expect(result.current.filteredLogs.data).toHaveLength(2); + expect(result.current.filteredLogs.data).toEqual(logs.data); + }); + + it("should filter logs by team_id when Team ID filter is set", () => { + const logs = createPaginatedResponse([ + createLogEntry({ request_id: "req-1", team_id: "team-a" }), + createLogEntry({ request_id: "req-2", team_id: "team-b" }), + createLogEntry({ request_id: "req-3", team_id: "team-a" }), + ]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ "Team ID": "team-a" }); + }); + + expect(result.current.filteredLogs.data).toHaveLength(2); + expect(result.current.filteredLogs.data.every((log) => log.team_id === "team-a")).toBe(true); + }); + + it("should filter logs by status when Status filter is set to success", () => { + const logs = createPaginatedResponse([ + createLogEntry({ request_id: "req-1", status: "success" }), + createLogEntry({ request_id: "req-2" }), + createLogEntry({ request_id: "req-3", status: "error" }), + ]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ Status: "success" }); + }); + + expect(result.current.filteredLogs.data).toHaveLength(2); + expect(result.current.filteredLogs.data.every((log) => !log.status || log.status === "success")).toBe(true); + }); + + it("should filter logs by status when Status filter is set to error", () => { + const logs = createPaginatedResponse([ + createLogEntry({ request_id: "req-1", status: "success" }), + createLogEntry({ request_id: "req-2", status: "error" }), + ]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ Status: "error" }); + }); + + expect(result.current.filteredLogs.data).toHaveLength(1); + expect(result.current.filteredLogs.data[0].status).toBe("error"); + }); + + it("should filter logs by model_id when Model filter is set", async () => { + const filteredLogs = [ + createLogEntry({ request_id: "req-1", model_id: "gpt-4" }), + createLogEntry({ request_id: "req-3", model_id: "gpt-4" }), + ]; + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse(filteredLogs), + ); + const logs = createPaginatedResponse([ + createLogEntry({ request_id: "req-1", model_id: "gpt-4" }), + createLogEntry({ request_id: "req-2", model_id: "gpt-3.5" }), + createLogEntry({ request_id: "req-3", model_id: "gpt-4" }), + ]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ Model: "gpt-4" }); + }); + + await waitFor( + () => { + expect(result.current.filteredLogs.data).toHaveLength(2); + expect(result.current.filteredLogs.data.every((log) => log.model_id === "gpt-4")).toBe(true); + }, + { timeout: 500 }, + ); + }); + + it("should filter logs by api_key when Key Hash filter is set", async () => { + const filteredLog = createLogEntry({ request_id: "req-1", api_key: "key-x" }); + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([filteredLog]), + ); + const logs = createPaginatedResponse([ + createLogEntry({ request_id: "req-1", api_key: "key-x" }), + createLogEntry({ request_id: "req-2", api_key: "key-y" }), + ]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ "Key Hash": "key-x" }); + }); + + await waitFor( + () => { + expect(result.current.filteredLogs.data).toHaveLength(1); + expect(result.current.filteredLogs.data[0].api_key).toBe("key-x"); + }, + { timeout: 500 }, + ); + }); + + it("should filter logs by end_user when End User filter is set", async () => { + const filteredLog = createLogEntry({ request_id: "req-1", end_user: "user-a" }); + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([filteredLog]), + ); + const logs = createPaginatedResponse([ + createLogEntry({ request_id: "req-1", end_user: "user-a" }), + createLogEntry({ request_id: "req-2", end_user: "user-b" }), + ]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ "End User": "user-a" }); + }); + + await waitFor( + () => { + expect(result.current.filteredLogs.data).toHaveLength(1); + expect(result.current.filteredLogs.data[0].end_user).toBe("user-a"); + }, + { timeout: 500 }, + ); + }); + + it("should filter logs by error_code when Error Code filter is set", async () => { + const filteredLog = createLogEntry({ + request_id: "req-1", + metadata: { error_information: { error_code: "429" } }, + }); + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([filteredLog]), + ); + const logs = createPaginatedResponse([ + createLogEntry({ + request_id: "req-1", + metadata: { error_information: { error_code: "429" } }, + }), + createLogEntry({ + request_id: "req-2", + metadata: { error_information: { error_code: "500" } }, + }), + ]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ "Error Code": "429" }); + }); + + await waitFor( + () => { + expect(result.current.filteredLogs.data).toHaveLength(1); + expect(result.current.filteredLogs.data[0].metadata?.error_information?.error_code).toBe("429"); + }, + { timeout: 500 }, + ); + }); + + it("should return empty data when logs is null or has no data", () => { + const { result } = renderHook( + () => + useLogFilterLogic({ + ...defaultProps, + logs: { data: [], total: 0, page: 1, page_size: 50, total_pages: 0 }, + }), + { wrapper }, + ); + + expect(result.current.filteredLogs.data).toEqual([]); + expect(result.current.filteredLogs.total).toBe(0); + }); + + it("should reset filters when handleFilterReset is called", () => { + const logs = createPaginatedResponse([createLogEntry()]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + 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("should call setCurrentPage with 1 when handleFilterChange is invoked", () => { + const setCurrentPage = vi.fn(); + const logs = createPaginatedResponse([createLogEntry()]); + const { result } = renderHook( + () => useLogFilterLogic({ ...defaultProps, logs, setCurrentPage }), + { wrapper }, + ); + + act(() => { + result.current.handleFilterChange({ "Team ID": "team-1" }); + }); + + expect(setCurrentPage).toHaveBeenCalledWith(1); + }); + + it("should call uiSpendLogsCall when backend filter is set and debounce elapses", async () => { + const logs = createPaginatedResponse([createLogEntry()]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await waitFor( + () => { + expect(uiSpendLogsCall).toHaveBeenCalled(); + }, + { timeout: 500 }, + ); + }); + + it("should not call uiSpendLogsCall when accessToken is null", async () => { + const logs = createPaginatedResponse([createLogEntry()]); + const { result } = renderHook( + () => useLogFilterLogic({ ...defaultProps, logs, accessToken: null }), + { wrapper }, + ); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(uiSpendLogsCall).not.toHaveBeenCalled(); + }); + + it("should use backend filtered logs when backend filters are active and API returns data", async () => { + const backendLog = createLogEntry({ request_id: "backend-req" }); + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([backendLog]), + ); + const logs = createPaginatedResponse([createLogEntry({ request_id: "client-req" })]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + 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("should call uiSpendLogsCall with request_id when Request ID filter is set", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([createLogEntry({ request_id: "req-xyz" })]), + ); + const logs = createPaginatedResponse([createLogEntry()]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ "Request ID": "req-xyz" }); + }); + + await waitFor( + () => { + expect(uiSpendLogsCall).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ request_id: "req-xyz" }), + }), + ); + }, + { timeout: 500 }, + ); + }); + + it("should call uiSpendLogsCall with user_id when User ID filter is set", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([createLogEntry()]), + ); + const logs = createPaginatedResponse([createLogEntry()]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ "User ID": "user-123" }); + }); + + await waitFor( + () => { + expect(uiSpendLogsCall).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ user_id: "user-123" }), + }), + ); + }, + { timeout: 500 }, + ); + }); + + it("should call uiSpendLogsCall with error_message when Error Message filter is set", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([createLogEntry()]), + ); + const logs = createPaginatedResponse([createLogEntry()]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ "Error Message": "rate limit exceeded" }); + }); + + await waitFor( + () => { + expect(uiSpendLogsCall).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ error_message: "rate limit exceeded" }), + }), + ); + }, + { timeout: 500 }, + ); + }); + + it("should fall back to logs when backend filters are active but API returns empty", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue({ + data: [], + total: 0, + page: 1, + page_size: 50, + total_pages: 0, + }); + const clientLog = createLogEntry({ request_id: "client-req" }); + const logs = createPaginatedResponse([clientLog]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await waitFor( + () => { + expect(uiSpendLogsCall).toHaveBeenCalled(); + }, + { timeout: 500 }, + ); + + expect(result.current.filteredLogs.data).toHaveLength(1); + expect(result.current.filteredLogs.data[0].request_id).toBe("client-req"); + }); + + it("should refetch when sortBy changes and backend filters are active", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([createLogEntry()]), + ); + const logs = createPaginatedResponse([createLogEntry()]); + const { result, rerender } = renderHook( + (props: { sortBy?: LogsSortField }) => + useLogFilterLogic({ ...defaultProps, logs, ...props }), + { wrapper, initialProps: { sortBy: "startTime" as LogsSortField } }, + ); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { + timeout: 500, + }); + + rerender({ sortBy: "spend" as LogsSortField }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { + timeout: 500, + }); + expect(uiSpendLogsCall).toHaveBeenLastCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ sort_by: "spend" }), + }), + ); + }); + + it("should refetch when sortOrder changes and backend filters are active", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([createLogEntry()]), + ); + const logs = createPaginatedResponse([createLogEntry()]); + const { result, rerender } = renderHook( + (props: { sortOrder?: "asc" | "desc" }) => + useLogFilterLogic({ ...defaultProps, logs, ...props }), + { wrapper, initialProps: { sortOrder: "desc" } }, + ); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + 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("should refetch when currentPage changes and backend filters are active", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([createLogEntry()]), + ); + const logs = createPaginatedResponse([createLogEntry()]); + const { result, rerender } = renderHook( + (props) => useLogFilterLogic({ ...defaultProps, logs, ...props }), + { wrapper, initialProps: { currentPage: 1 } }, + ); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-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("should not call setCurrentPage when handleFilterChange receives identical filters", async () => { + const setCurrentPage = vi.fn(); + const logs = createPaginatedResponse([createLogEntry()]); + const { result } = renderHook( + () => useLogFilterLogic({ ...defaultProps, logs, setCurrentPage }), + { wrapper }, + ); + + 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(); + }); + + it("should not crash when uiSpendLogsCall throws", async () => { + vi.mocked(uiSpendLogsCall).mockRejectedValue(new Error("Network error")); + const logs = createPaginatedResponse([createLogEntry()]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { + timeout: 500, + }); + + expect(result.current.filteredLogs).toBeDefined(); + expect(result.current.filters).toBeDefined(); + }); + + it("should clear backendFilteredLogs when handleFilterReset is called", async () => { + const backendLog = createLogEntry({ request_id: "backend-req" }); + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([backendLog]), + ); + const logs = createPaginatedResponse([createLogEntry({ request_id: "client-req" })]); + const { result } = renderHook(() => useLogFilterLogic({ ...defaultProps, logs }), { wrapper }); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await waitFor( + () => { + expect(result.current.filteredLogs.data[0].request_id).toBe("backend-req"); + }, + { timeout: 500 }, + ); + + act(() => { + result.current.handleFilterReset(); + }); + + expect(result.current.filteredLogs.data).toEqual(logs.data); + expect(result.current.filteredLogs.data[0].request_id).toBe("client-req"); + }); + + it("should pass correct start_date, end_date, sort_by, and sort_order to uiSpendLogsCall", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([createLogEntry()]), + ); + const logs = createPaginatedResponse([createLogEntry()]); + const { result } = renderHook( + () => + useLogFilterLogic({ + ...defaultProps, + logs, + startTime: "2025-01-15T00:00:00Z", + endTime: "2025-01-15T23:59:59Z", + isCustomDate: true, + sortBy: "spend", + sortOrder: "asc", + }), + { wrapper }, + ); + + 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 }, + ); + }); +}); 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 85eea0a8272..d9323b03afb 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 @@ -7,6 +7,7 @@ import { fetchAllKeyAliases, fetchAllTeams } from "../../components/key_team_hel import { debounce } from "lodash"; import { defaultPageSize } from "../constants"; import { PaginatedResponse } from "."; +import type { LogsSortField } from "./columns"; const FILTER_KEYS = { TEAM_ID: "Team ID", @@ -34,6 +35,9 @@ export function useLogFilterLogic({ setCurrentPage, userID, userRole, + sortBy = "startTime", + sortOrder = "desc", + currentPage = 1, }: { logs: PaginatedResponse; accessToken: string | null; @@ -44,6 +48,9 @@ export function useLogFilterLogic({ setCurrentPage: (page: number) => void; userID: string | null; userRole: string | null; + sortBy?: LogsSortField; + sortOrder?: "asc" | "desc"; + currentPage?: number; }) { const defaultFilters = useMemo( () => ({ @@ -84,24 +91,27 @@ export function useLogFilterLogic({ : moment().utc().format("YYYY-MM-DD HH:mm:ss"); try { - const response = await uiSpendLogsCall( + const response = await uiSpendLogsCall({ accessToken, - filters[FILTER_KEYS.KEY_HASH] || undefined, - filters[FILTER_KEYS.TEAM_ID] || undefined, - filters[FILTER_KEYS.REQUEST_ID] || undefined, - formattedStartTime, - formattedEndTime, + start_date: formattedStartTime, + end_date: formattedEndTime, page, - pageSize, - filters[FILTER_KEYS.USER_ID] || undefined, - filters[FILTER_KEYS.END_USER] || undefined, - filters[FILTER_KEYS.STATUS] || undefined, - undefined, - filters[FILTER_KEYS.MODEL] || undefined, - filters[FILTER_KEYS.KEY_ALIAS] || undefined, - filters[FILTER_KEYS.ERROR_CODE] || undefined, - filters[FILTER_KEYS.ERROR_MESSAGE] || undefined, - ); + page_size: pageSize, + params: { + api_key: filters[FILTER_KEYS.KEY_HASH] || undefined, + team_id: filters[FILTER_KEYS.TEAM_ID] || undefined, + request_id: filters[FILTER_KEYS.REQUEST_ID] || undefined, + user_id: filters[FILTER_KEYS.USER_ID] || undefined, + end_user: filters[FILTER_KEYS.END_USER] || undefined, + status_filter: filters[FILTER_KEYS.STATUS] || undefined, + model_id: filters[FILTER_KEYS.MODEL] || undefined, + key_alias: filters[FILTER_KEYS.KEY_ALIAS] || undefined, + error_code: filters[FILTER_KEYS.ERROR_CODE] || undefined, + error_message: filters[FILTER_KEYS.ERROR_MESSAGE] || undefined, + sort_by: sortBy, + sort_order: sortOrder, + }, + }); if (currentTimestamp === lastSearchTimestamp.current && response.data) { setBackendFilteredLogs(response); @@ -110,7 +120,7 @@ export function useLogFilterLogic({ console.error("Error searching users:", error); } }, - [accessToken, startTime, endTime, isCustomDate, pageSize], + [accessToken, startTime, endTime, isCustomDate, pageSize, sortBy, sortOrder], ); const debouncedSearch = useMemo( @@ -142,11 +152,19 @@ export function useLogFilterLogic({ filters[FILTER_KEYS.USER_ID] || filters[FILTER_KEYS.END_USER] || filters[FILTER_KEYS.ERROR_CODE] || - filters[FILTER_KEYS.ERROR_MESSAGE] + filters[FILTER_KEYS.ERROR_MESSAGE] || + filters[FILTER_KEYS.MODEL] ), [filters], ); + // Refetch when sort or page changes (backend filters use their own fetch, not the main query) + useEffect(() => { + if (hasBackendFilters && accessToken) { + performSearch(filters, currentPage); + } + }, [sortBy, sortOrder, currentPage]); + // Compute client-side filtered logs directly from incoming logs and filters const clientDerivedFilteredLogs: PaginatedResponse = useMemo(() => { if (!logs || !logs.data) { @@ -180,7 +198,7 @@ export function useLogFilterLogic({ } if (filters[FILTER_KEYS.MODEL]) { - filteredData = filteredData.filter((log) => log.model === filters[FILTER_KEYS.MODEL]); + filteredData = filteredData.filter((log) => log.model_id === filters[FILTER_KEYS.MODEL]); } if (filters[FILTER_KEYS.KEY_HASH]) { From 724ab1c1540d2259c861ab062cbb08a9ddb0311c Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 13 Feb 2026 11:53:04 -0800 Subject: [PATCH 2/2] Update ui/litellm-dashboard/src/components/networking.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/components/networking.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index cd5feed9d79..1a2e8bdc81d 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2636,7 +2636,7 @@ export const uiSpendLogsCall = async ({ if (value == null) continue; if (key === "min_spend" || key === "max_spend") { queryParams.append(key, value.toString()); - } else if (key === "sort_order" || value !== "") { + } else if (typeof value === "string" && value !== "") { queryParams.append(key, String(value)); } }