diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index bda369e8d3..345bcecca2 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1727,11 +1727,6 @@ async def ui_view_spend_logs( # noqa: PLR0915 # Calculate skip value for pagination skip = (page - 1) * page_size - # Get total count of records - total_records = await prisma_client.db.litellm_spendlogs.count( - where=where_conditions, - ) - # Get paginated data data = await prisma_client.db.litellm_spendlogs.find_many( where=where_conditions, @@ -1742,6 +1737,24 @@ async def ui_view_spend_logs( # noqa: PLR0915 take=page_size, ) + if status_filter: + status_lower = status_filter.strip().lower() + + def status_filter_fn(row): + metadata = getattr(row, "metadata", {}) or {} + status_val = metadata.get("status") if isinstance(metadata, dict) else None + if status_lower == "failure": + return status_val == "failure" + elif status_lower == "success": + return status_val != "failure" + return True + + data = list(filter(status_filter_fn, data)) + + # Get total count of records + total_records = await prisma_client.db.litellm_spendlogs.count( + where=where_conditions, + ) # Calculate total pages total_pages = (total_records + page_size - 1) // page_size diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 944abdb76a..4c913bffc4 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -88,6 +88,7 @@ export default function SpendLogsTable({ pageSize, userID, userRole, + autoRefreshInterval: 5000, }); const sessionLogs = useQuery<{data: LogEntry[]}>({ 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 60cdf8dedb..627c59225c 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,9 +2,9 @@ import { useCallback, useEffect, useState, useRef, useMemo } from "react"; import { LogEntry } from "./columns"; import { uiSpendLogsCall, Organization, Team, UserInfo, teamListCall, userListCall, keyListCall as fetchAllKeysCall, modelAvailableCall } from "../networking"; import { KeyResponse } from "../key_team_helpers/key_list"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient, QueryKey } from "@tanstack/react-query"; import { Setter } from "@/types"; -import { debounce } from "lodash"; +// import { debounce } from "lodash"; // No longer needed import { defaultPageSize } from "../constants"; import moment from "moment"; import { fetchAllKeyAliases, fetchAllTeams } from "../key_team_helpers/filter_helpers"; // Import fetchAllTeams @@ -31,6 +31,16 @@ export interface PaginationState { pageSize: number; } +// Define the expected response structure from uiSpendLogsCall for type safety +interface SpendLogsResponse { + data: LogEntry[]; + page: number; + total_pages: number; + total: number; + page_size: number; +} + + export function useLogFilterLogic({ accessToken, startTime, // Receive from SpendLogsTable @@ -39,7 +49,8 @@ export function useLogFilterLogic({ initialPage = 1, initialFilters = {}, userID, - userRole + userRole, + autoRefreshInterval // Added for auto-refresh }: { accessToken: string | null; startTime: string; @@ -49,6 +60,7 @@ export function useLogFilterLogic({ initialFilters?: Partial; userID: string | null; userRole: string | null; + autoRefreshInterval?: number; // Added for auto-refresh }) { const defaultFilters: LogFilterState = { 'Team ID': '', @@ -67,38 +79,22 @@ export function useLogFilterLogic({ const [currentPage, setCurrentPage] = useState(initialPage); const queryClient = useQueryClient(); - // States for manually managed data, loading, and error - const [logEntries, setLogEntries] = useState([]); - const [paginationDetails, setPaginationDetails] = useState({ - currentPage: initialPage, - totalPages: 0, - totalCount: 0, - pageSize - }); - const [isLoadingLogs, setIsLoadingLogs] = useState(false); - const [logsError, setLogsError] = useState(null); - const lastSearchTimestamp = useRef(0); - - const queryAllKeysQuery = useQuery({ - queryKey: ['allKeysForLogFilters', accessToken], // Ensure unique queryKey + const queryAllKeysQuery = useQuery({ + queryKey: ['allKeysForLogFilters', accessToken], queryFn: async () => { if (!accessToken) throw new Error('Access token required'); return await fetchAllKeyAliases(accessToken); }, enabled: !!accessToken }); - const allKeyAliases = queryAllKeysQuery.data || [] + const allKeyAliases = queryAllKeysQuery.data || []; - - // 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, }); @@ -107,8 +103,7 @@ export function useLogFilterLogic({ queryKey: ["allUsersForLogFilters", accessToken], queryFn: async () => { if (!accessToken) return []; - // Assuming userListCall fetches all users. Adapt if pagination or specific params are needed. - const response = await userListCall(accessToken, null, 1, 1000); // Fetch a large number for dropdown + const response = await userListCall(accessToken, null, 1, 100); return response.users || []; }, enabled: !!accessToken, @@ -118,113 +113,67 @@ export function useLogFilterLogic({ queryKey: ['allModels', accessToken, userID, userRole], queryFn: async () => { if (!accessToken || !userID || !userRole) return []; - const response = await modelAvailableCall( accessToken, userID, userRole, - false, // return_wildcard_routes - null // teamID + false, + null ); - return response.data.map((model: { id: string }) => model.id); }, enabled: !!accessToken && !!userID && !!userRole, }); - // Debounced API call - const debouncedSearch = useCallback( - debounce(async (currentFilters: LogFilterState, pageToFetch: number) => { - if (!accessToken) { - setLogEntries([]); - setPaginationDetails({ currentPage: pageToFetch, totalPages: 0, totalCount: 0, pageSize }); - setIsLoadingLogs(false); - setLogsError(null); - return; - } + const logsQueryKey: QueryKey = ['spendLogs', accessToken, filters, currentPage, startTime, endTime, pageSize, userID, userRole]; - const currentTimestamp = Date.now(); - lastSearchTimestamp.current = currentTimestamp; - setIsLoadingLogs(true); - setLogsError(null); + const logsQueryFn = async (): Promise => { + const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"); + const formattedEndTime = moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss"); - try { - const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"); - const formattedEndTime = moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss"); + const apiKeyParam = filters['Key Hash'] || undefined; + const teamIdParam = filters['Team ID'] || undefined; + const requestIdParam = filters['Request ID'] || undefined; + const userIdParamFilter = filters['User'] || undefined; + const statusParam = filters['Status'] || undefined; + const modelParam = filters['Model'] || undefined; - const apiKeyParam = currentFilters['Key Hash'] || undefined; - const teamIdParam = currentFilters['Team ID'] || undefined; - const requestIdParam = currentFilters['Request ID'] || undefined; - const userIdParam = currentFilters['User'] || undefined; - const statusParam = currentFilters['Status'] || undefined; - const modelParam = currentFilters['Model'] || undefined; - - const response = await uiSpendLogsCall( - accessToken, - apiKeyParam, - teamIdParam, - requestIdParam, - formattedStartTime, - formattedEndTime, - pageToFetch, - pageSize, - userIdParam, - statusParam, - modelParam, - ); - - if (currentTimestamp === lastSearchTimestamp.current) { - if (response && response.data) { - setLogEntries(response.data); - setPaginationDetails({ - currentPage: response.page, - totalPages: response.total_pages, - totalCount: response.total, - pageSize: response.page_size || pageSize, - }); - } else { - setLogEntries([]); - setPaginationDetails({ currentPage: pageToFetch, totalPages: 0, totalCount: 0, pageSize }); - } - } - } catch (error: any) { - if (currentTimestamp === lastSearchTimestamp.current) { - console.error("Error fetching logs:", error); - setLogsError(error); - setLogEntries([]); - setPaginationDetails({ currentPage: pageToFetch, totalPages: 0, totalCount: 0, pageSize }); - } - } finally { - if (currentTimestamp === lastSearchTimestamp.current) { - setIsLoadingLogs(false); - } - } - }, 300), - [accessToken, startTime, endTime, pageSize, setLogEntries, setPaginationDetails, setIsLoadingLogs, setLogsError] - ); - - // useEffect to trigger debouncedSearch when dependencies change - useEffect(() => { - if (accessToken) { - debouncedSearch(filters, currentPage); - } else { - // Clear data if accessToken is lost or not present - setLogEntries([]); - setPaginationDetails(prev => ({ ...prev, currentPage, totalPages: 0, totalCount: 0 })); - setIsLoadingLogs(false); - setLogsError(null); + if (!accessToken) { + // This case should ideally be prevented by the `enabled` option + // Returning a structure that matches SpendLogsResponse to satisfy Promise type + return { data: [], page: 1, total_pages: 0, total: 0, page_size: pageSize }; } - // Cleanup function for debounce - return () => { - debouncedSearch.cancel(); - }; - }, [accessToken, filters, currentPage, startTime, endTime, pageSize, debouncedSearch]); + + const response = await uiSpendLogsCall( + accessToken, + apiKeyParam, + teamIdParam, + requestIdParam, + formattedStartTime, + formattedEndTime, + currentPage, + pageSize, + userIdParamFilter, + statusParam, + modelParam, + ); + // Assuming uiSpendLogsCall returns a type compatible with SpendLogsResponse + return response as SpendLogsResponse; + }; + const logsQuery = useQuery({ + queryKey: logsQueryKey, + queryFn: logsQueryFn, + enabled: !!accessToken && !!userID && !!userRole, + refetchInterval: autoRefreshInterval && autoRefreshInterval > 0 ? autoRefreshInterval : undefined, + refetchIntervalInBackground: true, + placeholderData: (previousData) => previousData, + notifyOnChangeProps: ['data', 'error'], + }); const handleFilterChange = (newFilters: Partial) => { setFilters(prev => { - const updatedFilters = { ...prev, ...newFilters }; // Simpler update - // Ensure all keys in LogFilterState are present, defaulting to '' if not in newFilters + const updatedFilters = { ...prev, ...newFilters }; for (const key of Object.keys(defaultFilters) as Array) { if (!(key in updatedFilters)) { updatedFilters[key] = defaultFilters[key]; @@ -232,44 +181,58 @@ export function useLogFilterLogic({ } return updatedFilters as LogFilterState; }); - setCurrentPage(1); // Reset to first page when filters change - // debouncedSearch will be called by the useEffect hook + setCurrentPage(1); }; const handleFilterReset = () => { setFilters(defaultFilters); setCurrentPage(1); - // debouncedSearch will be called by the useEffect hook }; + + const pagination: PaginationState = useMemo(() => { + const data: SpendLogsResponse | undefined = logsQuery.data; + return { + currentPage: data?.page ?? currentPage, + totalPages: data?.total_pages ?? 0, + totalCount: data?.total ?? 0, + pageSize: data?.page_size ?? pageSize, + }; + }, [logsQuery.data, currentPage, pageSize]); - const pagination: PaginationState = { - currentPage: paginationDetails.currentPage, - totalPages: paginationDetails.totalPages, - totalCount: paginationDetails.totalCount, - pageSize: paginationDetails.pageSize, - }; + const [isManualRefreshing, setIsManualRefreshing] = useState(false); const handleRefresh = () => { - // Reset to first page - setCurrentPage(1); - // The useEffect in useLogFilterLogic will automatically trigger a refetch - // when currentPage changes + setIsManualRefreshing(true); // <-- ADD THIS LINE + // First invalidate all the filter queries + queryClient.invalidateQueries({ queryKey: ['allKeysForLogFilters'] }); + queryClient.invalidateQueries({ queryKey: ['allTeamsForLogFilters'] }); + queryClient.invalidateQueries({ queryKey: ['allUsersForLogFilters'] }); + queryClient.invalidateQueries({ queryKey: ['allModels'] }); + + // Force an immediate refetch of the logs query + queryClient.refetchQueries({ + queryKey: logsQueryKey, + exact: true, + type: 'active' + }).then(() => { + setIsManualRefreshing(false); // Reset loading state when refresh completes + }); }; return { filters, - filteredLogs: logEntries, + filteredLogs: logsQuery.data?.data || [], allKeyAliases, allTeams: allTeams || [], allUsers: allUsers || [], - allOrganizations: [], // Placeholder for now, can be fetched if needed + allOrganizations: [], allModels: allModels || [], handleFilterChange, handleFilterReset, - isLoading: isLoadingLogs, + isLoading: logsQuery.isFetching || isManualRefreshing, pagination, - setCurrentPage, - error: logsError, + setCurrentPage, + error: logsQuery.error, handleRefresh }; } \ No newline at end of file