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.
This commit is contained in:
ryan-crabbe-berri 2026-09-15 14:10:23 -07:00
parent 1debb438f5
commit 9b35954347
16 changed files with 242 additions and 11 deletions

View file

@ -71,6 +71,7 @@ const renderWith = (results: DailyData[], overrides: Partial<DailyActivityRange>
isFetchingMore: false,
progress: { currentPage: 1, totalPages: 1 },
cancelled: false,
failed: false,
cancel: vi.fn(),
...overrides,
}}

View file

@ -87,6 +87,7 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
<PaginationStatusAlerts
isFetchingMore={activity.isFetchingMore}
cancelled={activity.cancelled}
failed={activity.failed}
progress={activity.progress}
cancel={activity.cancel}
/>

View file

@ -35,6 +35,7 @@ describe("PromptCachingTab", () => {
isFetchingMore: false,
progress: { currentPage: 1, totalPages: 1 },
cancelled: false,
failed: false,
cancel: vi.fn(),
};
render(<PromptCachingTab accessToken="test-token" activity={activity} />);

View file

@ -123,6 +123,7 @@ const renderWith = (results: DailyData[], options: RenderOptions = {}) => {
isFetchingMore: false,
progress: { currentPage: 1, totalPages: 1 },
cancelled: false,
failed: false,
cancel: vi.fn(),
}}
/>,

View file

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

View file

@ -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<EntityUsageProps> = ({
const {
data: spendDataRaw,
loading,
isFetchingMore,
progress,
cancelled,
failed,
cancel,
} = usePaginatedDailyActivity({
fetchFn,
@ -163,6 +166,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
isFetchingMore: agentIsFetchingMore,
progress: agentProgress,
cancelled: agentCancelled,
failed: agentFailed,
cancel: agentCancel,
} = usePaginatedDailyActivity({
fetchFn: agentDailyActivityCall,
@ -660,11 +664,14 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
];
const spendFetchState = { loading, isFetchingMore, cancelled, failed };
return (
<div style={{ width: "100%" }} className="relative">
<PaginationStatusAlerts
isFetchingMore={isFetchingMore}
cancelled={cancelled}
failed={failed}
progress={progress}
cancel={cancel}
/>
@ -672,6 +679,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
<PaginationStatusAlerts
isFetchingMore={agentIsFetchingMore}
cancelled={agentCancelled}
failed={agentFailed}
progress={agentProgress}
cancel={agentCancel}
subject="agent data"
@ -689,6 +697,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
onFiltersChange={setSelectedTags}
filterOptions={getAllTags() || undefined}
teams={teams || []}
exportBlockedReason={getExportBlockedReason(spendFetchState)}
/>
<Tabs defaultValue={tabs[0].key}>
<TabsList className="mt-1">

View file

@ -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<UsagePageProps> = ({ 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<UsagePageProps> = ({ teams, organizations }) => {
<PaginationStatusAlerts
isFetchingMore={paginatedResult.isFetchingMore}
cancelled={paginatedResult.cancelled}
failed={paginatedResult.failed}
progress={paginatedResult.progress}
cancel={paginatedResult.cancel}
/>
@ -525,10 +535,16 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<Sparkles />
Ask AI
</Button>
<Button variant="outline" onClick={() => setIsGlobalExportModalOpen(true)}>
<Download />
Export Data
</Button>
<span title={exportBlockedReason}>
<Button
variant="outline"
disabled={exportBlockedReason !== undefined}
onClick={() => setIsGlobalExportModalOpen(true)}
>
<Download />
Export Data
</Button>
</span>
</div>
</div>
{/* Cost Panel */}

View file

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

View file

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

View file

@ -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(
<UsageExportHeader
{...defaultProps}
exportBlockedReason="Spend data is still loading, so an export would under-report. Wait for it to finish."
/>,
);
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(<UsageExportHeader {...defaultProps} exportBlockedReason="Spend data is still loading" />);
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(<UsageExportHeader {...defaultProps} />);

View file

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

View file

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason";
const state = (overrides: Partial<UsageFetchState> = {}): 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);
});
});

View file

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

View file

@ -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(
<PaginationStatusAlerts
isFetchingMore={false}
cancelled={false}
failed={true}
progress={{ currentPage: 7, totalPages: 42 }}
cancel={vi.fn()}
/>,
);
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(
<PaginationStatusAlerts
isFetchingMore={false}
cancelled={true}
failed={true}
progress={{ currentPage: 7, totalPages: 42 }}
cancel={vi.fn()}
/>,
);
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(
<PaginationStatusAlerts

View file

@ -9,6 +9,7 @@ interface PaginationStatusAlertsProps {
progress: { currentPage: number; totalPages: number };
cancel: () => 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 = ({
</AlertDescription>
</Alert>
)}
{cancelled && (
{failed && (
<Alert variant="error" className="mb-2">
<AlertDescription className="text-inherit">
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.
</AlertDescription>
</Alert>
)}
{cancelled && !failed && (
<Alert variant="info" className="mb-2">
<AlertDescription className="text-inherit">
Showing partial {subject} ({progress.currentPage}/{progress.totalPages} pages loaded)

View file

@ -40,6 +40,7 @@ const mockActivity = (
isFetchingMore: false,
progress: { currentPage: 1, totalPages: 1 },
cancelled: false,
failed: false,
cancel: vi.fn(),
...overrides,
});