Compare commits

..

19 Commits

16 changed files with 1459 additions and 132 deletions
+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>
);
}
@@ -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';
+24 -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}}"
},
@@ -802,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",
+24 -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": "Обновить квоту только для этих учётных данных",
@@ -805,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": "Успех",
+24 -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}}"
},
@@ -802,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": "成功",
+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;
}
+131 -4
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)));
@@ -208,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)) {
@@ -441,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,
]);
@@ -857,6 +932,9 @@ export function AuthFilesPage() {
json: null,
prefix: '',
proxyUrl: '',
priority: '',
excludedModelsText: '',
disableCooling: '',
});
try {
@@ -898,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;
@@ -909,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,
};
});
@@ -922,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 };
});
};
@@ -2171,7 +2262,7 @@ export function AuthFilesPage() {
)}
</Modal>
{/* prefix/proxy_url 编辑弹窗 */}
{/* 认证文件字段编辑弹窗 */}
<Modal
open={Boolean(prefixProxyEditor)}
onClose={() => setPrefixProxyEditor(null)}
@@ -2179,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={
@@ -2247,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>
</>
)}
+114
View File
@@ -25,6 +25,12 @@
flex-wrap: wrap;
}
.lastRefreshed {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
}
.timeRangeGroup {
display: inline-flex;
align-items: center;
@@ -423,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;
@@ -437,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 {
@@ -527,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;
@@ -550,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);
}
@@ -559,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;
+41 -1
View File
@@ -16,7 +16,7 @@ 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,
@@ -24,6 +24,9 @@ import {
ApiDetailsCard,
ModelStatsCard,
PriceSettingsCard,
CredentialStatsCard,
TokenBreakdownChart,
CostTrendChart,
useUsageData,
useSparklines,
useChartData
@@ -115,6 +118,7 @@ export function UsagePage() {
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);
@@ -134,6 +138,7 @@ export function UsagePage() {
usage,
loading,
error,
lastRefreshedAt,
modelPrices,
setModelPrices,
loadUsage,
@@ -305,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>
@@ -356,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}
+205 -11
View File
@@ -387,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`;
}
/**
* 格式化每分钟数值
*/
@@ -1277,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 };
}