diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 7f52d6b9da..9f199ec8ac 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -91,6 +91,11 @@ 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(() => { @@ -212,7 +217,7 @@ export default function SpendLogsTable({ return response; }, - enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs", + enabled: !!accessToken && !!token && !!userRole && !!userID && activeTab === "request logs" && isMainQueryEnabled, refetchInterval: isLiveTail && currentPage === 1 ? 15000 : false, placeholderData: keepPreviousData, refetchIntervalInBackground: true, @@ -235,6 +240,7 @@ export default function SpendLogsTable({ const { filters, filteredLogs, + hasBackendFilters, allTeams: hookAllTeams, allKeyAliases, handleFilterChange, @@ -254,25 +260,6 @@ export default function SpendLogsTable({ currentPage, }); - const fetchKeyHashForAlias = useCallback( - async (keyAlias: string) => { - if (!accessToken) return; - - try { - const response = await keyListCall(accessToken, null, null, keyAlias, null, null, currentPage, pageSize); - - const selectedKey = response.keys.find((key: any) => key.key_alias === keyAlias); - - if (selectedKey) { - setSelectedKeyHash(selectedKey.token); - } - } catch (error) { - console.error("Error fetching key hash for alias:", error); - } - }, - [accessToken, currentPage, pageSize], - ); - const handleFilterReset = useCallback(() => { handleFilterResetFromHook(); // Reset custom time range to default (last 24 hours) @@ -283,7 +270,13 @@ export default function SpendLogsTable({ setCurrentPage(1); }, [handleFilterResetFromHook]); - // Add this effect to update selected filters when filter changes + // 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; @@ -296,14 +289,11 @@ export default function SpendLogsTable({ setSelectedModelId(filters["Model"] || ""); setSelectedEndUser(filters["End User"] || ""); - if (filters["Key Hash"]) { - setSelectedKeyHash(filters["Key Hash"]); - } else if (filters["Key Alias"]) { - fetchKeyHashForAlias(filters["Key Alias"]); - } else { - setSelectedKeyHash(""); - } - }, [filters, accessToken, fetchKeyHashForAlias]); + // 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; @@ -592,6 +582,7 @@ export default function SpendLogsTable({ className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 rounded-md ${displayLabel === option.label ? "bg-blue-50 text-blue-600" : "" }`} onClick={() => { + setCurrentPage(1); setEndTime(moment().format("YYYY-MM-DDTHH:mm")); setStartTime( moment() @@ -694,7 +685,7 @@ export default function SpendLogsTable({ - {isLiveTail && currentPage === 1 && ( + {isLiveTail && currentPage === 1 && isMainQueryEnabled && (
Auto-refreshing every 15 seconds 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 da4822d018..0b9cd59b9a 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 @@ -570,6 +570,63 @@ describe("useLogFilterLogic", () => { ); }); + it("should refetch when startTime changes and backend filters are active", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([createLogEntry()]), + ); + const logs = createPaginatedResponse([createLogEntry()]); + const { result, rerender } = renderHook( + (props: { startTime?: string }) => + useLogFilterLogic({ ...defaultProps, logs, ...props }), + { 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" }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { + timeout: 500, + }); + expect(uiSpendLogsCall).toHaveBeenLastCalledWith( + expect.objectContaining({ + start_date: "2025-01-02 00:00:00", + }), + ); + }); + + it("should refetch when isCustomDate changes and backend filters are active", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue( + createPaginatedResponse([createLogEntry()]), + ); + const logs = createPaginatedResponse([createLogEntry()]); + const { result, rerender } = renderHook( + (props: { isCustomDate?: boolean }) => + useLogFilterLogic({ ...defaultProps, logs, ...props }), + { wrapper, initialProps: { isCustomDate: false } }, + ); + + act(() => { + result.current.handleFilterChange({ "Key Alias": "alias-1" }); + }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(1), { + timeout: 500, + }); + + rerender({ isCustomDate: true }); + + await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalledTimes(2), { + timeout: 500, + }); + }); + it("should not call setCurrentPage when handleFilterChange receives identical filters", async () => { const setCurrentPage = vi.fn(); const logs = createPaginatedResponse([createLogEntry()]); 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 d9323b03af..097519d2f3 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 @@ -158,12 +158,20 @@ export function useLogFilterLogic({ [filters], ); - // Refetch when sort or page changes (backend filters use their own fetch, not the main query) + // 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); } - }, [sortBy, sortOrder, 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 incoming logs and filters const clientDerivedFilteredLogs: PaginatedResponse = useMemo(() => { @@ -301,6 +309,7 @@ export function useLogFilterLogic({ return { filters, filteredLogs, + hasBackendFilters, allKeyAliases, allTeams, handleFilterChange,