Compare commits

..

31 Commits

32 changed files with 2566 additions and 281 deletions
+30 -6
View File
@@ -426,8 +426,24 @@ function PayloadRulesEditor({
protocolFirst?: boolean;
onChange: (next: PayloadRule[]) => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const rules = value.length ? value : [];
const protocolOptions = useMemo(
() =>
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
);
const payloadValueTypeOptions = useMemo(
() =>
VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
@@ -533,7 +549,7 @@ function PayloadRulesEditor({
<>
<ToastSelect
value={model.protocol ?? ''}
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
@@ -561,7 +577,7 @@ function PayloadRulesEditor({
/>
<ToastSelect
value={model.protocol ?? ''}
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
@@ -603,7 +619,7 @@ function PayloadRulesEditor({
/>
<ToastSelect
value={param.valueType}
options={VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS}
options={payloadValueTypeOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.param_type')}
onChange={(nextValue) =>
@@ -669,8 +685,16 @@ function PayloadFilterRulesEditor({
disabled?: boolean;
onChange: (next: PayloadFilterRule[]) => void;
}) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const rules = value.length ? value : [];
const protocolOptions = useMemo(
() =>
VISUAL_CONFIG_PROTOCOL_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey, { defaultValue: option.defaultLabel }),
})),
[t, i18n.resolvedLanguage]
);
const addRule = () => onChange([...rules, { id: makeClientId(), models: [], params: [] }]);
const removeRule = (ruleIndex: number) => onChange(rules.filter((_, i) => i !== ruleIndex));
@@ -738,7 +762,7 @@ function PayloadFilterRulesEditor({
/>
<ToastSelect
value={model.protocol ?? ''}
options={VISUAL_CONFIG_PROTOCOL_OPTIONS}
options={protocolOptions}
disabled={disabled}
ariaLabel={t('config_management.visual.payload_rules.provider_type')}
onChange={(nextValue) =>
@@ -87,7 +87,7 @@ export function OpenAISection({
<ProviderList<OpenAIProviderConfig>
items={configs}
loading={loading}
keyField={(item) => item.name}
keyField={(_, index) => `openai-provider-${index}`}
emptyTitle={t('ai_providers.openai_empty_title')}
emptyDescription={t('ai_providers.openai_empty_desc')}
onEdit={onEdit}
+2 -2
View File
@@ -6,7 +6,7 @@ import { EmptyState } from '@/components/ui/EmptyState';
interface ProviderListProps<T> {
items: T[];
loading: boolean;
keyField: (item: T) => string;
keyField: (item: T, index: number) => string;
renderContent: (item: T, index: number) => ReactNode;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
@@ -48,7 +48,7 @@ export function ProviderList<T>({
const rowDisabled = getRowDisabled ? getRowDisabled(item, index) : false;
return (
<div
key={keyField(item)}
key={keyField(item, index)}
className="item-row"
style={rowDisabled ? { opacity: 0.6 } : undefined}
>
+1 -1
View File
@@ -5,5 +5,5 @@
export { QuotaSection } from './QuotaSection';
export { QuotaCard } from './QuotaCard';
export { useQuotaLoader } from './useQuotaLoader';
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export type { QuotaConfig } from './quotaConfigs';
+155 -1
View File
@@ -10,6 +10,10 @@ import type {
AntigravityModelsPayload,
AntigravityQuotaState,
AuthFileItem,
ClaudeExtraUsage,
ClaudeQuotaState,
ClaudeQuotaWindow,
ClaudeUsagePayload,
CodexRateLimitInfo,
CodexQuotaState,
CodexUsageWindow,
@@ -23,6 +27,9 @@ import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
CLAUDE_USAGE_URL,
CLAUDE_REQUEST_HEADERS,
CLAUDE_USAGE_WINDOW_KEYS,
CODEX_USAGE_URL,
CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL,
@@ -34,6 +41,7 @@ import {
normalizeQuotaFraction,
normalizeStringValue,
parseAntigravityPayload,
parseClaudeUsagePayload,
parseCodexUsagePayload,
parseGeminiCliQuotaPayload,
resolveCodexChatgptAccountId,
@@ -46,6 +54,7 @@ import {
createStatusError,
getStatusFromError,
isAntigravityFile,
isClaudeFile,
isCodexFile,
isDisabledAuthFile,
isGeminiCliFile,
@@ -56,15 +65,17 @@ import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>;
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
@@ -558,6 +569,149 @@ const renderGeminiCliItems = (
});
};
const buildClaudeQuotaWindows = (
payload: ClaudeUsagePayload,
t: TFunction
): ClaudeQuotaWindow[] => {
const windows: ClaudeQuotaWindow[] = [];
for (const { key, id, labelKey } of CLAUDE_USAGE_WINDOW_KEYS) {
const window = payload[key as keyof ClaudeUsagePayload];
if (!window || typeof window !== 'object' || !('utilization' in window)) continue;
const typedWindow = window as { utilization: number; resets_at: string };
const usedPercent = normalizeNumberValue(typedWindow.utilization);
const resetLabel = formatQuotaResetTime(typedWindow.resets_at);
windows.push({
id,
label: t(labelKey),
labelKey,
usedPercent,
resetLabel,
});
}
return windows;
};
const fetchClaudeQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('claude_quota.missing_auth_index'));
}
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CLAUDE_USAGE_URL,
header: { ...CLAUDE_REQUEST_HEADERS },
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseClaudeUsagePayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('claude_quota.empty_windows'));
}
const windows = buildClaudeQuotaWindows(payload, t);
return { windows, extraUsage: payload.extra_usage };
};
const renderClaudeItems = (
quota: ClaudeQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h, Fragment } = React;
const windows = quota.windows ?? [];
const extraUsage = quota.extraUsage ?? null;
const nodes: ReactNode[] = [];
if (extraUsage && extraUsage.is_enabled) {
const usedLabel = `$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`;
nodes.push(
h(
'div',
{ key: 'extra', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('claude_quota.extra_usage_label')),
h('span', { className: styleMap.codexPlanValue }, usedLabel)
)
);
}
if (windows.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('claude_quota.empty_windows'))
);
return h(Fragment, null, ...nodes);
}
nodes.push(
...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 windowLabel = window.labelKey ? t(window.labelKey) : window.label;
return h(
'div',
{ key: window.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, windowLabel),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
);
})
);
return h(Fragment, null, ...nodes);
};
export const CLAUDE_CONFIG: QuotaConfig<
ClaudeQuotaState,
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }
> = {
type: 'claude',
i18nPrefix: 'claude_quota',
filterFn: (file) => isClaudeFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchClaudeQuota,
storeSelector: (state) => state.claudeQuota,
storeSetter: 'setClaudeQuota',
buildLoadingState: () => ({ status: 'loading', windows: [] }),
buildSuccessState: (data) => ({
status: 'success',
windows: data.windows,
extraUsage: data.extraUsage,
}),
buildErrorState: (message, status) => ({
status: 'error',
windows: [],
error: message,
errorStatus: status,
}),
cardClassName: styles.claudeCard,
controlsClassName: styles.claudeControls,
controlClassName: styles.claudeControl,
gridClassName: styles.claudeGrid,
renderQuotaItems: renderClaudeItems,
};
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
type: 'antigravity',
i18nPrefix: 'antigravity_quota',
+119 -54
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage';
import { formatCompactNumber, formatUsd, type ApiStats } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface ApiDetailsCardProps {
@@ -10,9 +10,14 @@ export interface ApiDetailsCardProps {
hasPrices: boolean;
}
type ApiSortKey = 'endpoint' | 'requests' | 'tokens' | 'cost';
type SortDir = 'asc' | 'desc';
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
const { t } = useTranslation();
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<ApiSortKey>('requests');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const toggleExpand = (endpoint: string) => {
setExpandedApis((prev) => {
@@ -26,65 +31,125 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
});
};
const handleSort = (key: ApiSortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir(key === 'endpoint' ? 'asc' : 'desc');
}
};
const sorted = useMemo(() => {
const list = [...apiStats];
const dir = sortDir === 'asc' ? 1 : -1;
list.sort((a, b) => {
switch (sortKey) {
case 'endpoint': return dir * a.endpoint.localeCompare(b.endpoint);
case 'requests': return dir * (a.totalRequests - b.totalRequests);
case 'tokens': return dir * (a.totalTokens - b.totalTokens);
case 'cost': return dir * (a.totalCost - b.totalCost);
default: return 0;
}
});
return list;
}, [apiStats, sortKey, sortDir]);
const arrow = (key: ApiSortKey) =>
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
return (
<Card title={t('usage_stats.api_details')}>
<Card title={t('usage_stats.api_details')} className={styles.detailsFixedCard}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : apiStats.length > 0 ? (
<div className={styles.apiList}>
{apiStats.map((api) => (
<div key={api.endpoint} className={styles.apiItem}>
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}>
<div className={styles.apiInfo}>
<span className={styles.apiEndpoint}>{api.endpoint}</span>
<div className={styles.apiStats}>
<span className={styles.apiBadge}>
<span className={styles.requestCountCell}>
<span>
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.apiBadge}>
{t('usage_stats.tokens_count')}: {formatTokensInMillions(api.totalTokens)}
</span>
{hasPrices && api.totalCost > 0 && (
<span className={styles.apiBadge}>
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
) : sorted.length > 0 ? (
<>
<div className={styles.apiSortBar}>
{([
['endpoint', 'usage_stats.api_endpoint'],
['requests', 'usage_stats.requests_count'],
['tokens', 'usage_stats.tokens_count'],
...(hasPrices ? [['cost', 'usage_stats.total_cost']] : []),
] as [ApiSortKey, string][]).map(([key, labelKey]) => (
<button
key={key}
type="button"
aria-pressed={sortKey === key}
className={`${styles.apiSortBtn} ${sortKey === key ? styles.apiSortBtnActive : ''}`}
onClick={() => handleSort(key)}
>
{t(labelKey)}{arrow(key)}
</button>
))}
</div>
<div className={styles.detailsScroll}>
<div className={styles.apiList}>
{sorted.map((api, index) => {
const isExpanded = expandedApis.has(api.endpoint);
const panelId = `api-models-${index}`;
return (
<div key={api.endpoint} className={styles.apiItem}>
<button
type="button"
className={styles.apiHeader}
onClick={() => toggleExpand(api.endpoint)}
aria-expanded={isExpanded}
aria-controls={panelId}
>
<div className={styles.apiInfo}>
<span className={styles.apiEndpoint}>{api.endpoint}</span>
<div className={styles.apiStats}>
<span className={styles.apiBadge}>
<span className={styles.requestCountCell}>
<span>
{t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()}
</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{api.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{api.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.apiBadge}>
{t('usage_stats.tokens_count')}: {formatCompactNumber(api.totalTokens)}
</span>
{hasPrices && api.totalCost > 0 && (
<span className={styles.apiBadge}>
{t('usage_stats.total_cost')}: {formatUsd(api.totalCost)}
</span>
)}
</div>
</div>
<span className={styles.expandIcon}>
{isExpanded ? '▼' : '▶'}
</span>
</button>
{isExpanded && (
<div id={panelId} className={styles.apiModels}>
{Object.entries(api.models).map(([model, stats]) => (
<div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span>
<span className={styles.modelStat}>
<span className={styles.requestCountCell}>
<span>{stats.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.modelStat}>{formatCompactNumber(stats.tokens)}</span>
</div>
))}
</div>
)}
</div>
</div>
<span className={styles.expandIcon}>
{expandedApis.has(api.endpoint) ? '▼' : '▶'}
</span>
</div>
{expandedApis.has(api.endpoint) && (
<div className={styles.apiModels}>
{Object.entries(api.models).map(([model, stats]) => (
<div key={model} className={styles.modelRow}>
<span className={styles.modelName}>{model}</span>
<span className={styles.modelStat}>
<span className={styles.requestCountCell}>
<span>{stats.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stats.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stats.failureCount.toLocaleString()}</span>)
</span>
</span>
</span>
<span className={styles.modelStat}>{formatTokensInMillions(stats.tokens)}</span>
</div>
))}
</div>
)}
);
})}
</div>
))}
</div>
</div>
</>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
+144
View File
@@ -0,0 +1,144 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ScriptableContext } from 'chart.js';
import { Line } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import {
buildHourlyCostSeries,
buildDailyCostSeries,
formatUsd,
type ModelPrice
} from '@/utils/usage';
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
export interface CostTrendChartProps {
usage: UsagePayload | null;
loading: boolean;
isDark: boolean;
isMobile: boolean;
modelPrices: Record<string, ModelPrice>;
hourWindowHours?: number;
}
const COST_COLOR = '#f59e0b';
const COST_BG = 'rgba(245, 158, 11, 0.15)';
function buildGradient(ctx: ScriptableContext<'line'>) {
const chart = ctx.chart;
const area = chart.chartArea;
if (!area) return COST_BG;
const gradient = chart.ctx.createLinearGradient(0, area.top, 0, area.bottom);
gradient.addColorStop(0, 'rgba(245, 158, 11, 0.28)');
gradient.addColorStop(0.6, 'rgba(245, 158, 11, 0.12)');
gradient.addColorStop(1, 'rgba(245, 158, 11, 0.02)');
return gradient;
}
export function CostTrendChart({
usage,
loading,
isDark,
isMobile,
modelPrices,
hourWindowHours
}: CostTrendChartProps) {
const { t } = useTranslation();
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
const hasPrices = Object.keys(modelPrices).length > 0;
const { chartData, chartOptions, hasData } = useMemo(() => {
if (!hasPrices || !usage) {
return { chartData: { labels: [], datasets: [] }, chartOptions: {}, hasData: false };
}
const series =
period === 'hour'
? buildHourlyCostSeries(usage, modelPrices, hourWindowHours)
: buildDailyCostSeries(usage, modelPrices);
const data = {
labels: series.labels,
datasets: [
{
label: t('usage_stats.total_cost'),
data: series.data,
borderColor: COST_COLOR,
backgroundColor: buildGradient,
pointBackgroundColor: COST_COLOR,
pointBorderColor: COST_COLOR,
fill: true,
tension: 0.35
}
]
};
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
const options = {
...baseOptions,
scales: {
...baseOptions.scales,
y: {
...baseOptions.scales?.y,
ticks: {
...(baseOptions.scales?.y && 'ticks' in baseOptions.scales.y ? baseOptions.scales.y.ticks : {}),
callback: (value: string | number) => formatUsd(Number(value))
}
}
}
};
return { chartData: data, chartOptions: options, hasData: series.hasData };
}, [usage, period, isDark, isMobile, modelPrices, hasPrices, hourWindowHours, t]);
return (
<Card
title={t('usage_stats.cost_trend')}
extra={
<div className={styles.periodButtons}>
<Button
variant={period === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={period === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : !hasPrices ? (
<div className={styles.hint}>{t('usage_stats.cost_need_price')}</div>
) : !hasData ? (
<div className={styles.hint}>{t('usage_stats.cost_no_data')}</div>
) : (
<div className={styles.chartWrapper}>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
: undefined
}
>
<Line data={chartData} options={chartOptions} />
</div>
</div>
</div>
</div>
)}
</Card>
);
}
@@ -0,0 +1,334 @@
import { useMemo, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import {
computeKeyStats,
collectUsageDetails,
buildCandidateUsageSourceIds,
formatCompactNumber
} from '@/utils/usage';
import { authFilesApi } from '@/services/api/authFiles';
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
import type { AuthFileItem } from '@/types/authFile';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
export interface CredentialStatsCardProps {
usage: UsagePayload | null;
loading: boolean;
geminiKeys: GeminiKeyConfig[];
claudeConfigs: ProviderKeyConfig[];
codexConfigs: ProviderKeyConfig[];
vertexConfigs: ProviderKeyConfig[];
openaiProviders: OpenAIProviderConfig[];
}
interface CredentialInfo {
name: string;
type: string;
}
interface CredentialRow {
key: string;
displayName: string;
type: string;
success: number;
failure: number;
total: number;
successRate: number;
}
interface CredentialBucket {
success: number;
failure: number;
}
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 || null;
}
return null;
}
export function CredentialStatsCard({
usage,
loading,
geminiKeys,
claudeConfigs,
codexConfigs,
vertexConfigs,
openaiProviders,
}: CredentialStatsCardProps) {
const { t } = useTranslation();
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
// Fetch auth files for auth_index-based matching
useEffect(() => {
let cancelled = false;
authFilesApi
.list()
.then((res) => {
if (cancelled) return;
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
if (!Array.isArray(files)) return;
const map = new Map<string, CredentialInfo>();
files.forEach((file) => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const key = normalizeAuthIndexValue(rawAuthIndex);
if (key) {
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString(),
});
}
});
setAuthFileMap(map);
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
// Aggregate rows: all from bySource only (no separate byAuthIndex rows to avoid duplicates).
// Auth files are used purely for name resolution of unmatched source IDs.
const rows = useMemo((): CredentialRow[] => {
if (!usage) return [];
const { bySource } = computeKeyStats(usage);
const details = collectUsageDetails(usage);
const result: CredentialRow[] = [];
const consumedSourceIds = new Set<string>();
const authIndexToRowIndex = new Map<string, number>();
const sourceToAuthIndex = new Map<string, string>();
const fallbackByAuthIndex = new Map<string, CredentialBucket>();
const mergeBucketToRow = (index: number, bucket: CredentialBucket) => {
const target = result[index];
if (!target) return;
target.success += bucket.success;
target.failure += bucket.failure;
target.total = target.success + target.failure;
target.successRate = target.total > 0 ? (target.success / target.total) * 100 : 100;
};
// Aggregate all candidate source IDs for one provider config into a single row
const addConfigRow = (
apiKey: string,
prefix: string | undefined,
name: string,
type: string,
rowKey: string,
) => {
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
let success = 0;
let failure = 0;
candidates.forEach((id) => {
const bucket = bySource[id];
if (bucket) {
success += bucket.success;
failure += bucket.failure;
consumedSourceIds.add(id);
}
});
const total = success + failure;
if (total > 0) {
result.push({
key: rowKey,
displayName: name,
type,
success,
failure,
total,
successRate: (success / total) * 100,
});
}
};
// Provider rows — one row per config, stats merged across all its candidate source IDs
geminiKeys.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Gemini #${i + 1}`, 'gemini', `gemini:${i}`));
claudeConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Claude #${i + 1}`, 'claude', `claude:${i}`));
codexConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Codex #${i + 1}`, 'codex', `codex:${i}`));
vertexConfigs.forEach((c, i) =>
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Vertex #${i + 1}`, 'vertex', `vertex:${i}`));
// OpenAI compatibility providers — one row per provider, merged across all apiKey entries (prefix counted once).
openaiProviders.forEach((provider, providerIndex) => {
const prefix = provider.prefix;
const displayName = prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
const candidates = new Set<string>();
buildCandidateUsageSourceIds({ prefix }).forEach((id) => candidates.add(id));
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
});
let success = 0;
let failure = 0;
candidates.forEach((id) => {
const bucket = bySource[id];
if (bucket) {
success += bucket.success;
failure += bucket.failure;
consumedSourceIds.add(id);
}
});
const total = success + failure;
if (total > 0) {
result.push({
key: `openai:${providerIndex}`,
displayName,
type: 'openai',
success,
failure,
total,
successRate: (success / total) * 100,
});
}
});
// Build source → auth file name mapping for remaining unmatched entries.
// Also collect fallback stats for details without source but with auth_index.
const sourceToAuthFile = new Map<string, CredentialInfo>();
details.forEach((d) => {
const authIdx = normalizeAuthIndexValue(d.auth_index);
if (!d.source) {
if (!authIdx) return;
const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 };
if (d.failed === true) {
fallback.failure += 1;
} else {
fallback.success += 1;
}
fallbackByAuthIndex.set(authIdx, fallback);
return;
}
if (!authIdx || consumedSourceIds.has(d.source)) return;
if (!sourceToAuthIndex.has(d.source)) {
sourceToAuthIndex.set(d.source, authIdx);
}
if (!sourceToAuthFile.has(d.source)) {
const mapped = authFileMap.get(authIdx);
if (mapped) sourceToAuthFile.set(d.source, mapped);
}
});
// Remaining unmatched bySource entries — resolve name from auth files if possible
Object.entries(bySource).forEach(([key, bucket]) => {
if (consumedSourceIds.has(key)) return;
const total = bucket.success + bucket.failure;
const authFile = sourceToAuthFile.get(key);
const row = {
key,
displayName: authFile?.name || (key.startsWith('t:') ? key.slice(2) : key),
type: authFile?.type || '',
success: bucket.success,
failure: bucket.failure,
total,
successRate: total > 0 ? (bucket.success / total) * 100 : 100,
};
const rowIndex = result.push(row) - 1;
const authIdx = sourceToAuthIndex.get(key);
if (authIdx && !authIndexToRowIndex.has(authIdx)) {
authIndexToRowIndex.set(authIdx, rowIndex);
}
});
// Include requests that have auth_index but missing source.
fallbackByAuthIndex.forEach((bucket, authIdx) => {
if (bucket.success + bucket.failure === 0) return;
const mapped = authFileMap.get(authIdx);
let targetRowIndex = authIndexToRowIndex.get(authIdx);
if (targetRowIndex === undefined && mapped) {
const matchedIndex = result.findIndex(
(row) => row.displayName === mapped.name && row.type === mapped.type
);
if (matchedIndex >= 0) {
targetRowIndex = matchedIndex;
authIndexToRowIndex.set(authIdx, matchedIndex);
}
}
if (targetRowIndex !== undefined) {
mergeBucketToRow(targetRowIndex, bucket);
return;
}
const total = bucket.success + bucket.failure;
const rowIndex = result.push({
key: `auth:${authIdx}`,
displayName: mapped?.name || authIdx,
type: mapped?.type || '',
success: bucket.success,
failure: bucket.failure,
total,
successRate: (bucket.success / total) * 100
}) - 1;
authIndexToRowIndex.set(authIdx, rowIndex);
});
return result.sort((a, b) => b.total - a.total);
}, [usage, geminiKeys, claudeConfigs, codexConfigs, vertexConfigs, openaiProviders, authFileMap]);
return (
<Card title={t('usage_stats.credential_stats')}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : rows.length > 0 ? (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.credential_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.success_rate')}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key}>
<td className={styles.modelCell}>
<span>{row.displayName}</span>
{row.type && (
<span className={styles.credentialType}>{row.type}</span>
)}
</td>
<td>
<span className={styles.requestCountCell}>
<span>{formatCompactNumber(row.total)}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{row.success.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{row.failure.toLocaleString()}</span>)
</span>
</span>
</td>
<td>
<span
className={
row.successRate >= 95
? styles.statSuccess
: row.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
>
{row.successRate.toFixed(1)}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}
+126 -31
View File
@@ -1,6 +1,7 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { formatTokensInMillions, formatUsd } from '@/utils/usage';
import { formatCompactNumber, formatUsd } from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
export interface ModelStat {
@@ -18,43 +19,137 @@ export interface ModelStatsCardProps {
hasPrices: boolean;
}
type SortKey = 'model' | 'requests' | 'tokens' | 'cost' | 'successRate';
type SortDir = 'asc' | 'desc';
interface ModelStatWithRate extends ModelStat {
successRate: number;
}
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
const { t } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>('requests');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const handleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir(key === 'model' ? 'asc' : 'desc');
}
};
const sorted = useMemo((): ModelStatWithRate[] => {
const list: ModelStatWithRate[] = modelStats.map((s) => ({
...s,
successRate: s.requests > 0 ? (s.successCount / s.requests) * 100 : 100,
}));
const dir = sortDir === 'asc' ? 1 : -1;
list.sort((a, b) => {
if (sortKey === 'model') return dir * a.model.localeCompare(b.model);
return dir * ((a[sortKey] as number) - (b[sortKey] as number));
});
return list;
}, [modelStats, sortKey, sortDir]);
const arrow = (key: SortKey) =>
sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : '';
const ariaSort = (key: SortKey): 'none' | 'ascending' | 'descending' =>
sortKey === key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
return (
<Card title={t('usage_stats.models')}>
<Card title={t('usage_stats.models')} className={styles.detailsFixedCard}>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : modelStats.length > 0 ? (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.model_name')}</th>
<th>{t('usage_stats.requests_count')}</th>
<th>{t('usage_stats.tokens_count')}</th>
{hasPrices && <th>{t('usage_stats.total_cost')}</th>}
</tr>
</thead>
<tbody>
{modelStats.map((stat) => (
<tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td>
<td>
<span className={styles.requestCountCell}>
<span>{stat.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
</span>
</span>
</td>
<td>{formatTokensInMillions(stat.tokens)}</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
) : sorted.length > 0 ? (
<div className={styles.detailsScroll}>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th className={styles.sortableHeader} aria-sort={ariaSort('model')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('model')}
>
{t('usage_stats.model_name')}{arrow('model')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('requests')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('requests')}
>
{t('usage_stats.requests_count')}{arrow('requests')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('tokens')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('tokens')}
>
{t('usage_stats.tokens_count')}{arrow('tokens')}
</button>
</th>
<th className={styles.sortableHeader} aria-sort={ariaSort('successRate')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('successRate')}
>
{t('usage_stats.success_rate')}{arrow('successRate')}
</button>
</th>
{hasPrices && (
<th className={styles.sortableHeader} aria-sort={ariaSort('cost')}>
<button
type="button"
className={styles.sortHeaderButton}
onClick={() => handleSort('cost')}
>
{t('usage_stats.total_cost')}{arrow('cost')}
</button>
</th>
)}
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{sorted.map((stat) => (
<tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td>
<td>
<span className={styles.requestCountCell}>
<span>{stat.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
</span>
</span>
</td>
<td>{formatCompactNumber(stat.tokens)}</td>
<td>
<span
className={
stat.successRate >= 95
? styles.statSuccess
: stat.successRate >= 80
? styles.statNeutral
: styles.statFailure
}
>
{stat.successRate.toFixed(1)}%
</span>
</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
+6 -6
View File
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Line } from 'react-chartjs-2';
import { IconDiamond, IconDollarSign, IconSatellite, IconTimer, IconTrendingUp } from '@/components/ui/icons';
import {
formatTokensInMillions,
formatCompactNumber,
formatPerMinuteValue,
formatUsd,
calculateTokenBreakdown,
@@ -81,14 +81,14 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
accent: '#8b5cf6',
accentSoft: 'rgba(139, 92, 246, 0.18)',
accentBorder: 'rgba(139, 92, 246, 0.35)',
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
value: loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0),
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)}
</span>
<span className={styles.statMetaItem}>
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)}
</span>
</>
),
@@ -119,7 +119,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(rateStats.tokenCount)}
</span>
),
trend: sparklines.tpm
@@ -135,7 +135,7 @@ export function StatCards({ usage, loading, modelPrices, sparklines }: StatCards
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
{t('usage_stats.total_tokens')}: {loading ? '-' : formatCompactNumber(usage?.total_tokens ?? 0)}
</span>
{!hasPrices && (
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
@@ -0,0 +1,145 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Line } from 'react-chartjs-2';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import {
buildHourlyTokenBreakdown,
buildDailyTokenBreakdown,
type TokenCategory
} from '@/utils/usage';
import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig';
import type { UsagePayload } from './hooks/useUsageData';
import styles from '@/pages/UsagePage.module.scss';
const TOKEN_COLORS: Record<TokenCategory, { border: string; bg: string }> = {
input: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.25)' },
output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' },
cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' },
reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' }
};
const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning'];
export interface TokenBreakdownChartProps {
usage: UsagePayload | null;
loading: boolean;
isDark: boolean;
isMobile: boolean;
hourWindowHours?: number;
}
export function TokenBreakdownChart({
usage,
loading,
isDark,
isMobile,
hourWindowHours
}: TokenBreakdownChartProps) {
const { t } = useTranslation();
const [period, setPeriod] = useState<'hour' | 'day'>('hour');
const { chartData, chartOptions } = useMemo(() => {
const series =
period === 'hour'
? buildHourlyTokenBreakdown(usage, hourWindowHours)
: buildDailyTokenBreakdown(usage);
const categoryLabels: Record<TokenCategory, string> = {
input: t('usage_stats.input_tokens'),
output: t('usage_stats.output_tokens'),
cached: t('usage_stats.cached_tokens'),
reasoning: t('usage_stats.reasoning_tokens')
};
const data = {
labels: series.labels,
datasets: CATEGORIES.map((cat) => ({
label: categoryLabels[cat],
data: series.dataByCategory[cat],
borderColor: TOKEN_COLORS[cat].border,
backgroundColor: TOKEN_COLORS[cat].bg,
pointBackgroundColor: TOKEN_COLORS[cat].border,
pointBorderColor: TOKEN_COLORS[cat].border,
fill: true,
tension: 0.35
}))
};
const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile });
const options = {
...baseOptions,
scales: {
...baseOptions.scales,
y: {
...baseOptions.scales?.y,
stacked: true
},
x: {
...baseOptions.scales?.x,
stacked: true
}
}
};
return { chartData: data, chartOptions: options };
}, [usage, period, isDark, isMobile, hourWindowHours, t]);
return (
<Card
title={t('usage_stats.token_breakdown')}
extra={
<div className={styles.periodButtons}>
<Button
variant={period === 'hour' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('hour')}
>
{t('usage_stats.by_hour')}
</Button>
<Button
variant={period === 'day' ? 'primary' : 'secondary'}
size="sm"
onClick={() => setPeriod('day')}
>
{t('usage_stats.by_day')}
</Button>
</div>
}
>
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : chartData.labels.length > 0 ? (
<div className={styles.chartWrapper}>
<div className={styles.chartLegend} aria-label="Chart legend">
{chartData.datasets.map((dataset, index) => (
<div
key={`${dataset.label}-${index}`}
className={styles.legendItem}
title={dataset.label}
>
<span className={styles.legendDot} style={{ backgroundColor: dataset.borderColor }} />
<span className={styles.legendLabel}>{dataset.label}</span>
</div>
))}
</div>
<div className={styles.chartArea}>
<div className={styles.chartScroller}>
<div
className={styles.chartCanvas}
style={
period === 'hour'
? { minWidth: getHourChartMinWidth(chartData.labels.length, isMobile) }
: undefined
}
>
<Line data={chartData} options={chartOptions} />
</div>
</div>
</div>
</div>
) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div>
)}
</Card>
);
}
+7 -5
View File
@@ -9,6 +9,7 @@ export interface UseChartDataOptions {
chartLines: string[];
isDark: boolean;
isMobile: boolean;
hourWindowHours?: number;
}
export interface UseChartDataReturn {
@@ -26,20 +27,21 @@ export function useChartData({
usage,
chartLines,
isDark,
isMobile
isMobile,
hourWindowHours
}: UseChartDataOptions): UseChartDataReturn {
const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('day');
const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('day');
const requestsChartData = useMemo(() => {
if (!usage) return { labels: [], datasets: [] };
return buildChartData(usage, requestsPeriod, 'requests', chartLines);
}, [usage, requestsPeriod, chartLines]);
return buildChartData(usage, requestsPeriod, 'requests', chartLines, { hourWindowHours });
}, [usage, requestsPeriod, chartLines, hourWindowHours]);
const tokensChartData = useMemo(() => {
if (!usage) return { labels: [], datasets: [] };
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
}, [usage, tokensPeriod, chartLines]);
return buildChartData(usage, tokensPeriod, 'tokens', chartLines, { hourWindowHours });
}, [usage, tokensPeriod, chartLines, hourWindowHours]);
const requestsChartOptions = useMemo(
() =>
@@ -17,6 +17,7 @@ export interface UseUsageDataReturn {
usage: UsagePayload | null;
loading: boolean;
error: string;
lastRefreshedAt: Date | null;
modelPrices: Record<string, ModelPrice>;
setModelPrices: (prices: Record<string, ModelPrice>) => void;
loadUsage: () => Promise<void>;
@@ -38,6 +39,7 @@ export function useUsageData(): UseUsageDataReturn {
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
const importInputRef = useRef<HTMLInputElement | null>(null);
const loadUsage = useCallback(async () => {
@@ -47,6 +49,7 @@ export function useUsageData(): UseUsageDataReturn {
const data = await usageApi.getUsage();
const payload = (data?.usage ?? data) as unknown;
setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null);
setLastRefreshedAt(new Date());
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
setError(message);
@@ -140,6 +143,7 @@ export function useUsageData(): UseUsageDataReturn {
usage,
loading,
error,
lastRefreshedAt,
modelPrices,
setModelPrices: handleSetModelPrices,
loadUsage,
+9
View File
@@ -26,3 +26,12 @@ export type { ModelStatsCardProps, ModelStat } from './ModelStatsCard';
export { PriceSettingsCard } from './PriceSettingsCard';
export type { PriceSettingsCardProps } from './PriceSettingsCard';
export { CredentialStatsCard } from './CredentialStatsCard';
export type { CredentialStatsCardProps } from './CredentialStatsCard';
export { TokenBreakdownChart } from './TokenBreakdownChart';
export type { TokenBreakdownChartProps } from './TokenBreakdownChart';
export { CostTrendChart } from './CostTrendChart';
export type { CostTrendChartProps } from './CostTrendChart';
+175 -96
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { isMap, parse as parseYaml, parseDocument } from 'yaml';
import type {
PayloadFilterRule,
PayloadParamValueType,
@@ -8,10 +8,6 @@ import type {
} from '@/types/visualConfig';
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
function hasOwn(obj: unknown, key: string): obj is Record<string, unknown> {
return obj !== null && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key);
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
@@ -48,53 +44,58 @@ function parseApiKeysText(raw: unknown): string {
return keys.join('\n');
}
function ensureRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = asRecord(parent[key]);
if (existing) return existing;
const next: Record<string, unknown> = {};
parent[key] = next;
return next;
type YamlDocument = ReturnType<typeof parseDocument>;
type YamlPath = string[];
function docHas(doc: YamlDocument, path: YamlPath): boolean {
return doc.hasIn(path);
}
function deleteIfEmpty(parent: Record<string, unknown>, key: string): void {
const value = asRecord(parent[key]);
if (!value) return;
if (Object.keys(value).length === 0) delete parent[key];
function ensureMapInDoc(doc: YamlDocument, path: YamlPath): void {
const existing = doc.getIn(path, true);
if (isMap(existing)) return;
doc.setIn(path, {});
}
function setBoolean(obj: Record<string, unknown>, key: string, value: boolean): void {
function deleteIfMapEmpty(doc: YamlDocument, path: YamlPath): void {
const value = doc.getIn(path, true);
if (!isMap(value)) return;
if (value.items.length === 0) doc.deleteIn(path);
}
function setBooleanInDoc(doc: YamlDocument, path: YamlPath, value: boolean): void {
if (value) {
obj[key] = true;
doc.setIn(path, true);
return;
}
if (hasOwn(obj, key)) obj[key] = false;
if (docHas(doc, path)) doc.setIn(path, false);
}
function setString(obj: Record<string, unknown>, key: string, value: unknown): void {
function setStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim();
if (trimmed !== '') {
obj[key] = safe;
doc.setIn(path, safe);
return;
}
if (hasOwn(obj, key)) delete obj[key];
if (docHas(doc, path)) doc.deleteIn(path);
}
function setIntFromString(obj: Record<string, unknown>, key: string, value: unknown): void {
function setIntFromStringInDoc(doc: YamlDocument, path: YamlPath, value: unknown): void {
const safe = typeof value === 'string' ? value : '';
const trimmed = safe.trim();
if (trimmed === '') {
if (hasOwn(obj, key)) delete obj[key];
if (docHas(doc, path)) doc.deleteIn(path);
return;
}
const parsed = Number.parseInt(trimmed, 10);
if (Number.isFinite(parsed)) {
obj[key] = parsed;
doc.setIn(path, parsed);
return;
}
if (hasOwn(obj, key)) delete obj[key];
if (docHas(doc, path)) doc.deleteIn(path);
}
function deepClone<T>(value: T): T {
@@ -351,78 +352,95 @@ export function useVisualConfig() {
const applyVisualChangesToYaml = useCallback(
(currentYaml: string): string => {
try {
const parsed = (parseYaml(currentYaml) || {}) as Record<string, unknown>;
const doc = parseDocument(currentYaml);
if (doc.errors.length > 0) return currentYaml;
if (!isMap(doc.contents)) {
doc.contents = doc.createNode({}) as unknown as typeof doc.contents;
}
const values = visualValues;
setString(parsed, 'host', values.host);
setIntFromString(parsed, 'port', values.port);
setStringInDoc(doc, ['host'], values.host);
setIntFromStringInDoc(doc, ['port'], values.port);
if (
hasOwn(parsed, 'tls') ||
docHas(doc, ['tls']) ||
values.tlsEnable ||
values.tlsCert.trim() ||
values.tlsKey.trim()
) {
const tls = ensureRecord(parsed, 'tls');
setBoolean(tls, 'enable', values.tlsEnable);
setString(tls, 'cert', values.tlsCert);
setString(tls, 'key', values.tlsKey);
deleteIfEmpty(parsed, 'tls');
ensureMapInDoc(doc, ['tls']);
setBooleanInDoc(doc, ['tls', 'enable'], values.tlsEnable);
setStringInDoc(doc, ['tls', 'cert'], values.tlsCert);
setStringInDoc(doc, ['tls', 'key'], values.tlsKey);
deleteIfMapEmpty(doc, ['tls']);
}
if (
hasOwn(parsed, 'remote-management') ||
docHas(doc, ['remote-management']) ||
values.rmAllowRemote ||
values.rmSecretKey.trim() ||
values.rmDisableControlPanel ||
values.rmPanelRepo.trim()
) {
const rm = ensureRecord(parsed, 'remote-management');
setBoolean(rm, 'allow-remote', values.rmAllowRemote);
setString(rm, 'secret-key', values.rmSecretKey);
setBoolean(rm, 'disable-control-panel', values.rmDisableControlPanel);
setString(rm, 'panel-github-repository', values.rmPanelRepo);
if (hasOwn(rm, 'panel-repo')) delete rm['panel-repo'];
deleteIfEmpty(parsed, 'remote-management');
ensureMapInDoc(doc, ['remote-management']);
setBooleanInDoc(doc, ['remote-management', 'allow-remote'], values.rmAllowRemote);
setStringInDoc(doc, ['remote-management', 'secret-key'], values.rmSecretKey);
setBooleanInDoc(
doc,
['remote-management', 'disable-control-panel'],
values.rmDisableControlPanel
);
setStringInDoc(doc, ['remote-management', 'panel-github-repository'], values.rmPanelRepo);
if (docHas(doc, ['remote-management', 'panel-repo'])) {
doc.deleteIn(['remote-management', 'panel-repo']);
}
deleteIfMapEmpty(doc, ['remote-management']);
}
setString(parsed, 'auth-dir', values.authDir);
setStringInDoc(doc, ['auth-dir'], values.authDir);
if (values.apiKeysText !== baselineValues.apiKeysText) {
const apiKeys = values.apiKeysText
.split('\n')
.map((key) => key.trim())
.filter(Boolean);
if (apiKeys.length > 0) {
parsed['api-keys'] = apiKeys;
} else if (hasOwn(parsed, 'api-keys')) {
delete parsed['api-keys'];
doc.setIn(['api-keys'], apiKeys);
} else if (docHas(doc, ['api-keys'])) {
doc.deleteIn(['api-keys']);
}
}
setBoolean(parsed, 'debug', values.debug);
setBooleanInDoc(doc, ['debug'], values.debug);
setBoolean(parsed, 'commercial-mode', values.commercialMode);
setBoolean(parsed, 'logging-to-file', values.loggingToFile);
setIntFromString(parsed, 'logs-max-total-size-mb', values.logsMaxTotalSizeMb);
setBoolean(parsed, 'usage-statistics-enabled', values.usageStatisticsEnabled);
setBooleanInDoc(doc, ['commercial-mode'], values.commercialMode);
setBooleanInDoc(doc, ['logging-to-file'], values.loggingToFile);
setIntFromStringInDoc(doc, ['logs-max-total-size-mb'], values.logsMaxTotalSizeMb);
setBooleanInDoc(doc, ['usage-statistics-enabled'], values.usageStatisticsEnabled);
setString(parsed, 'proxy-url', values.proxyUrl);
setBoolean(parsed, 'force-model-prefix', values.forceModelPrefix);
setIntFromString(parsed, 'request-retry', values.requestRetry);
setIntFromString(parsed, 'max-retry-interval', values.maxRetryInterval);
setBoolean(parsed, 'ws-auth', values.wsAuth);
setStringInDoc(doc, ['proxy-url'], values.proxyUrl);
setBooleanInDoc(doc, ['force-model-prefix'], values.forceModelPrefix);
setIntFromStringInDoc(doc, ['request-retry'], values.requestRetry);
setIntFromStringInDoc(doc, ['max-retry-interval'], values.maxRetryInterval);
setBooleanInDoc(doc, ['ws-auth'], values.wsAuth);
if (hasOwn(parsed, 'quota-exceeded') || !values.quotaSwitchProject || !values.quotaSwitchPreviewModel) {
const quota = ensureRecord(parsed, 'quota-exceeded');
quota['switch-project'] = values.quotaSwitchProject;
quota['switch-preview-model'] = values.quotaSwitchPreviewModel;
deleteIfEmpty(parsed, 'quota-exceeded');
if (
docHas(doc, ['quota-exceeded']) ||
!values.quotaSwitchProject ||
!values.quotaSwitchPreviewModel
) {
ensureMapInDoc(doc, ['quota-exceeded']);
doc.setIn(['quota-exceeded', 'switch-project'], values.quotaSwitchProject);
doc.setIn(
['quota-exceeded', 'switch-preview-model'],
values.quotaSwitchPreviewModel
);
deleteIfMapEmpty(doc, ['quota-exceeded']);
}
if (hasOwn(parsed, 'routing') || values.routingStrategy !== 'round-robin') {
const routing = ensureRecord(parsed, 'routing');
routing.strategy = values.routingStrategy;
deleteIfEmpty(parsed, 'routing');
if (docHas(doc, ['routing']) || values.routingStrategy !== 'round-robin') {
ensureMapInDoc(doc, ['routing']);
doc.setIn(['routing', 'strategy'], values.routingStrategy);
deleteIfMapEmpty(doc, ['routing']);
}
const keepaliveSeconds =
@@ -435,42 +453,55 @@ export function useVisualConfig() {
: '';
const streamingDefined =
hasOwn(parsed, 'streaming') || keepaliveSeconds.trim() || bootstrapRetries.trim();
docHas(doc, ['streaming']) || keepaliveSeconds.trim() || bootstrapRetries.trim();
if (streamingDefined) {
const streaming = ensureRecord(parsed, 'streaming');
setIntFromString(streaming, 'keepalive-seconds', keepaliveSeconds);
setIntFromString(streaming, 'bootstrap-retries', bootstrapRetries);
deleteIfEmpty(parsed, 'streaming');
ensureMapInDoc(doc, ['streaming']);
setIntFromStringInDoc(doc, ['streaming', 'keepalive-seconds'], keepaliveSeconds);
setIntFromStringInDoc(doc, ['streaming', 'bootstrap-retries'], bootstrapRetries);
deleteIfMapEmpty(doc, ['streaming']);
}
setIntFromString(parsed, 'nonstream-keepalive-interval', nonstreamKeepaliveInterval);
setIntFromStringInDoc(
doc,
['nonstream-keepalive-interval'],
nonstreamKeepaliveInterval
);
if (
hasOwn(parsed, 'payload') ||
docHas(doc, ['payload']) ||
values.payloadDefaultRules.length > 0 ||
values.payloadOverrideRules.length > 0 ||
values.payloadFilterRules.length > 0
) {
const payload = ensureRecord(parsed, 'payload');
ensureMapInDoc(doc, ['payload']);
if (values.payloadDefaultRules.length > 0) {
payload.default = serializePayloadRulesForYaml(values.payloadDefaultRules);
} else if (hasOwn(payload, 'default')) {
delete payload.default;
doc.setIn(
['payload', 'default'],
serializePayloadRulesForYaml(values.payloadDefaultRules)
);
} else if (docHas(doc, ['payload', 'default'])) {
doc.deleteIn(['payload', 'default']);
}
if (values.payloadOverrideRules.length > 0) {
payload.override = serializePayloadRulesForYaml(values.payloadOverrideRules);
} else if (hasOwn(payload, 'override')) {
delete payload.override;
doc.setIn(
['payload', 'override'],
serializePayloadRulesForYaml(values.payloadOverrideRules)
);
} else if (docHas(doc, ['payload', 'override'])) {
doc.deleteIn(['payload', 'override']);
}
if (values.payloadFilterRules.length > 0) {
payload.filter = serializePayloadFilterRulesForYaml(values.payloadFilterRules);
} else if (hasOwn(payload, 'filter')) {
delete payload.filter;
doc.setIn(
['payload', 'filter'],
serializePayloadFilterRulesForYaml(values.payloadFilterRules)
);
} else if (docHas(doc, ['payload', 'filter'])) {
doc.deleteIn(['payload', 'filter']);
}
deleteIfEmpty(parsed, 'payload');
deleteIfMapEmpty(doc, ['payload']);
}
return stringifyYaml(parsed, { indent: 2, lineWidth: 120, minContentWidth: 0 });
return doc.toString({ indent: 2, lineWidth: 120, minContentWidth: 0 });
} catch {
return currentYaml;
}
@@ -498,18 +529,66 @@ export function useVisualConfig() {
}
export const VISUAL_CONFIG_PROTOCOL_OPTIONS = [
{ value: '', label: '默认' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'openai-response', label: 'OpenAI Response' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'claude', label: 'Claude' },
{ value: 'codex', label: 'Codex' },
{ value: 'antigravity', label: 'Antigravity' },
{
value: '',
labelKey: 'config_management.visual.payload_rules.provider_default',
defaultLabel: 'Default',
},
{
value: 'openai',
labelKey: 'config_management.visual.payload_rules.provider_openai',
defaultLabel: 'OpenAI',
},
{
value: 'openai-response',
labelKey: 'config_management.visual.payload_rules.provider_openai_response',
defaultLabel: 'OpenAI Response',
},
{
value: 'gemini',
labelKey: 'config_management.visual.payload_rules.provider_gemini',
defaultLabel: 'Gemini',
},
{
value: 'claude',
labelKey: 'config_management.visual.payload_rules.provider_claude',
defaultLabel: 'Claude',
},
{
value: 'codex',
labelKey: 'config_management.visual.payload_rules.provider_codex',
defaultLabel: 'Codex',
},
{
value: 'antigravity',
labelKey: 'config_management.visual.payload_rules.provider_antigravity',
defaultLabel: 'Antigravity',
},
] as const;
export const VISUAL_CONFIG_PAYLOAD_VALUE_TYPE_OPTIONS = [
{ value: 'string', label: '字符串' },
{ value: 'number', label: '数字' },
{ value: 'boolean', label: '布尔' },
{ value: 'json', label: 'JSON' },
] as const satisfies ReadonlyArray<{ value: PayloadParamValueType; label: string }>;
{
value: 'string',
labelKey: 'config_management.visual.payload_rules.value_type_string',
defaultLabel: 'String',
},
{
value: 'number',
labelKey: 'config_management.visual.payload_rules.value_type_number',
defaultLabel: 'Number',
},
{
value: 'boolean',
labelKey: 'config_management.visual.payload_rules.value_type_boolean',
defaultLabel: 'Boolean',
},
{
value: 'json',
labelKey: 'config_management.visual.payload_rules.value_type_json',
defaultLabel: 'JSON',
},
] as const satisfies ReadonlyArray<{
value: PayloadParamValueType;
labelKey: string;
defaultLabel: string;
}>;
+60 -8
View File
@@ -419,15 +419,25 @@
"status_toggle_label": "Enabled",
"status_enabled_success": "\"{{name}}\" enabled",
"status_disabled_success": "\"{{name}}\" disabled",
"prefix_proxy_button": "Edit prefix/proxy_url",
"prefix_proxy_loading": "Loading credential...",
"prefix_proxy_source_label": "Credential JSON",
"prefix_label": "prefix",
"proxy_url_label": "proxy_url",
"prefix_proxy_button": "Edit Auth Fields",
"auth_field_editor_title": "Edit Auth Fields - {{name}}",
"prefix_proxy_loading": "Loading auth file...",
"prefix_proxy_source_label": "Auth file JSON (preview)",
"prefix_label": "Prefix (prefix)",
"proxy_url_label": "Proxy URL (proxy_url)",
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully",
"priority_label": "Priority (priority)",
"priority_placeholder": "e.g. 10 or -1",
"priority_hint": "Integers only. Invalid values are ignored. Larger value means higher priority.",
"excluded_models_label": "Excluded models (excluded_models)",
"excluded_models_placeholder": "Comma or newline separated, e.g. model-a, gpt-5-*, *-preview",
"excluded_models_hint": "Saved as an array and normalized by trim/lowercase/dedup/sort.",
"disable_cooling_label": "Disable cooling (disable_cooling)",
"disable_cooling_placeholder": "e.g. true / false / 1 / 0",
"disable_cooling_hint": "Supports booleans, numeric 0/non-0, and strings like true/false/1/0; unparseable values are ignored.",
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
"quota_refresh_failed": "Failed to refresh quota for \"{{name}}\": {{message}}"
},
@@ -443,6 +453,26 @@
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All"
},
"claude_quota": {
"title": "Claude Quota",
"empty_title": "No Claude OAuth Files",
"empty_desc": "Log in with Claude OAuth to view quota.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"empty_windows": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"five_hour": "5-hour limit",
"seven_day": "7-day limit",
"seven_day_oauth_apps": "7-day OAuth apps",
"seven_day_opus": "7-day Opus",
"seven_day_sonnet": "7-day Sonnet",
"seven_day_cowork": "7-day Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "Extra Usage"
},
"codex_quota": {
"title": "Codex Quota",
"empty_title": "No Codex Auth Files",
@@ -730,6 +760,11 @@
"api_details": "API Details",
"by_hour": "By Hour",
"by_day": "By Day",
"range_filter": "Time Range",
"range_all": "All Time",
"range_7h": "Last 7 Hours",
"range_24h": "Last 24 Hours",
"range_7d": "Last 7 Days",
"refresh": "Refresh",
"export": "Export",
"import": "Import",
@@ -777,7 +812,13 @@
"cost_axis_label": "Cost ($)",
"cost_need_price": "Set a model price to view cost stats",
"cost_need_usage": "No usage data available to calculate cost",
"cost_no_data": "No cost data yet"
"cost_no_data": "No cost data yet",
"credential_stats": "Credential Statistics",
"credential_name": "Credential",
"token_breakdown": "Token Type Breakdown",
"input_tokens": "Input Tokens",
"output_tokens": "Output Tokens",
"last_updated": "Updated"
},
"stats": {
"success": "Success",
@@ -975,6 +1016,17 @@
"add_param": "Add Parameter",
"no_rules": "No rules",
"add_rule": "Add Rule",
"provider_default": "Default",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"value_type_string": "String",
"value_type_number": "Number",
"value_type_boolean": "Boolean",
"value_type_json": "JSON",
"value_string": "String value",
"value_number": "Number value (e.g., 0.7)",
"value_boolean": "true or false",
+60 -8
View File
@@ -419,15 +419,25 @@
"status_toggle_label": "Включено",
"status_enabled_success": "\"{{name}}\" включён",
"status_disabled_success": "\"{{name}}\" отключён",
"prefix_proxy_button": "Изменить prefix/proxy_url",
"prefix_proxy_loading": "Загрузка учётных данных...",
"prefix_proxy_source_label": "JSON учётных данных",
"prefix_label": "prefix",
"proxy_url_label": "proxy_url",
"prefix_proxy_button": "Редактировать поля файла авторизации",
"auth_field_editor_title": "Редактировать поля файла авторизации - {{name}}",
"prefix_proxy_loading": "Загрузка файла авторизации...",
"prefix_proxy_source_label": "JSON файла авторизации (предпросмотр)",
"prefix_label": "Префикс (prefix)",
"proxy_url_label": "URL прокси (proxy_url)",
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "Эти учётные данные не являются JSON-объектом и не могут быть изменены.",
"prefix_proxy_saved_success": "\"{{name}}\" успешно обновлён",
"priority_label": "Приоритет (priority)",
"priority_placeholder": "например: 10 или -1",
"priority_hint": "Только целые числа. Некорректные значения игнорируются. Чем больше число, тем выше приоритет.",
"excluded_models_label": "Исключённые модели (excluded_models)",
"excluded_models_placeholder": "Через запятую или с новой строки, например: model-a, gpt-5-*, *-preview",
"excluded_models_hint": "Сохраняется как массив; значения trim/нижний регистр/без дублей/с сортировкой.",
"disable_cooling_label": "Отключение охлаждения (disable_cooling)",
"disable_cooling_placeholder": "например: true / false / 1 / 0",
"disable_cooling_hint": "Поддерживает boolean, числа 0/не 0 и строки true/false/1/0; непарсируемые значения игнорируются.",
"prefix_proxy_invalid_json": "Этот файл авторизации не является JSON-объектом, поэтому поля нельзя редактировать.",
"prefix_proxy_saved_success": "Файл авторизации \"{{name}}\" успешно обновлён",
"card_tools_title": "Инструменты",
"quota_refresh_single": "Обновить квоту",
"quota_refresh_hint": "Обновить квоту только для этих учётных данных",
@@ -446,6 +456,26 @@
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все"
},
"claude_quota": {
"title": "Квота Claude",
"empty_title": "Файлы авторизации Claude OAuth отсутствуют",
"empty_desc": "Войдите через Claude OAuth, чтобы увидеть квоту.",
"idle": "Не загружено. Нажмите \"Обновить квоту\".",
"loading": "Загрузка квоты...",
"load_failed": "Не удалось загрузить квоту: {{message}}",
"missing_auth_index": "В файле авторизации отсутствует auth_index",
"empty_windows": "Данные по квоте отсутствуют",
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все",
"five_hour": "Лимит на 5 часов",
"seven_day": "Лимит на 7 дней",
"seven_day_oauth_apps": "7 дней OAuth приложения",
"seven_day_opus": "7 дней Opus",
"seven_day_sonnet": "7 дней Sonnet",
"seven_day_cowork": "7 дней Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "Дополнительное использование"
},
"codex_quota": {
"title": "Квота Codex",
"empty_title": "Файлы авторизации Codex отсутствуют",
@@ -733,6 +763,11 @@
"api_details": "Детали API",
"by_hour": "По часам",
"by_day": "По дням",
"range_filter": "Диапазон времени",
"range_all": "За всё время",
"range_7h": "Последние 7 часов",
"range_24h": "Последние 24 часа",
"range_7d": "Последние 7 дней",
"refresh": "Обновить",
"export": "Экспорт",
"import": "Импорт",
@@ -780,7 +815,13 @@
"cost_axis_label": "Стоимость ($)",
"cost_need_price": "Задайте стоимость модели, чтобы увидеть статистику затрат",
"cost_need_usage": "Нет данных использования для расчёта стоимости",
"cost_no_data": "Данных о стоимости ещё нет"
"cost_no_data": "Данных о стоимости ещё нет",
"credential_stats": "Статистика учётных данных",
"credential_name": "Учётные данные",
"token_breakdown": "Распределение типов токенов",
"input_tokens": "Входные токены",
"output_tokens": "Выходные токены",
"last_updated": "Обновлено"
},
"stats": {
"success": "Успех",
@@ -980,6 +1021,17 @@
"add_param": "Добавить параметр",
"no_rules": "Правил нет",
"add_rule": "Добавить правило",
"provider_default": "По умолчанию",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"value_type_string": "Строка",
"value_type_number": "Число",
"value_type_boolean": "Булево",
"value_type_json": "JSON",
"value_string": "Строковое значение",
"value_number": "Числовое значение (например, 0.7)",
"value_boolean": "true или false",
+60 -8
View File
@@ -419,15 +419,25 @@
"status_toggle_label": "启用",
"status_enabled_success": "已启用 \"{{name}}\"",
"status_disabled_success": "已停用 \"{{name}}\"",
"prefix_proxy_button": "配置 prefix/proxy_url",
"prefix_proxy_loading": "正在加载凭证文件...",
"prefix_proxy_source_label": "凭证 JSON",
"prefix_label": "prefix",
"proxy_url_label": "proxy_url",
"prefix_proxy_button": "编辑认证文件字段",
"auth_field_editor_title": "编辑认证文件字段 - {{name}}",
"prefix_proxy_loading": "正在加载认证文件...",
"prefix_proxy_source_label": "认证文件 JSON(预览)",
"prefix_label": "前缀(prefix",
"proxy_url_label": "代理 URLproxy_url",
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
"prefix_proxy_saved_success": "已更新 \"{{name}}\"",
"priority_label": "优先级(priority",
"priority_placeholder": "例如: 10 或 -1",
"priority_hint": "仅支持整数;非法值会被忽略。数值越大优先级越高。",
"excluded_models_label": "排除模型(excluded_models",
"excluded_models_placeholder": "用逗号或换行分隔,例如: model-a, gpt-5-*, *-preview",
"excluded_models_hint": "保存为数组;会自动 trim、小写、去重并排序。",
"disable_cooling_label": "禁用冷却(disable_cooling",
"disable_cooling_placeholder": "例如: true / false / 1 / 0",
"disable_cooling_hint": "支持布尔值、0/非0 数字或字符串 true/false/1/0;无法解析时忽略。",
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
"quota_refresh_failed": "刷新 \"{{name}}\" 的额度失败:{{message}}"
},
@@ -443,6 +453,26 @@
"refresh_button": "刷新额度",
"fetch_all": "获取全部"
},
"claude_quota": {
"title": "Claude 额度",
"empty_title": "暂无 Claude OAuth 认证",
"empty_desc": "使用 Claude OAuth 登录后即可查看额度。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"empty_windows": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"five_hour": "5 小时限额",
"seven_day": "7 天限额",
"seven_day_oauth_apps": "7 天 OAuth 应用",
"seven_day_opus": "7 天 Opus",
"seven_day_sonnet": "7 天 Sonnet",
"seven_day_cowork": "7 天 Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "额外用量"
},
"codex_quota": {
"title": "Codex 额度",
"empty_title": "暂无 Codex 认证",
@@ -730,6 +760,11 @@
"api_details": "API 详细统计",
"by_hour": "按小时",
"by_day": "按天",
"range_filter": "时间范围",
"range_all": "全部时间",
"range_7h": "最近7小时",
"range_24h": "最近24小时",
"range_7d": "最近7天",
"refresh": "刷新",
"export": "导出数据",
"import": "导入数据",
@@ -777,7 +812,13 @@
"cost_axis_label": "花费 ($)",
"cost_need_price": "请先设置模型价格",
"cost_need_usage": "暂无使用数据,无法计算花费",
"cost_no_data": "没有可计算的花费数据"
"cost_no_data": "没有可计算的花费数据",
"credential_stats": "凭证统计",
"credential_name": "凭证",
"token_breakdown": "Token 类型分布",
"input_tokens": "输入 Tokens",
"output_tokens": "输出 Tokens",
"last_updated": "更新于"
},
"stats": {
"success": "成功",
@@ -975,6 +1016,17 @@
"add_param": "添加参数",
"no_rules": "暂无规则",
"add_rule": "添加规则",
"provider_default": "默认",
"provider_openai": "OpenAI",
"provider_openai_response": "OpenAI Response",
"provider_gemini": "Gemini",
"provider_claude": "Claude",
"provider_codex": "Codex",
"provider_antigravity": "Antigravity",
"value_type_string": "字符串",
"value_type_number": "数字",
"value_type_boolean": "布尔",
"value_type_json": "JSON",
"value_string": "字符串值",
"value_number": "数字值 (如 0.7)",
"value_boolean": "true 或 false",
+13 -7
View File
@@ -77,8 +77,6 @@ export function AiProvidersOpenAIEditLayout() {
const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const isCacheValid = useConfigStore((state) => state.isCacheValid);
const [providers, setProviders] = useState<OpenAIProviderConfig[]>(
@@ -335,9 +333,18 @@ export function AiProvidersOpenAIEditLayout() {
: [...providers, payload];
await providersApi.saveOpenAIProviders(nextList);
setProviders(nextList);
updateConfigValue('openai-compatibility', nextList);
clearCache('openai-compatibility');
let syncedProviders = nextList;
try {
const latest = await fetchConfig('openai-compatibility', true);
if (Array.isArray(latest)) {
syncedProviders = latest as OpenAIProviderConfig[];
}
} catch {
// 保存成功后刷新失败时,回退到本地计算结果,避免页面数据为空或回退
}
setProviders(syncedProviders);
showNotification(
editIndex !== null
? t('notification.openai_provider_updated')
@@ -351,15 +358,14 @@ export function AiProvidersOpenAIEditLayout() {
setSaving(false);
}
}, [
clearCache,
editIndex,
fetchConfig,
form,
handleBack,
providers,
testModel,
showNotification,
t,
updateConfigValue,
]);
const resolvedLoading = !draft?.initialized;
+9 -1
View File
@@ -80,12 +80,13 @@
.filterTag {
display: inline-flex;
align-items: center;
align-items: baseline;
gap: 8px;
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
line-height: 1;
border: 1px solid transparent;
cursor: pointer;
transition: all $transition-fast;
@@ -101,12 +102,19 @@
}
.filterTagLabel {
display: inline-flex;
align-items: baseline;
white-space: nowrap;
}
.filterTagCount {
display: inline-flex;
align-items: baseline;
justify-content: flex-end;
min-width: 2ch;
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
opacity: 0.85;
}
+177 -14
View File
@@ -95,6 +95,9 @@ const MIN_CARD_PAGE_SIZE = 3;
const MAX_CARD_PAGE_SIZE = 30;
const MAX_AUTH_FILE_SIZE = 50 * 1024;
const AUTH_FILES_UI_STATE_KEY = 'authFilesPage.uiState';
const INTEGER_STRING_PATTERN = /^[+-]?\d+$/;
const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
const FALSY_TEXT_VALUES = new Set(['false', '0', 'no', 'n', 'off']);
const clampCardPageSize = (value: number) =>
Math.min(MAX_CARD_PAGE_SIZE, Math.max(MIN_CARD_PAGE_SIZE, Math.round(value)));
@@ -170,6 +173,34 @@ const writeAuthFilesUiState = (state: AuthFilesUiState) => {
}
};
const copyToClipboard = async (text: string): Promise<boolean> => {
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fallback below
}
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
return copied;
} catch {
return false;
}
};
interface PrefixProxyEditorState {
fileName: string;
loading: boolean;
@@ -180,7 +211,54 @@ interface PrefixProxyEditorState {
json: Record<string, unknown> | null;
prefix: string;
proxyUrl: string;
priority: string;
excludedModelsText: string;
disableCooling: string;
}
const parsePriorityValue = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isInteger(value) ? value : undefined;
}
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
if (!trimmed || !INTEGER_STRING_PATTERN.test(trimmed)) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isSafeInteger(parsed) ? parsed : undefined;
};
const normalizeExcludedModels = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const normalized: string[] = [];
value.forEach((entry) => {
const model = String(entry ?? '')
.trim()
.toLowerCase();
if (!model || seen.has(model)) return;
seen.add(model);
normalized.push(model);
});
return normalized.sort((a, b) => a.localeCompare(b));
};
const parseExcludedModelsText = (value: string): string[] =>
normalizeExcludedModels(value.split(/[\n,]+/));
const parseDisableCoolingValue = (value: unknown): boolean | undefined => {
if (typeof value === 'boolean') return value;
if (typeof value === 'number' && Number.isFinite(value)) return value !== 0;
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (!normalized) return undefined;
if (TRUTHY_TEXT_VALUES.has(normalized)) return true;
if (FALSY_TEXT_VALUES.has(normalized)) return false;
return undefined;
};
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -413,11 +491,36 @@ export function AuthFilesPage() {
if ('proxy_url' in next || prefixProxyEditor.proxyUrl.trim()) {
next.proxy_url = prefixProxyEditor.proxyUrl;
}
const parsedPriority = parsePriorityValue(prefixProxyEditor.priority);
if (parsedPriority !== undefined) {
next.priority = parsedPriority;
} else if ('priority' in next) {
delete next.priority;
}
const excludedModels = parseExcludedModelsText(prefixProxyEditor.excludedModelsText);
if (excludedModels.length > 0) {
next.excluded_models = excludedModels;
} else if ('excluded_models' in next) {
delete next.excluded_models;
}
const parsedDisableCooling = parseDisableCoolingValue(prefixProxyEditor.disableCooling);
if (parsedDisableCooling !== undefined) {
next.disable_cooling = parsedDisableCooling;
} else if ('disable_cooling' in next) {
delete next.disable_cooling;
}
return JSON.stringify(next);
}, [
prefixProxyEditor?.json,
prefixProxyEditor?.prefix,
prefixProxyEditor?.proxyUrl,
prefixProxyEditor?.priority,
prefixProxyEditor?.excludedModelsText,
prefixProxyEditor?.disableCooling,
prefixProxyEditor?.rawText,
]);
@@ -829,6 +932,9 @@ export function AuthFilesPage() {
json: null,
prefix: '',
proxyUrl: '',
priority: '',
excludedModelsText: '',
disableCooling: '',
});
try {
@@ -870,6 +976,9 @@ export function AuthFilesPage() {
const originalText = JSON.stringify(json);
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
const priority = parsePriorityValue(json.priority);
const excludedModels = normalizeExcludedModels(json.excluded_models);
const disableCooling = parseDisableCoolingValue(json.disable_cooling);
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
@@ -881,6 +990,10 @@ export function AuthFilesPage() {
json,
prefix,
proxyUrl,
priority: priority !== undefined ? String(priority) : '',
excludedModelsText: excludedModels.join('\n'),
disableCooling:
disableCooling === undefined ? '' : disableCooling ? 'true' : 'false',
error: null,
};
});
@@ -894,11 +1007,17 @@ export function AuthFilesPage() {
}
};
const handlePrefixProxyChange = (field: 'prefix' | 'proxyUrl', value: string) => {
const handlePrefixProxyChange = (
field: 'prefix' | 'proxyUrl' | 'priority' | 'excludedModelsText' | 'disableCooling',
value: string
) => {
setPrefixProxyEditor((prev) => {
if (!prev) return prev;
if (field === 'prefix') return { ...prev, prefix: value };
return { ...prev, proxyUrl: value };
if (field === 'proxyUrl') return { ...prev, proxyUrl: value };
if (field === 'priority') return { ...prev, priority: value };
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: value };
return { ...prev, disableCooling: value };
});
};
@@ -1016,14 +1135,28 @@ export function AuthFilesPage() {
}
};
const copyTextWithNotification = async (text: string) => {
const copied = await copyToClipboard(text);
showNotification(
copied
? t('notification.link_copied', { defaultValue: 'Copied to clipboard' })
: t('notification.copy_failed', { defaultValue: 'Copy failed' }),
copied ? 'success' : 'error'
);
};
// 检查模型是否被 OAuth 排除
const isModelExcluded = (modelId: string, providerType: string): boolean => {
const providerKey = normalizeProviderKey(providerType);
const excludedModels = excluded[providerKey] || excluded[providerType] || [];
return excludedModels.some((pattern) => {
if (pattern.includes('*')) {
// 支持通配符匹配
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i');
// 支持通配符匹配:先转义正则特殊字符,再将 * 视为通配符
const regexSafePattern = pattern
.split('*')
.map((segment) => segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('.*');
const regex = new RegExp(`^${regexSafePattern}$`, 'i');
return regex.test(modelId);
}
return pattern.toLowerCase() === modelId.toLowerCase();
@@ -2046,9 +2179,7 @@ export function AuthFilesPage() {
onClick={() => {
if (selectedFile) {
const text = JSON.stringify(selectedFile, null, 2);
navigator.clipboard.writeText(text).then(() => {
showNotification(t('notification.link_copied'), 'success');
});
void copyTextWithNotification(text);
}
}}
>
@@ -2104,11 +2235,7 @@ export function AuthFilesPage() {
key={model.id}
className={`${styles.modelItem} ${isExcluded ? styles.modelItemExcluded : ''}`}
onClick={() => {
navigator.clipboard.writeText(model.id);
showNotification(
t('notification.link_copied', { defaultValue: '已复制到剪贴板' }),
'success'
);
void copyTextWithNotification(model.id);
}}
title={
isExcluded
@@ -2135,7 +2262,7 @@ export function AuthFilesPage() {
)}
</Modal>
{/* prefix/proxy_url 编辑弹窗 */}
{/* 认证文件字段编辑弹窗 */}
<Modal
open={Boolean(prefixProxyEditor)}
onClose={() => setPrefixProxyEditor(null)}
@@ -2143,7 +2270,7 @@ export function AuthFilesPage() {
width={720}
title={
prefixProxyEditor?.fileName
? `${t('auth_files.prefix_proxy_button')} - ${prefixProxyEditor.fileName}`
? t('auth_files.auth_field_editor_title', { name: prefixProxyEditor.fileName })
: t('auth_files.prefix_proxy_button')
}
footer={
@@ -2211,6 +2338,42 @@ export function AuthFilesPage() {
}
onChange={(e) => handlePrefixProxyChange('proxyUrl', e.target.value)}
/>
<Input
label={t('auth_files.priority_label')}
value={prefixProxyEditor.priority}
placeholder={t('auth_files.priority_placeholder')}
hint={t('auth_files.priority_hint')}
disabled={
disableControls || prefixProxyEditor.saving || !prefixProxyEditor.json
}
onChange={(e) => handlePrefixProxyChange('priority', e.target.value)}
/>
<div className="form-group">
<label>{t('auth_files.excluded_models_label')}</label>
<textarea
className="input"
value={prefixProxyEditor.excludedModelsText}
placeholder={t('auth_files.excluded_models_placeholder')}
rows={4}
disabled={
disableControls || prefixProxyEditor.saving || !prefixProxyEditor.json
}
onChange={(e) =>
handlePrefixProxyChange('excludedModelsText', e.target.value)
}
/>
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
</div>
<Input
label={t('auth_files.disable_cooling_label')}
value={prefixProxyEditor.disableCooling}
placeholder={t('auth_files.disable_cooling_placeholder')}
hint={t('auth_files.disable_cooling_hint')}
disabled={
disableControls || prefixProxyEditor.saving || !prefixProxyEditor.json
}
onChange={(e) => handlePrefixProxyChange('disableCooling', e.target.value)}
/>
</div>
</>
)}
+16
View File
@@ -400,6 +400,8 @@ export function LogsPage() {
startY: number;
fired: boolean;
} | null>(null);
const logRequestInFlightRef = useRef(false);
const pendingFullReloadRef = useRef(false);
// 保存最新时间戳用于增量获取
const latestTimestampRef = useRef<number>(0);
@@ -424,6 +426,15 @@ export function LogsPage() {
return;
}
if (logRequestInFlightRef.current) {
if (!incremental) {
pendingFullReloadRef.current = true;
}
return;
}
logRequestInFlightRef.current = true;
if (!incremental) {
setLoading(true);
}
@@ -474,6 +485,11 @@ export function LogsPage() {
if (!incremental) {
setLoading(false);
}
logRequestInFlightRef.current = false;
if (pendingFullReloadRef.current) {
pendingFullReloadRef.current = false;
void loadLogs(false);
}
}
};
+9
View File
@@ -103,6 +103,7 @@
}
.antigravityGrid,
.claudeGrid,
.codexGrid,
.geminiCliGrid {
display: grid;
@@ -115,6 +116,7 @@
}
.antigravityControls,
.claudeControls,
.codexControls,
.geminiCliControls {
display: flex;
@@ -125,6 +127,7 @@
}
.antigravityControl,
.claudeControl,
.codexControl,
.geminiCliControl {
display: flex;
@@ -145,6 +148,12 @@
align-items: center;
}
.claudeCard {
background-image: linear-gradient(180deg,
rgba(252, 228, 236, 0.18),
rgba(252, 228, 236, 0));
}
.antigravityCard {
background-image: linear-gradient(180deg,
rgba(224, 247, 250, 0.12),
+7
View File
@@ -10,6 +10,7 @@ import { authFilesApi, configFileApi } from '@/services/api';
import {
QuotaSection,
ANTIGRAVITY_CONFIG,
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CLI_CONFIG
} from '@/components/quota';
@@ -69,6 +70,12 @@ export function QuotaPage() {
{error && <div className={styles.errorBox}>{error}</div>}
<QuotaSection
config={CLAUDE_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
<QuotaSection
config={ANTIGRAVITY_CONFIG}
files={files}
+223
View File
@@ -25,6 +25,121 @@
flex-wrap: wrap;
}
.lastRefreshed {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
}
.timeRangeGroup {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.timeRangeLabel {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
.timeRangeSelectWrap {
position: relative;
display: inline-flex;
align-items: center;
}
.timeRangeSelect {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 164px;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-primary);
box-shadow: var(--shadow);
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
appearance: none;
text-align: left;
&:hover {
border-color: var(--border-hover);
}
&:focus {
outline: none;
box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15);
}
&[aria-expanded='true'] {
border-color: var(--primary-color);
box-shadow: var(--shadow), 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
.timeRangeSelectedText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timeRangeSelectIcon {
display: inline-flex;
color: var(--text-secondary);
flex-shrink: 0;
transition: transform 0.2s ease;
[aria-expanded='true'] > & {
transform: rotate(180deg);
}
}
.timeRangeDropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 1000;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: $radius-lg;
padding: 6px;
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
gap: 4px;
}
.timeRangeOption {
padding: 8px 12px;
border-radius: $radius-md;
border: 1px solid transparent;
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: left;
font-size: 13px;
font-weight: 500;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover {
background: var(--bg-secondary);
}
}
.timeRangeOptionActive {
border-color: rgba(59, 130, 246, 0.5);
background: rgba(59, 130, 246, 0.10);
font-weight: 600;
}
.pageTitle {
font-size: 28px;
font-weight: 700;
@@ -314,6 +429,42 @@
}
// API List (80%比例)
.apiSortBar {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.apiSortBtn {
padding: 4px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 11px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 1px;
}
}
.apiSortBtnActive {
border-color: rgba(59, 130, 246, 0.5);
background: rgba(59, 130, 246, 0.10);
color: var(--text-primary);
font-weight: 600;
}
.apiList {
display: flex;
flex-direction: column;
@@ -328,16 +479,27 @@
}
.apiHeader {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--bg-tertiary);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: -2px;
}
}
.apiInfo {
@@ -418,6 +580,26 @@
}
}
// Fixed-height cards with internal scrolling (API details / model stats)
.detailsFixedCard {
display: flex;
flex-direction: column;
height: 520px;
overflow: hidden;
@include mobile {
height: 420px;
}
}
.detailsScroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
// Table (80%比例)
.tableWrapper {
overflow-x: auto;
@@ -441,6 +623,15 @@
white-space: nowrap;
}
th.sortableHeader {
user-select: none;
transition: color 0.15s ease;
&:hover {
color: var(--text-primary);
}
}
td {
color: var(--text-primary);
}
@@ -450,12 +641,44 @@
}
}
.sortHeaderButton {
display: inline-flex;
align-items: center;
width: 100%;
border: 0;
padding: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: $radius-sm;
}
}
.modelCell {
font-weight: 500;
max-width: 240px;
word-break: break-all;
}
.credentialType {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
border-radius: $radius-full;
font-size: 10px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
vertical-align: middle;
}
.requestCountCell {
display: inline-flex;
align-items: baseline;
+212 -11
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
Chart as ChartJS,
@@ -13,9 +13,10 @@ import {
} from 'chart.js';
import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { IconChevronDown } from '@/components/ui/icons';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useThemeStore } from '@/stores';
import { useThemeStore, useConfigStore } from '@/stores';
import {
StatCards,
UsageChart,
@@ -23,11 +24,20 @@ import {
ApiDetailsCard,
ModelStatsCard,
PriceSettingsCard,
CredentialStatsCard,
TokenBreakdownChart,
CostTrendChart,
useUsageData,
useSparklines,
useChartData
} from '@/components/usage';
import { getModelNamesFromUsage, getApiStats, getModelStats } from '@/utils/usage';
import {
getModelNamesFromUsage,
getApiStats,
getModelStats,
filterUsageByTimeRange,
type UsageTimeRange
} from '@/utils/usage';
import styles from './UsagePage.module.scss';
// Register Chart.js components
@@ -42,17 +52,93 @@ ChartJS.register(
Filler
);
const CHART_LINES_STORAGE_KEY = 'cli-proxy-usage-chart-lines-v1';
const TIME_RANGE_STORAGE_KEY = 'cli-proxy-usage-time-range-v1';
const DEFAULT_CHART_LINES = ['all'];
const DEFAULT_TIME_RANGE: UsageTimeRange = '24h';
const MAX_CHART_LINES = 9;
const TIME_RANGE_OPTIONS: ReadonlyArray<{ value: UsageTimeRange; labelKey: string }> = [
{ value: 'all', labelKey: 'usage_stats.range_all' },
{ value: '7h', labelKey: 'usage_stats.range_7h' },
{ value: '24h', labelKey: 'usage_stats.range_24h' },
{ value: '7d', labelKey: 'usage_stats.range_7d' },
];
const HOUR_WINDOW_BY_TIME_RANGE: Record<Exclude<UsageTimeRange, 'all'>, number> = {
'7h': 7,
'24h': 24,
'7d': 7 * 24
};
const isUsageTimeRange = (value: unknown): value is UsageTimeRange =>
value === '7h' || value === '24h' || value === '7d' || value === 'all';
const normalizeChartLines = (value: unknown, maxLines = MAX_CHART_LINES): string[] => {
if (!Array.isArray(value)) {
return DEFAULT_CHART_LINES;
}
const filtered = value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean)
.slice(0, maxLines);
return filtered.length ? filtered : DEFAULT_CHART_LINES;
};
const loadChartLines = (): string[] => {
try {
if (typeof localStorage === 'undefined') {
return DEFAULT_CHART_LINES;
}
const raw = localStorage.getItem(CHART_LINES_STORAGE_KEY);
if (!raw) {
return DEFAULT_CHART_LINES;
}
return normalizeChartLines(JSON.parse(raw));
} catch {
return DEFAULT_CHART_LINES;
}
};
const loadTimeRange = (): UsageTimeRange => {
try {
if (typeof localStorage === 'undefined') {
return DEFAULT_TIME_RANGE;
}
const raw = localStorage.getItem(TIME_RANGE_STORAGE_KEY);
return isUsageTimeRange(raw) ? raw : DEFAULT_TIME_RANGE;
} catch {
return DEFAULT_TIME_RANGE;
}
};
export function UsagePage() {
const { t } = useTranslation();
const isMobile = useMediaQuery('(max-width: 768px)');
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const isDark = resolvedTheme === 'dark';
const config = useConfigStore((state) => state.config);
// Time range dropdown
const [timeRangeOpen, setTimeRangeOpen] = useState(false);
const timeRangeRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!timeRangeOpen) return;
const handleClickOutside = (event: MouseEvent) => {
if (!timeRangeRef.current?.contains(event.target as Node)) setTimeRangeOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [timeRangeOpen]);
// Data hook
const {
usage,
loading,
error,
lastRefreshedAt,
modelPrices,
setModelPrices,
loadUsage,
@@ -67,8 +153,41 @@ export function UsagePage() {
useHeaderRefresh(loadUsage);
// Chart lines state
const [chartLines, setChartLines] = useState<string[]>(['all']);
const MAX_CHART_LINES = 9;
const [chartLines, setChartLines] = useState<string[]>(loadChartLines);
const [timeRange, setTimeRange] = useState<UsageTimeRange>(loadTimeRange);
const filteredUsage = useMemo(
() => (usage ? filterUsageByTimeRange(usage, timeRange) : null),
[usage, timeRange]
);
const hourWindowHours =
timeRange === 'all' ? undefined : HOUR_WINDOW_BY_TIME_RANGE[timeRange];
const handleChartLinesChange = useCallback((lines: string[]) => {
setChartLines(normalizeChartLines(lines));
}, []);
useEffect(() => {
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(CHART_LINES_STORAGE_KEY, JSON.stringify(chartLines));
} catch {
// Ignore storage errors.
}
}, [chartLines]);
useEffect(() => {
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(TIME_RANGE_STORAGE_KEY, timeRange);
} catch {
// Ignore storage errors.
}
}, [timeRange]);
// Sparklines hook
const {
@@ -77,7 +196,7 @@ export function UsagePage() {
rpmSparkline,
tpmSparkline,
costSparkline
} = useSparklines({ usage, loading });
} = useSparklines({ usage: filteredUsage, loading });
// Chart data hook
const {
@@ -89,12 +208,18 @@ export function UsagePage() {
tokensChartData,
requestsChartOptions,
tokensChartOptions
} = useChartData({ usage, chartLines, isDark, isMobile });
} = useChartData({ usage: filteredUsage, chartLines, isDark, isMobile, hourWindowHours });
// Derived data
const modelNames = useMemo(() => getModelNamesFromUsage(usage), [usage]);
const apiStats = useMemo(() => getApiStats(usage, modelPrices), [usage, modelPrices]);
const modelStats = useMemo(() => getModelStats(usage, modelPrices), [usage, modelPrices]);
const apiStats = useMemo(
() => getApiStats(filteredUsage, modelPrices),
[filteredUsage, modelPrices]
);
const modelStats = useMemo(
() => getModelStats(filteredUsage, modelPrices),
[filteredUsage, modelPrices]
);
const hasPrices = Object.keys(modelPrices).length > 0;
return (
@@ -111,6 +236,47 @@ export function UsagePage() {
<div className={styles.header}>
<h1 className={styles.pageTitle}>{t('usage_stats.title')}</h1>
<div className={styles.headerActions}>
<div className={styles.timeRangeGroup}>
<span className={styles.timeRangeLabel}>{t('usage_stats.range_filter')}</span>
<div className={styles.timeRangeSelectWrap} ref={timeRangeRef}>
<button
type="button"
className={styles.timeRangeSelect}
onClick={() => setTimeRangeOpen((prev) => !prev)}
aria-haspopup="listbox"
aria-expanded={timeRangeOpen}
>
<span className={styles.timeRangeSelectedText}>
{t(TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)?.labelKey ?? 'usage_stats.range_24h')}
</span>
<span className={styles.timeRangeSelectIcon} aria-hidden="true">
<IconChevronDown size={14} />
</span>
</button>
{timeRangeOpen && (
<div className={styles.timeRangeDropdown} role="listbox" aria-label={t('usage_stats.range_filter')}>
{TIME_RANGE_OPTIONS.map((opt) => {
const active = opt.value === timeRange;
return (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
className={`${styles.timeRangeOption} ${active ? styles.timeRangeOptionActive : ''}`}
onClick={() => {
setTimeRange(opt.value);
setTimeRangeOpen(false);
}}
>
{t(opt.labelKey)}
</button>
);
})}
</div>
)}
</div>
</div>
<Button
variant="secondary"
size="sm"
@@ -144,6 +310,11 @@ export function UsagePage() {
style={{ display: 'none' }}
onChange={handleImportChange}
/>
{lastRefreshedAt && (
<span className={styles.lastRefreshed}>
{t('usage_stats.last_updated')}: {lastRefreshedAt.toLocaleTimeString()}
</span>
)}
</div>
</div>
@@ -151,7 +322,7 @@ export function UsagePage() {
{/* Stats Overview Cards */}
<StatCards
usage={usage}
usage={filteredUsage}
loading={loading}
modelPrices={modelPrices}
sparklines={{
@@ -168,7 +339,7 @@ export function UsagePage() {
chartLines={chartLines}
modelNames={modelNames}
maxLines={MAX_CHART_LINES}
onChange={setChartLines}
onChange={handleChartLinesChange}
/>
{/* Charts Grid */}
@@ -195,12 +366,42 @@ export function UsagePage() {
/>
</div>
{/* Token Breakdown Chart */}
<TokenBreakdownChart
usage={filteredUsage}
loading={loading}
isDark={isDark}
isMobile={isMobile}
hourWindowHours={hourWindowHours}
/>
{/* Cost Trend Chart */}
<CostTrendChart
usage={filteredUsage}
loading={loading}
isDark={isDark}
isMobile={isMobile}
modelPrices={modelPrices}
hourWindowHours={hourWindowHours}
/>
{/* Details Grid */}
<div className={styles.detailsGrid}>
<ApiDetailsCard apiStats={apiStats} loading={loading} hasPrices={hasPrices} />
<ModelStatsCard modelStats={modelStats} loading={loading} hasPrices={hasPrices} />
</div>
{/* Credential Stats */}
<CredentialStatsCard
usage={filteredUsage}
loading={loading}
geminiKeys={config?.geminiApiKeys || []}
claudeConfigs={config?.claudeApiKeys || []}
codexConfigs={config?.codexApiKeys || []}
vertexConfigs={config?.vertexApiKeys || []}
openaiProviders={config?.openaiCompatibility || []}
/>
{/* Price Settings */}
<PriceSettingsCard
modelNames={modelNames}
+9 -1
View File
@@ -3,15 +3,17 @@
*/
import { create } from 'zustand';
import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
type QuotaUpdater<T> = T | ((prev: T) => T);
interface QuotaStoreState {
antigravityQuota: Record<string, AntigravityQuotaState>;
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
@@ -26,12 +28,17 @@ const resolveUpdater = <T,>(updater: QuotaUpdater<T>, prev: T): T => {
export const useQuotaStore = create<QuotaStoreState>((set) => ({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {},
setAntigravityQuota: (updater) =>
set((state) => ({
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
})),
setClaudeQuota: (updater) =>
set((state) => ({
claudeQuota: resolveUpdater(updater, state.claudeQuota)
})),
setCodexQuota: (updater) =>
set((state) => ({
codexQuota: resolveUpdater(updater, state.codexQuota)
@@ -43,6 +50,7 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
clearQuotaCache: () =>
set({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {}
})
+40
View File
@@ -97,6 +97,46 @@ export interface CodexUsagePayload {
codeReviewRateLimit?: CodexRateLimitInfo | null;
}
// Claude API payload types
export interface ClaudeUsageWindow {
utilization: number;
resets_at: string;
}
export interface ClaudeExtraUsage {
is_enabled: boolean;
monthly_limit: number;
used_credits: number;
utilization: number | null;
}
export interface ClaudeUsagePayload {
five_hour?: ClaudeUsageWindow | null;
seven_day?: ClaudeUsageWindow | null;
seven_day_oauth_apps?: ClaudeUsageWindow | null;
seven_day_opus?: ClaudeUsageWindow | null;
seven_day_sonnet?: ClaudeUsageWindow | null;
seven_day_cowork?: ClaudeUsageWindow | null;
iguana_necktie?: ClaudeUsageWindow | null;
extra_usage?: ClaudeExtraUsage | null;
}
export interface ClaudeQuotaWindow {
id: string;
label: string;
labelKey?: string;
usedPercent: number | null;
resetLabel: string;
}
export interface ClaudeQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
windows: ClaudeQuotaWindow[];
extraUsage?: ClaudeExtraUsage | null;
error?: string;
errorStatus?: number;
}
// Quota state types
export interface AntigravityQuotaGroup {
id: string;
+19
View File
@@ -151,6 +151,25 @@ export const GEMINI_CLI_GROUP_LOOKUP = new Map(
export const GEMINI_CLI_IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash'];
// Claude API configuration
export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
export const CLAUDE_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json',
'anthropic-beta': 'oauth-2025-04-20',
};
export const CLAUDE_USAGE_WINDOW_KEYS = [
{ key: 'five_hour', id: 'five-hour', labelKey: 'claude_quota.five_hour' },
{ key: 'seven_day', id: 'seven-day', labelKey: 'claude_quota.seven_day' },
{ key: 'seven_day_oauth_apps', id: 'seven-day-oauth-apps', labelKey: 'claude_quota.seven_day_oauth_apps' },
{ key: 'seven_day_opus', id: 'seven-day-opus', labelKey: 'claude_quota.seven_day_opus' },
{ key: 'seven_day_sonnet', id: 'seven-day-sonnet', labelKey: 'claude_quota.seven_day_sonnet' },
{ key: 'seven_day_cowork', id: 'seven-day-cowork', labelKey: 'claude_quota.seven_day_cowork' },
{ key: 'iguana_necktie', id: 'iguana-necktie', labelKey: 'claude_quota.iguana_necktie' },
] as const;
// Codex API configuration
export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
+18 -1
View File
@@ -2,7 +2,7 @@
* Normalization and parsing functions for quota data.
*/
import type { CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
@@ -129,6 +129,23 @@ export function parseAntigravityPayload(payload: unknown): Record<string, unknow
return null;
}
export function parseClaudeUsagePayload(payload: unknown): ClaudeUsagePayload | 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 ClaudeUsagePayload;
} catch {
return null;
}
}
if (typeof payload === 'object') {
return payload as ClaudeUsagePayload;
}
return null;
}
export function parseCodexUsagePayload(payload: unknown): CodexUsagePayload | null {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {
+17
View File
@@ -14,6 +14,23 @@ export function isAntigravityFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'antigravity';
}
export function isClaudeFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'claude';
}
export function isClaudeOAuthFile(file: AuthFileItem): boolean {
if (!isClaudeFile(file)) return false;
const metadata =
file && typeof file.metadata === 'object' && file.metadata !== null
? (file.metadata as Record<string, unknown>)
: null;
const accessToken =
metadata && typeof metadata.access_token === 'string'
? metadata.access_token.trim()
: '';
return accessToken.includes('sk-ant-oat');
}
export function isCodexFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'codex';
}
+359 -19
View File
@@ -61,8 +61,15 @@ export interface ApiStats {
models: Record<string, { requests: number; successCount: number; failureCount: number; tokens: number }>;
}
export type UsageTimeRange = '7h' | '24h' | '7d' | 'all';
const TOKENS_PER_PRICE_UNIT = 1_000_000;
const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
const USAGE_TIME_RANGE_MS: Record<Exclude<UsageTimeRange, 'all'>, number> = {
'7h': 7 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -73,6 +80,140 @@ const getApisRecord = (usageData: unknown): Record<string, unknown> | null => {
return isRecord(apisRaw) ? apisRaw : null;
};
interface UsageSummary {
totalRequests: number;
successCount: number;
failureCount: number;
totalTokens: number;
}
const createUsageSummary = (): UsageSummary => ({
totalRequests: 0,
successCount: 0,
failureCount: 0,
totalTokens: 0
});
const toUsageSummaryFields = (summary: UsageSummary) => ({
total_requests: summary.totalRequests,
success_count: summary.successCount,
failure_count: summary.failureCount,
total_tokens: summary.totalTokens
});
const isDetailWithinWindow = (detail: unknown, windowStart: number, nowMs: number): detail is Record<string, unknown> => {
if (!isRecord(detail) || typeof detail.timestamp !== 'string') {
return false;
}
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) {
return false;
}
return timestamp >= windowStart && timestamp <= nowMs;
};
const updateSummaryFromDetails = (summary: UsageSummary, details: unknown[]) => {
details.forEach((detail) => {
const detailRecord = isRecord(detail) ? detail : null;
if (!detailRecord) {
return;
}
summary.totalRequests += 1;
if (detailRecord.failed === true) {
summary.failureCount += 1;
} else {
summary.successCount += 1;
}
summary.totalTokens += extractTotalTokens(detailRecord);
});
};
export function filterUsageByTimeRange<T>(usageData: T, range: UsageTimeRange, nowMs: number = Date.now()): T {
if (range === 'all') {
return usageData;
}
const usageRecord = isRecord(usageData) ? usageData : null;
const apis = getApisRecord(usageData);
if (!usageRecord || !apis) {
return usageData;
}
const rangeMs = USAGE_TIME_RANGE_MS[range];
if (!Number.isFinite(rangeMs) || rangeMs <= 0) {
return usageData;
}
const windowStart = nowMs - rangeMs;
const filteredApis: Record<string, unknown> = {};
const totalSummary = createUsageSummary();
Object.entries(apis).forEach(([apiName, apiEntry]) => {
if (!isRecord(apiEntry)) {
return;
}
const models = isRecord(apiEntry.models) ? apiEntry.models : null;
if (!models) {
return;
}
const filteredModels: Record<string, unknown> = {};
const apiSummary = createUsageSummary();
Object.entries(models).forEach(([modelName, modelEntry]) => {
if (!isRecord(modelEntry)) {
return;
}
const detailsRaw = Array.isArray(modelEntry.details) ? modelEntry.details : [];
const filteredDetails = detailsRaw.filter((detail) =>
isDetailWithinWindow(detail, windowStart, nowMs)
);
if (!filteredDetails.length) {
return;
}
const modelSummary = createUsageSummary();
updateSummaryFromDetails(modelSummary, filteredDetails);
filteredModels[modelName] = {
...modelEntry,
...toUsageSummaryFields(modelSummary),
details: filteredDetails
};
apiSummary.totalRequests += modelSummary.totalRequests;
apiSummary.successCount += modelSummary.successCount;
apiSummary.failureCount += modelSummary.failureCount;
apiSummary.totalTokens += modelSummary.totalTokens;
});
if (Object.keys(filteredModels).length === 0) {
return;
}
filteredApis[apiName] = {
...apiEntry,
...toUsageSummaryFields(apiSummary),
models: filteredModels
};
totalSummary.totalRequests += apiSummary.totalRequests;
totalSummary.successCount += apiSummary.successCount;
totalSummary.failureCount += apiSummary.failureCount;
totalSummary.totalTokens += apiSummary.totalTokens;
});
return {
...usageRecord,
...toUsageSummaryFields(totalSummary),
apis: filteredApis
} as T;
}
const normalizeAuthIndex = (value: unknown) => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
@@ -246,17 +387,6 @@ export function maskUsageSensitiveValue(value: unknown, masker: (val: string) =>
return masked;
}
/**
* 格式化 tokens 为百万单位
*/
export function formatTokensInMillions(value: number): string {
const num = Number(value);
if (!Number.isFinite(num)) {
return '0.00M';
}
return `${(num / 1_000_000).toFixed(2)}M`;
}
/**
* 格式化每分钟数值
*/
@@ -735,23 +865,28 @@ export function formatDayLabel(date: Date): string {
*/
export function buildHourlySeriesByModel(
usageData: unknown,
metric: 'requests' | 'tokens' = 'requests'
metric: 'requests' | 'tokens' = 'requests',
hourWindow: number = 24
): {
labels: string[];
dataByModel: Map<string, number[]>;
hasData: boolean;
} {
const hourMs = 60 * 60 * 1000;
const resolvedHourWindow =
Number.isFinite(hourWindow) && hourWindow > 0
? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31)
: 24;
const now = new Date();
const currentHour = new Date(now);
currentHour.setMinutes(0, 0, 0);
const earliestBucket = new Date(currentHour);
earliestBucket.setHours(earliestBucket.getHours() - 23);
earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1));
const earliestTime = earliestBucket.getTime();
const labels: string[] = [];
for (let i = 0; i < 24; i++) {
for (let i = 0; i < resolvedHourWindow; i++) {
const bucketStart = earliestTime + i * hourMs;
labels.push(formatHourLabel(new Date(bucketStart)));
}
@@ -927,10 +1062,11 @@ export function buildChartData(
usageData: unknown,
period: 'hour' | 'day' = 'day',
metric: 'requests' | 'tokens' = 'requests',
selectedModels: string[] = []
selectedModels: string[] = [],
options: { hourWindowHours?: number } = {}
): ChartData {
const baseSeries = period === 'hour'
? buildHourlySeriesByModel(usageData, metric)
? buildHourlySeriesByModel(usageData, metric, options.hourWindowHours)
: buildDailySeriesByModel(usageData, metric);
const { labels, dataByModel } = baseSeries;
@@ -992,9 +1128,8 @@ export interface StatusBarData {
}
/**
* 计算状态栏数据(最近1小时,分为20个5分钟的时间块)
* 注意:20个块 × 5分钟 = 100分钟,但我们只使用最近60分钟的数据
* 所以实际只有最后12个块可能有数据,前8个块将始终为 idle
* 计算状态栏数据(最近200分钟,分为20个10分钟的时间块)
* 每个时间块代表窗口内的一个等长区间,用于展示成功/失败趋势
*/
export function calculateStatusBarData(
usageDetails: UsageDetail[],
@@ -1131,3 +1266,208 @@ export function computeKeyStats(usageData: unknown, masker: (val: string) => str
byAuthIndex: authIndexStats
};
}
export type TokenCategory = 'input' | 'output' | 'cached' | 'reasoning';
export interface TokenBreakdownSeries {
labels: string[];
dataByCategory: Record<TokenCategory, number[]>;
hasData: boolean;
}
/**
* 按 token 类别构建小时级别的堆叠序列
*/
export function buildHourlyTokenBreakdown(
usageData: unknown,
hourWindow: number = 24
): TokenBreakdownSeries {
const hourMs = 60 * 60 * 1000;
const resolvedHourWindow =
Number.isFinite(hourWindow) && hourWindow > 0
? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31)
: 24;
const now = new Date();
const currentHour = new Date(now);
currentHour.setMinutes(0, 0, 0);
const earliestBucket = new Date(currentHour);
earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1));
const earliestTime = earliestBucket.getTime();
const labels: string[] = [];
for (let i = 0; i < resolvedHourWindow; i++) {
labels.push(formatHourLabel(new Date(earliestTime + i * hourMs)));
}
const dataByCategory: Record<TokenCategory, number[]> = {
input: new Array(labels.length).fill(0),
output: new Array(labels.length).fill(0),
cached: new Array(labels.length).fill(0),
reasoning: new Array(labels.length).fill(0),
};
const details = collectUsageDetails(usageData);
let hasData = false;
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) return;
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
const bucketStart = normalized.getTime();
const lastBucketTime = earliestTime + (labels.length - 1) * hourMs;
if (bucketStart < earliestTime || bucketStart > lastBucketTime) return;
const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs);
if (bucketIndex < 0 || bucketIndex >= labels.length) return;
const tokens = detail.tokens;
const input = typeof tokens.input_tokens === 'number' ? Math.max(tokens.input_tokens, 0) : 0;
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
const cached = Math.max(
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0,
);
const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
dataByCategory.input[bucketIndex] += input;
dataByCategory.output[bucketIndex] += output;
dataByCategory.cached[bucketIndex] += cached;
dataByCategory.reasoning[bucketIndex] += reasoning;
hasData = true;
});
return { labels, dataByCategory, hasData };
}
/**
* 按 token 类别构建日级别的堆叠序列
*/
export function buildDailyTokenBreakdown(usageData: unknown): TokenBreakdownSeries {
const details = collectUsageDetails(usageData);
const dayMap: Record<string, Record<TokenCategory, number>> = {};
let hasData = false;
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) return;
const dayLabel = formatDayLabel(new Date(timestamp));
if (!dayLabel) return;
if (!dayMap[dayLabel]) {
dayMap[dayLabel] = { input: 0, output: 0, cached: 0, reasoning: 0 };
}
const tokens = detail.tokens;
const input = typeof tokens.input_tokens === 'number' ? Math.max(tokens.input_tokens, 0) : 0;
const output = typeof tokens.output_tokens === 'number' ? Math.max(tokens.output_tokens, 0) : 0;
const cached = Math.max(
typeof tokens.cached_tokens === 'number' ? Math.max(tokens.cached_tokens, 0) : 0,
typeof tokens.cache_tokens === 'number' ? Math.max(tokens.cache_tokens, 0) : 0,
);
const reasoning = typeof tokens.reasoning_tokens === 'number' ? Math.max(tokens.reasoning_tokens, 0) : 0;
dayMap[dayLabel].input += input;
dayMap[dayLabel].output += output;
dayMap[dayLabel].cached += cached;
dayMap[dayLabel].reasoning += reasoning;
hasData = true;
});
const labels = Object.keys(dayMap).sort();
const dataByCategory: Record<TokenCategory, number[]> = {
input: labels.map((l) => dayMap[l].input),
output: labels.map((l) => dayMap[l].output),
cached: labels.map((l) => dayMap[l].cached),
reasoning: labels.map((l) => dayMap[l].reasoning),
};
return { labels, dataByCategory, hasData };
}
export interface CostSeries {
labels: string[];
data: number[];
hasData: boolean;
}
/**
* 按小时构建费用时间序列
*/
export function buildHourlyCostSeries(
usageData: unknown,
modelPrices: Record<string, ModelPrice>,
hourWindow: number = 24
): CostSeries {
const hourMs = 60 * 60 * 1000;
const resolvedHourWindow =
Number.isFinite(hourWindow) && hourWindow > 0
? Math.min(Math.max(Math.floor(hourWindow), 1), 24 * 31)
: 24;
const now = new Date();
const currentHour = new Date(now);
currentHour.setMinutes(0, 0, 0);
const earliestBucket = new Date(currentHour);
earliestBucket.setHours(earliestBucket.getHours() - (resolvedHourWindow - 1));
const earliestTime = earliestBucket.getTime();
const labels: string[] = [];
for (let i = 0; i < resolvedHourWindow; i++) {
labels.push(formatHourLabel(new Date(earliestTime + i * hourMs)));
}
const data = new Array(labels.length).fill(0);
const details = collectUsageDetails(usageData);
let hasData = false;
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) return;
const normalized = new Date(timestamp);
normalized.setMinutes(0, 0, 0);
const bucketStart = normalized.getTime();
const lastBucketTime = earliestTime + (labels.length - 1) * hourMs;
if (bucketStart < earliestTime || bucketStart > lastBucketTime) return;
const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs);
if (bucketIndex < 0 || bucketIndex >= labels.length) return;
const cost = calculateCost(detail, modelPrices);
if (cost > 0) {
data[bucketIndex] += cost;
hasData = true;
}
});
return { labels, data, hasData };
}
/**
* 按天构建费用时间序列
*/
export function buildDailyCostSeries(
usageData: unknown,
modelPrices: Record<string, ModelPrice>
): CostSeries {
const details = collectUsageDetails(usageData);
const dayMap: Record<string, number> = {};
let hasData = false;
details.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp)) return;
const dayLabel = formatDayLabel(new Date(timestamp));
if (!dayLabel) return;
const cost = calculateCost(detail, modelPrices);
if (cost > 0) {
dayMap[dayLabel] = (dayMap[dayLabel] || 0) + cost;
hasData = true;
}
});
const labels = Object.keys(dayMap).sort();
const data = labels.map((l) => dayMap[l]);
return { labels, data, hasData };
}