From 9b3595434713e7ec26adc697e1f6fc1da3ae9ed2 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 15 Sep 2026 14:10:23 -0700 Subject: [PATCH 1/2] fix(ui): block usage export and flag the range when a spend page fails The Usage page drains the daily activity endpoint page by page. A page that threw was only logged to the console: the loading banner disappeared, the partial totals stayed on screen looking final, and Export Data stayed clickable, so the CSV handed to finance was silently short. The hook now reports `failed`, PaginationStatusAlerts renders it as an error banner naming how many pages actually loaded, and the export is blocked with the reason on hover while the data on screen does not cover the range. --- .../_components/CacheLeakageCard.test.tsx | 1 + .../_components/CostOptimizationView.tsx | 1 + .../_components/PromptCachingTab.test.tsx | 1 + .../_components/UsageTab.test.tsx | 1 + .../_components/useDailyActivityRange.ts | 4 +- .../components/EntityUsage/EntityUsage.tsx | 9 +++ .../_components/components/UsagePageView.tsx | 24 +++++-- .../hooks/usePaginatedDailyActivity.test.ts | 66 +++++++++++++++++++ .../hooks/usePaginatedDailyActivity.ts | 8 ++- .../UsageExportHeader.test.tsx | 21 ++++++ .../EntityUsageExport/UsageExportHeader.tsx | 13 ++-- .../exportBlockedReason.test.ts | 39 +++++++++++ .../EntityUsageExport/exportBlockedReason.ts | 21 ++++++ .../shared/PaginationStatusAlerts.test.tsx | 31 +++++++++ .../shared/PaginationStatusAlerts.tsx | 12 +++- .../KeySavingsTab.integration.test.tsx | 1 + 16 files changed, 242 insertions(+), 11 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts create mode 100644 ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx index f320d8e0f97..54af13d8a90 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx @@ -71,6 +71,7 @@ const renderWith = (results: DailyData[], overrides: Partial isFetchingMore: false, progress: { currentPage: 1, totalPages: 1 }, cancelled: false, + failed: false, cancel: vi.fn(), ...overrides, }} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx index 8094fa2e8b6..25bd3de0382 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx @@ -87,6 +87,7 @@ const CostOptimizationView: React.FC = ({ accessToken diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx index 2c602033171..66db347e70f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx @@ -35,6 +35,7 @@ describe("PromptCachingTab", () => { isFetchingMore: false, progress: { currentPage: 1, totalPages: 1 }, cancelled: false, + failed: false, cancel: vi.fn(), }; render(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx index f85a667a074..c62208aacc5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx @@ -123,6 +123,7 @@ const renderWith = (results: DailyData[], options: RenderOptions = {}) => { isFetchingMore: false, progress: { currentPage: 1, totalPages: 1 }, cancelled: false, + failed: false, cancel: vi.fn(), }} />, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts index 3435b57dbc8..9f793a68bf5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts @@ -20,6 +20,7 @@ export interface DailyActivityRange { isFetchingMore: boolean; progress: { currentPage: number; totalPages: number }; cancelled: boolean; + failed: boolean; cancel: () => void; } @@ -64,7 +65,7 @@ export const useScopedDailyActivityRange = ( args: [accessToken, startTime, endTime, userId, true, apiKey], enabled: !!accessToken && !!startTime && !!endTime, }; - const { data, loading, isFetchingMore, progress, cancelled, cancel } = + const { data, loading, isFetchingMore, progress, cancelled, failed, cancel } = usePaginatedDailyActivity(activityQueryOptions); return { @@ -75,6 +76,7 @@ export const useScopedDailyActivityRange = ( isFetchingMore, progress, cancelled, + failed, cancel, }; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 6c15b3c418d..debc5602277 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -25,6 +25,7 @@ import TeamMultiSelect from "@/components/common_components/team_multi_select"; import UserDropdown from "@/components/common_components/UserDropdown"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; import { UsageExportHeader } from "@/components/EntityUsageExport"; +import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason"; import type { EntityType } from "@/components/EntityUsageExport/types"; import { agentDailyActivityCall, @@ -145,9 +146,11 @@ const EntityUsage: React.FC = ({ const { data: spendDataRaw, + loading, isFetchingMore, progress, cancelled, + failed, cancel, } = usePaginatedDailyActivity({ fetchFn, @@ -163,6 +166,7 @@ const EntityUsage: React.FC = ({ isFetchingMore: agentIsFetchingMore, progress: agentProgress, cancelled: agentCancelled, + failed: agentFailed, cancel: agentCancel, } = usePaginatedDailyActivity({ fetchFn: agentDailyActivityCall, @@ -660,11 +664,14 @@ const EntityUsage: React.FC = ({ { key: "endpoints", label: "Endpoint Activity", content: }, ]; + const spendFetchState = { loading, isFetchingMore, cancelled, failed }; + return (
@@ -672,6 +679,7 @@ const EntityUsage: React.FC = ({ = ({ onFiltersChange={setSelectedTags} filterOptions={getAllTags() || undefined} teams={teams || []} + exportBlockedReason={getExportBlockedReason(spendFetchState)} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index de353948db9..31c9288c911 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -30,6 +30,7 @@ import { ActivityMetrics, processActivityData } from "@/components/activity_metr import CloudZeroExportModal from "@/components/cloudzero_export_modal"; import UserDropdown from "@/components/common_components/UserDropdown"; import EntityUsageExportModal from "@/components/EntityUsageExport"; +import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason"; import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel"; import { Team } from "@/components/key_team_helpers/key_list"; import { @@ -249,6 +250,14 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const loading = aggregatedLoading || paginatedResult.loading; + const spendFetchState = { + loading, + isFetchingMore: paginatedResult.isFetchingMore, + cancelled: paginatedResult.cancelled, + failed: paginatedResult.failed, + }; + const exportBlockedReason = getExportBlockedReason(spendFetchState); + // Clear isDateChanging when paginated data starts arriving useEffect(() => { if (aggregatedFailed && !paginatedResult.loading && paginatedResult.data.results.length > 0) { @@ -489,6 +498,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { @@ -525,10 +535,16 @@ const UsagePage: React.FC = ({ teams, organizations }) => { Ask AI - + + +
{/* Cost Panel */} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts index 0537f469920..5257e5fc8a6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts @@ -156,3 +156,69 @@ describe("usePaginatedDailyActivity page accumulation", () => { expect(result.current.data.metadata.total_spend).toBe(5.5); }); }); + +describe("usePaginatedDailyActivity failure reporting", () => { + const firstPage = { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 1, total_spend: 2 } }; + const start = new Date("2026-08-10"); + const end = new Date("2026-08-17"); + + it("reports a failed range so partial totals cannot pass as the whole range", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => + page === 1 ? Promise.resolve(firstPage) : Promise.reject(new Error("page 2 never came back")), + ); + + const { result } = renderHook(() => + usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), + ); + + await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 }); + + expect(result.current.isFetchingMore).toBe(false); + expect(result.current.loading).toBe(false); + expect(result.current.data.metadata.total_spend).toBe(2); + consoleError.mockRestore(); + }); + + it("stays unfailed when every page arrives", async () => { + const pages = [ + firstPage, + { results: [dayOf("2026-08-15", 1)], metadata: { total_pages: 2, page: 2, total_spend: 1 } }, + ]; + const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => + Promise.resolve({ ...pages[page - 1], metadata: { ...pages[page - 1].metadata, total_pages: 2 } }), + ); + + const { result } = renderHook(() => + usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), + ); + + await waitFor(() => expect(result.current.data.metadata.page).toBe(2), { timeout: 5000 }); + + expect(result.current.failed).toBe(false); + }); + + it("clears the failure when a new range is requested, so the banner cannot outlive it", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const fetchFn = vi.fn((...callArgs: unknown[]) => { + const [, , , page, filter] = callArgs as [string, Date, Date, number, string | null]; + if (filter !== "broken") + return Promise.resolve({ ...firstPage, metadata: { ...firstPage.metadata, total_pages: 1 } }); + if (page === 1) return Promise.resolve({ ...firstPage, metadata: { ...firstPage.metadata, total_pages: 2 } }); + return Promise.reject(new Error("page 2 never came back")); + }); + + const { result, rerender } = renderHook( + ({ filter }: { filter: string | null }) => + usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, filter], enabled: true }), + { initialProps: { filter: "broken" as string | null } }, + ); + + await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 }); + + rerender({ filter: "healthy" }); + + await waitFor(() => expect(result.current.failed).toBe(false), { timeout: 5000 }); + consoleError.mockRestore(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts index e023feda2e3..aef0021cc3d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts @@ -61,6 +61,8 @@ interface UsePaginatedDailyActivityReturn { isFetchingMore: boolean; progress: PaginationProgress; cancelled: boolean; + /** A page request threw, so `data` covers only part of the requested range. */ + failed: boolean; cancel: () => void; } @@ -200,6 +202,7 @@ export function usePaginatedDailyActivity({ totalPages: 0, }); const [cancelled, setCancelled] = useState(false); + const [failed, setFailed] = useState(false); const fetchIdRef = useRef(0); const cancelledRef = useRef(false); @@ -230,12 +233,14 @@ export function usePaginatedDailyActivity({ setIsFetchingMore(false); setProgress({ currentPage: 0, totalPages: 0 }); setCancelled(false); + setFailed(false); return; } const currentFetchId = ++fetchIdRef.current; cancelledRef.current = false; setCancelled(false); + setFailed(false); const isStale = () => fetchIdRef.current !== currentFetchId || cancelledRef.current; @@ -333,6 +338,7 @@ export function usePaginatedDailyActivity({ console.error("Error fetching daily activity:", error); setLoading(false); setIsFetchingMore(false); + setFailed(true); } } }; @@ -350,5 +356,5 @@ export function usePaginatedDailyActivity({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, fetchFn, aggregatedFetchFn, argsKey]); - return { data, loading, isFetchingMore, progress, cancelled, cancel }; + return { data, loading, isFetchingMore, progress, cancelled, failed, cancel }; } diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx index 52fc7605d90..460646dc39c 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx @@ -41,6 +41,27 @@ describe("UsageExportHeader", () => { expect(screen.getByTestId("export-modal")).toBeInTheDocument(); }); + it("blocks the export while the data on screen does not cover the range", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + const exportButton = screen.getByRole("button", { name: /export data/i }); + expect(exportButton).toBeDisabled(); + await user.click(exportButton); + expect(screen.queryByTestId("export-modal")).not.toBeInTheDocument(); + }); + + it("explains why the export is blocked on hover", () => { + renderWithProviders(); + + expect(screen.getByTitle("Spend data is still loading")).toBeInTheDocument(); + }); + it("should close the export modal when onClose is called", async () => { const user = userEvent.setup(); renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index f5bb56265ed..3c4c7f6f4ee 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -34,6 +34,8 @@ interface UsageExportHeaderProps { customTitle?: string; compactLayout?: boolean; teams?: Team[]; + /** Set to block the export and explain why; see getExportBlockedReason. */ + exportBlockedReason?: string; } const UsageExportHeader: React.FC = ({ @@ -50,6 +52,7 @@ const UsageExportHeader: React.FC = ({ customTitle, compactLayout = false, teams = [], + exportBlockedReason, }) => { const anchor = useComboboxAnchor(); const [isExportModalOpen, setIsExportModalOpen] = useState(false); @@ -121,10 +124,12 @@ const UsageExportHeader: React.FC = ({ )}
- + + +
diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts new file mode 100644 index 00000000000..0b32aef52fa --- /dev/null +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason"; + +const state = (overrides: Partial = {}): UsageFetchState => ({ + loading: false, + isFetchingMore: false, + cancelled: false, + failed: false, + ...overrides, +}); + +describe("getExportBlockedReason", () => { + it("lets the export through once the range has fully loaded", () => { + expect(getExportBlockedReason(state())).toBeUndefined(); + }); + + it("blocks the first load, before any page has arrived", () => { + expect(getExportBlockedReason(state({ loading: true }))).toMatch(/still loading/i); + }); + + it("blocks while later pages are still arriving, which is when a CSV silently under-reports", () => { + expect(getExportBlockedReason(state({ isFetchingMore: true }))).toMatch(/still loading/i); + }); + + it("blocks after a stopped fetch and says a reload is what fixes it", () => { + const reason = getExportBlockedReason(state({ cancelled: true })); + + expect(reason).toMatch(/stopped/i); + expect(reason).toMatch(/reload/i); + }); + + it("blocks after a failed page and names the failure rather than the stop", () => { + const reason = getExportBlockedReason(state({ failed: true, cancelled: true })); + + expect(reason).toMatch(/failed to load/i); + expect(reason).not.toMatch(/stopped/i); + }); +}); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts new file mode 100644 index 00000000000..ebe67f1ccfe --- /dev/null +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts @@ -0,0 +1,21 @@ +export interface UsageFetchState { + loading: boolean; + isFetchingMore: boolean; + cancelled: boolean; + failed: boolean; +} + +/** Why exporting what is on screen would under-report, or undefined once it covers the whole range. */ +export const getExportBlockedReason = ({ + loading, + isFetchingMore, + cancelled, + failed, +}: UsageFetchState): string | undefined => { + if (failed) return "Some spend data failed to load, so an export would under-report. Reload the page to try again."; + if (cancelled) + return "Loading was stopped before the whole range arrived, so an export would under-report. Reload the page to load it all."; + if (loading || isFetchingMore) + return "Spend data is still loading, so an export would under-report. Wait for it to finish."; + return undefined; +}; diff --git a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx index 5bd48b1aa4c..1eefc43ecb4 100644 --- a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx @@ -33,6 +33,37 @@ describe("PaginationStatusAlerts", () => { expect(screen.getByText("Showing partial spend data (7/42 pages loaded)")).toBeInTheDocument(); }); + it("calls out a failed page as an error so partial totals do not read as final", () => { + render( + , + ); + + expect( + screen.getByText(/Fetching spend data failed, so the totals below cover only part of the range \(7\/42 pages/), + ).toBeInTheDocument(); + }); + + it("shows only the failure when a stopped fetch also failed", () => { + render( + , + ); + + expect(screen.getByText(/Fetching spend data failed/)).toBeInTheDocument(); + expect(screen.queryByText(/Showing partial spend data/)).not.toBeInTheDocument(); + }); + it("names the subject it is fetching", () => { render( void; subject?: string; + failed?: boolean; } const PaginationStatusAlerts = ({ @@ -17,6 +18,7 @@ const PaginationStatusAlerts = ({ progress, cancel, subject = "spend data", + failed = false, }: PaginationStatusAlertsProps) => ( <> {isFetchingMore && ( @@ -38,7 +40,15 @@ const PaginationStatusAlerts = ({ )} - {cancelled && ( + {failed && ( + + + Fetching {subject} failed, so the totals below cover only part of the range ({progress.currentPage}/ + {progress.totalPages} pages loaded). Reload the page to try again. + + + )} + {cancelled && !failed && ( Showing partial {subject} ({progress.currentPage}/{progress.totalPages} pages loaded) diff --git a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx index 385c3967d02..00cd1e47e10 100644 --- a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx @@ -40,6 +40,7 @@ const mockActivity = ( isFetchingMore: false, progress: { currentPage: 1, totalPages: 1 }, cancelled: false, + failed: false, cancel: vi.fn(), ...overrides, }); From 69c32122200131efe14ac5ca4b70e3609e46b910 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 15 Sep 2026 16:21:37 -0700 Subject: [PATCH 2/2] fix(ui): gate the usage export on range coverage, not on a fetch being in flight A loading flag only flips once the fetch effect runs, so the render right after a date or filter change still reported the previous range as loaded and let an export read its rows. Stamp the completed range on the hook and compare it during render instead, the way the tiles already do. Also stop the failure banner claiming a page loaded when the first request is what failed, which left it reading 1/1. --- .../components/EntityUsage/EntityUsage.tsx | 4 +- .../_components/components/UsagePageView.tsx | 5 +- .../hooks/usePaginatedDailyActivity.test.ts | 95 +++++++++++++++++++ .../hooks/usePaginatedDailyActivity.ts | 16 +++- .../EntityUsageExport/UsageExportHeader.tsx | 1 - .../exportBlockedReason.test.ts | 17 ++-- .../EntityUsageExport/exportBlockedReason.ts | 14 +-- .../shared/PaginationStatusAlerts.test.tsx | 17 +++- .../shared/PaginationStatusAlerts.tsx | 10 +- 9 files changed, 144 insertions(+), 35 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index debc5602277..6687bd4df03 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -146,11 +146,11 @@ const EntityUsage: React.FC = ({ const { data: spendDataRaw, - loading, isFetchingMore, progress, cancelled, failed, + coversRange, cancel, } = usePaginatedDailyActivity({ fetchFn, @@ -664,7 +664,7 @@ const EntityUsage: React.FC = ({ { key: "endpoints", label: "Endpoint Activity", content: }, ]; - const spendFetchState = { loading, isFetchingMore, cancelled, failed }; + const spendFetchState = { coversRange, cancelled, failed }; return (
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 31c9288c911..691c5dc839a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -250,9 +250,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { const loading = aggregatedLoading || paginatedResult.loading; + // Read through the same range stamp as the tiles, so the export is blocked from the first + // render of a new range rather than from whenever the fetch effect gets around to running. const spendFetchState = { - loading, - isFetchingMore: paginatedResult.isFetchingMore, + coversRange: activeAggregated !== null || paginatedResult.coversRange, cancelled: paginatedResult.cancelled, failed: paginatedResult.failed, }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts index 5257e5fc8a6..aa6cf64483a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts @@ -180,6 +180,20 @@ describe("usePaginatedDailyActivity failure reporting", () => { consoleError.mockRestore(); }); + it("reports no pages loaded when the very first request is what failed", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const fetchFn = vi.fn(() => Promise.reject(new Error("page 1 never came back"))); + + const { result } = renderHook(() => + usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), + ); + + await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 }); + + expect(result.current.progress).toEqual({ currentPage: 0, totalPages: 0 }); + consoleError.mockRestore(); + }); + it("stays unfailed when every page arrives", async () => { const pages = [ firstPage, @@ -222,3 +236,84 @@ describe("usePaginatedDailyActivity failure reporting", () => { consoleError.mockRestore(); }); }); + +describe("usePaginatedDailyActivity range coverage", () => { + const start = new Date("2026-08-10"); + const end = new Date("2026-08-17"); + const singlePage = { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 1, page: 1, total_spend: 2 } }; + + it("does not cover the range while the hook is disabled", () => { + const fetchFn = vi.fn(() => Promise.resolve(singlePage)); + + const { result } = renderHook(() => + usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: false }), + ); + + expect(result.current.coversRange).toBe(false); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("covers the range only once every page of it has landed", async () => { + const pages = [ + { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 2, page: 1, total_spend: 2 } }, + { results: [dayOf("2026-08-15", 1)], metadata: { total_pages: 2, page: 2, total_spend: 1 } }, + ]; + const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => Promise.resolve(pages[page - 1])); + + const { result } = renderHook(() => + usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }), + ); + + expect(result.current.coversRange).toBe(false); + await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 }); + }); + + it("never reports a range as covered while the data on screen is empty", async () => { + // Disabling the hook empties the data. Re-enabling it asks for the same args the last + // completed fetch used, so coverage that survives the disable would vouch for nothing. + const seen: Array<{ coversRange: boolean; rows: number }> = []; + const fetchFn = vi.fn(() => Promise.resolve(singlePage)); + + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => { + const activity = usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled }); + seen.push({ coversRange: activity.coversRange, rows: activity.data.results.length }); + return activity; + }, + { initialProps: { enabled: true } }, + ); + + await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 }); + + rerender({ enabled: false }); + rerender({ enabled: true }); + + await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 }); + expect(seen.filter((render) => render.coversRange && render.rows === 0)).toEqual([]); + }); + + it("stops covering the range on the very render the args change, not once an effect catches up", async () => { + // The render after a filter change still holds the previous filter's rows, so resetting + // coverage inside the fetch effect would leave a paint where the export reads them as the + // new range. That paint is the whole thing the gate exists to stop. + const seen: Array<{ filter: string; coversRange: boolean }> = []; + const fetchFn = vi.fn(() => Promise.resolve(singlePage)); + + const { result, rerender } = renderHook( + ({ filter }: { filter: string }) => { + const activity = usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, filter], enabled: true }); + seen.push({ filter, coversRange: activity.coversRange }); + return activity; + }, + { initialProps: { filter: "team-a" } }, + ); + + await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 }); + + rerender({ filter: "team-b" }); + + const rendersForNewFilter = seen.filter((render) => render.filter === "team-b"); + expect(rendersForNewFilter.length).toBeGreaterThan(0); + expect(rendersForNewFilter.map((render) => render.coversRange)).not.toContain(true); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts index aef0021cc3d..0c1d79cb112 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts @@ -61,8 +61,8 @@ interface UsePaginatedDailyActivityReturn { isFetchingMore: boolean; progress: PaginationProgress; cancelled: boolean; - /** A page request threw, so `data` covers only part of the requested range. */ failed: boolean; + coversRange: boolean; cancel: () => void; } @@ -203,6 +203,7 @@ export function usePaginatedDailyActivity({ }); const [cancelled, setCancelled] = useState(false); const [failed, setFailed] = useState(false); + const [completedKey, setCompletedKey] = useState(null); const fetchIdRef = useRef(0); const cancelledRef = useRef(false); @@ -216,6 +217,11 @@ export function usePaginatedDailyActivity({ // Stable serialised key so the effect only re-runs when the arg *values* change. const argsKey = JSON.stringify(args); + // Stamped like the data itself and compared during render, so the render that follows an arg + // change already reports the new range as uncovered. Clearing it inside the fetch effect would + // be one render too late, leaving a paint where an export reads the previous range's rows. + const coversRange = enabled && completedKey === argsKey; + const cancel = useCallback(() => { cancelledRef.current = true; setCancelled(true); @@ -234,6 +240,7 @@ export function usePaginatedDailyActivity({ setProgress({ currentPage: 0, totalPages: 0 }); setCancelled(false); setFailed(false); + setCompletedKey(null); return; } @@ -257,7 +264,7 @@ export function usePaginatedDailyActivity({ const currentArgs = argsRef.current; setLoading(true); setIsFetchingMore(false); - setProgress({ currentPage: 1, totalPages: 1 }); + setProgress({ currentPage: 0, totalPages: 0 }); if (aggregatedFetchFn) { try { @@ -266,6 +273,7 @@ export function usePaginatedDailyActivity({ setData(aggregated); setProgress({ currentPage: 1, totalPages: 1 }); setLoading(false); + setCompletedKey(argsKey); return; } catch (error) { if (isStale()) return; @@ -288,6 +296,7 @@ export function usePaginatedDailyActivity({ if (totalPages <= 1) { setLoading(false); + setCompletedKey(argsKey); return; } @@ -333,6 +342,7 @@ export function usePaginatedDailyActivity({ } setIsFetchingMore(false); + setCompletedKey(argsKey); } catch (error) { if (!isStale()) { console.error("Error fetching daily activity:", error); @@ -356,5 +366,5 @@ export function usePaginatedDailyActivity({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, fetchFn, aggregatedFetchFn, argsKey]); - return { data, loading, isFetchingMore, progress, cancelled, failed, cancel }; + return { data, loading, isFetchingMore, progress, cancelled, failed, coversRange, cancel }; } diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index 3c4c7f6f4ee..388a211d9bb 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -34,7 +34,6 @@ interface UsageExportHeaderProps { customTitle?: string; compactLayout?: boolean; teams?: Team[]; - /** Set to block the export and explain why; see getExportBlockedReason. */ exportBlockedReason?: string; } diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts index 0b32aef52fa..e39b01a5dea 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts @@ -3,35 +3,30 @@ import { describe, expect, it } from "vitest"; import { getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason"; const state = (overrides: Partial = {}): UsageFetchState => ({ - loading: false, - isFetchingMore: false, + coversRange: true, cancelled: false, failed: false, ...overrides, }); describe("getExportBlockedReason", () => { - it("lets the export through once the range has fully loaded", () => { + it("lets the export through once the data on screen covers the range", () => { expect(getExportBlockedReason(state())).toBeUndefined(); }); - it("blocks the first load, before any page has arrived", () => { - expect(getExportBlockedReason(state({ loading: true }))).toMatch(/still loading/i); - }); - - it("blocks while later pages are still arriving, which is when a CSV silently under-reports", () => { - expect(getExportBlockedReason(state({ isFetchingMore: true }))).toMatch(/still loading/i); + it("blocks whenever the data on screen does not cover the range, which is when a CSV silently under-reports", () => { + expect(getExportBlockedReason(state({ coversRange: false }))).toMatch(/still loading/i); }); it("blocks after a stopped fetch and says a reload is what fixes it", () => { - const reason = getExportBlockedReason(state({ cancelled: true })); + const reason = getExportBlockedReason(state({ coversRange: false, cancelled: true })); expect(reason).toMatch(/stopped/i); expect(reason).toMatch(/reload/i); }); it("blocks after a failed page and names the failure rather than the stop", () => { - const reason = getExportBlockedReason(state({ failed: true, cancelled: true })); + const reason = getExportBlockedReason(state({ coversRange: false, failed: true, cancelled: true })); expect(reason).toMatch(/failed to load/i); expect(reason).not.toMatch(/stopped/i); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts index ebe67f1ccfe..71408ba8f3f 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts @@ -1,21 +1,13 @@ export interface UsageFetchState { - loading: boolean; - isFetchingMore: boolean; + coversRange: boolean; cancelled: boolean; failed: boolean; } -/** Why exporting what is on screen would under-report, or undefined once it covers the whole range. */ -export const getExportBlockedReason = ({ - loading, - isFetchingMore, - cancelled, - failed, -}: UsageFetchState): string | undefined => { +export const getExportBlockedReason = ({ coversRange, cancelled, failed }: UsageFetchState): string | undefined => { if (failed) return "Some spend data failed to load, so an export would under-report. Reload the page to try again."; if (cancelled) return "Loading was stopped before the whole range arrived, so an export would under-report. Reload the page to load it all."; - if (loading || isFetchingMore) - return "Spend data is still loading, so an export would under-report. Wait for it to finish."; + if (!coversRange) return "Spend data is still loading, so an export would under-report. Wait for it to finish."; return undefined; }; diff --git a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx index 1eefc43ecb4..93d464b6615 100644 --- a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx @@ -45,10 +45,25 @@ describe("PaginationStatusAlerts", () => { ); expect( - screen.getByText(/Fetching spend data failed, so the totals below cover only part of the range \(7\/42 pages/), + screen.getByText(/Fetching spend data failed, so the totals below cover only 7 of 42 pages of the range/), ).toBeInTheDocument(); }); + it("does not claim a page loaded when the very first request is what failed", () => { + render( + , + ); + + expect(screen.getByText(/failed before any of it arrived/)).toBeInTheDocument(); + expect(screen.queryByText(/pages of the range/)).not.toBeInTheDocument(); + }); + it("shows only the failure when a stopped fetch also failed", () => { render( + progress.currentPage === 0 + ? `Fetching ${subject} failed before any of it arrived, so the totals below are empty rather than final. Reload the page to try again.` + : `Fetching ${subject} failed, so the totals below cover only ${progress.currentPage} of ${progress.totalPages} pages of the range. Reload the page to try again.`; + const PaginationStatusAlerts = ({ isFetchingMore, cancelled, @@ -42,10 +47,7 @@ const PaginationStatusAlerts = ({ )} {failed && ( - - Fetching {subject} failed, so the totals below cover only part of the range ({progress.currentPage}/ - {progress.totalPages} pages loaded). Reload the page to try again. - + {failureMessage(subject, progress)} )} {cancelled && !failed && (