From 0d0a5ca9fe0723c66b0825fa7f1c1ba6b68503f2 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Sun, 17 May 2026 20:58:35 +0800 Subject: [PATCH] feat: port LTS Codex quota insights --- CONTEXT.md | 42 +++ src/components/quota/QuotaCard.test.tsx | 73 +++++ src/components/quota/QuotaCard.tsx | 34 ++- src/components/quota/QuotaSection.tsx | 40 +-- src/components/quota/quotaConfigs.ts | 366 ++++++++++++++++++++++-- src/i18n/locales/en.json | 21 +- src/i18n/locales/ru.json | 21 +- src/i18n/locales/zh-CN.json | 21 +- src/i18n/locales/zh-TW.json | 21 +- src/pages/QuotaPage.module.scss | 299 +++++++++++++++++-- src/services/api/codexQuota.ts | 53 +++- src/types/quota.ts | 89 ++++++ src/utils/quota/codexAnalytics.test.ts | 93 ++++++ src/utils/quota/codexAnalytics.ts | 286 ++++++++++++++++++ src/utils/quota/constants.ts | 14 +- src/utils/quota/index.ts | 1 + src/utils/quota/parsers.ts | 30 +- 17 files changed, 1421 insertions(+), 83 deletions(-) create mode 100644 CONTEXT.md create mode 100644 src/components/quota/QuotaCard.test.tsx create mode 100644 src/utils/quota/codexAnalytics.test.ts create mode 100644 src/utils/quota/codexAnalytics.ts diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..46331945b --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,42 @@ +# CPA-Manager Fork Context + +This context defines the product language for a CPA-Manager fork that selectively absorbs LTS quota capabilities. + +## Language + +**CPA-Manager Fork**: +The product line based on seakee/CPA-Manager that receives selected capabilities from CPA Panel LTS. +_Avoid_: CPA Panel LTS, ordinary upstream panel + +**CPA Panel LTS**: +The source product line for selected quota capabilities that should be ported into the CPA-Manager Fork. +_Avoid_: target product, replacement manager + +**Usage Service**: +The companion CPA-Manager service that owns persistent request usage for newer CPA deployments. +_Avoid_: optional stats add-on, replacement CPA runtime + +**Weekly Quota Estimate**: +The Codex quota capability that estimates or prioritizes weekly quota limits when presenting account usage. +_Avoid_: generic quota display, daily-only usage + +**Single Credential Refresh**: +The quota interaction that refreshes one credential without forcing a full credential-list refresh. +_Avoid_: global refresh, bulk refresh + +## Relationships + +- **CPA-Manager Fork** is based on CPA-Manager, not on **CPA Panel LTS**. +- **CPA-Manager Fork** uses **Usage Service** for persistent request usage in newer CPA deployments. +- **CPA Panel LTS** supplies **Weekly Quota Estimate** and **Single Credential Refresh** behavior to the **CPA-Manager Fork**. +- **Single Credential Refresh** applies to one credential at a time. + +## Example dialogue + +> **Dev:** "Are we merging CPA-Manager into the LTS panel?" +> **Domain expert:** "No. We are forking CPA-Manager and porting the LTS weekly quota estimate plus single credential refresh into it." + +## Flagged ambiguities + +- "Fuse CPA-Manager" was used ambiguously; resolved: the target is a **CPA-Manager Fork**, and **CPA Panel LTS** is only a source for selected quota capabilities. +- "Built-in statistics" was used ambiguously; resolved: newer CPA deployments rely on **Usage Service** for persistent request usage. diff --git a/src/components/quota/QuotaCard.test.tsx b/src/components/quota/QuotaCard.test.tsx new file mode 100644 index 000000000..827306549 --- /dev/null +++ b/src/components/quota/QuotaCard.test.tsx @@ -0,0 +1,73 @@ +import { act } from 'react'; +import { create } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; +import { Button } from '@/components/ui/Button'; +import { QuotaCard } from './QuotaCard'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('QuotaCard', () => { + it('renders an icon refresh button for a single credential when refresh is available', () => { + const onRefresh = vi.fn(); + let renderer: ReturnType; + + act(() => { + renderer = create( + quota} + /> + ); + }); + + const refreshButton = renderer!.root + .findAllByType(Button) + .find((button) => button.props['aria-label'] === 'codex_quota.refresh_button'); + + expect(refreshButton).toBeTruthy(); + + act(() => { + refreshButton!.props.onClick(); + }); + + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('disables the single credential refresh button while that credential is loading', () => { + let renderer: ReturnType; + + act(() => { + renderer = create( + {}} + renderQuotaItems={() => quota} + /> + ); + }); + + const refreshButton = renderer!.root + .findAllByType(Button) + .find((button) => button.props['aria-label'] === 'codex_quota.refresh_button'); + + expect(refreshButton?.props.disabled).toBe(true); + expect(refreshButton?.props.loading).toBe(true); + }); +}); diff --git a/src/components/quota/QuotaCard.tsx b/src/components/quota/QuotaCard.tsx index df68fe000..696de8810 100644 --- a/src/components/quota/QuotaCard.tsx +++ b/src/components/quota/QuotaCard.tsx @@ -5,6 +5,8 @@ import { useTranslation } from 'react-i18next'; import type { ReactElement, ReactNode } from 'react'; import type { TFunction } from 'i18next'; +import { Button } from '@/components/ui/Button'; +import { IconRefreshCw } from '@/components/ui/icons'; import type { AuthFileItem, ResolvedTheme, ThemeColors } from '@/types'; import { TYPE_COLORS } from '@/utils/quota'; import styles from '@/pages/QuotaPage.module.scss'; @@ -26,10 +28,9 @@ export interface QuotaProgressBarProps { export function QuotaProgressBar({ percent, highThreshold, - mediumThreshold + mediumThreshold, }: QuotaProgressBarProps) { - const clamp = (value: number, min: number, max: number) => - Math.min(max, Math.max(min, value)); + const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); const normalized = percent === null ? null : clamp(percent, 0, 100); const fillClass = normalized === null @@ -79,7 +80,7 @@ export function QuotaCard({ defaultType, canRefresh = false, onRefresh, - renderQuotaItems + renderQuotaItems, }: QuotaCardProps) { const { t } = useTranslation(); @@ -94,7 +95,11 @@ export function QuotaCard({ quota?.errorStatus, quota?.error || t('common.unknown_error') ); - const idleMessageKey = onRefresh ? `${i18nPrefix}.idle` : (cardIdleMessageKey ?? `${i18nPrefix}.idle`); + const idleMessageKey = onRefresh + ? `${i18nPrefix}.idle` + : (cardIdleMessageKey ?? `${i18nPrefix}.idle`); + const refreshLabel = t(`${i18nPrefix}.refresh_button`); + const refreshButtonLoading = quotaStatus === 'loading'; const getTypeLabel = (type: string): string => { const key = `auth_files.filter_${type}`; @@ -112,12 +117,27 @@ export function QuotaCard({ style={{ backgroundColor: typeColor.bg, color: typeColor.text, - ...(typeColor.border ? { border: typeColor.border } : {}) + ...(typeColor.border ? { border: typeColor.border } : {}), }} > {getTypeLabel(displayType)} {item.name} + {onRefresh && ( + + )}
@@ -139,7 +159,7 @@ export function QuotaCard({ ) : quotaStatus === 'error' ? (
{t(`${i18nPrefix}.load_failed`, { - message: quotaErrorMessage + message: quotaErrorMessage, })}
) : quota ? ( diff --git a/src/components/quota/QuotaSection.tsx b/src/components/quota/QuotaSection.tsx index 4f1cee19f..ad91d7b70 100644 --- a/src/components/quota/QuotaSection.tsx +++ b/src/components/quota/QuotaSection.tsx @@ -98,7 +98,7 @@ const useQuotaPagination = (items: T[], defaultPageSize = 6): QuotaPaginatio goToNext, loading, loadingScope, - setLoading + setLoading, }; }; @@ -117,7 +117,7 @@ export function QuotaSection({ loading, disabled, searchQuery = '', - sortMode = 'default' + sortMode = 'default', }: QuotaSectionProps) { const { t } = useTranslation(); const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme); @@ -131,10 +131,10 @@ export function QuotaSection({ const [viewMode, setViewMode] = useState('paged'); const [showTooManyWarning, setShowTooManyWarning] = useState(false); - const filteredFiles = useMemo(() => files.filter((file) => config.filterFn(file)), [ - files, - config - ]); + const filteredFiles = useMemo( + () => files.filter((file) => config.filterFn(file)), + [files, config] + ); const normalizedSearchQuery = searchQuery.trim().toLowerCase(); const { quota, loadQuota } = useQuotaLoader(config); @@ -154,7 +154,7 @@ export function QuotaSection({ fileQuota?.status, fileQuota?.error, fileQuota?.errorStatus, - ...(config.getSearchText?.(file, fileQuota, t) ?? []) + ...(config.getSearchText?.(file, fileQuota, t) ?? []), ]; return stringifySearchValue(searchValues).some((value) => @@ -180,8 +180,7 @@ export function QuotaSection({ if (leftKnown || rightKnown) { if (!leftKnown) return 1; if (!rightKnown) return -1; - const rankDiff = - sortMode === 'plan-desc' ? rightRank - leftRank : leftRank - rightRank; + const rankDiff = sortMode === 'plan-desc' ? rightRank - leftRank : leftRank - rightRank; if (rankDiff !== 0) return rankDiff; } @@ -204,7 +203,7 @@ export function QuotaSection({ goToPrev, goToNext, loading: sectionLoading, - setLoading + setLoading, } = useQuotaPagination(displayFiles); useEffect(() => { @@ -235,6 +234,7 @@ export function QuotaSection({ const pendingQuotaRefreshRef = useRef(false); const prevFilesLoadingRef = useRef(loading); + const singleRefreshInFlightRef = useRef>(new Set()); const handleRefresh = useCallback(() => { pendingQuotaRefreshRef.current = true; @@ -278,17 +278,20 @@ export function QuotaSection({ async (file: AuthFileItem) => { if (disabled || file.disabled) return; if (quota[file.name]?.status === 'loading') return; + if (singleRefreshInFlightRef.current.has(file.name)) return; + + singleRefreshInFlightRef.current.add(file.name); setQuota((prev) => ({ ...prev, - [file.name]: config.buildLoadingState() + [file.name]: config.buildLoadingState(), })); try { const data = await config.fetchQuota(file, t); setQuota((prev) => ({ ...prev, - [file.name]: config.buildSuccessState(data) + [file.name]: config.buildSuccessState(data), })); showNotification(t('auth_files.quota_refresh_success', { name: file.name }), 'success'); } catch (err: unknown) { @@ -296,12 +299,14 @@ export function QuotaSection({ const status = getStatusFromError(err); setQuota((prev) => ({ ...prev, - [file.name]: config.buildErrorState(message, status) + [file.name]: config.buildErrorState(message, status), })); showNotification( t('auth_files.quota_refresh_failed', { name: file.name, message }), 'error' ); + } finally { + singleRefreshInFlightRef.current.delete(file.name); } }, [config, disabled, quota, setQuota, showNotification, t] @@ -400,19 +405,14 @@ export function QuotaSection({
{displayFiles.length > pageSize && effectiveViewMode === 'paged' && (
-
{t('auth_files.pagination_info', { current: currentPage, total: totalPages, - count: displayFiles.length + count: displayFiles.length, })}