From 62100a6860314cec890df91f31087d1ff1466078 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 16 Apr 2026 00:45:48 -0700 Subject: [PATCH 01/13] [Refactor] UI - Spend Logs: consolidate filter state, extract components, remove dead code - Lift filter state into index.tsx and pass to hook (removes selectedX vars + sync useEffect) - Move main useQuery into useLogFilterLogic hook (removes isMainQueryEnabled toggle) - Delete dead RequestViewer component (300 lines, replaced by LogDetailsDrawer) - Extract LogsTableToolbar component (search, date range, pagination, live tail) - Extract filter options config to filter_options.ts - Remove dead code: handleRefresh, handleSelectLog, handleCloseDrawer, formatTimeUnit, showFilters/showColumnDropdown state, dropdownRef/filtersRef --- .../components/view_logs/LogsTableToolbar.tsx | 246 ++++++ .../components/view_logs/filter_options.ts | 71 ++ .../src/components/view_logs/index.test.tsx | 153 +--- .../src/components/view_logs/index.tsx | 797 ++---------------- .../components/view_logs/log_filter_logic.tsx | 139 ++- 5 files changed, 487 insertions(+), 919 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/filter_options.ts diff --git a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx new file mode 100644 index 000000000000..fcd6a1eba8e6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx @@ -0,0 +1,246 @@ +import moment from "moment"; +import { useEffect, useRef, useState } from "react"; +import { SyncOutlined } from "@ant-design/icons"; +import { Switch } from "@tremor/react"; +import { Button } from "antd"; +import { QUICK_SELECT_OPTIONS } from "./constants"; +import { getTimeRangeDisplay } from "./logs_utils"; +import type { PaginatedResponse } from "."; + +interface LogsTableToolbarProps { + searchTerm: string; + onSearchChange: (value: string) => void; + startTime: string; + onStartTimeChange: (value: string) => void; + endTime: string; + onEndTimeChange: (value: string) => void; + isCustomDate: boolean; + onIsCustomDateChange: (value: boolean) => void; + selectedTimeInterval: { value: number; unit: string }; + 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; + hasBackendFilters: boolean; +} + +export function LogsTableToolbar({ + searchTerm, + onSearchChange, + startTime, + onStartTimeChange, + endTime, + onEndTimeChange, + isCustomDate, + onIsCustomDateChange, + selectedTimeInterval, + onSelectedTimeIntervalChange, + isLiveTail, + onIsLiveTailChange, + currentPage, + onCurrentPageChange, + pageSize, + isLoading, + isButtonLoading, + onRefetch, + filteredLogs, + hasBackendFilters, +}: 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 selectedOption = QUICK_SELECT_OPTIONS.find( + (option) => option.value === selectedTimeInterval.value && option.unit === selectedTimeInterval.unit, + ); + const displayLabel = isCustomDate ? getTimeRangeDisplay(isCustomDate, startTime, endTime) : selectedOption?.label; + + return ( + <> +
+
+
+
+ onSearchChange(e.target.value)} + /> + + + +
+ +
+
+ + + {quickSelectOpen && ( +
+
+ {QUICK_SELECT_OPTIONS.map((option) => ( + + ))} +
+ +
+
+ )} +
+ +
+ Live Tail + +
+ + +
+ + {isCustomDate && ( +
+
+ { + onStartTimeChange(e.target.value); + onCurrentPageChange(1); + }} + className="px-3 py-2 border rounded-md text-sm focus:outline-none 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-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ )} +
+ +
+ + Showing {isLoading ? "..." : filteredLogs ? (currentPage - 1) * pageSize + 1 : 0} -{" "} + {isLoading + ? "..." + : filteredLogs + ? Math.min(currentPage * pageSize, filteredLogs.total) + : 0}{" "} + of {isLoading ? "..." : filteredLogs ? filteredLogs.total : 0} results + +
+ + Page {isLoading ? "..." : currentPage} of{" "} + {isLoading ? "..." : filteredLogs ? filteredLogs.total_pages : 1} + + + +
+
+
+
+ {isLiveTail && currentPage === 1 && !hasBackendFilters && ( +
+
+ Auto-refreshing every 15 seconds +
+ +
+ )} + + ); +} diff --git a/ui/litellm-dashboard/src/components/view_logs/filter_options.ts b/ui/litellm-dashboard/src/components/view_logs/filter_options.ts new file mode 100644 index 000000000000..0b0c4754b5a5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/filter_options.ts @@ -0,0 +1,71 @@ +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"; + +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: "Model", + label: "Model", + customComponent: PaginatedModelSelect, + }, + { + 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: "Key Hash", + label: "Key Hash", + isSearchable: false, + }, + { + name: "Error Message", + label: "Error Message", + 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 427c55c92bb3..ea34a07e76c4 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.test.tsx @@ -1,20 +1,30 @@ -import { render, screen, waitFor } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import SpendLogsTable, { RequestViewer } from "./index"; -import type { LogEntry } from "./columns"; -import type { Row } from "@tanstack/react-table"; +import SpendLogsTable from "./index"; import { renderWithProviders } from "../../../tests/test-utils"; const mockHandleFilterResetFromHook = vi.fn(); vi.mock("./log_filter_logic", () => ({ useLogFilterLogic: vi.fn(() => ({ - filters: {}, + 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, })), + defaultFilters: { + "Team ID": "", + "Key Hash": "", + "Request ID": "", + "Model": "", + "User ID": "", + "End User": "", + "Status": "", + "Key Alias": "", + "Error Code": "", + "Error Message": "", + }, })); vi.mock("../networking", async (importOriginal) => { @@ -38,139 +48,6 @@ vi.mock("../key_team_helpers/filter_helpers", () => ({ fetchAllTeams: vi.fn().mockResolvedValue([]), })); -const baseLogEntry: LogEntry = { - request_id: "chatcmpl-test-id", - api_key: "api-key", - team_id: "team-id", - model: "gpt-4", - model_id: "gpt-4", - call_type: "chat", - spend: 0, - total_tokens: 0, - prompt_tokens: 0, - completion_tokens: 0, - startTime: "2025-11-14T00:00:00Z", - endTime: "2025-11-14T00:00:00Z", - cache_hit: "miss", - request_duration_ms: 1000, - messages: [{ role: "user", content: "hello" }], - response: { status: "ok" }, - metadata: { - status: "success", - additional_usage_values: { - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - }, - request_tags: {}, - custom_llm_provider: "openai", - api_base: "https://api.example.com", -}; - -const createRow = (overrides: Partial = {}): Row => - ({ - original: { - ...baseLogEntry, - ...overrides, - }, - }) as unknown as Row; - -describe("Request Viewer", () => { - it("renders the request details heading", () => { - render(); - expect(screen.getByText("Request Details")).toBeInTheDocument(); - }); - - it("should truncate the request id if it is longer than 64 characters", () => { - const LONG_REQUEST_ID = "a".repeat(128); - const TRUNCATED_REQUEST_ID = `${"a".repeat(64)}...`; - render( - , - ); - - expect(screen.getByText(TRUNCATED_REQUEST_ID)).toBeInTheDocument(); - }); - - it("should display LiteLLM Overhead when litellm_overhead_time_ms is present in metadata", () => { - render( - , - ); - - expect(screen.getByText("LiteLLM Overhead:")).toBeInTheDocument(); - expect(screen.getByText("150 ms")).toBeInTheDocument(); - }); - - it("should not display LiteLLM Overhead when litellm_overhead_time_ms is not present in metadata", () => { - render(); - - expect(screen.queryByText("LiteLLM Overhead:")).not.toBeInTheDocument(); - }); - - it("should display retry count when attempted_retries > 0 in metadata", () => { - render( - , - ); - - expect(screen.getByText("Retries:")).toBeInTheDocument(); - expect(screen.getByText("2 / 3")).toBeInTheDocument(); - }); - - it("should display green 'None' tag when attempted_retries is 0", () => { - render( - , - ); - - expect(screen.getByText("Retries:")).toBeInTheDocument(); - expect(screen.getByText("None")).toBeInTheDocument(); - }); - - it("should display '-' for Retries when attempted_retries is not present in metadata", () => { - render(); - - expect(screen.getByText("Retries:")).toBeInTheDocument(); - expect(screen.getByText("-")).toBeInTheDocument(); - }); -}); - describe("SpendLogsTable", () => { const defaultProps = { accessToken: "test-token", diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 97e24cb516a0..24b8a2b023bf 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -1,36 +1,24 @@ -import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; import moment from "moment"; -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, SyncOutlined } from "@ant-design/icons"; -import { Row } from "@tanstack/react-table"; -import { Switch, Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; -import { Button, Tag, Tooltip } from "antd"; +import { useCallback, useDeferredValue, useEffect, useState } from "react"; +import { SettingOutlined } from "@ant-design/icons"; +import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; +import { Button } from "antd"; import { internalUserRoles } from "../../utils/roles"; import DeletedKeysPage from "../DeletedKeysPage/DeletedKeysPage"; import DeletedTeamsPage from "../DeletedTeamsPage/DeletedTeamsPage"; -import FilterTeamDropdown from "../common_components/FilterTeamDropdown"; import { KeyResponse } from "../key_team_helpers/key_list"; -import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect"; -import { PaginatedModelSelect } from "../ModelSelect/PaginatedModelSelect/PaginatedModelSelect"; -import FilterComponent, { FilterOption } from "../molecules/filter"; -import { allEndUsersCall, keyInfoV1Call, uiSpendLogsCall } from "../networking"; +import FilterComponent from "../molecules/filter"; +import { keyInfoV1Call } from "../networking"; import KeyInfoView from "../templates/key_info_view"; import AuditLogs from "./audit_logs"; import { createColumns, LogEntry, type LogsSortField } from "./columns"; -import { ConfigInfoMessage } from "./ConfigInfoMessage"; -import { AGENT_CALL_TYPES, ERROR_CODE_OPTIONS, MCP_CALL_TYPES, QUICK_SELECT_OPTIONS } from "./constants"; -import { CostBreakdownViewer } from "./CostBreakdownViewer"; -import { ErrorViewer } from "./ErrorViewer"; -import { useLogFilterLogic } from "./log_filter_logic"; +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 { getTimeRangeDisplay } from "./logs_utils"; -import { RequestResponsePanel } from "./RequestResponsePanel"; +import { LogsTableToolbar } from "./LogsTableToolbar"; import SpendLogsSettingsModal from "./SpendLogsSettingsModal/SpendLogsSettingsModal"; import { DataTable } from "./table"; -import { VectorStoreViewer } from "./VectorStoreViewer"; interface SpendLogsTableProps { accessToken: string | null; @@ -56,29 +44,17 @@ export default function SpendLogsTable({ premiumUser, }: SpendLogsTableProps) { const [searchTerm, setSearchTerm] = useState(""); - const [showFilters, setShowFilters] = useState(false); - const [showColumnDropdown, setShowColumnDropdown] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [pageSize] = useState(50); - const dropdownRef = useRef(null); - const filtersRef = useRef(null); - const quickSelectRef = useRef(null); // 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 [quickSelectOpen, setQuickSelectOpen] = useState(false); - const [tempTeamId, setTempTeamId] = useState(""); - const [tempKeyHash, setTempKeyHash] = useState(""); - const [selectedTeamId, setSelectedTeamId] = useState(""); - const [selectedKeyHash, setSelectedKeyHash] = useState(""); - const [selectedModelId, setSelectedModelId] = useState(""); + const [filters, setFilters] = useState(defaultFilters); const [selectedKeyInfo, setSelectedKeyInfo] = useState(null); const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState(null); - const [selectedStatus, setSelectedStatus] = useState(""); - const [selectedEndUser, setSelectedEndUser] = useState(""); const [filterByCurrentUser, setFilterByCurrentUser] = useState(userRole && internalUserRoles.includes(userRole)); const [activeTab, setActiveTab] = useState("request logs"); @@ -90,12 +66,6 @@ export default function SpendLogsTable({ const [sortBy, setSortBy] = useState("startTime"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); - // Tracks whether any filter that uses performSearch (backend) is active. - // Used to disable the main query so it doesn't fire redundant unfiltered requests - // when time range / sort / page changes while a backend filter is in effect. - const [isMainQueryEnabled, setIsMainQueryEnabled] = useState(true); - - const queryClient = useQueryClient(); const [isLiveTail, setIsLiveTail] = useState(() => { const storedValue = sessionStorage.getItem("isLiveTail"); @@ -128,23 +98,6 @@ export default function SpendLogsTable({ fetchKeyInfo(); }, [selectedKeyIdInfoView, accessToken]); - // Close dropdown when clicking outside - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setShowColumnDropdown(false); - } - if (filtersRef.current && !filtersRef.current.contains(event.target as Node)) { - setShowFilters(false); - } - if (quickSelectRef.current && !quickSelectRef.current.contains(event.target as Node)) { - setQuickSelectOpen(false); - } - } - - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); useEffect(() => { if (userRole && internalUserRoles.includes(userRole)) { @@ -152,112 +105,39 @@ export default function SpendLogsTable({ } }, [userRole]); - const LiveTailControls = () => { - return ( -
- Live Tail - -
- ); - }; - - const logs = useQuery({ - queryKey: [ - "logs", - "table", - currentPage, - pageSize, - startTime, - endTime, - selectedTeamId, - selectedKeyHash, - filterByCurrentUser ? userID : null, - selectedStatus, - selectedModelId, - sortBy, - sortOrder, - ], - queryFn: async () => { - if (!accessToken || !token || !userRole || !userID) { - return { - data: [], - total: 0, - page: 1, - page_size: pageSize, - total_pages: 0, - }; - } - - const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"); - const formattedEndTime = isCustomDate - ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") - : moment().utc().format("YYYY-MM-DD HH:mm:ss"); - - // 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({ - accessToken, - 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" && isMainQueryEnabled, - 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, - page: 1, - page_size: pageSize || 10, - total_pages: 1, - }; - const { - filters, + logsQuery, filteredLogs, hasBackendFilters, allTeams, handleFilterChange, handleFilterReset: handleFilterResetFromHook, } = useLogFilterLogic({ - logs: logsData, accessToken, + token, + userRole, + userID, + filters, + setFilters, + filterByCurrentUser: !!filterByCurrentUser, + activeTab, + isLiveTail, startTime, endTime, pageSize, isCustomDate, setCurrentPage, - userID, - userRole, sortBy, sortOrder, currentPage, }); + // 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(logsQuery.isFetching); + const isButtonLoading = logsQuery.isFetching || isFetchingDeferred; + const handleFilterReset = useCallback(() => { handleFilterResetFromHook(); // Reset custom time range to default (last 24 hours) @@ -268,30 +148,6 @@ export default function SpendLogsTable({ setCurrentPage(1); }, [handleFilterResetFromHook]); - // Disable the main query whenever backend filters are active so it doesn't fire - // redundant unfiltered requests when time range / sort / page changes. - useEffect(() => { - setIsMainQueryEnabled(!hasBackendFilters); - }, [hasBackendFilters]); - - // Sync filter state into the individual selectedX state variables used by the main query - useEffect(() => { - if (!accessToken) return; - - if (filters["Team ID"]) { - setSelectedTeamId(filters["Team ID"]); - } else { - setSelectedTeamId(""); - } - setSelectedStatus(filters["Status"] || ""); - setSelectedModelId(filters["Model"] || ""); - setSelectedEndUser(filters["End User"] || ""); - - // Key Alias filtering is handled server-side by performSearch via the key_alias param. - // We intentionally do not translate the alias to a hash here to avoid firing a - // redundant main-query request (api_key=hash) alongside performSearch's key_alias request. - setSelectedKeyHash(filters["Key Hash"] || ""); - }, [filters, accessToken]); if (!accessToken || !token || !userRole || !userID) { return null; @@ -361,13 +217,8 @@ export default function SpendLogsTable({ return sessionRepresentativeMap.get(log.session_id)?.requestId === log.request_id; }) || []; - // Add this function to handle manual refresh - const handleRefresh = () => { - logs.refetch(); - }; - const handleRowClick = (log: LogEntry) => { - // Multi-call session row: open in the same right-side drawer (session mode) + // Multi-call session row: open in the same right-side drawer (session mode)can if (log.session_id && (log.session_total_count || 1) > 1) { setSelectedSessionId(log.session_id); setSelectedLog(log); @@ -380,95 +231,6 @@ export default function SpendLogsTable({ setIsDrawerOpen(true); }; - const handleCloseDrawer = () => { - setIsDrawerOpen(false); - setSelectedSessionId(null); - }; - - const handleSelectLog = (log: LogEntry) => { - setSelectedLog(log); - }; - - const logFilterOptions: FilterOption[] = [ - { - name: "Team ID", - label: "Team ID", - customComponent: FilterTeamDropdown, - }, - { - name: "Status", - label: "Status", - isSearchable: false, - options: [ - { label: "Success", value: "success" }, - { label: "Failure", value: "failure" }, - ], - }, - { - name: "Model", - label: "Model", - customComponent: PaginatedModelSelect, - }, - { - name: "Key Alias", - label: "Key Alias", - customComponent: PaginatedKeyAliasSelect, - }, - { - name: "End User", - label: "End User", - isSearchable: true, - searchFn: async (searchText: string) => { - if (!accessToken) return []; - const data = await allEndUsersCall(accessToken); - // data if set, is a list of objects, with key = user_id - 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: "Key Hash", - label: "Key Hash", - isSearchable: false, - }, - { - name: "Error Message", - label: "Error Message", - isSearchable: false, - }, - ]; - - const formatTimeUnit = (value: number, unit: string) => { - if (value === 1) { - if (unit === "minutes") return "minute"; - if (unit === "hours") return "hour"; - if (unit === "days") return "day"; - } - return unit; - }; - - const selectedOption = QUICK_SELECT_OPTIONS.find( - (option) => option.value === selectedTimeInterval.value && option.unit === selectedTimeInterval.unit, - ); - - const displayLabel = isCustomDate ? getTimeRangeDisplay(isCustomDate, startTime, endTime) : selectedOption?.label; - return (
setActiveTab(index === 0 ? "request logs" : "audit logs")}> @@ -499,7 +261,7 @@ export default function SpendLogsTable({ ) : ( <> @@ -509,174 +271,28 @@ export default function SpendLogsTable({ onSuccess={() => setIsSpendLogsSettingsModalVisible(false)} />
-
-
-
-
- setSearchTerm(e.target.value)} - /> - - - -
- -
-
- - - {quickSelectOpen && ( -
-
- {QUICK_SELECT_OPTIONS.map((option) => ( - - ))} -
- -
-
- )} -
- - - - -
- - {isCustomDate && ( -
-
- { - setStartTime(e.target.value); - setCurrentPage(1); - }} - className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - /> -
- to -
- { - setEndTime(e.target.value); - setCurrentPage(1); - }} - className="px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - /> -
-
- )} -
- -
- - Showing {logs.isLoading ? "..." : filteredLogs ? (currentPage - 1) * pageSize + 1 : 0} -{" "} - {logs.isLoading - ? "..." - : filteredLogs - ? Math.min(currentPage * pageSize, filteredLogs.total) - : 0}{" "} - of {logs.isLoading ? "..." : filteredLogs ? filteredLogs.total : 0} results - -
- - Page {logs.isLoading ? "..." : currentPage} of{" "} - {logs.isLoading ? "..." : filteredLogs ? filteredLogs.total_pages : 1} - - - -
-
-
-
- {isLiveTail && currentPage === 1 && isMainQueryEnabled && ( -
-
- Auto-refreshing every 15 seconds -
- -
- )} + logsQuery.refetch()} + filteredLogs={filteredLogs} + hasBackendFilters={hasBackendFilters} + />
@@ -713,323 +329,16 @@ export default function SpendLogsTable({ {/* Log Details Drawer */} { setIsDrawerOpen(false); setSelectedSessionId(null); }} logEntry={selectedLog} sessionId={selectedSessionId} accessToken={accessToken} onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)} allLogs={filteredData} - onSelectLog={handleSelectLog} + onSelectLog={setSelectedLog} startTime={moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss")} />
); } -export function RequestViewer({ row, onOpenSettings }: { row: Row; onOpenSettings?: () => void }) { - // Helper function to clean metadata by removing specific fields - const formatData = (input: any) => { - if (typeof input === "string") { - try { - return JSON.parse(input); - } catch { - return input; - } - } - return input; - }; - - // New helper function to get raw request - const getRawRequest = () => { - // First check if proxy_server_request exists in metadata - if (row.original?.proxy_server_request) { - return formatData(row.original.proxy_server_request); - } - // Fall back to messages if proxy_server_request is empty - return formatData(row.original.messages); - }; - - // Extract error information from metadata if available - const metadata = row.original.metadata || {}; - const hasError = metadata.status === "failure"; - const errorInfo = hasError ? metadata.error_information : null; - - // Check if request/response data is missing - const hasMessages = - row.original.messages && - (Array.isArray(row.original.messages) - ? row.original.messages.length > 0 - : Object.keys(row.original.messages).length > 0); - const hasResponse = row.original.response && Object.keys(formatData(row.original.response)).length > 0; - const missingData = !hasMessages && !hasResponse && !hasError; - - // Format the response with error details if present - const formattedResponse = () => { - if (hasError && errorInfo) { - return { - error: { - message: errorInfo.error_message || "An error occurred", - type: errorInfo.error_class || "error", - code: errorInfo.error_code || "unknown", - param: null, - }, - }; - } - return formatData(row.original.response); - }; - - // Extract vector store request metadata if available - const hasVectorStoreData = - metadata.vector_store_request_metadata && - Array.isArray(metadata.vector_store_request_metadata) && - metadata.vector_store_request_metadata.length > 0; - - // Extract guardrail information from metadata if available - const guardrailInfo = row.original.metadata?.guardrail_information; - const guardrailEntries = Array.isArray(guardrailInfo) ? guardrailInfo : guardrailInfo ? [guardrailInfo] : []; - const hasGuardrailData = guardrailEntries.length > 0; - - // Calculate total masked entities if guardrail data exists - const totalMaskedEntities = guardrailEntries.reduce((sum, entry) => { - const maskedCounts = entry?.masked_entity_count; - if (!maskedCounts) { - return sum; - } - return ( - sum + - Object.values(maskedCounts).reduce((acc, count) => (typeof count === "number" ? acc + count : acc), 0) - ); - }, 0); - - const primaryGuardrailLabel = - guardrailEntries.length === 1 - ? guardrailEntries[0]?.guardrail_name ?? "-" - : guardrailEntries.length > 1 - ? `${guardrailEntries.length} guardrails` - : "-"; - - const truncatedRequestId = truncateString(row.original.request_id, 64); - - return ( -
- {/* Combined Info Card */} -
-
-

Request Details

-
-
-
-
- Request ID: - {row.original.request_id.length > 64 ? ( - - {truncatedRequestId} - - ) : ( - {row.original.request_id} - )} -
-
- Model: - {row.original.model} -
-
- Model ID: - {row.original.model_id} -
-
- Call Type: - {row.original.call_type} -
-
- Provider: - {row.original.custom_llm_provider || "-"} -
-
- API Base: - - {row.original.api_base || "-"} - -
- {row?.original?.requester_ip_address && ( -
- IP Address: - {row?.original?.requester_ip_address} -
- )} - {hasGuardrailData && ( -
- Guardrail: -
- {primaryGuardrailLabel} - {totalMaskedEntities > 0 && ( - - {totalMaskedEntities} masked - - )} -
-
- )} -
-
-
- Tokens: - - {row.original.total_tokens} ({row.original.prompt_tokens} prompt tokens +{" "} - {row.original.completion_tokens} completion tokens) - -
-
- Cache Read Tokens: - - {formatNumberWithCommas(row.original.metadata?.additional_usage_values?.cache_read_input_tokens || 0)} - -
-
- Cache Creation Tokens: - - {formatNumberWithCommas(row.original.metadata?.additional_usage_values.cache_creation_input_tokens)} - -
-
- Cost: - ${formatNumberWithCommas(row.original.spend || 0, 6)} -
-
- Cache Hit: - {row.original.cache_hit} -
- -
- Status: - - {(row.original.metadata?.status || "Success").toLowerCase() !== "failure" ? "Success" : "Failure"} - -
-
- Start Time: - {row.original.startTime} -
-
- End Time: - {row.original.endTime} -
-
- Duration: - {row.original.request_duration_ms != null ? (row.original.request_duration_ms / 1000).toFixed(3) : "-"} s. -
- {row.original.metadata?.litellm_overhead_time_ms !== undefined && ( -
- LiteLLM Overhead: - {row.original.metadata.litellm_overhead_time_ms} ms -
- )} -
- Retries: - - {row.original.metadata?.attempted_retries !== undefined && row.original.metadata?.attempted_retries !== null - ? row.original.metadata.attempted_retries > 0 - ? `${row.original.metadata.attempted_retries}${row.original.metadata.max_retries !== undefined && row.original.metadata.max_retries !== null ? ` / ${row.original.metadata.max_retries}` : ''}` - : None - : '-'} - -
-
-
-
- - {/* Cost Breakdown - Show if cost breakdown data is available */} - - - {/* Configuration Info Message - Show when data is missing */} - - - {/* Request/Response Panel */} -
- -
- - {/* Guardrail Data - Show only if present */} - {hasGuardrailData && } - - {/* Vector Store Request Data - Show only if present */} - {hasVectorStoreData && } - - {/* Error Card - Only show for failures */} - {hasError && errorInfo && } - - {/* Tags Card - Only show if there are tags */} - {row.original.request_tags && Object.keys(row.original.request_tags).length > 0 && ( -
-
-

Request Tags

-
-
-
- {Object.entries(row.original.request_tags).map(([key, value]) => ( - - {key}: {String(value)} - - ))} -
-
-
- )} - - {/* Metadata Card - Only show if there's metadata */} - {row.original.metadata && Object.keys(row.original.metadata).length > 0 && ( -
-
-

Metadata

- -
-
-
-              {JSON.stringify(row.original.metadata, null, 2)}
-            
-
-
- )} -
- ); -} 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 8c88de49d0db..58c36dd43012 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 @@ -2,14 +2,14 @@ import moment from "moment"; import { useCallback, useEffect, useState, useRef, useMemo } from "react"; import { uiSpendLogsCall } from "../networking"; import { Team } from "../key_team_helpers/key_list"; -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { fetchAllTeams } from "../../components/key_team_helpers/filter_helpers"; import { debounce } from "lodash"; import { defaultPageSize } from "../constants"; import { PaginatedResponse } from "."; import type { LogsSortField } from "./columns"; -const FILTER_KEYS = { +export const FILTER_KEYS = { TEAM_ID: "Team ID", KEY_HASH: "Key Hash", REQUEST_ID: "Request ID", @@ -25,50 +25,56 @@ const FILTER_KEYS = { export type FilterKey = keyof typeof FILTER_KEYS; export type LogFilterState = Record<(typeof FILTER_KEYS)[FilterKey], string>; +export const defaultFilters: LogFilterState = { + [FILTER_KEYS.TEAM_ID]: "", + [FILTER_KEYS.KEY_HASH]: "", + [FILTER_KEYS.REQUEST_ID]: "", + [FILTER_KEYS.MODEL]: "", + [FILTER_KEYS.USER_ID]: "", + [FILTER_KEYS.END_USER]: "", + [FILTER_KEYS.STATUS]: "", + [FILTER_KEYS.KEY_ALIAS]: "", + [FILTER_KEYS.ERROR_CODE]: "", + [FILTER_KEYS.ERROR_MESSAGE]: "", +}; + export function useLogFilterLogic({ - logs, accessToken, - startTime, // Receive from SpendLogsTable - endTime, // Receive from SpendLogsTable + token, + userRole, + userID, + filters, + setFilters, + filterByCurrentUser, + activeTab, + isLiveTail, + startTime, + endTime, pageSize = defaultPageSize, isCustomDate, setCurrentPage, - userID, - userRole, sortBy = "startTime", sortOrder = "desc", currentPage = 1, }: { - logs: PaginatedResponse; accessToken: string | null; + token: string | null; + userRole: string | null; + userID: string | null; + filters: LogFilterState; + setFilters: React.Dispatch>; + filterByCurrentUser: boolean | null; + activeTab: string; + isLiveTail: boolean; startTime: string; endTime: string; pageSize?: number; isCustomDate: boolean; setCurrentPage: (page: number) => void; - userID: string | null; - userRole: string | null; sortBy?: LogsSortField; sortOrder?: "asc" | "desc"; currentPage?: number; }) { - const defaultFilters = useMemo( - () => ({ - [FILTER_KEYS.TEAM_ID]: "", - [FILTER_KEYS.KEY_HASH]: "", - [FILTER_KEYS.REQUEST_ID]: "", - [FILTER_KEYS.MODEL]: "", - [FILTER_KEYS.USER_ID]: "", - [FILTER_KEYS.END_USER]: "", - [FILTER_KEYS.STATUS]: "", - [FILTER_KEYS.KEY_ALIAS]: "", - [FILTER_KEYS.ERROR_CODE]: "", - [FILTER_KEYS.ERROR_MESSAGE]: "", - }), - [], - ); - - const [filters, setFilters] = useState(defaultFilters); const [backendFilteredLogs, setBackendFilteredLogs] = useState(null); const lastSearchTimestamp = useRef(0); const performSearch = useCallback( @@ -152,6 +158,64 @@ export function useLogFilterLogic({ [filters], ); + const logsQuery = useQuery({ + queryKey: [ + "logs", + "table", + currentPage, + pageSize, + startTime, + endTime, + filters[FILTER_KEYS.TEAM_ID], + filters[FILTER_KEYS.KEY_HASH], + filterByCurrentUser ? userID : null, + filters[FILTER_KEYS.STATUS], + filters[FILTER_KEYS.MODEL], + sortBy, + sortOrder, + ], + queryFn: async () => { + if (!accessToken || !token || !userRole || !userID) { + return { + data: [], + total: 0, + page: 1, + page_size: pageSize, + total_pages: 0, + }; + } + + const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"); + const formattedEndTime = isCustomDate + ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") + : moment().utc().format("YYYY-MM-DD HH:mm:ss"); + + const response = await uiSpendLogsCall({ + accessToken, + start_date: formattedStartTime, + end_date: formattedEndTime, + page: currentPage, + page_size: pageSize, + params: { + api_key: filters[FILTER_KEYS.KEY_HASH] || undefined, + team_id: filters[FILTER_KEYS.TEAM_ID] || undefined, + user_id: filterByCurrentUser ? userID ?? undefined : undefined, + end_user: filters[FILTER_KEYS.END_USER] || undefined, + status_filter: filters[FILTER_KEYS.STATUS] || undefined, + model_id: filters[FILTER_KEYS.MODEL] || undefined, + sort_by: sortBy, + sort_order: sortOrder, + }, + }); + + return response; + }, + enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs" && !hasBackendFilters, + refetchInterval: isLiveTail && currentPage === 1 ? 15000 : false, + placeholderData: keepPreviousData, + refetchIntervalInBackground: true, + }); + // Refetch when sort, page, or time range changes (backend filters use their own fetch, not the main query) useEffect(() => { if (hasBackendFilters && accessToken) { @@ -167,9 +231,10 @@ export function useLogFilterLogic({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [sortBy, sortOrder, currentPage, startTime, endTime, isCustomDate]); - // Compute client-side filtered logs directly from incoming logs and filters + // Compute client-side filtered logs directly from query data and filters + const spendLogsData = logsQuery.data; const clientDerivedFilteredLogs: PaginatedResponse = useMemo(() => { - if (!logs || !logs.data) { + if (!spendLogsData) { return { data: [], total: 0, @@ -181,10 +246,10 @@ export function useLogFilterLogic({ // If backend filters are on, don't perform client-side filtering here if (hasBackendFilters) { - return logs; + return spendLogsData; } - let filteredData = [...logs.data]; + let filteredData = [...spendLogsData.data]; if (filters[FILTER_KEYS.TEAM_ID]) { filteredData = filteredData.filter((log) => log.team_id === filters[FILTER_KEYS.TEAM_ID]); @@ -221,12 +286,12 @@ export function useLogFilterLogic({ return { data: filteredData, - total: logs.total, - page: logs.page, - page_size: logs.page_size, - total_pages: logs.total_pages, + total: spendLogsData.total, + page: spendLogsData.page, + page_size: spendLogsData.page_size, + total_pages: spendLogsData.total_pages, }; - }, [logs, filters, hasBackendFilters]); + }, [spendLogsData, filters, hasBackendFilters]); // Choose which filtered logs to expose: backend result when active, otherwise client-derived const filteredLogs: PaginatedResponse = useMemo(() => { @@ -300,7 +365,7 @@ export function useLogFilterLogic({ }; return { - filters, + logsQuery, filteredLogs, hasBackendFilters, allTeams, From b5455d12c0dcc8e6d71c47c260144260db455411 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 16 Apr 2026 00:57:31 -0700 Subject: [PATCH 02/13] Fix PR feedback: use antd Switch instead of Tremor in new file, fix typo --- .../src/components/view_logs/LogsTableToolbar.tsx | 5 ++--- ui/litellm-dashboard/src/components/view_logs/index.tsx | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx index fcd6a1eba8e6..1706bd0c23a3 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx @@ -1,8 +1,7 @@ import moment from "moment"; import { useEffect, useRef, useState } from "react"; import { SyncOutlined } from "@ant-design/icons"; -import { Switch } from "@tremor/react"; -import { Button } from "antd"; +import { Button, Switch } from "antd"; import { QUICK_SELECT_OPTIONS } from "./constants"; import { getTimeRangeDisplay } from "./logs_utils"; import type { PaginatedResponse } from "."; @@ -152,7 +151,7 @@ export function LogsTableToolbar({
Live Tail - +
- {isLiveTail && currentPage === 1 && !hasBackendFilters && ( + {isLiveTail && currentPage === 1 && (
Auto-refreshing every 15 seconds diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 00ecc729a897..1777827a154c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -108,7 +108,6 @@ export default function SpendLogsTable({ const { logsQuery, filteredLogs, - hasBackendFilters, allTeams, handleFilterChange, handleFilterReset: handleFilterResetFromHook, @@ -291,7 +290,6 @@ export default function SpendLogsTable({ isButtonLoading={isButtonLoading} onRefetch={() => logsQuery.refetch()} filteredLogs={filteredLogs} - hasBackendFilters={hasBackendFilters} /> (value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + return debounced; +} + export const FILTER_KEYS = { TEAM_ID: "Team ID", KEY_HASH: "Key Hash", @@ -75,88 +83,10 @@ export function useLogFilterLogic({ sortOrder?: "asc" | "desc"; currentPage?: number; }) { - const [backendFilteredLogs, setBackendFilteredLogs] = useState(null); - const lastSearchTimestamp = useRef(0); - const performSearch = useCallback( - async (filters: LogFilterState, page = 1) => { - if (!accessToken) return; - - console.log("Filters being sent to API:", filters); - const currentTimestamp = Date.now(); - lastSearchTimestamp.current = currentTimestamp; - - const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"); - const formattedEndTime = isCustomDate - ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") - : moment().utc().format("YYYY-MM-DD HH:mm:ss"); - - try { - const response = await uiSpendLogsCall({ - accessToken, - start_date: formattedStartTime, - end_date: formattedEndTime, - page, - 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) { - setBackendFilteredLogs({ - ...response, - data: response.data ?? [], - }); - } - } catch (error) { - console.error("Error searching users:", error); - setBackendFilteredLogs({ - data: [], - total: 0, - page: 1, - page_size: pageSize, - total_pages: 0, - }); - } - }, - [accessToken, startTime, endTime, isCustomDate, pageSize, sortBy, sortOrder], - ); - - const debouncedSearch = useMemo( - () => debounce((filters: LogFilterState, page: number) => performSearch(filters, page), 300), - [performSearch], - ); - - useEffect(() => { - return () => debouncedSearch.cancel(); - }, [debouncedSearch]); - - // Determine when backend filters are active (server-side filtering) - const hasBackendFilters = useMemo( - () => - !!( - filters[FILTER_KEYS.KEY_ALIAS] || - filters[FILTER_KEYS.KEY_HASH] || - filters[FILTER_KEYS.REQUEST_ID] || - filters[FILTER_KEYS.USER_ID] || - filters[FILTER_KEYS.END_USER] || - filters[FILTER_KEYS.ERROR_CODE] || - filters[FILTER_KEYS.ERROR_MESSAGE] || - filters[FILTER_KEYS.MODEL] - ), - [filters], - ); + // Debounce filters so text inputs (Key Hash, Error Message) don't fire a + // request per keystroke. Dropdown selects get a 300ms delay too, which is + // imperceptible since the user just clicked an option. + const debouncedFilters = useDebouncedValue(filters, 300); const logsQuery = useQuery({ queryKey: [ @@ -166,11 +96,8 @@ export function useLogFilterLogic({ pageSize, startTime, endTime, - filters[FILTER_KEYS.TEAM_ID], - filters[FILTER_KEYS.KEY_HASH], + debouncedFilters, filterByCurrentUser ? userID : null, - filters[FILTER_KEYS.STATUS], - filters[FILTER_KEYS.MODEL], sortBy, sortOrder, ], @@ -197,12 +124,16 @@ export function useLogFilterLogic({ page: currentPage, page_size: pageSize, params: { - api_key: filters[FILTER_KEYS.KEY_HASH] || undefined, - team_id: filters[FILTER_KEYS.TEAM_ID] || undefined, - user_id: filterByCurrentUser ? userID ?? undefined : undefined, - end_user: filters[FILTER_KEYS.END_USER] || undefined, - status_filter: filters[FILTER_KEYS.STATUS] || undefined, - model_id: filters[FILTER_KEYS.MODEL] || undefined, + api_key: debouncedFilters[FILTER_KEYS.KEY_HASH] || undefined, + team_id: debouncedFilters[FILTER_KEYS.TEAM_ID] || undefined, + request_id: debouncedFilters[FILTER_KEYS.REQUEST_ID] || undefined, + user_id: debouncedFilters[FILTER_KEYS.USER_ID] || (filterByCurrentUser ? userID ?? undefined : undefined), + end_user: debouncedFilters[FILTER_KEYS.END_USER] || undefined, + status_filter: debouncedFilters[FILTER_KEYS.STATUS] || undefined, + model_id: debouncedFilters[FILTER_KEYS.MODEL] || undefined, + key_alias: debouncedFilters[FILTER_KEYS.KEY_ALIAS] || undefined, + error_code: debouncedFilters[FILTER_KEYS.ERROR_CODE] || undefined, + error_message: debouncedFilters[FILTER_KEYS.ERROR_MESSAGE] || undefined, sort_by: sortBy, sort_order: sortOrder, }, @@ -210,164 +141,53 @@ export function useLogFilterLogic({ return response; }, - enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs" && !hasBackendFilters, + enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs", refetchInterval: isLiveTail && currentPage === 1 ? 15000 : false, placeholderData: keepPreviousData, refetchIntervalInBackground: true, }); - // Refetch when sort, page, or time range changes (backend filters use their own fetch, not the main query) - useEffect(() => { - if (hasBackendFilters && accessToken) { - // Cancel any pending debounced search to prevent it from overwriting this page's results - debouncedSearch.cancel(); - performSearch(filters, currentPage); - } - // Intentionally omitted from deps: - // - `filters` / `debouncedSearch` / `performSearch`: filter changes are handled by - // handleFilterChange → debouncedSearch; adding them here would double-fetch on filter apply. - // - `hasBackendFilters` / `accessToken`: stable across sort/page/time changes; including them - // would cause spurious re-runs when the filter state first becomes active. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sortBy, sortOrder, currentPage, startTime, endTime, isCustomDate]); - - // Compute client-side filtered logs directly from query data and filters - const spendLogsData = logsQuery.data; - const clientDerivedFilteredLogs: PaginatedResponse = useMemo(() => { - if (!spendLogsData) { - return { - data: [], - total: 0, - page: 1, - page_size: pageSize, - total_pages: 0, - }; - } - - // If backend filters are on, don't perform client-side filtering here - if (hasBackendFilters) { - return spendLogsData; - } - - let filteredData = [...spendLogsData.data]; - - if (filters[FILTER_KEYS.TEAM_ID]) { - filteredData = filteredData.filter((log) => log.team_id === filters[FILTER_KEYS.TEAM_ID]); - } - - if (filters[FILTER_KEYS.STATUS]) { - filteredData = filteredData.filter((log) => { - if (filters[FILTER_KEYS.STATUS] === "success") { - return !log.status || log.status === "success"; - } - return log.status === filters[FILTER_KEYS.STATUS]; - }); - } - - if (filters[FILTER_KEYS.MODEL]) { - filteredData = filteredData.filter((log) => log.model_id === filters[FILTER_KEYS.MODEL]); - } - - if (filters[FILTER_KEYS.KEY_HASH]) { - filteredData = filteredData.filter((log) => log.api_key === filters[FILTER_KEYS.KEY_HASH]); - } - - if (filters[FILTER_KEYS.END_USER]) { - filteredData = filteredData.filter((log) => log.end_user === filters[FILTER_KEYS.END_USER]); - } - - if (filters[FILTER_KEYS.ERROR_CODE]) { - filteredData = filteredData.filter((log) => { - const metadata = log.metadata || {}; - const errorInfo = metadata.error_information; - return errorInfo && errorInfo.error_code === filters[FILTER_KEYS.ERROR_CODE]; - }); - } - - return { - data: filteredData, - total: spendLogsData.total, - page: spendLogsData.page, - page_size: spendLogsData.page_size, - total_pages: spendLogsData.total_pages, - }; - }, [spendLogsData, filters, hasBackendFilters]); - - // Choose which filtered logs to expose: backend result when active, otherwise client-derived - const filteredLogs: PaginatedResponse = useMemo(() => { - if (hasBackendFilters) { - // When backend filters are active, only show backend results. - // If search hasn't completed yet (null), show empty state rather than - // falling back to unfiltered logs — that caused filtered views to - // display mismatched data when the filter matched zero rows. - if (backendFilteredLogs !== null) { - return backendFilteredLogs; - } - return { - data: [], - total: 0, - page: 1, - page_size: pageSize, - total_pages: 0, - }; - } - return clientDerivedFilteredLogs; - }, [hasBackendFilters, backendFilteredLogs, clientDerivedFilteredLogs]); + const filteredLogs: PaginatedResponse = logsQuery.data ?? { + data: [], + total: 0, + page: 1, + page_size: pageSize, + total_pages: 0, + }; - // Fetch all teams and users for potential filter dropdowns (optional, can be adapted) const { data: allTeams } = useQuery({ queryKey: ["allTeamsForLogFilters", accessToken], queryFn: async () => { if (!accessToken) return []; - // Use fetchAllTeams helper function for consistency and abstraction - // Assuming fetchAllTeams returns Team[] directly const teamsData = await fetchAllTeams(accessToken); - return teamsData || []; // Ensure it returns an array + return teamsData || []; }, enabled: !!accessToken, }); - // Update filters state const handleFilterChange = (newFilters: Partial) => { setFilters((prev) => { const updatedFilters = { ...prev, ...newFilters }; - - // Ensure all keys in LogFilterState are present, defaulting to '' if not in newFilters for (const key of Object.keys(defaultFilters) as Array) { if (!(key in updatedFilters)) { updatedFilters[key] = defaultFilters[key]; } } - - // Only call debouncedSearch if filters have actually changed if (JSON.stringify(updatedFilters) !== JSON.stringify(prev)) { setCurrentPage(1); - setBackendFilteredLogs(null); - debouncedSearch(updatedFilters, 1); } - return updatedFilters as LogFilterState; }); }; const handleFilterReset = () => { - // Reset filters state setFilters(defaultFilters); - - // Clear backend filtered logs to ensure fresh render - setBackendFilteredLogs(null); - - // Cancel any in-flight debounced search - debouncedSearch.cancel(); - - // Reset to first page so the unfiltered view starts at page 1 setCurrentPage(1); }; return { logsQuery, filteredLogs, - hasBackendFilters, allTeams, handleFilterChange, handleFilterReset, From 5816246dbb2ce35704630c253f381e9ed95a836e Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 16 Apr 2026 09:50:16 -0700 Subject: [PATCH 04/13] Clean up remaining smells: remove isFetchingDeferred, internalize selectedTimeInterval, fix circular import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove useDeferredValue/isButtonLoading — pass logsQuery.isFetching directly - Move selectedTimeInterval into LogsTableToolbar as internal state - Move PaginatedResponse type from index.tsx to log_filter_logic.tsx --- .../components/view_logs/LogsTableToolbar.tsx | 9 +++---- .../src/components/view_logs/index.tsx | 26 ++----------------- .../components/view_logs/log_filter_logic.tsx | 11 ++++++-- 3 files changed, 14 insertions(+), 32 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx index 72133a2ff9b4..d13530375820 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx @@ -4,7 +4,7 @@ import { SyncOutlined } from "@ant-design/icons"; import { Button, Switch } from "antd"; import { QUICK_SELECT_OPTIONS } from "./constants"; import { getTimeRangeDisplay } from "./logs_utils"; -import type { PaginatedResponse } from "."; +import type { PaginatedResponse } from "./log_filter_logic"; interface LogsTableToolbarProps { searchTerm: string; @@ -15,8 +15,6 @@ interface LogsTableToolbarProps { onEndTimeChange: (value: string) => void; isCustomDate: boolean; onIsCustomDateChange: (value: boolean) => void; - selectedTimeInterval: { value: number; unit: string }; - onSelectedTimeIntervalChange: (value: { value: number; unit: string }) => void; isLiveTail: boolean; onIsLiveTailChange: (value: boolean) => void; currentPage: number; @@ -37,8 +35,6 @@ export function LogsTableToolbar({ onEndTimeChange, isCustomDate, onIsCustomDateChange, - selectedTimeInterval, - onSelectedTimeIntervalChange, isLiveTail, onIsLiveTailChange, currentPage, @@ -50,6 +46,7 @@ export function LogsTableToolbar({ filteredLogs, }: LogsTableToolbarProps) { const [quickSelectOpen, setQuickSelectOpen] = useState(false); + const [selectedTimeInterval, setSelectedTimeInterval] = useState<{ value: number; unit: string }>({ value: 24, unit: "hours" }); const quickSelectRef = useRef(null); useEffect(() => { @@ -127,7 +124,7 @@ export function LogsTableToolbar({ .subtract(option.value, option.unit as any) .format("YYYY-MM-DDTHH:mm"), ); - onSelectedTimeIntervalChange({ value: option.value, unit: option.unit }); + setSelectedTimeInterval({ value: option.value, unit: option.unit }); onIsCustomDateChange(false); setQuickSelectOpen(false); }} diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 1777827a154c..e0d327a2335f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -1,5 +1,5 @@ import moment from "moment"; -import { useCallback, useDeferredValue, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { SettingOutlined } from "@ant-design/icons"; import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; import { Button } from "antd"; @@ -28,14 +28,6 @@ interface SpendLogsTableProps { premiumUser: boolean; } -export interface PaginatedResponse { - data: LogEntry[]; - total: number; - page: number; - page_size: number; - total_pages: number; -} - export default function SpendLogsTable({ accessToken, token, @@ -77,10 +69,6 @@ export default function SpendLogsTable({ sessionStorage.setItem("isLiveTail", JSON.stringify(isLiveTail)); }, [isLiveTail]); - const [selectedTimeInterval, setSelectedTimeInterval] = useState<{ value: number; unit: string }>({ - value: 24, - unit: "hours", - }); useEffect(() => { const fetchKeyInfo = async () => { @@ -131,19 +119,11 @@ export default function SpendLogsTable({ currentPage, }); - // 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(logsQuery.isFetching); - const isButtonLoading = logsQuery.isFetching || isFetchingDeferred; - const handleFilterReset = useCallback(() => { handleFilterResetFromHook(); - // Reset custom time range to default (last 24 hours) 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]); @@ -279,15 +259,13 @@ export default function SpendLogsTable({ onEndTimeChange={setEndTime} isCustomDate={isCustomDate} onIsCustomDateChange={setIsCustomDate} - selectedTimeInterval={selectedTimeInterval} - onSelectedTimeIntervalChange={setSelectedTimeInterval} isLiveTail={isLiveTail} onIsLiveTailChange={setIsLiveTail} currentPage={currentPage} onCurrentPageChange={setCurrentPage} pageSize={pageSize} isLoading={logsQuery.isLoading} - isButtonLoading={isButtonLoading} + isButtonLoading={logsQuery.isFetching} onRefetch={() => logsQuery.refetch()} filteredLogs={filteredLogs} /> 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 04b8d409056d..147c00a8994b 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 @@ -5,8 +5,15 @@ 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 { PaginatedResponse } from "."; -import type { LogsSortField } from "./columns"; +import type { LogEntry, LogsSortField } from "./columns"; + +export interface PaginatedResponse { + data: LogEntry[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} function useDebouncedValue(value: T, delayMs: number): T { const [debounced, setDebounced] = useState(value); From 4fe54aa2f6e7f4cdb1d1ce255d81f7400e4e5f4a Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 16 Apr 2026 10:30:37 -0700 Subject: [PATCH 05/13] Fix quick-select dropdown overlapping sidebar --- .../src/components/view_logs/LogsTableToolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx index d13530375820..4c57ab852976 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogsTableToolbar.tsx @@ -110,7 +110,7 @@ export function LogsTableToolbar({ {quickSelectOpen && ( -
+
{QUICK_SELECT_OPTIONS.map((option) => (
{isLiveTail && currentPage === 1 && ( -
+
Auto-refreshing every 15 seconds
From 4c8eabaca1ee77ad6e53ee487170a71bfc885523 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Mon, 20 Apr 2026 13:46:40 -0700 Subject: [PATCH 10/13] memoize columns and derived table data in SpendLogsTable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table's columns array, four-pass data pipeline, and sort-change handler were all being rebuilt on every parent render. That made every filter click re-instance all 23 TanStack-Table columns, re-run filter/reduce/map over all rows, and recreate per-row click closures — all before the intentional 300ms debounce timer even got a chance to fire. Local measurement (40 rows, dev mode): filter click → query fires: 1957ms → 1217ms (−38%) Wrap createColumns in useMemo keyed on sortBy/sortOrder, hoist onSortChange into a useCallback, and move the searchedLogs / sessionComposition / sessionRepresentativeMap / filteredData derivations into a single useMemo keyed on filteredLogs.data + searchTerm. These were pre-existing issues on main — not regressions from the hook refactor — but the refactor made them user-visible because the new query debounce put render cost on the critical path. --- .../src/components/view_logs/index.tsx | 150 +++++++++--------- 1 file changed, 79 insertions(+), 71 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 85eabd40b6fb..f00c01d86e56 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -1,5 +1,5 @@ import moment from "moment"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { SettingOutlined } from "@ant-design/icons"; import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@tremor/react"; import { Button } from "antd"; @@ -133,74 +133,90 @@ export default function SpendLogsTable({ setCurrentPage(1); }, [handleFilterResetFromHook]); + const handleSortChange = useCallback( + (newSortBy: LogsSortField, newSortOrder: "asc" | "desc") => { + setSortBy(newSortBy); + setSortOrder(newSortOrder); + setCurrentPage(1); + }, + [], + ); - if (!accessToken || !token || !userRole || !userID) { - return null; - } + const columns = useMemo( + () => createColumns({ sortBy, sortOrder, onSortChange: handleSortChange }), + [sortBy, sortOrder, handleSortChange], + ); - const searchedLogs = filteredLogs.data.filter((log) => { - const matchesSearch = - !searchTerm || - log.request_id.includes(searchTerm) || - log.model.includes(searchTerm) || - (log.user && log.user.includes(searchTerm)); + 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; - }); + // 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; - }, {}); + 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 }); + // 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 }); + } } - } - const filteredData = - 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; - }) || []; + 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]); + + if (!accessToken || !token || !userRole || !userID) { + return null; + } const handleRowClick = (log: LogEntry) => { // Multi-call session row: open in the same right-side drawer (session mode) @@ -278,15 +294,7 @@ export default function SpendLogsTable({ filteredLogs={filteredLogs} /> { - setSortBy(newSortBy); - setSortOrder(newSortOrder); - setCurrentPage(1); - }, - })} + columns={columns} data={filteredData} onRowClick={handleRowClick} isLoading={logsQuery.isLoading} From d9878ca2f92843d447159c76557838b1acd6905c Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Thu, 23 Apr 2026 16:46:43 -0700 Subject: [PATCH 11/13] apply dropdown filters instantly, debounce only text inputs Dropdown selects now bypass the 300ms debounce so a click updates the table immediately. Text inputs (Key Hash, Error Message, Request ID, User ID) still debounce. handleFilterReset also clears the pending debounced value so a half-typed text filter can't re-fire after reset. --- .../view_logs/log_filter_logic.test.tsx | 132 ++++++++++++------ .../components/view_logs/log_filter_logic.tsx | 55 +++++--- 2 files changed, 125 insertions(+), 62 deletions(-) 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 450ad2345edf..d1f0e98e9e6f 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 @@ -277,32 +277,94 @@ describe("useLogFilterLogic", () => { }); describe("debounce", () => { - it("calls uiSpendLogsCall after the debounce elapses", async () => { + it("calls uiSpendLogsCall after the debounce elapses for text filters", async () => { const { result } = renderFilterHook(); act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); + result.current.handleFilterChange({ "Key Hash": "hash-1" }); }); - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); + await waitFor( + () => + expect(uiSpendLogsCall).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ api_key: "hash-1" }), + }), + ), + { timeout: 500 }, + ); }); - it("does not call uiSpendLogsCall before the debounce elapses", async () => { + it("does not call uiSpendLogsCall with a text filter before the debounce elapses", async () => { const { result } = renderFilterHook(); - // Wait for the initial query fire, then reset the spy so we only observe - // calls triggered by the filter change below. await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled(), { timeout: 500 }); vi.mocked(uiSpendLogsCall).mockClear(); act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); + result.current.handleFilterChange({ "Key Hash": "hash-1" }); }); await new Promise((resolve) => setTimeout(resolve, 100)); - expect(uiSpendLogsCall).not.toHaveBeenCalled(); + 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 }, + ); + }); + + it("applies dropdown filter changes without waiting for the debounce", async () => { + const { result } = renderFilterHook(); 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 }, + ); + }); + }); + + 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(); + } }); }); @@ -348,25 +410,20 @@ describe("useLogFilterLogic", () => { describe("refetch triggers", () => { it("refetches when sortBy changes", async () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( (props: { sortBy: LogsSortField }) => { const [filters, setFilters] = useState(defaultFilters); - const hook = useLogFilterLogic({ + return useLogFilterLogic({ ...defaultProps, filters, setFilters, setCurrentPage: vi.fn(), sortBy: props.sortBy, }); - return { ...hook, filters, setFilters }; }, { wrapper, initialProps: { sortBy: "startTime" } }, ); - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); rerender({ sortBy: "spend" }); @@ -380,25 +437,20 @@ describe("useLogFilterLogic", () => { }); it("refetches when sortOrder changes", async () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( (props: { sortOrder: "asc" | "desc" }) => { const [filters, setFilters] = useState(defaultFilters); - const hook = useLogFilterLogic({ + return useLogFilterLogic({ ...defaultProps, filters, setFilters, setCurrentPage: vi.fn(), sortOrder: props.sortOrder, }); - return { ...hook, filters, setFilters }; }, { wrapper, initialProps: { sortOrder: "desc" } }, ); - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); rerender({ sortOrder: "asc" }); @@ -412,25 +464,20 @@ describe("useLogFilterLogic", () => { }); it("refetches when currentPage changes", async () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( (props: { currentPage: number }) => { const [filters, setFilters] = useState(defaultFilters); - const hook = useLogFilterLogic({ + return useLogFilterLogic({ ...defaultProps, filters, setFilters, setCurrentPage: vi.fn(), currentPage: props.currentPage, }); - return { ...hook, filters, setFilters }; }, { wrapper, initialProps: { currentPage: 1 } }, ); - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); rerender({ currentPage: 2 }); @@ -442,25 +489,20 @@ describe("useLogFilterLogic", () => { }); it("refetches when startTime changes", async () => { - const { result, rerender } = renderHook( + const { rerender } = renderHook( (props: { startTime: string }) => { const [filters, setFilters] = useState(defaultFilters); - const hook = useLogFilterLogic({ + return useLogFilterLogic({ ...defaultProps, filters, setFilters, setCurrentPage: vi.fn(), startTime: props.startTime, }); - return { ...hook, filters, setFilters }; }, { wrapper, initialProps: { startTime: "2025-01-01T00:00:00Z" } }, ); - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { timeout: 500 }); rerender({ startTime: "2025-01-02T00:00:00Z" }); @@ -471,31 +513,33 @@ describe("useLogFilterLogic", () => { ); }); - it("refetches when isCustomDate changes", async () => { - const { result, rerender } = renderHook( + 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); - const hook = useLogFilterLogic({ + return useLogFilterLogic({ ...defaultProps, + endTime: customEndTime, filters, setFilters, setCurrentPage: vi.fn(), isCustomDate: props.isCustomDate, }); - return { ...hook, filters, setFilters }; }, { wrapper, initialProps: { isCustomDate: false } }, ); - act(() => { - result.current.handleFilterChange({ "Key Alias": "alias-1" }); - }); - 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); }); }); 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 147c00a8994b..e0e6c65e49d8 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,5 +1,5 @@ import moment from "moment"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { uiSpendLogsCall } from "../networking"; import { Team } from "../key_team_helpers/key_list"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; @@ -15,13 +15,16 @@ export interface PaginatedResponse { total_pages: number; } -function useDebouncedValue(value: T, delayMs: number): T { +function useDebouncedValue( + value: T, + delayMs: number, +): [T, React.Dispatch>] { const [debounced, setDebounced] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebounced(value), delayMs); return () => clearTimeout(timer); }, [value, delayMs]); - return debounced; + return [debounced, setDebounced]; } export const FILTER_KEYS = { @@ -40,6 +43,14 @@ export const FILTER_KEYS = { export type FilterKey = keyof typeof FILTER_KEYS; export type LogFilterState = Record<(typeof FILTER_KEYS)[FilterKey], string>; +// 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.USER_ID, +]; + export const defaultFilters: LogFilterState = { [FILTER_KEYS.TEAM_ID]: "", [FILTER_KEYS.KEY_HASH]: "", @@ -90,10 +101,16 @@ export function useLogFilterLogic({ sortOrder?: "asc" | "desc"; currentPage?: number; }) { - // Debounce filters so text inputs (Key Hash, Error Message) don't fire a - // request per keystroke. Dropdown selects get a 300ms delay too, which is - // imperceptible since the user just clicked an option. - const debouncedFilters = useDebouncedValue(filters, 300); + const [debouncedFilters, setDebouncedFilters] = useDebouncedValue(filters, 300); + + // 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({ queryKey: [ @@ -103,7 +120,8 @@ export function useLogFilterLogic({ pageSize, startTime, endTime, - debouncedFilters, + isCustomDate, + effectiveFilters, filterByCurrentUser ? userID : null, sortBy, sortOrder, @@ -131,16 +149,16 @@ export function useLogFilterLogic({ page: currentPage, page_size: pageSize, params: { - api_key: debouncedFilters[FILTER_KEYS.KEY_HASH] || undefined, - team_id: debouncedFilters[FILTER_KEYS.TEAM_ID] || undefined, - request_id: debouncedFilters[FILTER_KEYS.REQUEST_ID] || undefined, - user_id: debouncedFilters[FILTER_KEYS.USER_ID] || (filterByCurrentUser ? userID ?? undefined : undefined), - end_user: debouncedFilters[FILTER_KEYS.END_USER] || undefined, - status_filter: debouncedFilters[FILTER_KEYS.STATUS] || undefined, - model_id: debouncedFilters[FILTER_KEYS.MODEL] || undefined, - key_alias: debouncedFilters[FILTER_KEYS.KEY_ALIAS] || undefined, - error_code: debouncedFilters[FILTER_KEYS.ERROR_CODE] || undefined, - error_message: debouncedFilters[FILTER_KEYS.ERROR_MESSAGE] || undefined, + api_key: effectiveFilters[FILTER_KEYS.KEY_HASH] || undefined, + team_id: effectiveFilters[FILTER_KEYS.TEAM_ID] || undefined, + request_id: effectiveFilters[FILTER_KEYS.REQUEST_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, + key_alias: effectiveFilters[FILTER_KEYS.KEY_ALIAS] || undefined, + error_code: effectiveFilters[FILTER_KEYS.ERROR_CODE] || undefined, + error_message: effectiveFilters[FILTER_KEYS.ERROR_MESSAGE] || undefined, sort_by: sortBy, sort_order: sortOrder, }, @@ -189,6 +207,7 @@ export function useLogFilterLogic({ const handleFilterReset = () => { setFilters(defaultFilters); + setDebouncedFilters(defaultFilters); setCurrentPage(1); }; From 112b7c04daf15ce2fa63cc75e3079a843e53c5e3 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sun, 17 May 2026 15:25:33 -0700 Subject: [PATCH 12/13] fix(ui/spend-logs): restore lost loading/debounce behavior + cover dropped tests Regressions from the spend-logs-view refactor: - debounce the 'Public model / search tool' text filter (was firing a backend query per keystroke) via TEXT_FILTER_KEYS - restore Fetch-button smoothing through table repaint using useDeferredValue on the rendered data (explicit staleness) - show AntDLoadingSpinner during the auth-resolve phase instead of a blank screen on first load - only live-tail-poll while the tab is visible (refetchIntervalInBackground: false) - extract getLiveTailRefetchInterval helper for the poll decision Tests: - LogDetailContent: retries display (>0 / 0 / absent), overhead-absent - log_filter_logic: regression guard that the public-model filter debounces; getLiveTailRefetchInterval unit tests - logs_utils: getTimeRangeDisplay quick-select window labels --- .../LogDetailContent.test.tsx | 38 +++++- .../src/components/view_logs/index.tsx | 88 +++++++------- .../view_logs/log_filter_logic.test.tsx | 109 ++++++++++++------ .../components/view_logs/log_filter_logic.tsx | 16 ++- .../components/view_logs/logs_utils.test.tsx | 45 ++++++++ 5 files changed, 211 insertions(+), 85 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/view_logs/logs_utils.test.tsx diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx index a2da51367550..85a38e269779 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx @@ -172,12 +172,7 @@ describe("LogDetailContent", () => { }); it("should display loading state when isLoadingDetails is true", () => { - render( - , - ); + render(); expect(screen.getByText("Loading request & response data...")).toBeInTheDocument(); }); @@ -298,6 +293,37 @@ describe("LogDetailContent", () => { expect(screen.getByText("42.50 ms")).toBeInTheDocument(); }); + it("should not display LiteLLM Overhead when litellm_overhead_time_ms is absent from metadata", () => { + render(); + + expect(screen.queryByText("LiteLLM Overhead")).not.toBeInTheDocument(); + }); + + const retriesItem = () => screen.getByText("Retries").closest(".ant-descriptions-item") as HTMLElement; + + it("should display attempted_retries / max_retries for Retries when attempted_retries > 0", () => { + render( + , + ); + + expect(within(retriesItem()).getByText("2 / 3")).toBeInTheDocument(); + }); + + it("should display a green 'None' tag for Retries when attempted_retries is 0", () => { + render(); + + const noneTag = within(retriesItem()).getByText("None"); + expect(noneTag.closest(".ant-tag")).toHaveClass("ant-tag-green"); + }); + + it("should display '-' for Retries when attempted_retries is absent from metadata", () => { + render(); + + expect(within(retriesItem()).getByText("-")).toBeInTheDocument(); + }); + it("should display start and end time in ISO format", () => { render( ("startTime"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); - const [selectedTimeInterval, setSelectedTimeInterval] = useState<{ value: number; unit: string }>({ value: 24, unit: "hours", @@ -70,7 +64,6 @@ export default function SpendLogsTable({ sessionStorage.setItem("isLiveTail", JSON.stringify(isLiveTail)); }, [isLiveTail]); - useEffect(() => { const fetchKeyInfo = async () => { if (selectedKeyIdInfoView && accessToken) { @@ -87,7 +80,6 @@ export default function SpendLogsTable({ fetchKeyInfo(); }, [selectedKeyIdInfoView, accessToken]); - useEffect(() => { if (userRole && internalUserRoles.includes(userRole)) { setFilterByCurrentUser(true); @@ -129,14 +121,11 @@ export default function SpendLogsTable({ setCurrentPage(1); }, [handleFilterResetFromHook]); - const handleSortChange = useCallback( - (newSortBy: LogsSortField, newSortOrder: "asc" | "desc") => { - setSortBy(newSortBy); - setSortOrder(newSortOrder); - setCurrentPage(1); - }, - [], - ); + const handleSortChange = useCallback((newSortBy: LogsSortField, newSortOrder: "asc" | "desc") => { + setSortBy(newSortBy); + setSortOrder(newSortOrder); + setCurrentPage(1); + }, []); const columns = useMemo( () => createColumns({ sortBy, sortOrder, onSortChange: handleSortChange }), @@ -155,20 +144,23 @@ export default function SpendLogsTable({ 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; - }, {}); + 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. @@ -210,8 +202,19 @@ export default function SpendLogsTable({ ); }, [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; + if (!accessToken || !token || !userRole || !userID) { - return null; + return ( +
+ +
+ ); } const handleRowClick = (log: LogEntry) => { @@ -275,13 +278,13 @@ export default function SpendLogsTable({ onCurrentPageChange={setCurrentPage} pageSize={pageSize} isLoading={logsQuery.isLoading} - isButtonLoading={logsQuery.isFetching} + isButtonLoading={isButtonLoading} onRefetch={() => logsQuery.refetch()} filteredLogs={filteredLogs} /> @@ -299,15 +302,22 @@ export default function SpendLogsTable({ premiumUser={premiumUser} /> - - + + + + + + {/* Log Details Drawer */} { setIsDrawerOpen(false); setSelectedSessionId(null); }} + onClose={() => { + setIsDrawerOpen(false); + setSelectedSessionId(null); + }} logEntry={selectedLog} sessionId={selectedSessionId} accessToken={accessToken} 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 596276eadca5..cbe37e0b70f2 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 @@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { LogsSortField } from "./columns"; import { defaultFilters, + getLiveTailRefetchInterval, + LIVE_TAIL_INTERVAL_MS, useLogFilterLogic, type LogFilterState, type PaginatedResponse, @@ -134,24 +136,27 @@ describe("useLogFilterLogic", () => { 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, + 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 }); + ); + }, + { timeout: 500 }, + ); }); }); @@ -344,6 +349,35 @@ describe("useLogFilterLogic", () => { { timeout: 100 }, ); }); + + // 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(); + + 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", () => { @@ -484,9 +518,7 @@ describe("useLogFilterLogic", () => { rerender({ currentPage: 2 }); await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { timeout: 500 }); - expect(uiSpendLogsCall).toHaveBeenLastCalledWith( - expect.objectContaining({ page: 2 }), - ); + expect(uiSpendLogsCall).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })); }); it("refetches when startTime changes", async () => { @@ -509,9 +541,7 @@ describe("useLogFilterLogic", () => { 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" }), - ); + expect(uiSpendLogsCall).toHaveBeenLastCalledWith(expect.objectContaining({ start_date: "2025-01-02 00:00:00" })); }); it("refetches with a different end_date when isCustomDate toggles", async () => { @@ -552,20 +582,17 @@ describe("useLogFilterLogic", () => { { name: "userID", override: { userID: null } }, ]; - it.each(nullCredentialCases)( - "does not call uiSpendLogsCall when $name is null", - async ({ override }) => { - const { result } = renderFilterHook(override); + 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" }); - }); + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); - await new Promise((resolve) => setTimeout(resolve, 350)); + await new Promise((resolve) => setTimeout(resolve, 350)); - expect(uiSpendLogsCall).not.toHaveBeenCalled(); - }, - ); + expect(uiSpendLogsCall).not.toHaveBeenCalled(); + }); it("does not call uiSpendLogsCall when activeTab is not 'request logs'", async () => { const { result } = renderFilterHook({ activeTab: "audit logs" }); @@ -620,3 +647,17 @@ describe("useLogFilterLogic", () => { }); }); }); + +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("does not poll when live tail is off", () => { + expect(getLiveTailRefetchInterval(false, 1)).toBe(false); + }); + + it("does not poll when not on page 1, even with live tail on", () => { + expect(getLiveTailRefetchInterval(true, 2)).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 5258651df70c..d9830a83f6e7 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 @@ -15,10 +15,7 @@ export interface PaginatedResponse { total_pages: number; } -function useDebouncedValue( - value: T, - delayMs: number, -): [T, React.Dispatch>] { +function useDebouncedValue(value: T, delayMs: number): [T, React.Dispatch>] { const [debounced, setDebounced] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebounced(value), delayMs); @@ -52,8 +49,14 @@ const TEXT_FILTER_KEYS: readonly (keyof LogFilterState)[] = [ FILTER_KEYS.ERROR_MESSAGE, FILTER_KEYS.REQUEST_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]: "", @@ -172,9 +175,10 @@ export function useLogFilterLogic({ return response; }, enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs", - refetchInterval: isLiveTail && currentPage === 1 ? 15000 : false, + refetchInterval: getLiveTailRefetchInterval(isLiveTail, currentPage), placeholderData: keepPreviousData, - refetchIntervalInBackground: true, + // Only live-tail-poll while the tab is visible. + refetchIntervalInBackground: false, }); const filteredLogs: PaginatedResponse = logsQuery.data ?? { diff --git a/ui/litellm-dashboard/src/components/view_logs/logs_utils.test.tsx b/ui/litellm-dashboard/src/components/view_logs/logs_utils.test.tsx new file mode 100644 index 000000000000..b0b74df4c24c --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/logs_utils.test.tsx @@ -0,0 +1,45 @@ +import moment from "moment"; +import { describe, expect, it } from "vitest"; +import { getTimeRangeDisplay } from "./logs_utils"; + +// startTime built relative to "now"; getTimeRangeDisplay computes now() internally. +const ago = (amount: number, unit: moment.unitOfTime.DurationConstructor) => + moment().subtract(amount, unit).toISOString(); + +describe("getTimeRangeDisplay", () => { + it("labels a ~1-minute window as 'Last 1 Minute'", () => { + expect(getTimeRangeDisplay(false, ago(1, "minutes"), "")).toBe("Last 1 Minute"); + }); + + it("labels a ~10-minute window as 'Last 15 Minutes'", () => { + expect(getTimeRangeDisplay(false, ago(10, "minutes"), "")).toBe("Last 15 Minutes"); + }); + + it("labels a ~30-minute window as 'Last Hour'", () => { + expect(getTimeRangeDisplay(false, ago(30, "minutes"), "")).toBe("Last Hour"); + }); + + it("labels a ~2-hour window as 'Last 4 Hours'", () => { + expect(getTimeRangeDisplay(false, ago(2, "hours"), "")).toBe("Last 4 Hours"); + }); + + it("labels a ~10-hour window as 'Last 24 Hours'", () => { + expect(getTimeRangeDisplay(false, ago(10, "hours"), "")).toBe("Last 24 Hours"); + }); + + it("labels a ~3-day window as 'Last 7 Days'", () => { + expect(getTimeRangeDisplay(false, ago(3, "days"), "")).toBe("Last 7 Days"); + }); + + it("falls back to a 'MMM D - MMM D' range beyond 7 days", () => { + const label = getTimeRangeDisplay(false, ago(30, "days"), ""); + expect(label).toMatch(/^[A-Z][a-z]{2} \d{1,2} - [A-Z][a-z]{2} \d{1,2}$/); + }); + + it("renders an explicit start - end range when isCustomDate is true", () => { + const start = "2025-01-02T03:04:00Z"; + const end = "2025-01-05T06:07:00Z"; + const expected = `${moment(start).format("MMM D, h:mm A")} - ${moment(end).format("MMM D, h:mm A")}`; + expect(getTimeRangeDisplay(true, start, end)).toBe(expected); + }); +}); From 40e7ef3d2aed358c13bbe65419f184ed6274a473 Mon Sep 17 00:00:00 2001 From: Ryan Crabbe Date: Sun, 17 May 2026 15:38:38 -0700 Subject: [PATCH 13/13] test(ui/spend-logs): cover the cold-load auth-not-ready spinner guard Asserts SpendLogsTable shows a loading spinner (not a blank screen) while credentials are unresolved, and renders the table once present. --- .../src/components/view_logs/index.test.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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 2d666c86bfa2..aed194a29722 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.test.tsx @@ -72,7 +72,9 @@ describe("SpendLogsTable", () => { 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 }); + 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 @@ -97,4 +99,20 @@ describe("SpendLogsTable", () => { expect(inputsAfterReset.length).toBe(0); }); }); + + describe("auth-not-ready guard", () => { + it("shows a loading spinner when credentials are not yet resolved", () => { + renderWithProviders(); + + expect(document.querySelector(".ant-spin")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reset Filters" })).not.toBeInTheDocument(); + }); + + it("renders the table (no spinner) once all credentials are present", () => { + renderWithProviders(); + + expect(document.querySelector(".ant-spin")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reset Filters" })).toBeInTheDocument(); + }); + }); });