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 { useState } from 'react';
|
||||||
import { useSetAccountProxy } from '@/hooks/useCloudAccounts';
|
import { useSetAccountProxy } from '@/hooks/useCloudAccounts';
|
||||||
import { isValidProxyUrl } from '@/utils/url';
|
import { isValidProxyUrl } from '@/utils/url';
|
||||||
|
import { getValidationBlockedStatusLabel } from '@/components/accountValidationStatus';
|
||||||
|
|
||||||
type ModelQuotaEntry = [string, CloudQuotaModelInfo];
|
type ModelQuotaEntry = [string, CloudQuotaModelInfo];
|
||||||
|
|
||||||
@@ -336,6 +337,12 @@ export function CloudAccountCard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
|
||||||
|
account.status,
|
||||||
|
account.status_reason,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<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' : ''}`}
|
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="mb-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge
|
<Badge
|
||||||
variant={account.status === 'rate_limited' ? 'destructive' : 'outline'}
|
variant={
|
||||||
|
account.status === 'rate_limited' || account.status === 'expired'
|
||||||
|
? 'destructive'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
{account.provider.toUpperCase()}
|
{account.provider.toUpperCase()}
|
||||||
@@ -484,9 +495,9 @@ export function CloudAccountCard({
|
|||||||
{t('cloud.card.active')}
|
{t('cloud.card.active')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{account.status === 'rate_limited' && (
|
{validationBlockedStatusLabel && (
|
||||||
<span className="text-destructive text-xs font-medium">
|
<span className="text-destructive text-xs font-medium">
|
||||||
{t('cloud.card.rateLimited')}
|
{validationBlockedStatusLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -636,6 +647,12 @@ export function CompactCloudAccountCard({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validationBlockedStatusLabel = getValidationBlockedStatusLabel(
|
||||||
|
account.status,
|
||||||
|
account.status_reason,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
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">
|
<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 ? (
|
{account.avatar_url ? (
|
||||||
@@ -656,7 +673,11 @@ export function CompactCloudAccountCard({
|
|||||||
{account.name || t('cloud.card.unknown')}
|
{account.name || t('cloud.card.unknown')}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant={account.status === 'rate_limited' ? 'destructive' : 'outline'}
|
variant={
|
||||||
|
account.status === 'rate_limited' || account.status === 'expired'
|
||||||
|
? 'destructive'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
className="shrink-0 text-[10px]"
|
className="shrink-0 text-[10px]"
|
||||||
>
|
>
|
||||||
{account.provider.toUpperCase()}
|
{account.provider.toUpperCase()}
|
||||||
@@ -673,6 +694,9 @@ export function CompactCloudAccountCard({
|
|||||||
|
|
||||||
<div className="text-muted-foreground flex items-center gap-3 text-xs">
|
<div className="text-muted-foreground flex items-center gap-3 text-xs">
|
||||||
<span className="truncate">{account.email}</span>
|
<span className="truncate">{account.email}</span>
|
||||||
|
{validationBlockedStatusLabel && (
|
||||||
|
<span className="text-destructive shrink-0 font-medium">{validationBlockedStatusLabel}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{shouldShowAiCredits && aiCredits && (
|
{shouldShowAiCredits && aiCredits && (
|
||||||
<span className="shrink-0 text-amber-500">
|
<span className="shrink-0 text-amber-500">
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { CloudAccountRepo } from '../ipc/database/cloudHandler';
|
|||||||
import { GoogleAPIService } from './GoogleAPIService';
|
import { GoogleAPIService } from './GoogleAPIService';
|
||||||
import { AutoSwitchService } from './AutoSwitchService';
|
import { AutoSwitchService } from './AutoSwitchService';
|
||||||
import { logger } from '../utils/logger';
|
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 {
|
export class CloudMonitorService {
|
||||||
private static intervalId: NodeJS.Timeout | null = null;
|
private static intervalId: NodeJS.Timeout | null = null;
|
||||||
@@ -106,14 +116,23 @@ export class CloudMonitorService {
|
|||||||
const newToken = await GoogleAPIService.refreshAccessToken(
|
const newToken = await GoogleAPIService.refreshAccessToken(
|
||||||
account.token.refresh_token,
|
account.token.refresh_token,
|
||||||
account.proxy_url,
|
account.proxy_url,
|
||||||
|
account.token.oauth_client_key,
|
||||||
);
|
);
|
||||||
account.token.access_token = newToken.access_token;
|
account.token.access_token = newToken.access_token;
|
||||||
account.token.expires_in = newToken.expires_in;
|
account.token.expires_in = newToken.expires_in;
|
||||||
account.token.expiry_timestamp = now + 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);
|
await CloudAccountRepo.updateToken(account.id, account.token);
|
||||||
accessToken = newToken.access_token;
|
accessToken = newToken.access_token;
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
logger.error(`Monitor: Token refresh failed for ${account.email}`, 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;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,9 +158,18 @@ export class CloudMonitorService {
|
|||||||
// 3. Update DB
|
// 3. Update DB
|
||||||
await CloudAccountRepo.updateQuota(account.id, quota);
|
await CloudAccountRepo.updateQuota(account.id, quota);
|
||||||
await CloudAccountRepo.updateLastUsed(account.id);
|
await CloudAccountRepo.updateLastUsed(account.id);
|
||||||
|
await CloudAccountRepo.setAccountStatus(account.id, 'active', null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Monitor: Failed to update ${account.email}`, 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;
|
await pollPromise;
|
||||||
|
|
||||||
expect(CloudAccountRepo.getAccounts).toHaveBeenCalled();
|
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.updateQuota).toHaveBeenCalledWith('acc1', expect.anything());
|
||||||
expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1');
|
expect(CloudAccountRepo.updateLastUsed).toHaveBeenCalledWith('acc1');
|
||||||
expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled();
|
expect(AutoSwitchService.checkAndSwitchIfNeeded).toHaveBeenCalled();
|
||||||
@@ -93,9 +93,13 @@ describe('CloudMonitorService', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(1000);
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
await pollPromise;
|
await pollPromise;
|
||||||
|
|
||||||
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith('ref_token');
|
expect(GoogleAPIService.refreshAccessToken).toHaveBeenCalledWith(
|
||||||
|
'ref_token',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
expect(CloudAccountRepo.updateToken).toHaveBeenCalled();
|
expect(CloudAccountRepo.updateToken).toHaveBeenCalled();
|
||||||
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token');
|
expect(GoogleAPIService.fetchQuota).toHaveBeenCalledWith('new_token', undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleAppFocus (Smart Refresh)', () => {
|
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