Preserve stable snapshots and stabilize Electron e2e (#734)

This commit is contained in:
Lingxuan Zuo
2026-04-01 20:35:01 +08:00
committed by GitHub
Unverified
parent 34bbb039d3
commit 5a3da41562
21 changed files with 758 additions and 78 deletions

View File

@@ -106,6 +106,7 @@ export function Agents() {
deleteAgent,
} = useAgentsStore();
const [channelGroups, setChannelGroups] = useState<ChannelGroupItem[]>([]);
const [hasCompletedInitialLoad, setHasCompletedInitialLoad] = useState(() => agents.length > 0);
const [showAddDialog, setShowAddDialog] = useState(false);
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
@@ -116,13 +117,21 @@ export function Agents() {
const response = await hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[] }>('/api/channels/accounts');
setChannelGroups(response.channels || []);
} catch {
setChannelGroups([]);
// Keep the last rendered snapshot when channel account refresh fails.
}
}, []);
useEffect(() => {
let mounted = true;
// eslint-disable-next-line react-hooks/set-state-in-effect
void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]);
void Promise.all([fetchAgents(), fetchChannelAccounts(), refreshProviderSnapshot()]).finally(() => {
if (mounted) {
setHasCompletedInitialLoad(true);
}
});
return () => {
mounted = false;
};
}, [fetchAgents, fetchChannelAccounts, refreshProviderSnapshot]);
useEffect(() => {
@@ -150,11 +159,15 @@ export function Agents() {
() => agents.find((agent) => agent.id === activeAgentId) ?? null,
[activeAgentId, agents],
);
const visibleAgents = agents;
const visibleChannelGroups = channelGroups;
const isUsingStableValue = loading && hasCompletedInitialLoad;
const handleRefresh = () => {
void Promise.all([fetchAgents(), fetchChannelAccounts()]);
};
if (loading) {
if (loading && !hasCompletedInitialLoad) {
return (
<div className="flex flex-col -m-6 dark:bg-background min-h-[calc(100vh-2.5rem)] items-center justify-center">
<LoadingSpinner size="lg" />
@@ -163,7 +176,7 @@ export function Agents() {
}
return (
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div data-testid="agents-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16">
<div className="flex flex-col md:flex-row md:items-start justify-between mb-12 shrink-0 gap-4">
<div>
@@ -181,7 +194,7 @@ export function Agents() {
onClick={handleRefresh}
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground transition-colors"
>
<RefreshCw className="h-3.5 w-3.5 mr-2" />
<RefreshCw className={cn('h-3.5 w-3.5 mr-2', isUsingStableValue && 'animate-spin')} />
{t('refresh')}
</Button>
<Button
@@ -214,11 +227,11 @@ export function Agents() {
)}
<div className="space-y-3">
{agents.map((agent) => (
{visibleAgents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
channelGroups={channelGroups}
channelGroups={visibleChannelGroups}
onOpenSettings={() => setActiveAgentId(agent.id)}
onDelete={() => setAgentToDelete(agent)}
/>
@@ -241,7 +254,7 @@ export function Agents() {
{activeAgent && (
<AgentSettingsModal
agent={activeAgent}
channelGroups={channelGroups}
channelGroups={visibleChannelGroups}
onClose={() => setActiveAgentId(null)}
/>
)}

View File

@@ -91,6 +91,10 @@ export function Channels() {
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
const displayedChannelTypes = getPrimaryChannels();
const visibleChannelGroups = channelGroups;
const visibleAgents = agents;
const hasStableValue = visibleChannelGroups.length > 0 || visibleAgents.length > 0;
const isUsingStableValue = hasStableValue && (loading || Boolean(error));
const fetchPageData = useCallback(async () => {
setLoading(true);
@@ -143,21 +147,21 @@ export function Channels() {
}, [fetchPageData, gatewayStatus.state]);
const configuredTypes = useMemo(
() => channelGroups.map((group) => group.channelType),
[channelGroups],
() => visibleChannelGroups.map((group) => group.channelType),
[visibleChannelGroups],
);
const groupedByType = useMemo(() => {
return Object.fromEntries(channelGroups.map((group) => [group.channelType, group]));
}, [channelGroups]);
return Object.fromEntries(visibleChannelGroups.map((group) => [group.channelType, group]));
}, [visibleChannelGroups]);
const configuredGroups = useMemo(() => {
const known = displayedChannelTypes
.map((type) => groupedByType[type])
.filter((group): group is ChannelGroupItem => Boolean(group));
const unknown = channelGroups.filter((group) => !displayedChannelTypes.includes(group.channelType as ChannelType));
const unknown = visibleChannelGroups.filter((group) => !displayedChannelTypes.includes(group.channelType as ChannelType));
return [...known, ...unknown];
}, [channelGroups, displayedChannelTypes, groupedByType]);
}, [visibleChannelGroups, displayedChannelTypes, groupedByType]);
const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type));
@@ -217,7 +221,7 @@ export function Channels() {
return nextAccountId;
};
if (loading) {
if (loading && !hasStableValue) {
return (
<div className="flex flex-col -m-6 dark:bg-background min-h-[calc(100vh-2.5rem)] items-center justify-center">
<LoadingSpinner size="lg" />
@@ -226,7 +230,7 @@ export function Channels() {
}
return (
<div className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div data-testid="channels-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
<div className="w-full max-w-5xl mx-auto flex flex-col h-full p-10 pt-16">
<div className="flex flex-col md:flex-row md:items-start justify-between mb-12 shrink-0 gap-4">
<div>
@@ -245,7 +249,7 @@ export function Channels() {
disabled={gatewayStatus.state !== 'running'}
className="h-9 text-[13px] font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground transition-colors"
>
<RefreshCw className="h-3.5 w-3.5 mr-2" />
<RefreshCw className={cn('h-3.5 w-3.5 mr-2', isUsingStableValue && 'animate-spin')} />
{t('refresh')}
</Button>
</div>
@@ -368,7 +372,7 @@ export function Channels() {
}}
>
<option value="">{t('account.unassigned')}</option>
{agents.map((agent) => (
{visibleAgents.map((agent) => (
<option key={agent.id} value={agent.id}>{agent.name}</option>
))}
</select>

View File

@@ -15,6 +15,8 @@ import { FeedbackState } from '@/components/common/FeedbackState';
import {
filterUsageHistoryByWindow,
groupUsageHistory,
resolveStableUsageHistory,
resolveVisibleUsageHistory,
type UsageGroupBy,
type UsageHistoryEntry,
type UsageWindow,
@@ -22,6 +24,7 @@ import {
const DEFAULT_USAGE_FETCH_MAX_ATTEMPTS = 2;
const WINDOWS_USAGE_FETCH_MAX_ATTEMPTS = 3;
const USAGE_FETCH_RETRY_DELAY_MS = 1500;
const USAGE_AUTO_REFRESH_INTERVAL_MS = 15_000;
export function Models() {
const { t } = useTranslation(['dashboard', 'settings']);
@@ -36,6 +39,7 @@ export function Models() {
const [usageWindow, setUsageWindow] = useState<UsageWindow>('7d');
const [usagePage, setUsagePage] = useState(1);
const [selectedUsageEntry, setSelectedUsageEntry] = useState<UsageHistoryEntry | null>(null);
const [usageRefreshNonce, setUsageRefreshNonce] = useState(0);
const HIDDEN_USAGE_SOURCES = new Set([
'gateway-injected',
'delivery-mirror',
@@ -71,35 +75,79 @@ export function Models() {
type FetchState = {
status: 'idle' | 'loading' | 'done';
data: UsageHistoryEntry[];
stableData: UsageHistoryEntry[];
};
type FetchAction =
| { type: 'start' }
| { type: 'done'; data: UsageHistoryEntry[] }
| { type: 'failed' }
| { type: 'reset' };
const [fetchState, dispatchFetch] = useReducer(
(state: FetchState, action: FetchAction): FetchState => {
switch (action.type) {
case 'start':
return { status: 'loading', data: state.data };
return { ...state, status: 'loading' };
case 'done':
return { status: 'done', data: action.data };
return {
status: 'done',
data: action.data,
stableData: resolveStableUsageHistory(state.stableData, action.data),
};
case 'failed':
return { ...state, status: 'done' };
case 'reset':
return { status: 'idle', data: [] };
return { status: 'idle', data: [], stableData: [] };
default:
return state;
}
},
{ status: 'idle' as const, data: [] as UsageHistoryEntry[] },
{ status: 'idle' as const, data: [] as UsageHistoryEntry[], stableData: [] as UsageHistoryEntry[] },
);
const usageFetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const usageFetchGenerationRef = useRef(0);
const usageFetchStatusRef = useRef<FetchState['status']>('idle');
useEffect(() => {
usageFetchStatusRef.current = fetchState.status;
}, [fetchState.status]);
useEffect(() => {
trackUiEvent('models.page_viewed');
}, []);
useEffect(() => {
if (!isGatewayRunning) {
return;
}
const requestRefresh = () => {
if (usageFetchStatusRef.current === 'loading') return;
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
setUsageRefreshNonce((value) => value + 1);
};
const intervalId = window.setInterval(requestRefresh, USAGE_AUTO_REFRESH_INTERVAL_MS);
const handleFocus = () => {
requestRefresh();
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
requestRefresh();
}
};
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.clearInterval(intervalId);
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [isGatewayRunning]);
useEffect(() => {
if (usageFetchTimerRef.current) {
clearTimeout(usageFetchTimerRef.current);
@@ -128,7 +176,7 @@ export function Models() {
generation,
restartMarker,
});
dispatchFetch({ type: 'done', data: [] });
dispatchFetch({ type: 'failed' });
}, 30_000);
const fetchUsageHistoryWithRetry = async (attempt: number) => {
@@ -191,7 +239,7 @@ export function Models() {
}, USAGE_FETCH_RETRY_DELAY_MS);
return;
}
dispatchFetch({ type: 'done', data: [] });
dispatchFetch({ type: 'failed' });
trackUiEvent('models.token_usage_fetch_exhausted', {
generation,
attempt,
@@ -210,18 +258,25 @@ export function Models() {
usageFetchTimerRef.current = null;
}
};
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts]);
}, [isGatewayRunning, gatewayStatus.connectedAt, gatewayStatus.pid, usageFetchMaxAttempts, usageRefreshNonce]);
const visibleUsageHistory = isGatewayRunning
const usageHistory = isGatewayRunning
? fetchState.data.filter((entry) => !shouldHideUsageEntry(entry))
: [];
const stableUsageHistory = isGatewayRunning
? fetchState.stableData.filter((entry) => !shouldHideUsageEntry(entry))
: [];
const visibleUsageHistory = resolveVisibleUsageHistory(usageHistory, stableUsageHistory, {
preferStableOnEmpty: isGatewayRunning && fetchState.status === 'loading',
});
const filteredUsageHistory = filterUsageHistoryByWindow(visibleUsageHistory, usageWindow);
const usageGroups = groupUsageHistory(filteredUsageHistory, usageGroupBy);
const usagePageSize = 5;
const usageTotalPages = Math.max(1, Math.ceil(filteredUsageHistory.length / usagePageSize));
const safeUsagePage = Math.min(usagePage, usageTotalPages);
const pagedUsageHistory = filteredUsageHistory.slice((safeUsagePage - 1) * usagePageSize, safeUsagePage * usagePageSize);
const usageLoading = isGatewayRunning && fetchState.status === 'loading';
const usageLoading = isGatewayRunning && fetchState.status === 'loading' && visibleUsageHistory.length === 0;
const usageRefreshing = isGatewayRunning && fetchState.status === 'loading' && visibleUsageHistory.length > 0;
return (
<div data-testid="models-page" className="flex flex-col -m-6 dark:bg-background h-[calc(100vh-2.5rem)] overflow-hidden">
@@ -328,7 +383,9 @@ export function Models() {
</div>
</div>
<p className="text-[13px] font-medium text-muted-foreground">
{t('dashboard:recentTokenHistory.showingLast', { count: filteredUsageHistory.length })}
{usageRefreshing
? t('dashboard:recentTokenHistory.loading')
: t('dashboard:recentTokenHistory.showingLast', { count: filteredUsageHistory.length })}
</p>
</div>

View File

@@ -26,6 +26,30 @@ export type UsageGroup = {
sortKey: number | string;
};
export function resolveStableUsageHistory(
previousStableEntries: UsageHistoryEntry[],
nextEntries: UsageHistoryEntry[],
options: { preservePreviousOnEmpty?: boolean } = {},
): UsageHistoryEntry[] {
if (nextEntries.length > 0) {
return nextEntries;
}
return options.preservePreviousOnEmpty ? previousStableEntries : [];
}
export function resolveVisibleUsageHistory(
currentEntries: UsageHistoryEntry[],
stableEntries: UsageHistoryEntry[],
options: { preferStableOnEmpty?: boolean } = {},
): UsageHistoryEntry[] {
if (options.preferStableOnEmpty && currentEntries.length === 0) {
return stableEntries;
}
return currentEntries;
}
export function formatUsageDay(timestamp: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp;