feat(cloud-status): classify blocked accounts and surface validation state
This commit is contained in:
1 parent
bcdf2a6c24
commit
388fbcb2a9
4 files changed
+137
-8
No files matched your search
@@ -37,6 +37,7 @@ import {
|
||||
import { useState } from 'react';
|
||||
import { useSetAccountProxy } from '@/hooks/useCloudAccounts';
|
||||
import { isValidProxyUrl } from '@/utils/url';
|
||||
import { getValidationBlockedStatusLabel } from '@/components/accountValidationStatus';
|
||||
|
||||
type ModelQuotaEntry = [string, CloudQuotaModelInfo];
|
||||
|
||||
@@ -336,6 +337,12 @@ export function CloudAccountCard({
|
||||
}
|
||||
};
|
||||
|
||||
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
|
||||
account.status,
|
||||
account.status_reason,
|
||||
t,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
|
||||
@@ -474,7 +481,11 @@ export function CloudAccountCard({
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={account.status === 'rate_limited' ? 'destructive' : 'outline'}
|
||||
variant={
|
||||
account.status === 'rate_limited' || account.status === 'expired'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{account.provider.toUpperCase()}
|
||||
@@ -484,9 +495,9 @@ export function CloudAccountCard({
|
||||
{t('cloud.card.active')}
|
||||
</Badge>
|
||||
)}
|
||||
{account.status === 'rate_limited' && (
|
||||
{validationBlockedStatusLabel && (
|
||||
<span className="text-destructive text-xs font-medium">
|
||||
{t('cloud.card.rateLimited')}
|
||||
{validationBlockedStatusLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -636,6 +647,12 @@ export function CompactCloudAccountCard({
|
||||
}
|
||||
};
|
||||
|
||||
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
|
||||
account.status,
|
||||
account.status_reason,
|
||||
t,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group bg-card hover:border-primary/40 flex items-center gap-3 rounded-lg border px-3 py-2 transition-all duration-200">
|
||||
{account.avatar_url ? (
|
||||
@@ -656,7 +673,11 @@ export function CompactCloudAccountCard({
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</span>
|
||||
<Badge
|
||||
variant={account.status === 'rate_limited' ? 'destructive' : 'outline'}
|
||||
variant={
|
||||
account.status === 'rate_limited' || account.status === 'expired'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="shrink-0 text-[10px]"
|
||||
>
|
||||
{account.provider.toUpperCase()}
|
||||
@@ -673,6 +694,9 @@ export function CompactCloudAccountCard({
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-3 text-xs">
|
||||
<span className="truncate">{account.email}</span>
|
||||
{validationBlockedStatusLabel && (
|
||||
<span className="text-destructive shrink-0 font-medium">{validationBlockedStatusLabel}</span>
|
||||
)}
|
||||
|
||||
{shouldShowAiCredits && aiCredits && (
|
||||
<span className="shrink-0 text-amber-500">
|
||||
|
||||
@@ -3,6 +3,16 @@ import { CloudAccountRepo } from '../ipc/database/cloudHandler';
|
||||
import { GoogleAPIService } from './GoogleAPIService';
|
||||
import { AutoSwitchService } from './AutoSwitchService';
|
||||
import { logger } from '../utils/logger';
|
||||
import { classifyAccountStatusFromError } from '../utils/account-status';
|
||||
|
||||
function hasReusableCachedQuota(account: {
|
||||
quota?: { models?: Record<string, unknown> };
|
||||
}): boolean {
|
||||
if (!account.quota || !account.quota.models) {
|
||||
return false;
|
||||
}
|
||||
return Object.keys(account.quota.models).length > 0;
|
||||
}
|
||||
|
||||
export class CloudMonitorService {
|
||||
private static intervalId: NodeJS.Timeout | null = null;
|
||||
@@ -106,14 +116,23 @@ export class CloudMonitorService {
|
||||
const newToken = await GoogleAPIService.refreshAccessToken(
|
||||
account.token.refresh_token,
|
||||
account.proxy_url,
|
||||
account.token.oauth_client_key,
|
||||
);
|
||||
account.token.access_token = newToken.access_token;
|
||||
account.token.expires_in = newToken.expires_in;
|
||||
account.token.expiry_timestamp = now + newToken.expires_in;
|
||||
account.token.oauth_client_key = GoogleAPIService.normalizeRefreshedOAuthClientKey(
|
||||
account.token,
|
||||
newToken.oauth_client_key,
|
||||
);
|
||||
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||
accessToken = newToken.access_token;
|
||||
} catch (refreshError) {
|
||||
logger.error(`Monitor: Token refresh failed for ${account.email}`, refreshError);
|
||||
const classified = classifyAccountStatusFromError(refreshError);
|
||||
if (classified) {
|
||||
await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -139,9 +158,18 @@ export class CloudMonitorService {
|
||||
// 3. Update DB
|
||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||
await CloudAccountRepo.updateLastUsed(account.id);
|
||||
await CloudAccountRepo.setAccountStatus(account.id, 'active', null);
|
||||
} catch (error) {
|
||||
logger.error(`Monitor: Failed to update ${account.email}`, error);
|
||||
// Could mark status as 'error' or 'rate_limited' if 429
|
||||
const classified = classifyAccountStatusFromError(error);
|
||||
if (classified) {
|
||||
await CloudAccountRepo.setAccountStatus(account.id, classified.status, classified.reason);
|
||||
if (classified.status === 'rate_limited' && hasReusableCachedQuota(account)) {
|
||||
logger.warn(
|
||||
`Monitor: Quota request rate-limited for ${account.email}, keeping cached quota as fallback.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('CloudMonitorService', () => {
|
||||
await pollPromise;
|
||||
|
||||
expect(CloudAccountRepo.getAccounts).toHaveBeenCalled();
|
||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token');
|
||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('valid_token', undefined);
|
||||
expect(CloudAccountRepo.updateQuota).toHaveBeenCalledWith('acc1', expect.anything());
|
||||
expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1');
|
||||
expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled();
|
||||
@@ -93,9 +93,13 @@ describe('CloudMonitorService', () => {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await pollPromise;
|
||||
|
||||
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith('ref_token');
|
||||
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith(
|
||||
'ref_token',
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(CloudAccountRepo.updateToken).toHaveBeenCalled();
|
||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token');
|
||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token', undefined);
|
||||
});
|
||||
|
||||
describe('handleAppFocus (Smart Refresh)', () => {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
export const RATE_LIMIT_HINTS = [
|
||||
'resource_exhausted',
|
||||
'too many requests',
|
||||
'429',
|
||||
'rate limit',
|
||||
'rate-limited',
|
||||
'risk control',
|
||||
'risk-controlled',
|
||||
'frozen',
|
||||
] as const;
|
||||
|
||||
export const OAUTH_HINTS = [
|
||||
'unauthorized_client',
|
||||
'invalid_client',
|
||||
'invalid_grant',
|
||||
'oauth client',
|
||||
'not authorized',
|
||||
'token expired',
|
||||
'reauth',
|
||||
'verify your account',
|
||||
'further action is required',
|
||||
'validation required',
|
||||
'validation_url',
|
||||
'appeal_url',
|
||||
] as const;
|
||||
|
||||
function includesAny(text: string, hints: readonly string[]): boolean {
|
||||
return hints.some((hint) => text.includes(hint));
|
||||
}
|
||||
|
||||
export function isRateLimitReason(reason: string): boolean {
|
||||
return includesAny(reason.toLowerCase(), RATE_LIMIT_HINTS);
|
||||
}
|
||||
|
||||
export function isOAuthReauthReason(reason: string): boolean {
|
||||
return includesAny(reason.toLowerCase(), OAUTH_HINTS);
|
||||
}
|
||||
|
||||
export function extractErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function classifyAccountStatusFromError(
|
||||
error: unknown,
|
||||
): { status: 'rate_limited' | 'expired'; reason: string } | null {
|
||||
const reason = extractErrorMessage(error).trim();
|
||||
if (!reason) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedReason = reason.toLowerCase();
|
||||
if (isRateLimitReason(normalizedReason)) {
|
||||
return { status: 'rate_limited', reason };
|
||||
}
|
||||
if (isOAuthReauthReason(normalizedReason)) {
|
||||
return { status: 'expired', reason };
|
||||
}
|
||||
|
||||
if (normalizedReason.includes('forbidden')) {
|
||||
return { status: 'rate_limited', reason };
|
||||
}
|
||||
if (normalizedReason.includes('unauthorized')) {
|
||||
return { status: 'expired', reason };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user