[Feature] UI - Usage: Auto-paginate daily spend data with progressive rendering

Previously, EntityUsage only fetched page 1 of paginated daily spend endpoints,
showing incomplete data. UsagePageView fetched all pages but blocked the UI until
completion. This adds a reusable usePaginatedDailyActivity hook that fetches pages
sequentially with 500ms delays, updates charts progressively, and supports
cancellation on unmount or user action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-03-14 00:30:03 -07:00
parent 6549b4583f
commit 3ae9e070d9
3 changed files with 373 additions and 191 deletions

View file

@ -22,7 +22,8 @@ import {
Text,
Title,
} from "@tremor/react";
import React, { useEffect, useState } from "react";
import { LoadingOutlined } from "@ant-design/icons";
import React, { useMemo, useState } from "react";
import { ActivityMetrics, processActivityData } from "../../../activity_metrics";
import { UsageExportHeader } from "../../../EntityUsageExport";
import type { EntityType } from "../../../EntityUsageExport/types";
@ -35,6 +36,7 @@ import {
userDailyActivityCall,
} from "../../../networking";
import { getProviderLogoAndName } from "../../../provider_info_helpers";
import { usePaginatedDailyActivity } from "../../hooks/usePaginatedDailyActivity";
import { BreakdownMetrics, DailyData, EntityMetricWithMetadata, KeyMetricWithMetadata, TagUsage } from "../../types";
import { valueFormatterSpend } from "../../utils/value_formatters";
import EndpointUsage from "../EndpointUsage/EndpointUsage";
@ -87,119 +89,64 @@ interface EntityUsageProps {
dateValue: DateRangePickerValue;
}
const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
tag: tagDailyActivityCall,
team: teamDailyActivityCall,
organization: organizationDailyActivityCall,
customer: customerDailyActivityCall,
agent: agentDailyActivityCall,
user: userDailyActivityCall,
};
const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, entityId, entityList, dateValue }) => {
const [spendData, setSpendData] = useState<EntitySpendData>({
results: [],
metadata: {
total_spend: 0,
total_api_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,
total_tokens: 0,
},
});
const { teams } = useTeams();
const [agentSpendData, setAgentSpendData] = useState<EntitySpendData>({
results: [],
metadata: {
total_spend: 0,
total_api_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,
total_tokens: 0,
},
});
const modelMetrics = processActivityData(spendData, "models", teams || []);
const keyMetrics = processActivityData(spendData, "api_keys", teams || []);
const agentMetrics = entityType === "team" ? processActivityData(agentSpendData, "entities", teams || []) : {};
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
const [topAgentsLimit, setTopAgentsLimit] = useState<number>(5);
const fetchSpendData = async () => {
if (!accessToken || !dateValue.from || !dateValue.to) return;
// Create new Date objects to avoid mutating the original dates
const startTime = new Date(dateValue.from);
const endTime = new Date(dateValue.to);
const startTime = useMemo(() => dateValue.from ? new Date(dateValue.from) : null, [dateValue.from]);
const endTime = useMemo(() => dateValue.to ? new Date(dateValue.to) : null, [dateValue.to]);
if (entityType === "tag") {
const data = await tagDailyActivityCall(
accessToken,
startTime,
endTime,
1,
selectedTags.length > 0 ? selectedTags : null,
);
setSpendData(data);
} else if (entityType === "team") {
const data = await teamDailyActivityCall(
accessToken,
startTime,
endTime,
1,
selectedTags.length > 0 ? selectedTags : null,
);
setSpendData(data);
} else if (entityType === "organization") {
const data = await organizationDailyActivityCall(
accessToken,
startTime,
endTime,
1,
selectedTags.length > 0 ? selectedTags : null,
);
setSpendData(data);
} else if (entityType === "customer") {
const data = await customerDailyActivityCall(
accessToken,
startTime,
endTime,
1,
selectedTags.length > 0 ? selectedTags : null,
);
setSpendData(data);
} else if (entityType === "agent") {
const data = await agentDailyActivityCall(
accessToken,
startTime,
endTime,
1,
selectedTags.length > 0 ? selectedTags : null,
);
setSpendData(data);
} else if (entityType === "user") {
const data = await userDailyActivityCall(
accessToken,
startTime,
endTime,
1,
selectedTags.length > 0 ? selectedTags[0] : null,
);
setSpendData(data);
} else {
throw new Error("Invalid entity type");
}
};
const entityFilterArg = useMemo(() => {
if (entityType === "user") return selectedTags.length > 0 ? selectedTags[0] : null;
return selectedTags.length > 0 ? selectedTags : null;
}, [entityType, selectedTags]);
const fetchAgentSpendData = async () => {
if (!accessToken || !dateValue.from || !dateValue.to || entityType !== "team") return;
const startTime = new Date(dateValue.from);
const endTime = new Date(dateValue.to);
try {
const data = await agentDailyActivityCall(accessToken, startTime, endTime, 1, null);
setAgentSpendData(data);
} catch (e) {
console.error("Failed to fetch agent activity data:", e);
}
};
const fetchFn = ENTITY_FETCH_FNS[entityType];
const enabled = !!accessToken && !!startTime && !!endTime;
useEffect(() => {
fetchSpendData();
fetchAgentSpendData();
}, [accessToken, dateValue, entityId, selectedTags]);
const {
data: spendDataRaw,
isFetchingMore,
progress,
cancelled,
cancel,
} = usePaginatedDailyActivity({
fetchFn,
args: [accessToken, startTime, endTime, entityFilterArg],
enabled,
});
const spendData = spendDataRaw as unknown as EntitySpendData;
const {
data: agentSpendDataRaw,
isFetchingMore: agentIsFetchingMore,
progress: agentProgress,
cancelled: agentCancelled,
cancel: agentCancel,
} = usePaginatedDailyActivity({
fetchFn: agentDailyActivityCall,
args: [accessToken, startTime, endTime, null],
enabled: enabled && entityType === "team",
});
const agentSpendData = agentSpendDataRaw as unknown as EntitySpendData;
const modelMetrics = processActivityData(spendData, "models", teams || []);
const keyMetrics = processActivityData(spendData, "api_keys", teams || []);
const agentMetrics = entityType === "team" ? processActivityData(agentSpendData, "entities", teams || []) : {};
const getTopModels = () => {
const modelSpend: { [key: string]: any } = {};
@ -448,6 +395,29 @@ const EntityUsage: React.FC<EntityUsageProps> = ({ accessToken, entityType, enti
return (
<div style={{ width: "100%" }} className="relative">
{(isFetchingMore || cancelled || agentIsFetchingMore || agentCancelled) && (
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
{isFetchingMore && (
<>
<LoadingOutlined spin className="text-xs" />
<span>Loading spend data... (page {progress.currentPage}/{progress.totalPages})</span>
<button onClick={cancel} className="text-blue-600 hover:text-blue-800 underline text-xs">Stop</button>
</>
)}
{cancelled && (
<span className="text-yellow-600 text-xs">
Showing partial data ({progress.currentPage}/{progress.totalPages} pages loaded)
</span>
)}
{agentIsFetchingMore && entityType === "team" && (
<>
<LoadingOutlined spin className="text-xs" />
<span>Loading agent data... (page {agentProgress.currentPage}/{agentProgress.totalPages})</span>
<button onClick={agentCancel} className="text-blue-600 hover:text-blue-800 underline text-xs">Stop</button>
</>
)}
</div>
)}
<UsageExportHeader
dateValue={dateValue}
entityType={entityType}

View file

@ -23,7 +23,7 @@ import {
} from "@tremor/react";
import { Alert, Segmented, Select, Tooltip, Typography } from "antd";
import { useDebouncedState } from "@tanstack/react-pacer/debouncer";
import React, { useCallback, useEffect, useMemo, useState, type UIEvent } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react";
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
@ -43,6 +43,7 @@ import { ChartLoader } from "../../shared/chart_loader";
import { Tag } from "../../tag_management/types";
import UserAgentActivity from "../../user_agent_activity";
import ViewUserSpend from "../../view_user_spend";
import { usePaginatedDailyActivity } from "../hooks/usePaginatedDailyActivity";
import { DailyData, KeyMetricWithMetadata, MetricWithMetadata } from "../types";
import { valueFormatterSpend } from "../utils/value_formatters";
import EndpointUsage from "./EndpointUsage/EndpointUsage";
@ -59,13 +60,12 @@ interface UsagePageProps {
const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
const [userSpendData, setUserSpendData] = useState<{
results: DailyData[];
metadata: any;
}>({ results: [], metadata: {} });
// Aggregated endpoint: try first, fall back to paginated if unavailable
const [aggregatedData, setAggregatedData] = useState<{ results: DailyData[]; metadata: any } | null>(null);
const [aggregatedFailed, setAggregatedFailed] = useState(false);
const [aggregatedLoading, setAggregatedLoading] = useState(false);
// Separate loading states for better UX
const [loading, setLoading] = useState(false);
const [isDateChanging, setIsDateChanging] = useState(false);
// Create initial dates outside of state to prevent recreation
@ -173,6 +173,67 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
}
}, [isAdmin, userID]);
// For non-admins, always pass their own user_id
const effectiveUserId = isAdmin ? selectedUserId : (userID || null);
const startTime = useMemo(() => dateValue.from ? new Date(dateValue.from) : null, [dateValue.from]);
const endTime = useMemo(() => dateValue.to ? new Date(dateValue.to) : null, [dateValue.to]);
// Try aggregated endpoint first, fall back to paginated on failure
const aggregatedFetchIdRef = useRef(0);
useEffect(() => {
if (!accessToken || !startTime || !endTime) return;
const fetchId = ++aggregatedFetchIdRef.current;
setAggregatedLoading(true);
setAggregatedFailed(false);
setAggregatedData(null);
userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId)
.then((data) => {
if (aggregatedFetchIdRef.current !== fetchId) return;
setAggregatedData(data);
setAggregatedLoading(false);
setIsDateChanging(false);
})
.catch(() => {
if (aggregatedFetchIdRef.current !== fetchId) return;
setAggregatedFailed(true);
setAggregatedLoading(false);
});
}, [accessToken, startTime, endTime, effectiveUserId]);
// Paginated fallback — only enabled when aggregated endpoint fails
const paginatedResult = usePaginatedDailyActivity({
fetchFn: userDailyActivityCall,
args: [accessToken, startTime, endTime, effectiveUserId],
enabled: aggregatedFailed && !!accessToken && !!startTime && !!endTime,
});
// Derive userSpendData from whichever source is active
const userSpendData = useMemo(() => {
if (aggregatedData) return aggregatedData;
if (aggregatedFailed) return paginatedResult.data;
return { results: [] as DailyData[], metadata: {} as any };
}, [aggregatedData, aggregatedFailed, paginatedResult.data]);
const loading = aggregatedLoading || paginatedResult.loading;
// Clear isDateChanging when paginated data starts arriving
useEffect(() => {
if (aggregatedFailed && !paginatedResult.loading && paginatedResult.data.results.length > 0) {
setIsDateChanging(false);
}
}, [aggregatedFailed, paginatedResult.loading, paginatedResult.data.results.length]);
// Super responsive date change handler
const handleDateChange = useCallback((newValue: DateRangePickerValue) => {
// Instant visual feedback
setIsDateChanging(true);
// Update date immediately for UI responsiveness
setDateValue(newValue);
}, []);
// Derived states from userSpendData
const totalSpend = userSpendData.metadata?.total_spend || 0;
@ -362,87 +423,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
.slice(0, topKeysLimit);
}, [userSpendData.results, topKeysLimit]);
const fetchUserSpendData = useCallback(async () => {
if (!accessToken || !dateValue.from || !dateValue.to) return;
// For non-admins, always pass their own user_id
const effectiveUserId = isAdmin ? selectedUserId : (userID || null);
setLoading(true);
// Create new Date objects to avoid mutating the original dates
const startTime = new Date(dateValue.from);
const endTime = new Date(dateValue.to);
try {
// Prefer aggregated endpoint to avoid many page requests
try {
const aggregated = await userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId);
setUserSpendData(aggregated);
return;
} catch (e) {
// Fallback to paginated calls if aggregated endpoint is unavailable
}
const firstPageData = await userDailyActivityCall(accessToken, startTime, endTime, 1, effectiveUserId);
if (firstPageData.metadata.total_pages <= 1) {
setUserSpendData(firstPageData);
return;
}
const allResults = [...firstPageData.results];
const aggregatedMetadata = { ...firstPageData.metadata };
for (let page = 2; page <= firstPageData.metadata.total_pages; page++) {
const pageData = await userDailyActivityCall(accessToken, startTime, endTime, page, effectiveUserId);
allResults.push(...pageData.results);
if (pageData.metadata) {
aggregatedMetadata.total_spend = (aggregatedMetadata.total_spend || 0) + (pageData.metadata.total_spend || 0);
aggregatedMetadata.total_api_requests = (aggregatedMetadata.total_api_requests || 0) + (pageData.metadata.total_api_requests || 0);
aggregatedMetadata.total_successful_requests = (aggregatedMetadata.total_successful_requests || 0) + (pageData.metadata.total_successful_requests || 0);
aggregatedMetadata.total_failed_requests = (aggregatedMetadata.total_failed_requests || 0) + (pageData.metadata.total_failed_requests || 0);
aggregatedMetadata.total_tokens = (aggregatedMetadata.total_tokens || 0) + (pageData.metadata.total_tokens || 0);
aggregatedMetadata.total_prompt_tokens = (aggregatedMetadata.total_prompt_tokens || 0) + (pageData.metadata.total_prompt_tokens || 0);
aggregatedMetadata.total_completion_tokens = (aggregatedMetadata.total_completion_tokens || 0) + (pageData.metadata.total_completion_tokens || 0);
aggregatedMetadata.total_cache_read_input_tokens = (aggregatedMetadata.total_cache_read_input_tokens || 0) + (pageData.metadata.total_cache_read_input_tokens || 0);
aggregatedMetadata.total_cache_creation_input_tokens = (aggregatedMetadata.total_cache_creation_input_tokens || 0) + (pageData.metadata.total_cache_creation_input_tokens || 0);
}
}
setUserSpendData({
results: allResults,
metadata: aggregatedMetadata,
});
} catch (error) {
console.error("Error fetching user spend data:", error);
} finally {
setLoading(false);
setIsDateChanging(false);
}
}, [accessToken, dateValue.from, dateValue.to, selectedUserId, isAdmin, userID]);
// Super responsive date change handler
const handleDateChange = useCallback((newValue: DateRangePickerValue) => {
// Instant visual feedback
setIsDateChanging(true);
setLoading(true);
// Update date immediately for UI responsiveness
setDateValue(newValue);
}, []);
// Debounced effect for data fetching with shorter delay
useEffect(() => {
if (!dateValue.from || !dateValue.to) return;
const timeoutId = setTimeout(() => {
fetchUserSpendData();
}, 50); // Very short debounce
return () => clearTimeout(timeoutId);
}, [fetchUserSpendData]);
const sortedDailyResults = useMemo(
() => [...userSpendData.results].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()),
[userSpendData.results],
@ -502,6 +482,29 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
/>
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</div>
{(paginatedResult.isFetchingMore || paginatedResult.cancelled) && (
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
{paginatedResult.isFetchingMore && (
<>
<LoadingOutlined spin className="text-xs" />
<span>
Loading spend data... (page {paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages})
</span>
<button
onClick={paginatedResult.cancel}
className="text-blue-600 hover:text-blue-800 underline text-xs"
>
Stop
</button>
</>
)}
{paginatedResult.cancelled && (
<span className="text-yellow-600 text-xs">
Showing partial data ({paginatedResult.progress.currentPage}/{paginatedResult.progress.totalPages} pages loaded)
</span>
)}
</div>
)}
{/* Your Usage Panel */}
{usageView === "global" && (
<>

View file

@ -0,0 +1,209 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { DailyData } from "../types";
export interface PaginationProgress {
currentPage: number;
totalPages: number;
}
/** Delay between sequential page fetches (ms) to avoid overloading the backend. */
const PAGE_FETCH_DELAY_MS = 500;
/** The metadata fields returned by the daily activity API that should be summed across pages. */
const SUMMABLE_METADATA_KEYS = [
"total_spend",
"total_prompt_tokens",
"total_completion_tokens",
"total_tokens",
"total_api_requests",
"total_successful_requests",
"total_failed_requests",
"total_cache_read_input_tokens",
"total_cache_creation_input_tokens",
] as const;
interface DailyActivityResponse {
results: DailyData[];
metadata: Record<string, any>;
}
type FetchPageFn = (...args: any[]) => Promise<DailyActivityResponse>;
interface UsePaginatedDailyActivityParams {
/** The API call function (e.g., userDailyActivityCall). */
fetchFn: FetchPageFn;
/** Arguments to pass to fetchFn: [accessToken, startTime, endTime, ...extraArgs]. Page is injected by the hook at index 3. */
args: any[];
/** Whether the hook should fetch. Set to false to disable. */
enabled: boolean;
}
interface UsePaginatedDailyActivityReturn {
data: DailyActivityResponse;
loading: boolean;
isFetchingMore: boolean;
progress: PaginationProgress;
cancelled: boolean;
cancel: () => void;
}
const EMPTY_DATA: DailyActivityResponse = {
results: [],
metadata: {
total_spend: 0,
total_prompt_tokens: 0,
total_completion_tokens: 0,
total_tokens: 0,
total_api_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,
total_cache_read_input_tokens: 0,
total_cache_creation_input_tokens: 0,
total_pages: 1,
has_more: false,
page: 1,
},
};
function sumMetadata(
a: Record<string, any>,
b: Record<string, any>,
): Record<string, any> {
const result = { ...a };
for (const key of SUMMABLE_METADATA_KEYS) {
result[key] = (a[key] || 0) + (b[key] || 0);
}
return result;
}
/**
* Hook that auto-paginates daily activity endpoints, updating state after each
* page so charts render progressively. Cancels on unmount, param changes, or
* manual cancel().
*
* The `args` array should contain every argument the fetchFn expects EXCEPT
* the `page` parameter. The hook injects `page` as the 4th argument (index 3),
* matching the signature of all daily activity calls:
* (accessToken, startTime, endTime, page, ...rest)
*/
export function usePaginatedDailyActivity({
fetchFn,
args,
enabled,
}: UsePaginatedDailyActivityParams): UsePaginatedDailyActivityReturn {
const [data, setData] = useState<DailyActivityResponse>(EMPTY_DATA);
const [loading, setLoading] = useState(false);
const [isFetchingMore, setIsFetchingMore] = useState(false);
const [progress, setProgress] = useState<PaginationProgress>({
currentPage: 0,
totalPages: 0,
});
const [cancelled, setCancelled] = useState(false);
const fetchIdRef = useRef(0);
const cancelledRef = useRef(false);
const cancel = useCallback(() => {
cancelledRef.current = true;
setCancelled(true);
setIsFetchingMore(false);
}, []);
useEffect(() => {
if (!enabled) {
setData(EMPTY_DATA);
setLoading(false);
setIsFetchingMore(false);
setProgress({ currentPage: 0, totalPages: 0 });
setCancelled(false);
return;
}
const currentFetchId = ++fetchIdRef.current;
cancelledRef.current = false;
setCancelled(false);
const isStale = () =>
fetchIdRef.current !== currentFetchId || cancelledRef.current;
const run = async () => {
setLoading(true);
setIsFetchingMore(false);
setProgress({ currentPage: 1, totalPages: 1 });
try {
// Inject page=1 as the 4th argument.
const argsWithPage = [...args.slice(0, 3), 1, ...args.slice(3)];
const firstPage = await fetchFn(...argsWithPage);
if (isStale()) return;
setData(firstPage);
const totalPages = firstPage.metadata?.total_pages || 1;
setProgress({ currentPage: 1, totalPages });
if (totalPages <= 1) {
setLoading(false);
return;
}
// More pages — start fetching sequentially.
setLoading(false);
setIsFetchingMore(true);
let accumulatedResults = [...firstPage.results];
let accumulatedMetadata = { ...firstPage.metadata };
for (let page = 2; page <= totalPages; page++) {
if (isStale()) return;
// Small delay to avoid overwhelming the backend.
await new Promise((resolve) =>
setTimeout(resolve, PAGE_FETCH_DELAY_MS),
);
if (isStale()) return;
const argsForPage = [...args.slice(0, 3), page, ...args.slice(3)];
const pageData = await fetchFn(...argsForPage);
if (isStale()) return;
accumulatedResults = [...accumulatedResults, ...pageData.results];
accumulatedMetadata = sumMetadata(
accumulatedMetadata,
pageData.metadata,
);
accumulatedMetadata.total_pages = totalPages;
accumulatedMetadata.has_more = page < totalPages;
accumulatedMetadata.page = page;
setData({
results: accumulatedResults,
metadata: accumulatedMetadata,
});
setProgress({ currentPage: page, totalPages });
}
setIsFetchingMore(false);
} catch (error) {
if (!isStale()) {
console.error("Error fetching daily activity:", error);
setLoading(false);
setIsFetchingMore(false);
}
}
};
run();
return () => {
fetchIdRef.current++;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, fetchFn, ...args]);
return { data, loading, isFetchingMore, progress, cancelled, cancel };
}