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>
This commit is contained in:
Devin AI 2026-08-06 00:07:23 +00:00
parent 5277dab4f2
commit fe131b807a
8 changed files with 328 additions and 18 deletions

View file

@ -137,6 +137,8 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
isFetchingMore,
progress,
cancelled,
failed,
incomplete,
cancel,
} = usePaginatedDailyActivity({
fetchFn,
@ -151,6 +153,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
isFetchingMore: agentIsFetchingMore,
progress: agentProgress,
cancelled: agentCancelled,
failed: agentFailed,
cancel: agentCancel,
} = usePaginatedDailyActivity({
fetchFn: agentDailyActivityCall,
@ -651,10 +654,11 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
</AlertDescription>
</Alert>
)}
{cancelled && (
<Alert variant="info" className="mb-2">
{(cancelled || failed) && (
<Alert variant={failed ? "error" : "info"} className="mb-2">
<AlertDescription className="text-inherit">
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)
</AlertDescription>
</Alert>
)}
@ -677,10 +681,13 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
</AlertDescription>
</Alert>
)}
{agentCancelled && showAgentBreakdown && (
<Alert variant="info" className="mb-2">
{(agentCancelled || agentFailed) && showAgentBreakdown && (
<Alert variant={agentFailed ? "error" : "info"} className="mb-2">
<AlertDescription className="text-inherit">
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)
</AlertDescription>
</Alert>
)}
@ -696,6 +703,12 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
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."
}
/>
<Tabs defaultValue={tabs[0].key}>
<TabsList className="mt-1">

View file

@ -482,11 +482,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
</AlertDescription>
</Alert>
)}
{paginatedResult.cancelled && (
<Alert variant="info" className="mb-2">
{(paginatedResult.cancelled || paginatedResult.failed) && (
<Alert variant={paginatedResult.failed ? "error" : "info"} className="mb-2">
<AlertDescription className="text-inherit">
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)
</AlertDescription>
</Alert>
)}

View file

@ -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> = {}): 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<string, number>, 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<DailyData[]>((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]);
});
});

View file

@ -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 = <T>(
a: Record<string, T> | undefined,
b: Record<string, T> | undefined,
mergeEntry: (left: T, right: T) => T,
): Record<string, T> => {
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<DailyData[]>(
(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],
);

View file

@ -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);
});
});

View file

@ -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 };
}

View file

@ -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(<UsageExportHeader {...defaultProps} exportDisabled exportDisabledReason="Still loading" />);
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(<UsageExportHeader {...defaultProps} showFilters={false} />);
expect(screen.queryByText(/filter/i)).not.toBeInTheDocument();

View file

@ -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<UsageExportHeaderProps> = ({
@ -50,6 +53,8 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
customTitle,
compactLayout = false,
teams = [],
exportDisabled = false,
exportDisabledReason,
}) => {
const anchor = useComboboxAnchor();
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
@ -112,10 +117,12 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
)}
<div className="justify-self-end">
<Button onClick={() => setIsExportModalOpen(true)}>
<Download />
Export Data
</Button>
<span title={exportDisabled ? exportDisabledReason : undefined}>
<Button disabled={exportDisabled} onClick={() => setIsExportModalOpen(true)}>
<Download />
Export Data
</Button>
</span>
</div>
</div>
</div>