mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
[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:
parent
6549b4583f
commit
3ae9e070d9
3 changed files with 373 additions and 191 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue