mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 03:00:49 +08:00
1811 lines
61 KiB
TypeScript
1811 lines
61 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Card } from '@/components/ui/Card';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { EmptyState } from '@/components/ui/EmptyState';
|
|
import { useAuthStore, useQuotaStore, useThemeStore } from '@/stores';
|
|
import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api';
|
|
import type {
|
|
AntigravityQuotaGroup,
|
|
AntigravityQuotaState,
|
|
AuthFileItem,
|
|
CodexQuotaState,
|
|
CodexQuotaWindow,
|
|
GeminiCliQuotaBucketState,
|
|
GeminiCliQuotaState
|
|
} from '@/types';
|
|
import styles from './QuotaPage.module.scss';
|
|
|
|
type ThemeColors = { bg: string; text: string; border?: string };
|
|
type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
|
|
type ResolvedTheme = 'light' | 'dark';
|
|
|
|
// Match the legacy file-type badge colors from styles.css.
|
|
const TYPE_COLORS: Record<string, TypeColorSet> = {
|
|
qwen: {
|
|
light: { bg: '#e8f5e9', text: '#2e7d32' },
|
|
dark: { bg: '#1b5e20', text: '#81c784' }
|
|
},
|
|
gemini: {
|
|
light: { bg: '#e3f2fd', text: '#1565c0' },
|
|
dark: { bg: '#0d47a1', text: '#64b5f6' }
|
|
},
|
|
'gemini-cli': {
|
|
light: { bg: '#e7efff', text: '#1e4fa3' },
|
|
dark: { bg: '#1c3f73', text: '#a8c7ff' }
|
|
},
|
|
aistudio: {
|
|
light: { bg: '#f0f2f5', text: '#2f343c' },
|
|
dark: { bg: '#373c42', text: '#cfd3db' }
|
|
},
|
|
claude: {
|
|
light: { bg: '#fce4ec', text: '#c2185b' },
|
|
dark: { bg: '#880e4f', text: '#f48fb1' }
|
|
},
|
|
codex: {
|
|
light: { bg: '#fff3e0', text: '#ef6c00' },
|
|
dark: { bg: '#e65100', text: '#ffb74d' }
|
|
},
|
|
antigravity: {
|
|
light: { bg: '#e0f7fa', text: '#006064' },
|
|
dark: { bg: '#004d40', text: '#80deea' }
|
|
},
|
|
iflow: {
|
|
light: { bg: '#f3e5f5', text: '#7b1fa2' },
|
|
dark: { bg: '#4a148c', text: '#ce93d8' }
|
|
},
|
|
empty: {
|
|
light: { bg: '#f5f5f5', text: '#616161' },
|
|
dark: { bg: '#424242', text: '#bdbdbd' }
|
|
},
|
|
unknown: {
|
|
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
|
|
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' }
|
|
}
|
|
};
|
|
|
|
interface GeminiCliQuotaBucket {
|
|
modelId?: string;
|
|
model_id?: string;
|
|
tokenType?: string;
|
|
token_type?: string;
|
|
remainingFraction?: number | string;
|
|
remaining_fraction?: number | string;
|
|
remainingAmount?: number | string;
|
|
remaining_amount?: number | string;
|
|
resetTime?: string;
|
|
reset_time?: string;
|
|
}
|
|
|
|
interface GeminiCliQuotaPayload {
|
|
buckets?: GeminiCliQuotaBucket[];
|
|
}
|
|
|
|
interface AntigravityQuotaInfo {
|
|
displayName?: string;
|
|
quotaInfo?: {
|
|
remainingFraction?: number | string;
|
|
remaining_fraction?: number | string;
|
|
remaining?: number | string;
|
|
resetTime?: string;
|
|
reset_time?: string;
|
|
};
|
|
quota_info?: {
|
|
remainingFraction?: number | string;
|
|
remaining_fraction?: number | string;
|
|
remaining?: number | string;
|
|
resetTime?: string;
|
|
reset_time?: string;
|
|
};
|
|
}
|
|
|
|
type AntigravityModelsPayload = Record<string, AntigravityQuotaInfo>;
|
|
|
|
interface AntigravityQuotaGroupDefinition {
|
|
id: string;
|
|
label: string;
|
|
identifiers: string[];
|
|
labelFromModel?: boolean;
|
|
}
|
|
|
|
const ANTIGRAVITY_QUOTA_URLS = [
|
|
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
|
|
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
|
|
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels'
|
|
];
|
|
|
|
const ANTIGRAVITY_REQUEST_HEADERS = {
|
|
Authorization: 'Bearer $TOKEN$',
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': 'antigravity/1.11.5 windows/amd64'
|
|
};
|
|
|
|
const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
|
|
{
|
|
id: 'claude-gpt',
|
|
label: 'Claude/GPT',
|
|
identifiers: [
|
|
'claude-sonnet-4-5-thinking',
|
|
'claude-opus-4-5-thinking',
|
|
'claude-sonnet-4-5',
|
|
'gpt-oss-120b-medium'
|
|
]
|
|
},
|
|
{
|
|
id: 'gemini',
|
|
label: 'Gemini',
|
|
identifiers: [
|
|
'gemini-3-pro-high',
|
|
'gemini-3-pro-low',
|
|
'gemini-2.5-flash',
|
|
'gemini-2.5-flash-lite',
|
|
'rev19-uic3-1p'
|
|
]
|
|
},
|
|
{
|
|
id: 'gemini-3-flash',
|
|
label: 'Gemini 3 Flash',
|
|
identifiers: ['gemini-3-flash']
|
|
},
|
|
{
|
|
id: 'gemini-image',
|
|
label: 'gemini-3-pro-image',
|
|
identifiers: ['gemini-3-pro-image'],
|
|
labelFromModel: true
|
|
}
|
|
];
|
|
|
|
const GEMINI_CLI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
|
|
|
|
const GEMINI_CLI_REQUEST_HEADERS = {
|
|
Authorization: 'Bearer $TOKEN$',
|
|
'Content-Type': 'application/json'
|
|
};
|
|
|
|
interface CodexUsageWindow {
|
|
used_percent?: number | string;
|
|
usedPercent?: number | string;
|
|
limit_window_seconds?: number | string;
|
|
limitWindowSeconds?: number | string;
|
|
reset_after_seconds?: number | string;
|
|
resetAfterSeconds?: number | string;
|
|
reset_at?: number | string;
|
|
resetAt?: number | string;
|
|
}
|
|
|
|
interface CodexRateLimitInfo {
|
|
allowed?: boolean;
|
|
limit_reached?: boolean;
|
|
limitReached?: boolean;
|
|
primary_window?: CodexUsageWindow | null;
|
|
primaryWindow?: CodexUsageWindow | null;
|
|
secondary_window?: CodexUsageWindow | null;
|
|
secondaryWindow?: CodexUsageWindow | null;
|
|
}
|
|
|
|
interface CodexUsagePayload {
|
|
plan_type?: string;
|
|
planType?: string;
|
|
rate_limit?: CodexRateLimitInfo | null;
|
|
rateLimit?: CodexRateLimitInfo | null;
|
|
code_review_rate_limit?: CodexRateLimitInfo | null;
|
|
codeReviewRateLimit?: CodexRateLimitInfo | null;
|
|
}
|
|
|
|
const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
|
|
|
|
const CODEX_REQUEST_HEADERS = {
|
|
Authorization: 'Bearer $TOKEN$',
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal'
|
|
};
|
|
|
|
const createStatusError = (message: string, status?: number) => {
|
|
const error = new Error(message) as Error & { status?: number };
|
|
if (status !== undefined) {
|
|
error.status = status;
|
|
}
|
|
return error;
|
|
};
|
|
|
|
const getStatusFromError = (err: unknown): number | undefined => {
|
|
if (typeof err === 'object' && err !== null && 'status' in err) {
|
|
const rawStatus = (err as { status?: unknown }).status;
|
|
if (typeof rawStatus === 'number' && Number.isFinite(rawStatus)) {
|
|
return rawStatus;
|
|
}
|
|
const asNumber = Number(rawStatus);
|
|
if (Number.isFinite(asNumber) && asNumber > 0) {
|
|
return asNumber;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
// Normalize auth_index (align with usage.ts normalizeAuthIndex).
|
|
function normalizeAuthIndexValue(value: unknown): string | null {
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value.toString();
|
|
}
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeStringValue(value: unknown): string | null {
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
return value.toString();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeNumberValue(value: unknown): number | null {
|
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
const parsed = Number(trimmed);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeQuotaFraction(value: unknown): number | null {
|
|
const normalized = normalizeNumberValue(value);
|
|
if (normalized !== null) return normalized;
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
if (trimmed.endsWith('%')) {
|
|
const parsed = Number(trimmed.slice(0, -1));
|
|
return Number.isFinite(parsed) ? parsed / 100 : null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizePlanType(value: unknown): string | null {
|
|
const normalized = normalizeStringValue(value);
|
|
return normalized ? normalized.toLowerCase() : null;
|
|
}
|
|
|
|
function decodeBase64UrlPayload(value: string): string | null {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
try {
|
|
const normalized = trimmed.replace(/-/g, '+').replace(/_/g, '/');
|
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
|
if (typeof window !== 'undefined' && typeof window.atob === 'function') {
|
|
return window.atob(padded);
|
|
}
|
|
if (typeof atob === 'function') {
|
|
return atob(padded);
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseIdTokenPayload(value: unknown): Record<string, unknown> | null {
|
|
if (!value) return null;
|
|
if (typeof value === 'object') {
|
|
return Array.isArray(value) ? null : (value as Record<string, unknown>);
|
|
}
|
|
if (typeof value !== 'string') return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
try {
|
|
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
if (parsed && typeof parsed === 'object') return parsed;
|
|
} catch {
|
|
}
|
|
const segments = trimmed.split('.');
|
|
if (segments.length < 2) return null;
|
|
const decoded = decodeBase64UrlPayload(segments[1]);
|
|
if (!decoded) return null;
|
|
try {
|
|
const parsed = JSON.parse(decoded) as Record<string, unknown>;
|
|
if (parsed && typeof parsed === 'object') return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractCodexChatgptAccountId(value: unknown): string | null {
|
|
const payload = parseIdTokenPayload(value);
|
|
if (!payload) return null;
|
|
return normalizeStringValue(payload.chatgpt_account_id ?? payload.chatgptAccountId);
|
|
}
|
|
|
|
function resolveCodexChatgptAccountId(file: AuthFileItem): string | null {
|
|
const metadata =
|
|
file && typeof file.metadata === 'object' && file.metadata !== null
|
|
? (file.metadata as Record<string, unknown>)
|
|
: null;
|
|
const attributes =
|
|
file && typeof file.attributes === 'object' && file.attributes !== null
|
|
? (file.attributes as Record<string, unknown>)
|
|
: null;
|
|
|
|
const candidates = [file.id_token, metadata?.id_token, attributes?.id_token];
|
|
|
|
for (const candidate of candidates) {
|
|
const id = extractCodexChatgptAccountId(candidate);
|
|
if (id) return id;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveCodexPlanType(file: AuthFileItem): string | null {
|
|
const metadata =
|
|
file && typeof file.metadata === 'object' && file.metadata !== null
|
|
? (file.metadata as Record<string, unknown>)
|
|
: null;
|
|
const attributes =
|
|
file && typeof file.attributes === 'object' && file.attributes !== null
|
|
? (file.attributes as Record<string, unknown>)
|
|
: null;
|
|
const idToken =
|
|
file && typeof file.id_token === 'object' && file.id_token !== null
|
|
? (file.id_token as Record<string, unknown>)
|
|
: null;
|
|
const metadataIdToken =
|
|
metadata && typeof metadata.id_token === 'object' && metadata.id_token !== null
|
|
? (metadata.id_token as Record<string, unknown>)
|
|
: null;
|
|
const candidates = [
|
|
file.plan_type,
|
|
file.planType,
|
|
file['plan_type'],
|
|
file['planType'],
|
|
file.id_token,
|
|
idToken?.plan_type,
|
|
idToken?.planType,
|
|
metadata?.plan_type,
|
|
metadata?.planType,
|
|
metadata?.id_token,
|
|
metadataIdToken?.plan_type,
|
|
metadataIdToken?.planType,
|
|
attributes?.plan_type,
|
|
attributes?.planType,
|
|
attributes?.id_token
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const planType = normalizePlanType(candidate);
|
|
if (planType) return planType;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function extractGeminiCliProjectId(value: unknown): string | null {
|
|
if (typeof value !== 'string') return null;
|
|
const matches = Array.from(value.matchAll(/\(([^()]+)\)/g));
|
|
if (matches.length === 0) return null;
|
|
const candidate = matches[matches.length - 1]?.[1]?.trim();
|
|
return candidate ? candidate : null;
|
|
}
|
|
|
|
function resolveGeminiCliProjectId(file: AuthFileItem): string | null {
|
|
const metadata =
|
|
file && typeof file.metadata === 'object' && file.metadata !== null
|
|
? (file.metadata as Record<string, unknown>)
|
|
: null;
|
|
const attributes =
|
|
file && typeof file.attributes === 'object' && file.attributes !== null
|
|
? (file.attributes as Record<string, unknown>)
|
|
: null;
|
|
|
|
const candidates = [
|
|
file.account,
|
|
file['account'],
|
|
metadata?.account,
|
|
attributes?.account
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const projectId = extractGeminiCliProjectId(candidate);
|
|
if (projectId) return projectId;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseAntigravityPayload(payload: unknown): Record<string, unknown> | null {
|
|
if (payload === undefined || payload === null) return null;
|
|
if (typeof payload === 'string') {
|
|
const trimmed = payload.trim();
|
|
if (!trimmed) return null;
|
|
try {
|
|
return JSON.parse(trimmed) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (typeof payload === 'object') {
|
|
return payload as Record<string, unknown>;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseCodexUsagePayload(payload: unknown): CodexUsagePayload | null {
|
|
if (payload === undefined || payload === null) return null;
|
|
if (typeof payload === 'string') {
|
|
const trimmed = payload.trim();
|
|
if (!trimmed) return null;
|
|
try {
|
|
return JSON.parse(trimmed) as CodexUsagePayload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (typeof payload === 'object') {
|
|
return payload as CodexUsagePayload;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseGeminiCliQuotaPayload(payload: unknown): GeminiCliQuotaPayload | null {
|
|
if (payload === undefined || payload === null) return null;
|
|
if (typeof payload === 'string') {
|
|
const trimmed = payload.trim();
|
|
if (!trimmed) return null;
|
|
try {
|
|
return JSON.parse(trimmed) as GeminiCliQuotaPayload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
if (typeof payload === 'object') {
|
|
return payload as GeminiCliQuotaPayload;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
|
|
remainingFraction: number | null;
|
|
resetTime?: string;
|
|
displayName?: string;
|
|
} {
|
|
if (!entry) {
|
|
return { remainingFraction: null };
|
|
}
|
|
const quotaInfo = entry.quotaInfo ?? entry.quota_info ?? {};
|
|
const remainingValue =
|
|
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
|
|
const remainingFraction = normalizeQuotaFraction(remainingValue);
|
|
const resetValue = quotaInfo.resetTime ?? quotaInfo.reset_time;
|
|
const resetTime = typeof resetValue === 'string' ? resetValue : undefined;
|
|
const displayName = typeof entry.displayName === 'string' ? entry.displayName : undefined;
|
|
|
|
return {
|
|
remainingFraction,
|
|
resetTime,
|
|
displayName
|
|
};
|
|
}
|
|
|
|
function findAntigravityModel(
|
|
models: AntigravityModelsPayload,
|
|
identifier: string
|
|
): { id: string; entry: AntigravityQuotaInfo } | null {
|
|
const direct = models[identifier];
|
|
if (direct) {
|
|
return { id: identifier, entry: direct };
|
|
}
|
|
|
|
const match = Object.entries(models).find(([, entry]) => {
|
|
const name = typeof entry?.displayName === 'string' ? entry.displayName : '';
|
|
return name.toLowerCase() === identifier.toLowerCase();
|
|
});
|
|
if (match) {
|
|
return { id: match[0], entry: match[1] };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function buildAntigravityQuotaGroups(models: AntigravityModelsPayload): AntigravityQuotaGroup[] {
|
|
const groups: AntigravityQuotaGroup[] = [];
|
|
let geminiResetTime: string | undefined;
|
|
const [claudeDef, geminiDef, flashDef, imageDef] = ANTIGRAVITY_QUOTA_GROUPS;
|
|
|
|
const buildGroup = (
|
|
def: AntigravityQuotaGroupDefinition,
|
|
overrideResetTime?: string
|
|
): AntigravityQuotaGroup | null => {
|
|
const matches = def.identifiers
|
|
.map((identifier) => findAntigravityModel(models, identifier))
|
|
.filter((entry): entry is { id: string; entry: AntigravityQuotaInfo } => Boolean(entry));
|
|
|
|
const quotaEntries = matches
|
|
.map(({ id, entry }) => {
|
|
const info = getAntigravityQuotaInfo(entry);
|
|
const remainingFraction =
|
|
info.remainingFraction ?? (info.resetTime ? 0 : null);
|
|
if (remainingFraction === null) return null;
|
|
return {
|
|
id,
|
|
remainingFraction,
|
|
resetTime: info.resetTime,
|
|
displayName: info.displayName
|
|
};
|
|
})
|
|
.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
|
|
|
if (quotaEntries.length === 0) return null;
|
|
|
|
const remainingFraction = Math.min(...quotaEntries.map((entry) => entry.remainingFraction));
|
|
const resetTime =
|
|
overrideResetTime ?? quotaEntries.map((entry) => entry.resetTime).find(Boolean);
|
|
const displayName = quotaEntries.map((entry) => entry.displayName).find(Boolean);
|
|
const label = def.labelFromModel && displayName ? displayName : def.label;
|
|
|
|
return {
|
|
id: def.id,
|
|
label,
|
|
models: quotaEntries.map((entry) => entry.id),
|
|
remainingFraction,
|
|
resetTime
|
|
};
|
|
};
|
|
|
|
const claudeGroup = buildGroup(claudeDef);
|
|
if (claudeGroup) {
|
|
groups.push(claudeGroup);
|
|
}
|
|
|
|
const geminiGroup = buildGroup(geminiDef);
|
|
if (geminiGroup) {
|
|
geminiResetTime = geminiGroup.resetTime;
|
|
groups.push(geminiGroup);
|
|
}
|
|
|
|
const flashGroup = buildGroup(flashDef);
|
|
if (flashGroup) {
|
|
groups.push(flashGroup);
|
|
}
|
|
|
|
const imageGroup = buildGroup(imageDef, geminiResetTime);
|
|
if (imageGroup) {
|
|
groups.push(imageGroup);
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
function formatQuotaResetTime(value?: string): string {
|
|
if (!value) return '-';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return '-';
|
|
return date.toLocaleString(undefined, {
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
});
|
|
}
|
|
|
|
function formatUnixSeconds(value: number | null): string {
|
|
if (!value) return '-';
|
|
const date = new Date(value * 1000);
|
|
if (Number.isNaN(date.getTime())) return '-';
|
|
return date.toLocaleString(undefined, {
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false
|
|
});
|
|
}
|
|
|
|
function formatCodexResetLabel(window?: CodexUsageWindow | null): string {
|
|
if (!window) return '-';
|
|
const resetAt = normalizeNumberValue(window.reset_at ?? window.resetAt);
|
|
if (resetAt !== null && resetAt > 0) {
|
|
return formatUnixSeconds(resetAt);
|
|
}
|
|
const resetAfter = normalizeNumberValue(window.reset_after_seconds ?? window.resetAfterSeconds);
|
|
if (resetAfter !== null && resetAfter > 0) {
|
|
const targetSeconds = Math.floor(Date.now() / 1000 + resetAfter);
|
|
return formatUnixSeconds(targetSeconds);
|
|
}
|
|
return '-';
|
|
}
|
|
|
|
function resolveAuthProvider(file: AuthFileItem): string {
|
|
const raw = file.provider ?? file.type ?? '';
|
|
return String(raw).trim().toLowerCase();
|
|
}
|
|
|
|
function isAntigravityFile(file: AuthFileItem): boolean {
|
|
return resolveAuthProvider(file) === 'antigravity';
|
|
}
|
|
|
|
function isCodexFile(file: AuthFileItem): boolean {
|
|
return resolveAuthProvider(file) === 'codex';
|
|
}
|
|
|
|
function isGeminiCliFile(file: AuthFileItem): boolean {
|
|
return resolveAuthProvider(file) === 'gemini-cli';
|
|
}
|
|
|
|
function isRuntimeOnlyAuthFile(file: AuthFileItem): boolean {
|
|
const raw = file['runtime_only'] ?? file.runtimeOnly;
|
|
if (typeof raw === 'boolean') return raw;
|
|
if (typeof raw === 'string') return raw.trim().toLowerCase() === 'true';
|
|
return false;
|
|
}
|
|
|
|
export function QuotaPage() {
|
|
const { t } = useTranslation();
|
|
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
|
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
|
|
|
|
const [files, setFiles] = useState<AuthFileItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const [antigravityPage, setAntigravityPage] = useState(1);
|
|
const [antigravityPageSize, setAntigravityPageSize] = useState(6);
|
|
const [codexPage, setCodexPage] = useState(1);
|
|
const [codexPageSize, setCodexPageSize] = useState(6);
|
|
const [geminiCliPage, setGeminiCliPage] = useState(1);
|
|
const [geminiCliPageSize, setGeminiCliPageSize] = useState(6);
|
|
const [antigravityLoading, setAntigravityLoading] = useState(false);
|
|
const [antigravityLoadingScope, setAntigravityLoadingScope] = useState<
|
|
'page' | 'all' | null
|
|
>(null);
|
|
const [codexLoading, setCodexLoading] = useState(false);
|
|
const [codexLoadingScope, setCodexLoadingScope] = useState<'page' | 'all' | null>(null);
|
|
const [geminiCliLoading, setGeminiCliLoading] = useState(false);
|
|
const [geminiCliLoadingScope, setGeminiCliLoadingScope] = useState<
|
|
'page' | 'all' | null
|
|
>(null);
|
|
|
|
const antigravityQuota = useQuotaStore((state) => state.antigravityQuota);
|
|
const setAntigravityQuota = useQuotaStore((state) => state.setAntigravityQuota);
|
|
const codexQuota = useQuotaStore((state) => state.codexQuota);
|
|
const setCodexQuota = useQuotaStore((state) => state.setCodexQuota);
|
|
const geminiCliQuota = useQuotaStore((state) => state.geminiCliQuota);
|
|
const setGeminiCliQuota = useQuotaStore((state) => state.setGeminiCliQuota);
|
|
|
|
const antigravityLoadingRef = useRef(false);
|
|
const antigravityRequestIdRef = useRef(0);
|
|
const codexLoadingRef = useRef(false);
|
|
const codexRequestIdRef = useRef(0);
|
|
const geminiCliLoadingRef = useRef(false);
|
|
const geminiCliRequestIdRef = useRef(0);
|
|
|
|
const disableControls = connectionStatus !== 'connected';
|
|
|
|
const loadFiles = useCallback(async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const data = await authFilesApi.list();
|
|
setFiles(data?.files || []);
|
|
} catch (err: unknown) {
|
|
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
|
|
setError(errorMessage);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [t]);
|
|
|
|
const antigravityFiles = useMemo(
|
|
() => files.filter((file) => isAntigravityFile(file)),
|
|
[files]
|
|
);
|
|
const antigravityTotalPages = Math.max(
|
|
1,
|
|
Math.ceil(antigravityFiles.length / antigravityPageSize)
|
|
);
|
|
const antigravityCurrentPage = Math.min(antigravityPage, antigravityTotalPages);
|
|
const antigravityStart = (antigravityCurrentPage - 1) * antigravityPageSize;
|
|
const antigravityPageItems = antigravityFiles.slice(
|
|
antigravityStart,
|
|
antigravityStart + antigravityPageSize
|
|
);
|
|
|
|
const codexFiles = useMemo(() => files.filter((file) => isCodexFile(file)), [files]);
|
|
const codexTotalPages = Math.max(1, Math.ceil(codexFiles.length / codexPageSize));
|
|
const codexCurrentPage = Math.min(codexPage, codexTotalPages);
|
|
const codexStart = (codexCurrentPage - 1) * codexPageSize;
|
|
const codexPageItems = codexFiles.slice(codexStart, codexStart + codexPageSize);
|
|
|
|
const geminiCliFiles = useMemo(
|
|
() => files.filter((file) => isGeminiCliFile(file) && !isRuntimeOnlyAuthFile(file)),
|
|
[files]
|
|
);
|
|
const geminiCliTotalPages = Math.max(1, Math.ceil(geminiCliFiles.length / geminiCliPageSize));
|
|
const geminiCliCurrentPage = Math.min(geminiCliPage, geminiCliTotalPages);
|
|
const geminiCliStart = (geminiCliCurrentPage - 1) * geminiCliPageSize;
|
|
const geminiCliPageItems = geminiCliFiles.slice(
|
|
geminiCliStart,
|
|
geminiCliStart + geminiCliPageSize
|
|
);
|
|
|
|
const fetchAntigravityQuota = useCallback(
|
|
async (authIndex: string): Promise<AntigravityQuotaGroup[]> => {
|
|
let lastError = '';
|
|
let lastStatus: number | undefined;
|
|
let priorityStatus: number | undefined;
|
|
let hadSuccess = false;
|
|
|
|
for (const url of ANTIGRAVITY_QUOTA_URLS) {
|
|
try {
|
|
const result = await apiCallApi.request({
|
|
authIndex,
|
|
method: 'POST',
|
|
url,
|
|
header: { ...ANTIGRAVITY_REQUEST_HEADERS },
|
|
data: '{}'
|
|
});
|
|
|
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
|
lastError = getApiCallErrorMessage(result);
|
|
lastStatus = result.statusCode;
|
|
if (result.statusCode === 403 || result.statusCode === 404) {
|
|
priorityStatus ??= result.statusCode;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
hadSuccess = true;
|
|
const payload = parseAntigravityPayload(result.body ?? result.bodyText);
|
|
const models = payload?.models;
|
|
if (!models || typeof models !== 'object' || Array.isArray(models)) {
|
|
lastError = t('antigravity_quota.empty_models');
|
|
continue;
|
|
}
|
|
|
|
const groups = buildAntigravityQuotaGroups(models as AntigravityModelsPayload);
|
|
if (groups.length === 0) {
|
|
lastError = t('antigravity_quota.empty_models');
|
|
continue;
|
|
}
|
|
|
|
return groups;
|
|
} catch (err: unknown) {
|
|
lastError = err instanceof Error ? err.message : t('common.unknown_error');
|
|
const status = getStatusFromError(err);
|
|
if (status) {
|
|
lastStatus = status;
|
|
if (status === 403 || status === 404) {
|
|
priorityStatus ??= status;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hadSuccess) {
|
|
return [];
|
|
}
|
|
|
|
throw createStatusError(lastError || t('common.unknown_error'), priorityStatus ?? lastStatus);
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const loadAntigravityQuota = useCallback(
|
|
async (targets: AuthFileItem[], scope: 'page' | 'all') => {
|
|
if (antigravityLoadingRef.current) return;
|
|
antigravityLoadingRef.current = true;
|
|
const requestId = ++antigravityRequestIdRef.current;
|
|
setAntigravityLoading(true);
|
|
setAntigravityLoadingScope(scope);
|
|
|
|
try {
|
|
if (targets.length === 0) return;
|
|
|
|
setAntigravityQuota((prev) => {
|
|
const nextState = { ...prev };
|
|
targets.forEach((file) => {
|
|
nextState[file.name] = { status: 'loading', groups: [] };
|
|
});
|
|
return nextState;
|
|
});
|
|
|
|
const results = await Promise.all(
|
|
targets.map(async (file) => {
|
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
|
if (!authIndex) {
|
|
return {
|
|
name: file.name,
|
|
status: 'error' as const,
|
|
error: t('antigravity_quota.missing_auth_index')
|
|
};
|
|
}
|
|
|
|
try {
|
|
const groups = await fetchAntigravityQuota(authIndex);
|
|
return { name: file.name, status: 'success' as const, groups };
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
|
const errorStatus = getStatusFromError(err);
|
|
return { name: file.name, status: 'error' as const, error: message, errorStatus };
|
|
}
|
|
})
|
|
);
|
|
|
|
if (requestId !== antigravityRequestIdRef.current) return;
|
|
|
|
setAntigravityQuota((prev) => {
|
|
const nextState = { ...prev };
|
|
results.forEach((result) => {
|
|
if (result.status === 'success') {
|
|
nextState[result.name] = {
|
|
status: 'success',
|
|
groups: result.groups
|
|
};
|
|
} else {
|
|
nextState[result.name] = {
|
|
status: 'error',
|
|
groups: [],
|
|
error: result.error,
|
|
errorStatus: result.errorStatus
|
|
};
|
|
}
|
|
});
|
|
return nextState;
|
|
});
|
|
} finally {
|
|
if (requestId === antigravityRequestIdRef.current) {
|
|
setAntigravityLoading(false);
|
|
setAntigravityLoadingScope(null);
|
|
antigravityLoadingRef.current = false;
|
|
}
|
|
}
|
|
},
|
|
[fetchAntigravityQuota, setAntigravityQuota, t]
|
|
);
|
|
|
|
const buildCodexQuotaWindows = useCallback(
|
|
(payload: CodexUsagePayload): CodexQuotaWindow[] => {
|
|
const rateLimit = payload.rate_limit ?? payload.rateLimit ?? undefined;
|
|
const codeReviewLimit =
|
|
payload.code_review_rate_limit ?? payload.codeReviewRateLimit ?? undefined;
|
|
const windows: CodexQuotaWindow[] = [];
|
|
const addWindow = (
|
|
id: string,
|
|
label: string,
|
|
window?: CodexUsageWindow | null,
|
|
limitReached?: boolean,
|
|
allowed?: boolean
|
|
) => {
|
|
if (!window) return;
|
|
const resetLabel = formatCodexResetLabel(window);
|
|
const usedPercentRaw = normalizeNumberValue(window.used_percent ?? window.usedPercent);
|
|
const isLimitReached = Boolean(limitReached) || allowed === false;
|
|
const usedPercent =
|
|
usedPercentRaw ?? (isLimitReached && resetLabel !== '-' ? 100 : null);
|
|
windows.push({
|
|
id,
|
|
label,
|
|
usedPercent,
|
|
resetLabel
|
|
});
|
|
};
|
|
|
|
addWindow(
|
|
'primary',
|
|
t('codex_quota.primary_window'),
|
|
rateLimit?.primary_window ?? rateLimit?.primaryWindow,
|
|
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
|
rateLimit?.allowed
|
|
);
|
|
addWindow(
|
|
'secondary',
|
|
t('codex_quota.secondary_window'),
|
|
rateLimit?.secondary_window ?? rateLimit?.secondaryWindow,
|
|
rateLimit?.limit_reached ?? rateLimit?.limitReached,
|
|
rateLimit?.allowed
|
|
);
|
|
addWindow(
|
|
'code-review',
|
|
t('codex_quota.code_review_window'),
|
|
codeReviewLimit?.primary_window ?? codeReviewLimit?.primaryWindow,
|
|
codeReviewLimit?.limit_reached ?? codeReviewLimit?.limitReached,
|
|
codeReviewLimit?.allowed
|
|
);
|
|
|
|
return windows;
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const fetchCodexQuota = useCallback(
|
|
async (
|
|
file: AuthFileItem
|
|
): Promise<{ planType: string | null; windows: CodexQuotaWindow[] }> => {
|
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
|
if (!authIndex) {
|
|
throw new Error(t('codex_quota.missing_auth_index'));
|
|
}
|
|
|
|
const planTypeFromFile = resolveCodexPlanType(file);
|
|
const accountId = resolveCodexChatgptAccountId(file);
|
|
if (!accountId) {
|
|
throw new Error(t('codex_quota.missing_account_id'));
|
|
}
|
|
|
|
const requestUsage = async (requestHeader: Record<string, string>) => {
|
|
const result = await apiCallApi.request({
|
|
authIndex,
|
|
method: 'GET',
|
|
url: CODEX_USAGE_URL,
|
|
header: requestHeader
|
|
});
|
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
|
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
|
}
|
|
const payload = parseCodexUsagePayload(result.body ?? result.bodyText);
|
|
if (!payload) {
|
|
throw new Error(t('codex_quota.empty_windows'));
|
|
}
|
|
return payload;
|
|
};
|
|
|
|
const baseHeader: Record<string, string> = {
|
|
...CODEX_REQUEST_HEADERS,
|
|
'Chatgpt-Account-Id': accountId
|
|
};
|
|
|
|
const payload = await requestUsage(baseHeader);
|
|
const planTypeFromUsage = normalizePlanType(payload.plan_type ?? payload.planType);
|
|
const windows = buildCodexQuotaWindows(payload);
|
|
return { planType: planTypeFromUsage ?? planTypeFromFile, windows };
|
|
},
|
|
[buildCodexQuotaWindows, t]
|
|
);
|
|
|
|
const loadCodexQuota = useCallback(
|
|
async (targets: AuthFileItem[], scope: 'page' | 'all') => {
|
|
if (codexLoadingRef.current) return;
|
|
codexLoadingRef.current = true;
|
|
const requestId = ++codexRequestIdRef.current;
|
|
setCodexLoading(true);
|
|
setCodexLoadingScope(scope);
|
|
|
|
try {
|
|
if (targets.length === 0) return;
|
|
|
|
setCodexQuota((prev) => {
|
|
const nextState = { ...prev };
|
|
targets.forEach((file) => {
|
|
nextState[file.name] = { status: 'loading', windows: [] };
|
|
});
|
|
return nextState;
|
|
});
|
|
|
|
const results = await Promise.all(
|
|
targets.map(async (file) => {
|
|
try {
|
|
const { planType, windows } = await fetchCodexQuota(file);
|
|
return { name: file.name, status: 'success' as const, planType, windows };
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
|
const errorStatus = getStatusFromError(err);
|
|
return { name: file.name, status: 'error' as const, error: message, errorStatus };
|
|
}
|
|
})
|
|
);
|
|
|
|
if (requestId !== codexRequestIdRef.current) return;
|
|
|
|
setCodexQuota((prev) => {
|
|
const nextState = { ...prev };
|
|
results.forEach((result) => {
|
|
if (result.status === 'success') {
|
|
nextState[result.name] = {
|
|
status: 'success',
|
|
windows: result.windows,
|
|
planType: result.planType
|
|
};
|
|
} else {
|
|
nextState[result.name] = {
|
|
status: 'error',
|
|
windows: [],
|
|
error: result.error,
|
|
errorStatus: result.errorStatus
|
|
};
|
|
}
|
|
});
|
|
return nextState;
|
|
});
|
|
} finally {
|
|
if (requestId === codexRequestIdRef.current) {
|
|
setCodexLoading(false);
|
|
setCodexLoadingScope(null);
|
|
codexLoadingRef.current = false;
|
|
}
|
|
}
|
|
},
|
|
[fetchCodexQuota, setCodexQuota, t]
|
|
);
|
|
|
|
const fetchGeminiCliQuota = useCallback(
|
|
async (file: AuthFileItem): Promise<GeminiCliQuotaBucketState[]> => {
|
|
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
|
|
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
|
|
if (!authIndex) {
|
|
throw new Error(t('gemini_cli_quota.missing_auth_index'));
|
|
}
|
|
|
|
const projectId = resolveGeminiCliProjectId(file);
|
|
if (!projectId) {
|
|
throw new Error(t('gemini_cli_quota.missing_project_id'));
|
|
}
|
|
|
|
const result = await apiCallApi.request({
|
|
authIndex,
|
|
method: 'POST',
|
|
url: GEMINI_CLI_QUOTA_URL,
|
|
header: { ...GEMINI_CLI_REQUEST_HEADERS },
|
|
data: JSON.stringify({ project: projectId })
|
|
});
|
|
|
|
if (result.statusCode < 200 || result.statusCode >= 300) {
|
|
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
|
|
}
|
|
|
|
const payload = parseGeminiCliQuotaPayload(result.body ?? result.bodyText);
|
|
const buckets = Array.isArray(payload?.buckets) ? payload?.buckets : [];
|
|
if (buckets.length === 0) return [];
|
|
|
|
return buckets
|
|
.map((bucket, index) => {
|
|
const modelId = normalizeStringValue(bucket.modelId ?? bucket.model_id);
|
|
if (!modelId) return null;
|
|
const tokenType = normalizeStringValue(bucket.tokenType ?? bucket.token_type);
|
|
const remainingFractionRaw = normalizeQuotaFraction(
|
|
bucket.remainingFraction ?? bucket.remaining_fraction
|
|
);
|
|
const remainingAmount = normalizeNumberValue(
|
|
bucket.remainingAmount ?? bucket.remaining_amount
|
|
);
|
|
const resetTime = normalizeStringValue(bucket.resetTime ?? bucket.reset_time) ?? undefined;
|
|
let fallbackFraction: number | null = null;
|
|
if (remainingAmount !== null) {
|
|
fallbackFraction = remainingAmount <= 0 ? 0 : null;
|
|
} else if (resetTime) {
|
|
fallbackFraction = 0;
|
|
}
|
|
const remainingFraction = remainingFractionRaw ?? fallbackFraction;
|
|
return {
|
|
id: `${modelId}-${tokenType ?? index}`,
|
|
label: modelId,
|
|
remainingFraction,
|
|
remainingAmount,
|
|
resetTime,
|
|
tokenType
|
|
};
|
|
})
|
|
.filter((bucket): bucket is GeminiCliQuotaBucketState => bucket !== null);
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const loadGeminiCliQuota = useCallback(
|
|
async (targets: AuthFileItem[], scope: 'page' | 'all') => {
|
|
if (geminiCliLoadingRef.current) return;
|
|
geminiCliLoadingRef.current = true;
|
|
const requestId = ++geminiCliRequestIdRef.current;
|
|
setGeminiCliLoading(true);
|
|
setGeminiCliLoadingScope(scope);
|
|
|
|
try {
|
|
if (targets.length === 0) return;
|
|
|
|
setGeminiCliQuota((prev) => {
|
|
const nextState = { ...prev };
|
|
targets.forEach((file) => {
|
|
nextState[file.name] = { status: 'loading', buckets: [] };
|
|
});
|
|
return nextState;
|
|
});
|
|
|
|
const results = await Promise.all(
|
|
targets.map(async (file) => {
|
|
try {
|
|
const buckets = await fetchGeminiCliQuota(file);
|
|
return { name: file.name, status: 'success' as const, buckets };
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : t('common.unknown_error');
|
|
const errorStatus = getStatusFromError(err);
|
|
return { name: file.name, status: 'error' as const, error: message, errorStatus };
|
|
}
|
|
})
|
|
);
|
|
|
|
if (requestId !== geminiCliRequestIdRef.current) return;
|
|
|
|
setGeminiCliQuota((prev) => {
|
|
const nextState = { ...prev };
|
|
results.forEach((result) => {
|
|
if (result.status === 'success') {
|
|
nextState[result.name] = {
|
|
status: 'success',
|
|
buckets: result.buckets
|
|
};
|
|
} else {
|
|
nextState[result.name] = {
|
|
status: 'error',
|
|
buckets: [],
|
|
error: result.error,
|
|
errorStatus: result.errorStatus
|
|
};
|
|
}
|
|
});
|
|
return nextState;
|
|
});
|
|
} finally {
|
|
if (requestId === geminiCliRequestIdRef.current) {
|
|
setGeminiCliLoading(false);
|
|
setGeminiCliLoadingScope(null);
|
|
geminiCliLoadingRef.current = false;
|
|
}
|
|
}
|
|
},
|
|
[fetchGeminiCliQuota, setGeminiCliQuota, t]
|
|
);
|
|
|
|
useEffect(() => {
|
|
loadFiles();
|
|
}, [loadFiles]);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (antigravityFiles.length === 0) {
|
|
setAntigravityQuota({});
|
|
return;
|
|
}
|
|
setAntigravityQuota((prev) => {
|
|
const nextState: Record<string, AntigravityQuotaState> = {};
|
|
antigravityFiles.forEach((file) => {
|
|
const cached = prev[file.name];
|
|
if (cached) {
|
|
nextState[file.name] = cached;
|
|
}
|
|
});
|
|
return nextState;
|
|
});
|
|
}, [antigravityFiles, loading, setAntigravityQuota]);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (codexFiles.length === 0) {
|
|
setCodexQuota({});
|
|
return;
|
|
}
|
|
setCodexQuota((prev) => {
|
|
const nextState: Record<string, CodexQuotaState> = {};
|
|
codexFiles.forEach((file) => {
|
|
const cached = prev[file.name];
|
|
if (cached) {
|
|
nextState[file.name] = cached;
|
|
}
|
|
});
|
|
return nextState;
|
|
});
|
|
}, [codexFiles, loading, setCodexQuota]);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
if (geminiCliFiles.length === 0) {
|
|
setGeminiCliQuota({});
|
|
return;
|
|
}
|
|
setGeminiCliQuota((prev) => {
|
|
const nextState: Record<string, GeminiCliQuotaState> = {};
|
|
geminiCliFiles.forEach((file) => {
|
|
const cached = prev[file.name];
|
|
if (cached) {
|
|
nextState[file.name] = cached;
|
|
}
|
|
});
|
|
return nextState;
|
|
});
|
|
}, [geminiCliFiles, loading, setGeminiCliQuota]);
|
|
|
|
// Resolve type label text for badges.
|
|
const getTypeLabel = (type: string): string => {
|
|
const key = `auth_files.filter_${type}`;
|
|
const translated = t(key);
|
|
if (translated !== key) return translated;
|
|
if (type.toLowerCase() === 'iflow') return 'iFlow';
|
|
return type.charAt(0).toUpperCase() + type.slice(1);
|
|
};
|
|
|
|
// Resolve type colors for badges.
|
|
const getTypeColor = (type: string): ThemeColors => {
|
|
const set = TYPE_COLORS[type] || TYPE_COLORS.unknown;
|
|
return resolvedTheme === 'dark' && set.dark ? set.dark : set.light;
|
|
};
|
|
|
|
const getCodexPlanLabel = (planType?: string | null): string | null => {
|
|
const normalized = normalizePlanType(planType);
|
|
if (!normalized) return null;
|
|
if (normalized === 'plus') return t('codex_quota.plan_plus');
|
|
if (normalized === 'team') return t('codex_quota.plan_team');
|
|
if (normalized === 'free') return t('codex_quota.plan_free');
|
|
return planType || normalized;
|
|
};
|
|
|
|
const getQuotaErrorMessage = useCallback(
|
|
(status: number | undefined, fallback: string) => {
|
|
if (status === 404) return t('common.quota_update_required');
|
|
if (status === 403) return t('common.quota_check_credential');
|
|
return fallback;
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const renderAntigravityCard = (item: AuthFileItem) => {
|
|
const displayType = item.type || item.provider || 'antigravity';
|
|
const typeColor = getTypeColor(displayType);
|
|
const quotaState = antigravityQuota[item.name];
|
|
const quotaStatus = quotaState?.status ?? 'idle';
|
|
const quotaGroups = quotaState?.groups ?? [];
|
|
const quotaErrorMessage = getQuotaErrorMessage(
|
|
quotaState?.errorStatus,
|
|
quotaState?.error || t('common.unknown_error')
|
|
);
|
|
|
|
return (
|
|
<div key={item.name} className={`${styles.fileCard} ${styles.antigravityCard}`}>
|
|
<div className={styles.cardHeader}>
|
|
<span
|
|
className={styles.typeBadge}
|
|
style={{
|
|
backgroundColor: typeColor.bg,
|
|
color: typeColor.text,
|
|
...(typeColor.border ? { border: typeColor.border } : {})
|
|
}}
|
|
>
|
|
{getTypeLabel(displayType)}
|
|
</span>
|
|
<span className={styles.fileName}>{item.name}</span>
|
|
</div>
|
|
|
|
<div className={styles.quotaSection}>
|
|
{quotaStatus === 'loading' ? (
|
|
<div className={styles.quotaMessage}>{t('antigravity_quota.loading')}</div>
|
|
) : quotaStatus === 'idle' ? (
|
|
<div className={styles.quotaMessage}>{t('antigravity_quota.idle')}</div>
|
|
) : quotaStatus === 'error' ? (
|
|
<div className={styles.quotaError}>
|
|
{t('antigravity_quota.load_failed', {
|
|
message: quotaErrorMessage
|
|
})}
|
|
</div>
|
|
) : quotaGroups.length === 0 ? (
|
|
<div className={styles.quotaMessage}>{t('antigravity_quota.empty_models')}</div>
|
|
) : (
|
|
quotaGroups.map((group) => {
|
|
const clamped = Math.max(0, Math.min(1, group.remainingFraction));
|
|
const percent = Math.round(clamped * 100);
|
|
const resetLabel = formatQuotaResetTime(group.resetTime);
|
|
const quotaBarClass =
|
|
percent >= 60
|
|
? styles.quotaBarFillHigh
|
|
: percent >= 20
|
|
? styles.quotaBarFillMedium
|
|
: styles.quotaBarFillLow;
|
|
return (
|
|
<div key={group.id} className={styles.quotaRow}>
|
|
<div className={styles.quotaRowHeader}>
|
|
<span className={styles.quotaModel} title={group.models.join(', ')}>
|
|
{group.label}
|
|
</span>
|
|
<div className={styles.quotaMeta}>
|
|
<span className={styles.quotaPercent}>{percent}%</span>
|
|
<span className={styles.quotaReset}>{resetLabel}</span>
|
|
</div>
|
|
</div>
|
|
<div className={styles.quotaBar}>
|
|
<div
|
|
className={`${styles.quotaBarFill} ${quotaBarClass}`}
|
|
style={{ width: `${percent}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderCodexCard = (item: AuthFileItem) => {
|
|
const displayType = item.type || item.provider || 'codex';
|
|
const typeColor = getTypeColor(displayType);
|
|
const quotaState = codexQuota[item.name];
|
|
const quotaStatus = quotaState?.status ?? 'idle';
|
|
const windows = quotaState?.windows ?? [];
|
|
const planType = quotaState?.planType ?? null;
|
|
const planLabel = getCodexPlanLabel(planType);
|
|
const isFreePlan = normalizePlanType(planType) === 'free';
|
|
const quotaErrorMessage = getQuotaErrorMessage(
|
|
quotaState?.errorStatus,
|
|
quotaState?.error || t('common.unknown_error')
|
|
);
|
|
|
|
return (
|
|
<div key={item.name} className={`${styles.fileCard} ${styles.codexCard}`}>
|
|
<div className={styles.cardHeader}>
|
|
<span
|
|
className={styles.typeBadge}
|
|
style={{
|
|
backgroundColor: typeColor.bg,
|
|
color: typeColor.text,
|
|
...(typeColor.border ? { border: typeColor.border } : {})
|
|
}}
|
|
>
|
|
{getTypeLabel(displayType)}
|
|
</span>
|
|
<span className={styles.fileName}>{item.name}</span>
|
|
</div>
|
|
|
|
<div className={styles.quotaSection}>
|
|
{quotaStatus === 'loading' ? (
|
|
<div className={styles.quotaMessage}>{t('codex_quota.loading')}</div>
|
|
) : quotaStatus === 'idle' ? (
|
|
<div className={styles.quotaMessage}>{t('codex_quota.idle')}</div>
|
|
) : quotaStatus === 'error' ? (
|
|
<div className={styles.quotaError}>
|
|
{t('codex_quota.load_failed', {
|
|
message: quotaErrorMessage
|
|
})}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{planLabel && (
|
|
<div className={styles.codexPlan}>
|
|
<span className={styles.codexPlanLabel}>{t('codex_quota.plan_label')}</span>
|
|
<span className={styles.codexPlanValue}>{planLabel}</span>
|
|
</div>
|
|
)}
|
|
{isFreePlan ? (
|
|
<div className={styles.quotaWarning}>{t('codex_quota.no_access')}</div>
|
|
) : windows.length === 0 ? (
|
|
<div className={styles.quotaMessage}>{t('codex_quota.empty_windows')}</div>
|
|
) : (
|
|
windows.map((window) => {
|
|
const used = window.usedPercent;
|
|
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
|
|
const remaining =
|
|
clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
|
|
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
|
|
const quotaBarClass =
|
|
remaining === null
|
|
? styles.quotaBarFillMedium
|
|
: remaining >= 80
|
|
? styles.quotaBarFillHigh
|
|
: remaining >= 50
|
|
? styles.quotaBarFillMedium
|
|
: styles.quotaBarFillLow;
|
|
|
|
return (
|
|
<div key={window.id} className={styles.quotaRow}>
|
|
<div className={styles.quotaRowHeader}>
|
|
<span className={styles.quotaModel}>{window.label}</span>
|
|
<div className={styles.quotaMeta}>
|
|
<span className={styles.quotaPercent}>{percentLabel}</span>
|
|
<span className={styles.quotaReset}>{window.resetLabel}</span>
|
|
</div>
|
|
</div>
|
|
<div className={styles.quotaBar}>
|
|
<div
|
|
className={`${styles.quotaBarFill} ${quotaBarClass}`}
|
|
style={{ width: `${Math.round(remaining ?? 0)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderGeminiCliCard = (item: AuthFileItem) => {
|
|
const displayType = item.type || item.provider || 'gemini-cli';
|
|
const typeColor = getTypeColor(displayType);
|
|
const quotaState = geminiCliQuota[item.name];
|
|
const quotaStatus = quotaState?.status ?? 'idle';
|
|
const buckets = quotaState?.buckets ?? [];
|
|
const quotaErrorMessage = getQuotaErrorMessage(
|
|
quotaState?.errorStatus,
|
|
quotaState?.error || t('common.unknown_error')
|
|
);
|
|
|
|
return (
|
|
<div key={item.name} className={`${styles.fileCard} ${styles.geminiCliCard}`}>
|
|
<div className={styles.cardHeader}>
|
|
<span
|
|
className={styles.typeBadge}
|
|
style={{
|
|
backgroundColor: typeColor.bg,
|
|
color: typeColor.text,
|
|
...(typeColor.border ? { border: typeColor.border } : {})
|
|
}}
|
|
>
|
|
{getTypeLabel(displayType)}
|
|
</span>
|
|
<span className={styles.fileName}>{item.name}</span>
|
|
</div>
|
|
|
|
<div className={styles.quotaSection}>
|
|
{quotaStatus === 'loading' ? (
|
|
<div className={styles.quotaMessage}>{t('gemini_cli_quota.loading')}</div>
|
|
) : quotaStatus === 'idle' ? (
|
|
<div className={styles.quotaMessage}>{t('gemini_cli_quota.idle')}</div>
|
|
) : quotaStatus === 'error' ? (
|
|
<div className={styles.quotaError}>
|
|
{t('gemini_cli_quota.load_failed', {
|
|
message: quotaErrorMessage
|
|
})}
|
|
</div>
|
|
) : buckets.length === 0 ? (
|
|
<div className={styles.quotaMessage}>{t('gemini_cli_quota.empty_buckets')}</div>
|
|
) : (
|
|
buckets.map((bucket) => {
|
|
const fraction = bucket.remainingFraction;
|
|
const clamped = fraction === null ? null : Math.max(0, Math.min(1, fraction));
|
|
const percent = clamped === null ? null : Math.round(clamped * 100);
|
|
const percentLabel = percent === null ? '--' : `${percent}%`;
|
|
const resetLabel = formatQuotaResetTime(bucket.resetTime);
|
|
const remainingAmountLabel =
|
|
bucket.remainingAmount === null || bucket.remainingAmount === undefined
|
|
? null
|
|
: t('gemini_cli_quota.remaining_amount', {
|
|
count: bucket.remainingAmount
|
|
});
|
|
const quotaBarClass =
|
|
percent === null
|
|
? styles.quotaBarFillMedium
|
|
: percent >= 60
|
|
? styles.quotaBarFillHigh
|
|
: percent >= 20
|
|
? styles.quotaBarFillMedium
|
|
: styles.quotaBarFillLow;
|
|
|
|
return (
|
|
<div key={bucket.id} className={styles.quotaRow}>
|
|
<div className={styles.quotaRowHeader}>
|
|
<span
|
|
className={styles.quotaModel}
|
|
title={
|
|
bucket.tokenType ? `${bucket.label} (${bucket.tokenType})` : bucket.label
|
|
}
|
|
>
|
|
{bucket.label}
|
|
</span>
|
|
<div className={styles.quotaMeta}>
|
|
<span className={styles.quotaPercent}>{percentLabel}</span>
|
|
{remainingAmountLabel && (
|
|
<span className={styles.quotaAmount}>{remainingAmountLabel}</span>
|
|
)}
|
|
<span className={styles.quotaReset}>{resetLabel}</span>
|
|
</div>
|
|
</div>
|
|
<div className={styles.quotaBar}>
|
|
<div
|
|
className={`${styles.quotaBarFill} ${quotaBarClass}`}
|
|
style={{ width: `${percent ?? 0}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
<div className={styles.pageHeader}>
|
|
<h1 className={styles.pageTitle}>{t('quota_management.title')}</h1>
|
|
<p className={styles.description}>{t('quota_management.description')}</p>
|
|
<div className={styles.headerActions}>
|
|
<Button variant="secondary" size="sm" onClick={loadFiles} disabled={loading}>
|
|
{t('quota_management.refresh_files')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && <div className={styles.errorBox}>{error}</div>}
|
|
|
|
<Card
|
|
title={t('antigravity_quota.title')}
|
|
extra={
|
|
<div className={styles.headerActions}>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => loadAntigravityQuota(antigravityPageItems, 'page')}
|
|
disabled={disableControls || antigravityLoading || antigravityPageItems.length === 0}
|
|
loading={antigravityLoading && antigravityLoadingScope === 'page'}
|
|
>
|
|
{t('antigravity_quota.refresh_button')}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => loadAntigravityQuota(antigravityFiles, 'all')}
|
|
disabled={disableControls || antigravityLoading || antigravityFiles.length === 0}
|
|
loading={antigravityLoading && antigravityLoadingScope === 'all'}
|
|
>
|
|
{t('antigravity_quota.fetch_all')}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
{antigravityFiles.length === 0 ? (
|
|
<EmptyState
|
|
title={t('antigravity_quota.empty_title')}
|
|
description={t('antigravity_quota.empty_desc')}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className={styles.antigravityControls}>
|
|
<div className={styles.antigravityControl}>
|
|
<label>{t('auth_files.page_size_label')}</label>
|
|
<select
|
|
className={styles.pageSizeSelect}
|
|
value={antigravityPageSize}
|
|
onChange={(e) => {
|
|
setAntigravityPageSize(Number(e.target.value) || 6);
|
|
setAntigravityPage(1);
|
|
}}
|
|
>
|
|
<option value={6}>6</option>
|
|
<option value={9}>9</option>
|
|
<option value={12}>12</option>
|
|
<option value={18}>18</option>
|
|
<option value={24}>24</option>
|
|
</select>
|
|
</div>
|
|
<div className={styles.antigravityControl}>
|
|
<label>{t('common.info')}</label>
|
|
<div className={styles.statsInfo}>
|
|
{antigravityFiles.length} {t('auth_files.files_count')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className={styles.antigravityGrid}>
|
|
{antigravityPageItems.map(renderAntigravityCard)}
|
|
</div>
|
|
{antigravityFiles.length > antigravityPageSize && (
|
|
<div className={styles.pagination}>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setAntigravityPage(Math.max(1, antigravityCurrentPage - 1))}
|
|
disabled={antigravityCurrentPage <= 1}
|
|
>
|
|
{t('auth_files.pagination_prev')}
|
|
</Button>
|
|
<div className={styles.pageInfo}>
|
|
{t('auth_files.pagination_info', {
|
|
current: antigravityCurrentPage,
|
|
total: antigravityTotalPages,
|
|
count: antigravityFiles.length
|
|
})}
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() =>
|
|
setAntigravityPage(Math.min(antigravityTotalPages, antigravityCurrentPage + 1))
|
|
}
|
|
disabled={antigravityCurrentPage >= antigravityTotalPages}
|
|
>
|
|
{t('auth_files.pagination_next')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</Card>
|
|
|
|
<Card
|
|
title={t('codex_quota.title')}
|
|
extra={
|
|
<div className={styles.headerActions}>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => loadCodexQuota(codexPageItems, 'page')}
|
|
disabled={disableControls || codexLoading || codexPageItems.length === 0}
|
|
loading={codexLoading && codexLoadingScope === 'page'}
|
|
>
|
|
{t('codex_quota.refresh_button')}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => loadCodexQuota(codexFiles, 'all')}
|
|
disabled={disableControls || codexLoading || codexFiles.length === 0}
|
|
loading={codexLoading && codexLoadingScope === 'all'}
|
|
>
|
|
{t('codex_quota.fetch_all')}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
{codexFiles.length === 0 ? (
|
|
<EmptyState title={t('codex_quota.empty_title')} description={t('codex_quota.empty_desc')} />
|
|
) : (
|
|
<>
|
|
<div className={styles.codexControls}>
|
|
<div className={styles.codexControl}>
|
|
<label>{t('auth_files.page_size_label')}</label>
|
|
<select
|
|
className={styles.pageSizeSelect}
|
|
value={codexPageSize}
|
|
onChange={(e) => {
|
|
setCodexPageSize(Number(e.target.value) || 6);
|
|
setCodexPage(1);
|
|
}}
|
|
>
|
|
<option value={6}>6</option>
|
|
<option value={9}>9</option>
|
|
<option value={12}>12</option>
|
|
<option value={18}>18</option>
|
|
<option value={24}>24</option>
|
|
</select>
|
|
</div>
|
|
<div className={styles.codexControl}>
|
|
<label>{t('common.info')}</label>
|
|
<div className={styles.statsInfo}>
|
|
{codexFiles.length} {t('auth_files.files_count')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className={styles.codexGrid}>{codexPageItems.map(renderCodexCard)}</div>
|
|
{codexFiles.length > codexPageSize && (
|
|
<div className={styles.pagination}>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setCodexPage(Math.max(1, codexCurrentPage - 1))}
|
|
disabled={codexCurrentPage <= 1}
|
|
>
|
|
{t('auth_files.pagination_prev')}
|
|
</Button>
|
|
<div className={styles.pageInfo}>
|
|
{t('auth_files.pagination_info', {
|
|
current: codexCurrentPage,
|
|
total: codexTotalPages,
|
|
count: codexFiles.length
|
|
})}
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setCodexPage(Math.min(codexTotalPages, codexCurrentPage + 1))}
|
|
disabled={codexCurrentPage >= codexTotalPages}
|
|
>
|
|
{t('auth_files.pagination_next')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</Card>
|
|
|
|
<Card
|
|
title={t('gemini_cli_quota.title')}
|
|
extra={
|
|
<div className={styles.headerActions}>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => loadGeminiCliQuota(geminiCliPageItems, 'page')}
|
|
disabled={disableControls || geminiCliLoading || geminiCliPageItems.length === 0}
|
|
loading={geminiCliLoading && geminiCliLoadingScope === 'page'}
|
|
>
|
|
{t('gemini_cli_quota.refresh_button')}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => loadGeminiCliQuota(geminiCliFiles, 'all')}
|
|
disabled={disableControls || geminiCliLoading || geminiCliFiles.length === 0}
|
|
loading={geminiCliLoading && geminiCliLoadingScope === 'all'}
|
|
>
|
|
{t('gemini_cli_quota.fetch_all')}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
{geminiCliFiles.length === 0 ? (
|
|
<EmptyState
|
|
title={t('gemini_cli_quota.empty_title')}
|
|
description={t('gemini_cli_quota.empty_desc')}
|
|
/>
|
|
) : (
|
|
<>
|
|
<div className={styles.geminiCliControls}>
|
|
<div className={styles.geminiCliControl}>
|
|
<label>{t('auth_files.page_size_label')}</label>
|
|
<select
|
|
className={styles.pageSizeSelect}
|
|
value={geminiCliPageSize}
|
|
onChange={(e) => {
|
|
setGeminiCliPageSize(Number(e.target.value) || 6);
|
|
setGeminiCliPage(1);
|
|
}}
|
|
>
|
|
<option value={6}>6</option>
|
|
<option value={9}>9</option>
|
|
<option value={12}>12</option>
|
|
<option value={18}>18</option>
|
|
<option value={24}>24</option>
|
|
</select>
|
|
</div>
|
|
<div className={styles.geminiCliControl}>
|
|
<label>{t('common.info')}</label>
|
|
<div className={styles.statsInfo}>
|
|
{geminiCliFiles.length} {t('auth_files.files_count')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className={styles.geminiCliGrid}>{geminiCliPageItems.map(renderGeminiCliCard)}</div>
|
|
{geminiCliFiles.length > geminiCliPageSize && (
|
|
<div className={styles.pagination}>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setGeminiCliPage(Math.max(1, geminiCliCurrentPage - 1))}
|
|
disabled={geminiCliCurrentPage <= 1}
|
|
>
|
|
{t('auth_files.pagination_prev')}
|
|
</Button>
|
|
<div className={styles.pageInfo}>
|
|
{t('auth_files.pagination_info', {
|
|
current: geminiCliCurrentPage,
|
|
total: geminiCliTotalPages,
|
|
count: geminiCliFiles.length
|
|
})}
|
|
</div>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() =>
|
|
setGeminiCliPage(Math.min(geminiCliTotalPages, geminiCliCurrentPage + 1))
|
|
}
|
|
disabled={geminiCliCurrentPage >= geminiCliTotalPages}
|
|
>
|
|
{t('auth_files.pagination_next')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|