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 7d94cae468d..8c36b934789 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 @@ -2,6 +2,7 @@ import { fireEvent, render } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { DailyData, KeyMetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types"; +import type { DailyActivityRange } from "./useDailyActivityRange"; vi.mock("@/components/shared/advanced_date_picker", () => ({ __esModule: true, @@ -59,7 +60,7 @@ const dayWithModels = (date: string, models: Record +const renderWith = (results: DailyData[], overrides: Partial = {}) => render( results, loading: false, isFetchingMore: false, + progress: { currentPage: 1, totalPages: 1 }, + cancelled: false, + cancel: vi.fn(), + ...overrides, }} />, ); @@ -138,4 +143,38 @@ describe("CacheLeakageCard", () => { expect(getByText("No key usage in this range.")).toBeInTheDocument(); expect(queryByRole("table")).not.toBeInTheDocument(); }); + + it("tells the user the table is still filling in while fallback pages stream", () => { + const day = dayWithKeys("2026-07-12", { + "hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), + }); + const { getByText, getByRole } = renderWith([day], { isFetchingMore: true }); + + expect(getByRole("table")).toBeInTheDocument(); + expect( + getByText("Data is still loading; rows and totals will update as the rest of the range arrives."), + ).toBeInTheDocument(); + }); + + it("keeps the streaming note off while a fresh range loads over the previous range's rows", () => { + const day = dayWithKeys("2026-07-12", { + "hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), + }); + const { queryByText } = renderWith([day], { loading: true }); + + expect( + queryByText("Data is still loading; rows and totals will update as the rest of the range arrives."), + ).not.toBeInTheDocument(); + }); + + it("drops the streaming note once the range has settled", () => { + const day = dayWithKeys("2026-07-12", { + "hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }), + }); + const { queryByText } = renderWith([day]); + + expect( + queryByText("Data is still loading; rows and totals will update as the rest of the range arrives."), + ).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx index ca47b71725d..3f27449ebe1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx @@ -123,6 +123,11 @@ const CacheLeakageCard: React.FC = ({ activity }) => { + {rows.length > 0 && isFetchingMore && ( +

+ Data is still loading; rows and totals will update as the rest of the range arrives. +

+ )} {rows.length === 0 ? (

{loading || isFetchingMore ? "Loading..." : `No ${emptyNoun} usage in this range.`} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx index 9d98233f110..2d46ca48adb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx @@ -54,7 +54,7 @@ describe("CostOptimizationView daily activity", () => { useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "proxy_admin" }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const { getByRole, getByTestId, findByTestId } = render( + const { getByRole, getByTestId, findByTestId, queryByText } = render( , @@ -67,5 +67,28 @@ describe("CostOptimizationView daily activity", () => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1); expect(mockUserDailyActivityCall).not.toHaveBeenCalled(); + expect(queryByText(/Currently fetching spend data/)).not.toBeInTheDocument(); + }); + + it("shows the fetch-progress banner while the paginated fallback streams pages in", async () => { + mockUserDailyActivityAggregatedCall.mockReset(); + mockUserDailyActivityCall.mockReset(); + mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable")); + mockUserDailyActivityCall.mockImplementation((...args: unknown[]) => + args[3] === 1 + ? Promise.resolve({ results: [], metadata: { total_pages: 3, has_more: true, page: 1 } }) + : new Promise(() => {}), + ); + useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "proxy_admin" }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + const { findByText, getByRole } = render( + + + , + ); + + expect(await findByText(/Currently fetching spend data: fetched 1 \/ 3 pages/)).toBeInTheDocument(); + expect(getByRole("button", { name: "Stop" })).toBeInTheDocument(); }); }); 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 8122cfa7a9c..a588b28ecea 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 @@ -4,6 +4,7 @@ import React from "react"; import { Info, PiggyBank } from "lucide-react"; import useCan from "@/app/(dashboard)/hooks/useCan"; +import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import UsageTab from "./UsageTab"; import PromptCompressionTab from "./PromptCompressionTab"; @@ -62,6 +63,12 @@ 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 a18109e8133..38517dab0ab 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 @@ -33,6 +33,9 @@ describe("PromptCachingTab", () => { results: [], loading: false, isFetchingMore: false, + progress: { currentPage: 1, totalPages: 1 }, + cancelled: false, + cancel: vi.fn(), }; const { getByTestId } = 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 20d57754857..74a936369c9 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 @@ -121,6 +121,9 @@ const renderWith = (results: DailyData[], options: RenderOptions = {}) => { results, loading: false, isFetchingMore: false, + progress: { currentPage: 1, totalPages: 1 }, + cancelled: false, + cancel: vi.fn(), }} />, ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx index 43c4aa04e2b..2229438d844 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx @@ -3,10 +3,19 @@ import { describe, expect, it, vi } from "vitest"; const mockUsePaginatedDailyActivity = vi.fn(); +const mockCancel = vi.fn(); + vi.mock("@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity", () => ({ usePaginatedDailyActivity: (args: unknown) => { mockUsePaginatedDailyActivity(args); - return { data: { results: [] }, loading: false, isFetchingMore: false }; + return { + data: { results: [] }, + loading: false, + isFetchingMore: false, + progress: { currentPage: 4, totalPages: 9 }, + cancelled: false, + cancel: mockCancel, + }; }, })); @@ -41,6 +50,14 @@ describe("useDailyActivityRange", () => { ); }); + it("forwards the pagination progress and cancel affordances instead of dropping them", () => { + const { result } = renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin")); + + expect(result.current.progress).toEqual({ currentPage: 4, totalPages: 9 }); + expect(result.current.cancelled).toBe(false); + expect(result.current.cancel).toBe(mockCancel); + }); + it("stays disabled until an access token is available", () => { renderHook(() => useDailyActivityRange(null, "u1", "proxy_admin")); 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 e16458728a1..81ddb6af585 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 @@ -18,6 +18,9 @@ export interface DailyActivityRange { results: DailyData[]; loading: boolean; isFetchingMore: boolean; + progress: { currentPage: number; totalPages: number }; + cancelled: boolean; + cancel: () => void; } export const useDailyActivityRange = ( @@ -33,7 +36,7 @@ export const useDailyActivityRange = ( const endTime = dateValue.to ?? null; const effectiveUserId = all_admin_roles.includes(userRole) ? null : userId; - const { data, loading, isFetchingMore } = usePaginatedDailyActivity({ + const { data, loading, isFetchingMore, progress, cancelled, cancel } = usePaginatedDailyActivity({ fetchFn: userDailyActivityCall, aggregatedFetchFn: userDailyActivityAggregatedCall, args: [accessToken, startTime, endTime, effectiveUserId, true], @@ -46,5 +49,8 @@ export const useDailyActivityRange = ( results: data.results as DailyData[], loading, isFetchingMore, + progress, + cancelled, + 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 69198e42279..9501fa7a9a1 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 @@ -15,10 +15,9 @@ import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/compon import { hasCapability, type Capability } from "@/utils/capabilities"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; -import { ChevronDown, ChevronRight, ExternalLink, Info, Loader2 } from "lucide-react"; +import { ChevronDown, ChevronRight, Info } from "lucide-react"; import type { ColumnDef } from "@tanstack/react-table"; -import { Alert, AlertDescription } from "@/components/shared/Alert"; -import { Button } from "@/components/ui/button"; +import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import React, { type ReactNode, useMemo, useState } from "react"; @@ -643,57 +642,20 @@ const EntityUsage: React.FC = ({ return (
- {isFetchingMore && ( - - - - - Currently fetching spend data: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will - update periodically as data loads. Moving off of this page will stop and reset this. To continue using the - UI in the meantime,{" "} - - open a new tab - - . - - - - - )} - {cancelled && ( - - - Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded) - - - )} - {agentIsFetchingMore && showAgentBreakdown && ( - - - - - Currently fetching agent data: fetched {agentProgress.currentPage} / {agentProgress.totalPages} pages. - Charts will update periodically as data loads. Moving off of this page will stop and reset this. To - continue using the UI in the meantime,{" "} - - open a new tab - - . - - - - - )} - {agentCancelled && showAgentBreakdown && ( - - - Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded) - - + + {showAgentBreakdown && ( + )} = ({ teams, organizations }) => { />
- {paginatedResult.isFetchingMore && ( - - - - - Currently fetching spend data: fetched {paginatedResult.progress.currentPage} /{" "} - {paginatedResult.progress.totalPages} pages. Charts will update periodically as data loads. Moving off - of this page will stop and reset this. To continue using the UI in the meantime,{" "} - - open a new tab - - . - - - - - )} - {paginatedResult.cancelled && ( - - - Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages} pages - loaded) - - - )} + {/* Your Usage / Global Usage Panel */} {(usageView === "global" || usageView === "my-usage") && ( <> diff --git a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx new file mode 100644 index 00000000000..3698b68155e --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import PaginationStatusAlerts from "./PaginationStatusAlerts"; + +describe("PaginationStatusAlerts", () => { + it("shows page progress and wires the Stop button while fetching", () => { + const cancel = vi.fn(); + const { getByRole, getByText } = render( + , + ); + + expect(getByText(/Currently fetching spend data: fetched 7 \/ 42 pages/)).toBeInTheDocument(); + fireEvent.click(getByRole("button", { name: "Stop" })); + expect(cancel).toHaveBeenCalledTimes(1); + }); + + it("shows the partial-data notice after a cancel, frozen at the last fetched page", () => { + const { getByText } = render( + , + ); + + expect(getByText("Showing partial spend data (7/42 pages loaded)")).toBeInTheDocument(); + }); + + it("names the subject it is fetching", () => { + const { getByText } = render( + , + ); + + expect(getByText(/Currently fetching agent data: fetched 1 \/ 3 pages/)).toBeInTheDocument(); + }); + + it("renders nothing when idle", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx new file mode 100644 index 00000000000..af8b8439703 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx @@ -0,0 +1,51 @@ +import { ExternalLink, Loader2 } from "lucide-react"; + +import { Alert, AlertDescription } from "@/components/shared/Alert"; +import { Button } from "@/components/ui/button"; + +interface PaginationStatusAlertsProps { + isFetchingMore: boolean; + cancelled: boolean; + progress: { currentPage: number; totalPages: number }; + cancel: () => void; + subject?: string; +} + +const PaginationStatusAlerts = ({ + isFetchingMore, + cancelled, + progress, + cancel, + subject = "spend data", +}: PaginationStatusAlertsProps) => ( + <> + {isFetchingMore && ( + + + + + Currently fetching {subject}: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will + update periodically as data loads. Moving off of this page will stop and reset this. To continue using the + UI in the meantime,{" "} + + open a new tab + + . + + + + + )} + {cancelled && ( + + + Showing partial {subject} ({progress.currentPage}/{progress.totalPages} pages loaded) + + + )} + +); + +export default PaginationStatusAlerts;