From fe131b807a4cb79279c2cc60e640729e4ed78ffa Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:07:23 +0000 Subject: [PATCH] fix(ui): stop usage pagination from under-reporting wide date ranges Merge daily activity pages by date and block the CSV export until the whole range is loaded. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/EntityUsage/EntityUsage.tsx | 25 +++-- .../_components/components/UsagePageView.tsx | 10 +- .../hooks/mergeDailyActivity.test.ts | 90 ++++++++++++++++++ .../_components/hooks/mergeDailyActivity.ts | 93 +++++++++++++++++++ .../hooks/usePaginatedDailyActivity.test.ts | 89 +++++++++++++++++- .../hooks/usePaginatedDailyActivity.ts | 15 ++- .../UsageExportHeader.test.tsx | 9 ++ .../EntityUsageExport/UsageExportHeader.tsx | 15 ++- 8 files changed, 328 insertions(+), 18 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/mergeDailyActivity.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/mergeDailyActivity.ts 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 66265fb41d8..e12d6de6954 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 @@ -137,6 +137,8 @@ const EntityUsage: React.FC = ({ isFetchingMore, progress, cancelled, + failed, + incomplete, cancel, } = usePaginatedDailyActivity({ fetchFn, @@ -151,6 +153,7 @@ const EntityUsage: React.FC = ({ isFetchingMore: agentIsFetchingMore, progress: agentProgress, cancelled: agentCancelled, + failed: agentFailed, cancel: agentCancel, } = usePaginatedDailyActivity({ fetchFn: agentDailyActivityCall, @@ -651,10 +654,11 @@ const EntityUsage: React.FC = ({ )} - {cancelled && ( - + {(cancelled || failed) && ( + - Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded) + {failed ? "Fetching spend data failed, so totals cover only part of the range" : "Showing partial data"} ( + {progress.currentPage}/{progress.totalPages} pages loaded) )} @@ -677,10 +681,13 @@ const EntityUsage: React.FC = ({ )} - {agentCancelled && showAgentBreakdown && ( - + {(agentCancelled || agentFailed) && showAgentBreakdown && ( + - Showing partial agent data ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded) + {agentFailed + ? "Fetching agent data failed, so totals cover only part of the range" + : "Showing partial agent data"}{" "} + ({agentProgress.currentPage}/{agentProgress.totalPages} pages loaded) )} @@ -696,6 +703,12 @@ const EntityUsage: React.FC = ({ onFiltersChange={setSelectedTags} filterOptions={getAllTags() || undefined} teams={teams || []} + exportDisabled={incomplete} + exportDisabledReason={ + failed + ? "Spend data failed to load for the whole range, so an export would under-report. Reload the page first." + : "Spend data is still loading, so an export would under-report. Wait for it to finish." + } /> 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 9558b9a7f76..fce33e54d34 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 @@ -482,11 +482,13 @@ const UsagePage: React.FC = ({ teams, organizations }) => { )} - {paginatedResult.cancelled && ( - + {(paginatedResult.cancelled || paginatedResult.failed) && ( + - Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages} pages - loaded) + {paginatedResult.failed + ? "Fetching spend data failed, so totals cover only part of the range" + : "Showing partial data"}{" "} + ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages} pages loaded) )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/mergeDailyActivity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/mergeDailyActivity.test.ts new file mode 100644 index 00000000000..40fd6a5dd79 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/mergeDailyActivity.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; +import { mergeDailyResults } from "./mergeDailyActivity"; + +const metrics = (overrides: Partial = {}): SpendMetrics => ({ + spend: 0, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + api_requests: 0, + successful_requests: 0, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + ...overrides, +}); + +const day = (date: string, spend: number, teamSpend: Record, keySpend: number): DailyData => ({ + date, + metrics: metrics({ spend, total_tokens: spend * 10, api_requests: 1 }), + breakdown: { + models: {}, + model_groups: {}, + mcp_servers: {}, + providers: {}, + api_keys: { + "sk-a": { metrics: metrics({ spend: keySpend }), metadata: { key_alias: "a", team_id: "team-1" } }, + }, + entities: Object.fromEntries( + Object.entries(teamSpend).map(([team, value]) => [ + team, + { + metrics: metrics({ spend: value, total_tokens: value * 10 }), + metadata: { team_alias: team }, + api_key_breakdown: { + "sk-a": { metrics: metrics({ spend: value }), metadata: { key_alias: "a", team_id: team } }, + }, + }, + ]), + ), + }, +}); + +describe("mergeDailyResults", () => { + it("keeps one entry per date when a date straddles a page boundary", () => { + const pageOne = [day("2026-06-26", 5, { "team-1": 5 }, 5), day("2026-06-25", 22.38, { "team-1": 22.38 }, 22.38)]; + const pageTwo = [day("2026-06-25", 14.52, { "team-1": 14.52 }, 14.52), day("2026-06-24", 3, { "team-1": 3 }, 3)]; + + const merged = mergeDailyResults(pageOne, pageTwo); + + expect(merged.map((d) => d.date)).toEqual(["2026-06-26", "2026-06-25", "2026-06-24"]); + const splitDay = merged.find((d) => d.date === "2026-06-25")!; + expect(splitDay.metrics.spend).toBeCloseTo(36.9, 10); + expect(splitDay.metrics.total_tokens).toBeCloseTo(369, 10); + expect(splitDay.metrics.api_requests).toBe(2); + }); + + it("merges every breakdown bucket of a split date instead of dropping one page's share", () => { + const merged = mergeDailyResults( + [day("2026-06-25", 10, { "team-1": 6, "team-2": 4 }, 10)], + [day("2026-06-25", 5, { "team-2": 5 }, 5)], + ); + + const { entities, api_keys } = merged[0].breakdown; + expect(entities["team-1"].metrics.spend).toBeCloseTo(6, 10); + expect(entities["team-2"].metrics.spend).toBeCloseTo(9, 10); + expect(entities["team-2"].api_key_breakdown["sk-a"].metrics.spend).toBeCloseTo(9, 10); + expect(api_keys["sk-a"].metrics.spend).toBeCloseTo(15, 10); + }); + + it("preserves the per-day total across pages so day sums match the response metadata", () => { + const pages = [ + [day("2026-06-25", 22.38, { "team-1": 22.38 }, 22.38)], + [day("2026-06-25", 14.52, { "team-1": 14.52 }, 14.52)], + [day("2026-06-24", 3, { "team-1": 3 }, 3)], + ]; + + const merged = pages.reduce((acc, page) => mergeDailyResults(acc, page), []); + + expect(merged).toHaveLength(2); + expect(merged.reduce((total, d) => total + d.metrics.spend, 0)).toBeCloseTo(39.9, 10); + }); + + it("leaves distinct dates untouched", () => { + const pageOne = [day("2026-06-26", 5, { "team-1": 5 }, 5)]; + const pageTwo = [day("2026-06-25", 7, { "team-1": 7 }, 7)]; + + expect(mergeDailyResults(pageOne, pageTwo)).toEqual([...pageOne, ...pageTwo]); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/mergeDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/mergeDailyActivity.ts new file mode 100644 index 00000000000..cb216516559 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/mergeDailyActivity.ts @@ -0,0 +1,93 @@ +import type { + BreakdownMetrics, + DailyData, + KeyMetricWithMetadata, + MetricWithMetadata, + SpendMetrics, +} from "@/components/UsagePage/types"; + +const METRIC_KEYS: readonly (keyof SpendMetrics)[] = [ + "spend", + "prompt_tokens", + "completion_tokens", + "total_tokens", + "api_requests", + "successful_requests", + "failed_requests", + "cache_read_input_tokens", + "cache_creation_input_tokens", + "compression_saved_tokens", + "compression_savings_spend", + "prompt_caching_savings_spend", + "autorouter_savings_spend", +]; + +const addMetrics = (a: SpendMetrics, b: SpendMetrics): SpendMetrics => + METRIC_KEYS.reduce( + (acc, key) => + a[key] === undefined && b[key] === undefined ? acc : { ...acc, [key]: (a[key] ?? 0) + (b[key] ?? 0) }, + {} as SpendMetrics, + ); + +const mergeBuckets = ( + a: Record | undefined, + b: Record | undefined, + mergeEntry: (left: T, right: T) => T, +): Record => { + const left = a ?? {}; + const right = b ?? {}; + return Object.fromEntries( + Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).map((key) => { + const leftEntry = left[key]; + const rightEntry = right[key]; + if (leftEntry === undefined) return [key, rightEntry]; + if (rightEntry === undefined) return [key, leftEntry]; + return [key, mergeEntry(leftEntry, rightEntry)]; + }), + ); +}; + +const mergeKeyMetric = (a: KeyMetricWithMetadata, b: KeyMetricWithMetadata): KeyMetricWithMetadata => ({ + ...a, + metrics: addMetrics(a.metrics, b.metrics), +}); + +const mergeMetricWithMetadata = (a: MetricWithMetadata, b: MetricWithMetadata): MetricWithMetadata => ({ + ...a, + metrics: addMetrics(a.metrics, b.metrics), + api_key_breakdown: mergeBuckets(a.api_key_breakdown, b.api_key_breakdown, mergeKeyMetric), +}); + +const mergeBreakdown = (a: BreakdownMetrics, b: BreakdownMetrics): BreakdownMetrics => ({ + models: mergeBuckets(a.models, b.models, mergeMetricWithMetadata), + model_groups: mergeBuckets(a.model_groups, b.model_groups, mergeMetricWithMetadata), + mcp_servers: mergeBuckets(a.mcp_servers, b.mcp_servers, mergeMetricWithMetadata), + providers: mergeBuckets(a.providers, b.providers, mergeMetricWithMetadata), + entities: mergeBuckets(a.entities, b.entities, mergeMetricWithMetadata), + endpoints: mergeBuckets(a.endpoints, b.endpoints, mergeMetricWithMetadata), + api_keys: mergeBuckets(a.api_keys, b.api_keys, mergeKeyMetric), +}); + +const mergeDay = (a: DailyData, b: DailyData): DailyData => ({ + ...a, + metrics: addMetrics(a.metrics, b.metrics), + breakdown: mergeBreakdown(a.breakdown, b.breakdown), +}); + +/** + * Combine daily activity pages into one series with a single entry per date. + * + * The backend paginates over raw spend rows, so a date whose rows straddle a + * page boundary comes back once per page, each entry holding only that page's + * share of the day. Concatenating those entries leaves duplicate dates that + * under-report every per-day figure in the charts and the CSV export. + */ +export const mergeDailyResults = (existing: readonly DailyData[], incoming: readonly DailyData[]): DailyData[] => + incoming.reduce( + (acc, day) => { + const index = acc.findIndex((existingDay) => existingDay.date === day.date); + if (index === -1) return [...acc, day]; + return acc.map((existingDay, i) => (i === index ? mergeDay(existingDay, day) : existingDay)); + }, + [...existing], + ); 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 b1d467074f6..aae34fa6364 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 @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest"; -import { sumMetadata } from "./usePaginatedDailyActivity"; +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { sumMetadata, usePaginatedDailyActivity } from "./usePaginatedDailyActivity"; describe("sumMetadata", () => { it("sums flat cost across pages instead of keeping the first page's value", () => { @@ -49,3 +50,87 @@ describe("sumMetadata", () => { } }); }); + +const page = (date: string, spend: number, totalPages: number, pageNumber: number) => ({ + results: [ + { + date, + metrics: { + spend, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: spend * 10, + api_requests: 1, + successful_requests: 1, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + breakdown: { + models: {}, + model_groups: {}, + mcp_servers: {}, + providers: {}, + api_keys: {}, + entities: { + "team-1": { + metrics: { + spend, + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: spend * 10, + api_requests: 1, + successful_requests: 1, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + metadata: {}, + api_key_breakdown: {}, + }, + }, + }, + }, + ], + metadata: { total_spend: spend, total_tokens: spend * 10, total_pages: totalPages, page: pageNumber }, +}); + +const args = ["token", new Date("2026-02-05"), new Date("2026-08-05"), null]; + +describe("usePaginatedDailyActivity", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("collapses a date split across pages into a single day entry", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(page("2026-06-25", 22.38, 2, 1)) + .mockResolvedValueOnce(page("2026-06-25", 14.52, 2, 2)); + + const { result } = renderHook(() => usePaginatedDailyActivity({ fetchFn, args, enabled: true })); + + await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(2), { timeout: 3000 }); + await waitFor(() => expect(result.current.data.metadata.total_spend).toBeCloseTo(36.9, 10), { timeout: 3000 }); + + expect(result.current.data.results).toHaveLength(1); + expect(result.current.data.results[0].metrics.spend).toBeCloseTo(36.9, 10); + expect(result.current.data.results[0].breakdown.entities["team-1"].metrics.spend).toBeCloseTo(36.9, 10); + expect(result.current.incomplete).toBe(false); + }); + + it("flags the range as incomplete when a page fetch fails instead of looking complete", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(page("2026-06-25", 22.38, 3, 1)) + .mockRejectedValueOnce(new Error("boom")); + + const { result } = renderHook(() => usePaginatedDailyActivity({ fetchFn, args, enabled: true })); + + await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 3000 }); + + expect(result.current.incomplete).toBe(true); + expect(result.current.isFetchingMore).toBe(false); + expect(result.current.data.metadata.total_spend).toBeCloseTo(22.38, 10); + }); +}); 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 a8bfee5be2f..2fdfe1cf459 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 @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { DailyData } from "@/components/UsagePage/types"; +import { mergeDailyResults } from "./mergeDailyActivity"; export interface PaginationProgress { currentPage: number; @@ -48,6 +49,10 @@ interface UsePaginatedDailyActivityReturn { isFetchingMore: boolean; progress: PaginationProgress; cancelled: boolean; + /** True when a page fetch failed, so the data on screen covers only part of the range. */ + failed: boolean; + /** True whenever the data on screen is known not to cover the whole requested range. */ + incomplete: boolean; cancel: () => void; } @@ -105,6 +110,7 @@ export function usePaginatedDailyActivity({ totalPages: 0, }); const [cancelled, setCancelled] = useState(false); + const [failed, setFailed] = useState(false); const fetchIdRef = useRef(0); const cancelledRef = useRef(false); @@ -135,12 +141,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; @@ -197,7 +205,7 @@ export function usePaginatedDailyActivity({ if (isStale()) return; - accumulatedResults = [...accumulatedResults, ...pageData.results]; + accumulatedResults = mergeDailyResults(accumulatedResults, pageData.results); accumulatedMetadata = sumMetadata(accumulatedMetadata, pageData.metadata); accumulatedMetadata.total_pages = totalPages; accumulatedMetadata.has_more = page < totalPages; @@ -224,6 +232,7 @@ export function usePaginatedDailyActivity({ console.error("Error fetching daily activity:", error); setLoading(false); setIsFetchingMore(false); + setFailed(true); } } }; @@ -241,5 +250,7 @@ export function usePaginatedDailyActivity({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, fetchFn, argsKey]); - return { data, loading, isFetchingMore, progress, cancelled, cancel }; + const incomplete = isFetchingMore || cancelled || failed; + + return { data, loading, isFetchingMore, progress, cancelled, failed, incomplete, cancel }; } diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx index 27985c3db28..5ad3f1c4516 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.test.tsx @@ -49,6 +49,15 @@ describe("UsageExportHeader", () => { expect(screen.queryByTestId("export-modal")).not.toBeInTheDocument(); }); + it("should block the export while the data on screen is incomplete", 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("should not show filter dropdown when showFilters is false", () => { renderWithProviders(); expect(screen.queryByText(/filter/i)).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index f34c051fb9f..a0c8b31d877 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -34,6 +34,9 @@ interface UsageExportHeaderProps { customTitle?: string; compactLayout?: boolean; teams?: Team[]; + /** Blocks the export while the data on screen does not cover the whole requested range. */ + exportDisabled?: boolean; + exportDisabledReason?: string; } const UsageExportHeader: React.FC = ({ @@ -50,6 +53,8 @@ const UsageExportHeader: React.FC = ({ customTitle, compactLayout = false, teams = [], + exportDisabled = false, + exportDisabledReason, }) => { const anchor = useComboboxAnchor(); const [isExportModalOpen, setIsExportModalOpen] = useState(false); @@ -112,10 +117,12 @@ const UsageExportHeader: React.FC = ({ )}
- + + +