Skip to content

Commit 28c909c

Browse files
committed
feat: summarize remaining Codex quota
1 parent 113baa7 commit 28c909c

19 files changed

Lines changed: 1012 additions & 66 deletions

apps/web/src/components/quota/QuotaSection.test.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { act, useEffect } from 'react';
1+
import { act, useEffect, type ReactNode } from 'react';
22
import { create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
33
import { beforeEach, describe, expect, it, vi } from 'vitest';
44
import type { AuthFileItem } from '@/types';
@@ -162,6 +162,7 @@ const renderSection = (
162162
options: {
163163
config?: QuotaConfig<TestQuotaState, TestQuotaData>;
164164
files?: AuthFileItem[];
165+
summary?: ReactNode;
165166
} = {}
166167
) => {
167168
let renderer!: ReactTestRenderer;
@@ -173,6 +174,7 @@ const renderSection = (
173174
loading={false}
174175
disabled={false}
175176
accountDisplayMode="masked"
177+
summary={options.summary}
176178
/>
177179
);
178180
});
@@ -224,6 +226,18 @@ describe('QuotaSection account display mode', () => {
224226
(mocks.quotaStoreState.setCodexQuota as ReturnType<typeof vi.fn>).mockClear();
225227
});
226228

229+
it('renders an optional section summary before credential cards', () => {
230+
const renderer = renderSection({ summary: <div data-test-summary>aggregate</div> });
231+
const summary = renderer.root.findByProps({ 'data-test-summary': true });
232+
const grid = renderer.root.findByProps({ className: 'codex-grid' });
233+
234+
expect(summary.children).toContain('aggregate');
235+
expect(summary.parent).toBe(grid.parent);
236+
expect(summary.parent!.children.indexOf(summary)).toBeLessThan(
237+
summary.parent!.children.indexOf(grid)
238+
);
239+
});
240+
227241
it('uses masked names in single quota refresh notifications', async () => {
228242
mocks.fetchQuota.mockResolvedValue({ resetCredits: 1 });
229243
const renderer = renderSection();

apps/web/src/components/quota/QuotaSection.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@
22
* Generic quota section component.
33
*/
44

5-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5+
import {
6+
useCallback,
7+
useEffect,
8+
useMemo,
9+
useRef,
10+
useState,
11+
type ReactNode,
12+
} from 'react';
613
import { useTranslation } from 'react-i18next';
714
import { Card } from '@/components/ui/Card';
815
import { Button } from '@/components/ui/Button';
@@ -130,6 +137,7 @@ interface QuotaSectionProps<TState extends QuotaStatusState, TData> {
130137
accountDisplayMode?: QuotaAccountDisplayMode;
131138
onAccountDisplayModeChange?: (mode: QuotaAccountDisplayMode) => void;
132139
headerSnapshotLookup?: UsageHeaderSnapshotLookup;
140+
summary?: ReactNode;
133141
}
134142

135143
export function QuotaSection<TState extends QuotaStatusState, TData>({
@@ -145,6 +153,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
145153
accountDisplayMode,
146154
onAccountDisplayModeChange,
147155
headerSnapshotLookup,
156+
summary,
148157
}: QuotaSectionProps<TState, TData>) {
149158
const { t } = useTranslation();
150159
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
@@ -560,6 +569,7 @@ export function QuotaSection<TState extends QuotaStatusState, TData>({
560569
</div>
561570
}
562571
>
572+
{filteredFiles.length > 0 && summary}
563573
{filteredFiles.length === 0 ? (
564574
<EmptyState
565575
title={t(`${config.i18nPrefix}.empty_title`)}

apps/web/src/components/quota/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export {
1313
XAI_CONFIG,
1414
buildObservedCodexQuotaState,
1515
getQuotaStoreKey,
16+
getScopedQuotaState,
1617
resolveQuotaDisplayState,
1718
} from './quotaConfigs';
1819
export type { QuotaConfig } from './quotaConfigs';

apps/web/src/features/authFiles/AuthFilesPage.tsx

Lines changed: 11 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,6 @@ import {
6363
import {
6464
monitoringAnalyticsApi,
6565
usageServiceApi,
66-
type MonitoringAnalyticsCredentialStatRow,
67-
type MonitoringAnalyticsInclude,
68-
type MonitoringAnalyticsResponse,
6966
type QuotaCooldownInfo,
7067
type UsageHeaderSnapshot,
7168
} from '@/services/api/usageService';
@@ -80,6 +77,10 @@ import { useAuthFilesOauth } from '@/features/authFiles/hooks/useAuthFilesOauth'
8077
import { useAuthFilesPrefixProxyEditor } from '@/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
8178
import { useAuthFilesStatusBarCache } from '@/features/authFiles/hooks/useAuthFilesStatusBarCache';
8279
import { useAntigravitySubscriptions } from '@/features/authFiles/hooks/useAntigravitySubscriptions';
80+
import {
81+
fetchAuthFileUsageRows,
82+
type AuthFileUsageRows,
83+
} from '@/features/authFiles/hooks/useAuthFileUsageAnalytics';
8384
import {
8485
BATCH_BAR_BASE_TRANSFORM,
8586
BATCH_BAR_HIDDEN_TRANSFORM,
@@ -134,13 +135,6 @@ import type { AuthFileItem, CodexQuotaState } from '@/types';
134135
import { useAuthStore, useNotificationStore, useQuotaStore, useThemeStore } from '@/stores';
135136
import styles from './AuthFilesPage.module.scss';
136137

137-
const AUTH_FILE_USAGE_FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
138-
const AUTH_FILE_USAGE_WEEKLY_MS = 7 * 24 * 60 * 60 * 1000;
139-
const AUTH_FILE_USAGE_HISTORY_FROM_MS = 1;
140-
const AUTH_FILE_USAGE_ANALYTICS_INCLUDE = {
141-
credential_stats: true,
142-
} satisfies MonitoringAnalyticsInclude;
143-
144138
const hasInlineQuotaLayout = (file: AuthFileItem): boolean => {
145139
if (isRuntimeOnlyAuthFile(file)) return false;
146140
const provider = resolveAuthProvider(file);
@@ -199,25 +193,15 @@ type QuotaCooldownState = {
199193
items: Map<string, QuotaCooldownInfo>;
200194
};
201195

202-
type AuthFileUsageRows = {
203-
retained: MonitoringAnalyticsCredentialStatRow[];
204-
fiveHour: MonitoringAnalyticsCredentialStatRow[];
205-
weekly: MonitoringAnalyticsCredentialStatRow[];
206-
};
196+
const getQuotaCooldownContextKey = (managerServiceBase: string, managementKey: string): string =>
197+
`${managerServiceBase}\u0000${managementKey}`;
207198

208199
const EMPTY_AUTH_FILE_USAGE_ROWS: AuthFileUsageRows = {
209200
retained: [],
210201
fiveHour: [],
211202
weekly: [],
212203
};
213204

214-
const getQuotaCooldownContextKey = (managerServiceBase: string, managementKey: string): string =>
215-
`${managerServiceBase}\u0000${managementKey}`;
216-
217-
const readCredentialStats = (
218-
response: MonitoringAnalyticsResponse
219-
): MonitoringAnalyticsCredentialStatRow[] => response.credential_stats ?? [];
220-
221205
export function AuthFilesPage() {
222206
const { t } = useTranslation();
223207
const showNotification = useNotificationStore((state) => state.showNotification);
@@ -654,38 +638,13 @@ export function AuthFilesPage() {
654638
}
655639

656640
const id = ++authFileUsageReqId.current;
657-
const nowMs = Date.now();
658-
const buildRequest = (fromMs: number) => ({
659-
from_ms: fromMs,
660-
to_ms: nowMs,
661-
now_ms: nowMs,
662-
include: AUTH_FILE_USAGE_ANALYTICS_INCLUDE,
663-
});
664-
665641
try {
666-
const [retained, fiveHour, weekly] = await Promise.all([
667-
monitoringAnalyticsApi.getAnalytics(
668-
managerServiceBase,
669-
managementKey,
670-
buildRequest(AUTH_FILE_USAGE_HISTORY_FROM_MS)
671-
),
672-
monitoringAnalyticsApi.getAnalytics(
673-
managerServiceBase,
674-
managementKey,
675-
buildRequest(nowMs - AUTH_FILE_USAGE_FIVE_HOUR_MS)
676-
),
677-
monitoringAnalyticsApi.getAnalytics(
678-
managerServiceBase,
679-
managementKey,
680-
buildRequest(nowMs - AUTH_FILE_USAGE_WEEKLY_MS)
681-
),
682-
]);
683-
if (id !== authFileUsageReqId.current) return;
684-
setAuthFileUsageRows({
685-
retained: readCredentialStats(retained),
686-
fiveHour: readCredentialStats(fiveHour),
687-
weekly: readCredentialStats(weekly),
642+
const nextRows = await fetchAuthFileUsageRows({
643+
managerServiceBase,
644+
managementKey,
688645
});
646+
if (id !== authFileUsageReqId.current) return;
647+
setAuthFileUsageRows(nextRows);
689648
} catch {
690649
if (id === authFileUsageReqId.current) {
691650
setAuthFileUsageRows(EMPTY_AUTH_FILE_USAGE_ROWS);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { useEffect } from 'react';
2+
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import type { MonitoringAnalyticsResponse } from '@/services/api/usageService';
5+
import { useAuthFileUsageAnalytics } from './useAuthFileUsageAnalytics';
6+
7+
const { mocks } = vi.hoisted(() => ({
8+
mocks: {
9+
getAnalytics: vi.fn(),
10+
},
11+
}));
12+
13+
vi.mock('@/services/api/usageService', () => ({
14+
monitoringAnalyticsApi: {
15+
getAnalytics: mocks.getAnalytics,
16+
},
17+
}));
18+
19+
function Harness({ onRows }: { onRows: (value: { fiveHour: number; weekly: number }) => void }) {
20+
const { rows, load } = useAuthFileUsageAnalytics({
21+
managerServiceBase: 'http://manager.local:18317',
22+
managementKey: 'test-key',
23+
enabled: true,
24+
includeRetained: false,
25+
});
26+
27+
useEffect(() => {
28+
void load();
29+
}, [load]);
30+
31+
useEffect(() => {
32+
onRows({ fiveHour: rows.fiveHour.length, weekly: rows.weekly.length });
33+
}, [onRows, rows]);
34+
35+
return null;
36+
}
37+
38+
describe('useAuthFileUsageAnalytics', () => {
39+
beforeEach(() => {
40+
(
41+
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
42+
).IS_REACT_ACT_ENVIRONMENT = true;
43+
mocks.getAnalytics.mockReset();
44+
});
45+
46+
it('loads only 5-hour and weekly credential stats when retained history is disabled', async () => {
47+
const response = (id: string): MonitoringAnalyticsResponse => ({
48+
generated_at_ms: 1_700_000_000_000,
49+
granularity: 'hour',
50+
credential_stats: [
51+
{
52+
id,
53+
auth_file_snapshot: 'codex-main.json',
54+
auth_index: '0',
55+
calls: 1,
56+
success_calls: 1,
57+
failure_calls: 0,
58+
success_rate: 1,
59+
input_tokens: 0,
60+
output_tokens: 0,
61+
cached_tokens: 0,
62+
cache_read_tokens: 0,
63+
cache_creation_tokens: 0,
64+
total_tokens: 1,
65+
cost: 0.01,
66+
average_latency_ms: null,
67+
last_seen_ms: 0,
68+
},
69+
],
70+
});
71+
mocks.getAnalytics
72+
.mockResolvedValueOnce(response('five-hour'))
73+
.mockResolvedValueOnce(response('weekly'));
74+
const onRows = vi.fn();
75+
let renderer!: ReactTestRenderer;
76+
77+
await act(async () => {
78+
renderer = create(<Harness onRows={onRows} />);
79+
await Promise.resolve();
80+
await Promise.resolve();
81+
});
82+
83+
expect(mocks.getAnalytics).toHaveBeenCalledTimes(2);
84+
expect(mocks.getAnalytics.mock.calls[0]?.[2].include).toEqual({ credential_stats: true });
85+
expect(onRows).toHaveBeenLastCalledWith({ fiveHour: 1, weekly: 1 });
86+
87+
act(() => renderer.unmount());
88+
});
89+
});

0 commit comments

Comments
 (0)