mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 04:14:57 +00:00
feat(ui): show richer gemini quota details
This commit is contained in:
1 parent
a762d8dda0
commit
4c44042375
3 files changed
+151
-7
No files matched your search
@@ -37,6 +37,23 @@ function formatPlanLabel(planType: string | null | undefined): string | null {
|
|||||||
return normalized.length > 0 ? normalized.join(' ') : planType;
|
return normalized.length > 0 ? normalized.join(' ') : planType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAbsoluteResetTime(resetTime: string | null): string | null {
|
||||||
|
if (!resetTime) return null;
|
||||||
|
try {
|
||||||
|
const parsed = new Date(resetTime);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
|
return parsed.toLocaleString(undefined, {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): string {
|
function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): string {
|
||||||
switch (rateLimitType) {
|
switch (rateLimitType) {
|
||||||
case 'five_hour':
|
case 'five_hour':
|
||||||
@@ -235,19 +252,45 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
|||||||
|
|
||||||
// Gemini provider tooltip
|
// Gemini provider tooltip
|
||||||
if (isGeminiQuotaResult(quota)) {
|
if (isGeminiQuotaResult(quota)) {
|
||||||
|
const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-xs space-y-1">
|
<div className="text-xs space-y-1">
|
||||||
|
{quota.tierLabel && (
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">Tier</span>
|
||||||
|
<span className="font-mono">{quota.tierLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{quota.creditBalance !== null && quota.creditBalance !== undefined && (
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">Credits</span>
|
||||||
|
<span className="font-mono">{quota.creditBalance.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="font-medium">Buckets:</p>
|
<p className="font-medium">Buckets:</p>
|
||||||
{quota.buckets.map((b) => (
|
{quota.buckets.map((b) => (
|
||||||
<div key={b.id} className="flex justify-between gap-4">
|
<div key={b.id} className="space-y-0.5">
|
||||||
<span className={cn(b.remainingPercent < 20 && 'text-red-500')}>
|
<div className="flex justify-between gap-4">
|
||||||
{b.label}
|
<span className={cn(b.remainingPercent < 20 && 'text-red-500')}>
|
||||||
{b.tokenType ? ` (${b.tokenType})` : ''}
|
{b.label}
|
||||||
</span>
|
{b.tokenType ? ` (${b.tokenType})` : ''}
|
||||||
<span className="font-mono">{b.remainingPercent}%</span>
|
</span>
|
||||||
|
<span className="font-mono">{b.remainingPercent}%</span>
|
||||||
|
</div>
|
||||||
|
{((b.remainingAmount !== null && b.remainingAmount !== undefined) || b.resetTime) && (
|
||||||
|
<div className="flex justify-between gap-4 text-[11px] text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{b.remainingAmount !== null && b.remainingAmount !== undefined
|
||||||
|
? `${b.remainingAmount.toLocaleString()} remaining`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
<span>{formatAbsoluteResetTime(b.resetTime) ?? ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<ResetTimeIndicator resetTime={resetTime} />
|
{!hasBucketResetTime && <ResetTimeIndicator resetTime={resetTime} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -611,6 +611,8 @@ export interface GeminiCliBucket {
|
|||||||
remainingFraction: number;
|
remainingFraction: number;
|
||||||
/** Remaining quota as percentage (0-100) */
|
/** Remaining quota as percentage (0-100) */
|
||||||
remainingPercent: number;
|
remainingPercent: number;
|
||||||
|
/** Remaining quota count when the upstream API provides it */
|
||||||
|
remainingAmount?: number | null;
|
||||||
/** ISO timestamp when quota resets, null if unknown */
|
/** ISO timestamp when quota resets, null if unknown */
|
||||||
resetTime: string | null;
|
resetTime: string | null;
|
||||||
/** Model IDs in this bucket */
|
/** Model IDs in this bucket */
|
||||||
@@ -625,6 +627,12 @@ export interface GeminiCliQuotaResult {
|
|||||||
buckets: GeminiCliBucket[];
|
buckets: GeminiCliBucket[];
|
||||||
/** GCP project ID for this account */
|
/** GCP project ID for this account */
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
|
/** Human-readable Gemini tier label when available */
|
||||||
|
tierLabel?: string | null;
|
||||||
|
/** Stable Gemini tier identifier when available */
|
||||||
|
tierId?: string | null;
|
||||||
|
/** Available Google One AI credits when reported by the API */
|
||||||
|
creditBalance?: number | null;
|
||||||
/** Timestamp of fetch */
|
/** Timestamp of fetch */
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
/** Upstream HTTP status when available */
|
/** Upstream HTTP status when available */
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { render, screen } from '@tests/setup/test-utils';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||||
|
import type { GeminiCliQuotaResult } from '@/lib/api-client';
|
||||||
|
|
||||||
|
function createGeminiQuotaResult(
|
||||||
|
overrides: Partial<GeminiCliQuotaResult> = {}
|
||||||
|
): GeminiCliQuotaResult {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
buckets: [
|
||||||
|
{
|
||||||
|
id: 'gemini-flash-lite-series::combined',
|
||||||
|
label: 'Gemini Flash Lite Series',
|
||||||
|
tokenType: null,
|
||||||
|
remainingFraction: 1,
|
||||||
|
remainingPercent: 100,
|
||||||
|
remainingAmount: 100,
|
||||||
|
resetTime: '2026-01-30T09:00:00Z',
|
||||||
|
modelIds: ['gemini-2.5-flash-lite'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-flash-series::combined',
|
||||||
|
label: 'Gemini Flash Series',
|
||||||
|
tokenType: null,
|
||||||
|
remainingFraction: 0.82,
|
||||||
|
remainingPercent: 82,
|
||||||
|
remainingAmount: 82,
|
||||||
|
resetTime: '2026-01-30T14:00:00Z',
|
||||||
|
modelIds: ['gemini-3-flash-preview'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
projectId: 'cloudaicompanion-test-123',
|
||||||
|
tierLabel: 'Pro',
|
||||||
|
tierId: 'g1-pro-tier',
|
||||||
|
creditBalance: 12,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('QuotaTooltipContent', () => {
|
||||||
|
it('renders Gemini tier, credits, remaining amount, and bucket reset timestamps', () => {
|
||||||
|
const quota = createGeminiQuotaResult();
|
||||||
|
const expectedReset = new Date('2026-01-30T14:00:00Z').toLocaleString(undefined, {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<QuotaTooltipContent quota={quota} resetTime={quota.buckets[0].resetTime} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Tier')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Credits')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('12')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Gemini Flash Lite Series')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('100 remaining')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('82 remaining')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(expectedReset)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the shared reset indicator when Gemini buckets omit reset timestamps', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-01-29T00:00:00Z'));
|
||||||
|
|
||||||
|
const quota = createGeminiQuotaResult({
|
||||||
|
buckets: [
|
||||||
|
{
|
||||||
|
id: 'gemini-flash-series::combined',
|
||||||
|
label: 'Gemini Flash Series',
|
||||||
|
tokenType: null,
|
||||||
|
remainingFraction: 0.75,
|
||||||
|
remainingPercent: 75,
|
||||||
|
remainingAmount: 75,
|
||||||
|
resetTime: null,
|
||||||
|
modelIds: ['gemini-3-flash-preview'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
creditBalance: null,
|
||||||
|
tierLabel: null,
|
||||||
|
tierId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<QuotaTooltipContent quota={quota} resetTime="2026-01-29T03:00:00Z" />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Resets/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user