mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
feat(ui): display Codex/Gemini quota in dashboard
- update account-card.tsx to show quota for all CLIProxy providers - update account-item.tsx with provider-specific tooltip rendering - enable showQuota for codex and gemini in model-config-tab.tsx
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "ui",
|
||||
|
||||
@@ -9,11 +9,16 @@ import {
|
||||
getMinClaudeQuota,
|
||||
getModelsWithTiers,
|
||||
groupModelsByTier,
|
||||
getMinCodexQuota,
|
||||
getMinGeminiQuota,
|
||||
getCodexResetTime,
|
||||
getGeminiResetTime,
|
||||
type ModelTier,
|
||||
} from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react';
|
||||
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
|
||||
import type { CodexQuotaResult, GeminiCliQuotaResult, QuotaResult } from '@/lib/api-client';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
@@ -89,19 +94,36 @@ export function AccountCard({
|
||||
const borderColor = getBorderColorStyle(zone, account.color);
|
||||
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
|
||||
|
||||
// Quota for AGY accounts
|
||||
const isAgy = account.provider === 'agy';
|
||||
// Quota for CLIProxy accounts (agy, codex, gemini)
|
||||
const isCliproxyProvider = ['agy', 'codex', 'gemini'].includes(account.provider);
|
||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||
account.provider,
|
||||
account.id,
|
||||
isAgy
|
||||
isCliproxyProvider
|
||||
);
|
||||
// Show minimum quota of Claude models (primary), fallback to min of all models
|
||||
const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null;
|
||||
|
||||
// Get provider-specific minimum quota
|
||||
const getProviderMinQuota = () => {
|
||||
if (!quota?.success) return null;
|
||||
switch (account.provider) {
|
||||
case 'agy':
|
||||
return getMinClaudeQuota((quota as QuotaResult).models);
|
||||
case 'codex':
|
||||
return getMinCodexQuota((quota as CodexQuotaResult).windows);
|
||||
case 'gemini':
|
||||
return getMinGeminiQuota((quota as GeminiCliQuotaResult).buckets);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const minQuota = getProviderMinQuota();
|
||||
|
||||
// Tier badge (AGY only) - show P for Pro, U for Ultra
|
||||
const showTierBadge =
|
||||
isAgy && account.tier && account.tier !== 'unknown' && account.tier !== 'free';
|
||||
account.provider === 'agy' &&
|
||||
account.tier &&
|
||||
account.tier !== 'unknown' &&
|
||||
account.tier !== 'free';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -198,8 +220,8 @@ export function AccountCard({
|
||||
failure={account.failureCount}
|
||||
showDetails={showDetails}
|
||||
/>
|
||||
{/* Quota bar for AGY accounts */}
|
||||
{isAgy && (
|
||||
{/* Quota bar for CLIProxy accounts (agy, codex, gemini) */}
|
||||
{isCliproxyProvider && (
|
||||
<div className="mt-2 px-0.5">
|
||||
{quotaLoading ? (
|
||||
<div className="flex items-center gap-1 text-[8px] text-muted-foreground">
|
||||
@@ -244,47 +266,99 @@ export function AccountCard({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Model Quotas:</p>
|
||||
{(() => {
|
||||
const tiered = getModelsWithTiers(quota?.models || []);
|
||||
const groups = groupModelsByTier(tiered);
|
||||
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
||||
return tierOrder.map((tier, idx) => {
|
||||
const models = groups.get(tier);
|
||||
if (!models || models.length === 0) return null;
|
||||
const isFirst = tierOrder
|
||||
.slice(0, idx)
|
||||
.every((t) => !groups.get(t)?.length);
|
||||
return (
|
||||
<div key={tier}>
|
||||
{!isFirst && <div className="border-t border-border/40 my-1" />}
|
||||
{models.map((m) => (
|
||||
<div key={m.name} className="flex justify-between gap-4">
|
||||
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
|
||||
{m.displayName}
|
||||
</span>
|
||||
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
|
||||
{m.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{account.provider === 'agy' ? (
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Model Quotas:</p>
|
||||
{(() => {
|
||||
const tiered = getModelsWithTiers((quota as QuotaResult)?.models || []);
|
||||
const groups = groupModelsByTier(tiered);
|
||||
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
||||
return tierOrder.map((tier, idx) => {
|
||||
const models = groups.get(tier);
|
||||
if (!models || models.length === 0) return null;
|
||||
const isFirst = tierOrder
|
||||
.slice(0, idx)
|
||||
.every((t) => !groups.get(t)?.length);
|
||||
return (
|
||||
<div key={tier}>
|
||||
{!isFirst && <div className="border-t border-border/40 my-1" />}
|
||||
{models.map((m) => (
|
||||
<div key={m.name} className="flex justify-between gap-4">
|
||||
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
|
||||
{m.displayName}
|
||||
</span>
|
||||
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
|
||||
{m.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{(() => {
|
||||
const resetTime = getClaudeResetTime((quota as QuotaResult)?.models || []);
|
||||
return resetTime ? (
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(resetTime)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{(() => {
|
||||
const resetTime = getClaudeResetTime(quota?.models || []);
|
||||
return resetTime ? (
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(resetTime)}
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
) : account.provider === 'codex' ? (
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Rate Limits:</p>
|
||||
{(quota as CodexQuotaResult)?.windows?.map((w) => (
|
||||
<div key={w.label} className="flex justify-between gap-4">
|
||||
<span className={cn(w.remainingPercent < 20 && 'text-red-500')}>
|
||||
{w.label}
|
||||
</span>
|
||||
<span className="font-mono">{w.remainingPercent}%</span>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
const resetTime = getCodexResetTime(
|
||||
(quota as CodexQuotaResult)?.windows || []
|
||||
);
|
||||
return resetTime ? (
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(resetTime)}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
) : account.provider === 'gemini' ? (
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Buckets:</p>
|
||||
{(quota as GeminiCliQuotaResult)?.buckets?.map((b) => (
|
||||
<div key={b.id} className="flex justify-between gap-4">
|
||||
<span className={cn(b.remainingPercent < 20 && 'text-red-500')}>
|
||||
{b.label}
|
||||
</span>
|
||||
<span className="font-mono">{b.remainingPercent}%</span>
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
const resetTime = getGeminiResetTime(
|
||||
(quota as GeminiCliQuotaResult)?.buckets || []
|
||||
);
|
||||
return resetTime ? (
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(resetTime)}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -36,10 +36,20 @@ import {
|
||||
getMinClaudeQuota,
|
||||
getModelsWithTiers,
|
||||
groupModelsByTier,
|
||||
getMinCodexQuota,
|
||||
getMinGeminiQuota,
|
||||
getCodexResetTime,
|
||||
getGeminiResetTime,
|
||||
type ModelTier,
|
||||
} from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import {
|
||||
useAccountQuota,
|
||||
useCliproxyStats,
|
||||
type QuotaResult,
|
||||
type CodexQuotaResult,
|
||||
type GeminiCliQuotaResult,
|
||||
} from '@/hooks/use-cliproxy-stats';
|
||||
import type { AccountItemProps } from './types';
|
||||
|
||||
/**
|
||||
@@ -118,12 +128,152 @@ export function AccountItem({
|
||||
const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt;
|
||||
const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
|
||||
|
||||
// Show minimum quota of Claude models (primary), fallback to min of all models
|
||||
const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null;
|
||||
// Compute min quota based on provider
|
||||
const getProviderMinQuota = (): number | null => {
|
||||
if (!quota?.success) return null;
|
||||
switch (account.provider) {
|
||||
case 'agy':
|
||||
return getMinClaudeQuota((quota as QuotaResult).models);
|
||||
case 'codex':
|
||||
return getMinCodexQuota((quota as CodexQuotaResult).windows);
|
||||
case 'gemini':
|
||||
return getMinGeminiQuota((quota as GeminiCliQuotaResult).buckets);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const minQuota = getProviderMinQuota();
|
||||
|
||||
// Get earliest reset time
|
||||
const nextReset =
|
||||
quota?.success && quota.models.length > 0 ? getClaudeResetTime(quota.models) : null;
|
||||
// Compute reset time based on provider
|
||||
const getProviderResetTime = (): string | null => {
|
||||
if (!quota?.success) return null;
|
||||
switch (account.provider) {
|
||||
case 'agy':
|
||||
return getClaudeResetTime((quota as QuotaResult).models);
|
||||
case 'codex':
|
||||
return getCodexResetTime((quota as CodexQuotaResult).windows);
|
||||
case 'gemini':
|
||||
return getGeminiResetTime((quota as GeminiCliQuotaResult).buckets);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const nextReset = getProviderResetTime();
|
||||
|
||||
// Render Antigravity (agy) provider tooltip
|
||||
const renderAgyTooltip = () => {
|
||||
const tiered = getModelsWithTiers((quota as QuotaResult).models || []);
|
||||
const groups = groupModelsByTier(tiered);
|
||||
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
||||
return tierOrder.map((tier, idx) => {
|
||||
const models = groups.get(tier);
|
||||
if (!models || models.length === 0) return null;
|
||||
const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length);
|
||||
return (
|
||||
<div key={tier}>
|
||||
{!isFirst && <div className="border-t border-border/40 my-1" />}
|
||||
{models.map((m) => (
|
||||
<div key={m.name} className="flex justify-between gap-4">
|
||||
<span className={cn('truncate', m.exhausted && 'text-red-500')}>{m.displayName}</span>
|
||||
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
|
||||
{m.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Render Codex provider tooltip
|
||||
const renderCodexTooltip = () => {
|
||||
const windows = (quota as CodexQuotaResult).windows;
|
||||
const planType = (quota as CodexQuotaResult).planType;
|
||||
return (
|
||||
<>
|
||||
{planType && <p className="text-muted-foreground">Plan: {planType}</p>}
|
||||
{windows.map((w) => (
|
||||
<div key={w.label} className="flex justify-between gap-4">
|
||||
<span>{w.label}</span>
|
||||
<span className="font-mono">{w.remainingPercent}%</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Render Gemini provider tooltip
|
||||
const renderGeminiTooltip = () => {
|
||||
const buckets = (quota as GeminiCliQuotaResult).buckets;
|
||||
return (
|
||||
<>
|
||||
{buckets.map((b) => (
|
||||
<div key={b.id} className="flex justify-between gap-4">
|
||||
<span>
|
||||
{b.label}
|
||||
{b.tokenType ? ` (${b.tokenType})` : ''}
|
||||
</span>
|
||||
<span className="font-mono">{b.remainingPercent}%</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Provider-specific tooltip content renderer
|
||||
const renderQuotaTooltip = () => {
|
||||
if (!quota?.success) return null;
|
||||
|
||||
switch (account.provider) {
|
||||
case 'agy':
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Model Quotas:</p>
|
||||
{renderAgyTooltip()}
|
||||
{nextReset && (
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(nextReset)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'codex':
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Rate Limit Windows:</p>
|
||||
{renderCodexTooltip()}
|
||||
{nextReset && (
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(nextReset)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'gemini':
|
||||
return (
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Model Buckets:</p>
|
||||
{renderGeminiTooltip()}
|
||||
{nextReset && (
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(nextReset)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -356,44 +506,7 @@ export function AccountItem({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Model Quotas:</p>
|
||||
{(() => {
|
||||
const tiered = getModelsWithTiers(quota?.models || []);
|
||||
const groups = groupModelsByTier(tiered);
|
||||
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
|
||||
return tierOrder.map((tier, idx) => {
|
||||
const models = groups.get(tier);
|
||||
if (!models || models.length === 0) return null;
|
||||
const isFirst = tierOrder
|
||||
.slice(0, idx)
|
||||
.every((t) => !groups.get(t)?.length);
|
||||
return (
|
||||
<div key={tier}>
|
||||
{!isFirst && <div className="border-t border-border/40 my-1" />}
|
||||
{models.map((m) => (
|
||||
<div key={m.name} className="flex justify-between gap-4">
|
||||
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
|
||||
{m.displayName}
|
||||
</span>
|
||||
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
|
||||
{m.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{nextReset && (
|
||||
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(nextReset)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderQuotaTooltip()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -167,7 +167,7 @@ export function ModelConfigTab({
|
||||
isBulkPausing={isBulkPausing}
|
||||
isBulkResuming={isBulkResuming}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={provider === 'agy' && !isRemoteMode}
|
||||
showQuota={['agy', 'codex', 'gemini'].includes(provider) && !isRemoteMode}
|
||||
isKiro={isKiro}
|
||||
kiroNoIncognito={kiroNoIncognito}
|
||||
onKiroNoIncognitoChange={saveKiroNoIncognito}
|
||||
|
||||
Reference in New Issue
Block a user