fix(models): use useReducer for token usage fetch state to fix lint errors and reduce loading time (#586)

This commit is contained in:
paisley
2026-03-19 11:22:06 +08:00
committed by GitHub
Unverified
parent 1b527d2f49
commit 73343d16ea

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useReducer, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
ChevronLeft, ChevronLeft,
@@ -19,8 +19,8 @@ import {
type UsageHistoryEntry, type UsageHistoryEntry,
type UsageWindow, type UsageWindow,
} from './usage-history'; } from './usage-history';
const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 6; const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 2;
const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 10; const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 3;
const USAGE_FETCH_RETRY_DELAY_MS = 1500; const USAGE_FETCH_RETRY_DELAY_MS = 1500;
export function Models() { export function Models() {
@@ -32,23 +32,39 @@ export function Models() {
? WINDOWS_USAGE_FETCH_MAX_ATTEMPTS ? WINDOWS_USAGE_FETCH_MAX_ATTEMPTS
: DEFAULT_USAGE_FETCH_MAX_ATTEMPTS; : DEFAULT_USAGE_FETCH_MAX_ATTEMPTS;
const [usageHistory, setUsageHistory] = useState<UsageHistoryEntry[]>([]);
const [usageGroupBy, setUsageGroupBy] = useState<UsageGroupBy>('model'); const [usageGroupBy, setUsageGroupBy] = useState<UsageGroupBy>('model');
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d'); const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
const [usagePage, setUsagePage] = useState(1); const [usagePage, setUsagePage] = useState(1);
const [selectedUsageEntry, setSelectedUsageEntry] = useState<UsageHistoryEntry | null>(null); const [selectedUsageEntry, setSelectedUsageEntry] = useState<UsageHistoryEntry | null>(null);
const [usageFetchDoneKey, setUsageFetchDoneKey] = useState<string | null>(null);
type FetchState = {
status: 'idle' | 'loading' | 'done';
data: UsageHistoryEntry[];
};
type FetchAction =
| { type: 'start' }
| { type: 'done'; data: UsageHistoryEntry[] }
| { type: 'reset' };
const [fetchState, dispatchFetch] = useReducer(
(state: FetchState, action: FetchAction): FetchState => {
switch (action.type) {
case 'start':
return { status: 'loading', data: state.data };
case 'done':
return { status: 'done', data: action.data };
case 'reset':
return { status: 'idle', data: [] };
default:
return state;
}
},
{ status: 'idle' as const, data: [] as UsageHistoryEntry[] },
);
const usageFetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const usageFetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const usageFetchGenerationRef = useRef(0); const usageFetchGenerationRef = useRef(0);
// Stable key derived from the effect's dependencies — changes whenever a new
// fetch cycle should start. Comparing this to `usageFetchDoneKey` lets us
// derive the loading state without calling setState in the effect body or
// reading refs during render.
const usageFetchKey = isGatewayRunning
? `${gatewayStatus.pid ?? 'na'}:${gatewayStatus.connectedAt ?? 'na'}:${usageFetchMaxAttempts}`
: null;
useEffect(() => { useEffect(() => {
trackUiEvent('models.page_viewed'); trackUiEvent('models.page_viewed');
}, []); }, []);
@@ -60,10 +76,11 @@ export function Models() {
} }
if (!isGatewayRunning) { if (!isGatewayRunning) {
dispatchFetch({ type: 'reset' });
return; return;
} }
const fetchKey = `${gatewayStatus.pid ?? 'na'}:${gatewayStatus.connectedAt ?? 'na'}:${usageFetchMaxAttempts}`; dispatchFetch({ type: 'start' });
const generation = usageFetchGenerationRef.current + 1; const generation = usageFetchGenerationRef.current + 1;
usageFetchGenerationRef.current = generation; usageFetchGenerationRef.current = generation;
const restartMarker = `${gatewayStatus.pid ?? 'na'}:${gatewayStatus.connectedAt ?? 'na'}`; const restartMarker = `${gatewayStatus.pid ?? 'na'}:${gatewayStatus.connectedAt ?? 'na'}`;
@@ -72,6 +89,17 @@ export function Models() {
restartMarker, restartMarker,
}); });
// Safety timeout: if the fetch cycle hasn't resolved after 30 s,
// force-resolve to "done" with empty data to avoid an infinite spinner.
const safetyTimeout = setTimeout(() => {
if (usageFetchGenerationRef.current !== generation) return;
trackUiEvent('models.token_usage_fetch_safety_timeout', {
generation,
restartMarker,
});
dispatchFetch({ type: 'done', data: [] });
}, 30_000);
const fetchUsageHistoryWithRetry = async (attempt: number) => { const fetchUsageHistoryWithRetry = async (attempt: number) => {
trackUiEvent('models.token_usage_fetch_attempt', { trackUiEvent('models.token_usage_fetch_attempt', {
generation, generation,
@@ -83,7 +111,6 @@ export function Models() {
if (usageFetchGenerationRef.current !== generation) return; if (usageFetchGenerationRef.current !== generation) return;
const normalized = Array.isArray(entries) ? entries : []; const normalized = Array.isArray(entries) ? entries : [];
setUsageHistory(normalized);
setUsagePage(1); setUsagePage(1);
trackUiEvent('models.token_usage_fetch_succeeded', { trackUiEvent('models.token_usage_fetch_succeeded', {
generation, generation,
@@ -111,7 +138,7 @@ export function Models() {
restartMarker, restartMarker,
}); });
} }
setUsageFetchDoneKey(fetchKey); dispatchFetch({ type: 'done', data: normalized });
} }
} catch (error) { } catch (error) {
if (usageFetchGenerationRef.current !== generation) return; if (usageFetchGenerationRef.current !== generation) return;
@@ -133,8 +160,7 @@ export function Models() {
}, USAGE_FETCH_RETRY_DELAY_MS); }, USAGE_FETCH_RETRY_DELAY_MS);
return; return;
} }
setUsageHistory([]); dispatchFetch({ type: 'done', data: [] });
setUsageFetchDoneKey(fetchKey);
trackUiEvent('models.token_usage_fetch_exhausted', { trackUiEvent('models.token_usage_fetch_exhausted', {
generation, generation,
attempt, attempt,
@@ -147,6 +173,7 @@ export function Models() {
void fetchUsageHistoryWithRetry(1); void fetchUsageHistoryWithRetry(1);
return () => { return () => {
clearTimeout(safetyTimeout);
if (usageFetchTimerRef.current) { if (usageFetchTimerRef.current) {
clearTimeout(usageFetchTimerRef.current); clearTimeout(usageFetchTimerRef.current);
usageFetchTimerRef.current = null; usageFetchTimerRef.current = null;
@@ -154,6 +181,7 @@ export function Models() {
}; };
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]); }, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]);
const usageHistory = fetchState.data;
const visibleUsageHistory = isGatewayRunning ? usageHistory : []; const visibleUsageHistory = isGatewayRunning ? usageHistory : [];
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow); const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy); const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy);
@@ -161,7 +189,7 @@ export function Models() {
const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize)); const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize));
const safeUsagePage = Math.min(usagePage, usageTotalPages); const safeUsagePage = Math.min(usagePage, usageTotalPages);
const pagedUsageHistory = filteredUsageHistory.slice((safeUsagePage - 1) * usagePageSize, safeUsagePage * usagePageSize); const pagedUsageHistory = filteredUsageHistory.slice((safeUsagePage - 1) * usagePageSize, safeUsagePage * usagePageSize);
const usageLoading = isGatewayRunning && usageFetchDoneKey !== usageFetchKey; const usageLoading = isGatewayRunning && fetchState.status === 'loading';
return ( return (
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden"> <div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">