diff --git a/src/components/usage/CredentialStatsCard.tsx b/src/components/usage/CredentialStatsCard.tsx index 268dc27..46f628c 100644 --- a/src/components/usage/CredentialStatsCard.tsx +++ b/src/components/usage/CredentialStatsCard.tsx @@ -1,16 +1,12 @@ -import { useMemo, useState, useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; -import { - collectUsageDetails, - buildCandidateUsageSourceIds, - formatCompactNumber, - normalizeAuthIndex -} from '@/utils/usage'; import { authFilesApi } from '@/services/api/authFiles'; -import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types'; +import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types'; import type { AuthFileItem } from '@/types/authFile'; import type { CredentialInfo } from '@/types/sourceInfo'; +import { buildSourceInfoMap, resolveSourceDisplay } from '@/utils/sourceResolver'; +import { collectUsageDetails, formatCompactNumber, normalizeAuthIndex } from '@/utils/usage'; import type { UsagePayload } from './hooks/useUsageData'; import styles from '@/pages/UsagePage.module.scss'; @@ -34,11 +30,6 @@ interface CredentialRow { successRate: number; } -interface CredentialBucket { - success: number; - failure: number; -} - export function CredentialStatsCard({ usage, loading, @@ -51,223 +42,86 @@ export function CredentialStatsCard({ const { t } = useTranslation(); const [authFileMap, setAuthFileMap] = useState>(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(); files.forEach((file) => { - const rawAuthIndex = file['auth_index'] ?? file.authIndex; - const key = normalizeAuthIndex(rawAuthIndex); - if (key) { - map.set(key, { - name: file.name || key, - type: (file.type || file.provider || '').toString(), - }); - } + const key = normalizeAuthIndex(file['auth_index'] ?? file.authIndex); + if (!key) return; + + map.set(key, { + name: file.name || key, + type: (file.type || file.provider || '').toString(), + }); }); setAuthFileMap(map); }) .catch(() => {}); - return () => { cancelled = true; }; + + 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 sourceInfoMap = useMemo( + () => + buildSourceInfoMap({ + geminiApiKeys: geminiKeys, + claudeApiKeys: claudeConfigs, + codexApiKeys: codexConfigs, + vertexApiKeys: vertexConfigs, + openaiCompatibility: openaiProviders, + }), + [claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs] + ); + const rows = useMemo((): CredentialRow[] => { if (!usage) return []; - const details = collectUsageDetails(usage); - const bySource: Record = {}; - const result: CredentialRow[] = []; - const consumedSourceIds = new Set(); - const authIndexToRowIndex = new Map(); - const sourceToAuthIndex = new Map(); - const sourceToAuthFile = new Map(); - const fallbackByAuthIndex = new Map(); - details.forEach((detail) => { - const authIdx = normalizeAuthIndex(detail.auth_index); - const source = detail.source; - const isFailed = detail.failed === true; + const rowMap = new Map(); - if (!source) { - if (!authIdx) return; - const fallback = fallbackByAuthIndex.get(authIdx) ?? { success: 0, failure: 0 }; - if (isFailed) { - fallback.failure += 1; - } else { - fallback.success += 1; - } - fallbackByAuthIndex.set(authIdx, fallback); - return; - } + collectUsageDetails(usage).forEach((detail) => { + const sourceInfo = resolveSourceDisplay( + detail.source ?? '', + detail.auth_index, + sourceInfoMap, + authFileMap + ); + const key = sourceInfo.identityKey ?? sourceInfo.displayName; + const row = + rowMap.get(key) ?? + ({ + key, + displayName: sourceInfo.displayName, + type: sourceInfo.type, + success: 0, + failure: 0, + total: 0, + successRate: 100, + } satisfies CredentialRow); - const bucket = bySource[source] ?? { success: 0, failure: 0 }; - if (isFailed) { - bucket.failure += 1; + if (detail.failed === true) { + row.failure += 1; } else { - bucket.success += 1; + row.success += 1; } - bySource[source] = bucket; - if (authIdx && !sourceToAuthIndex.has(source)) { - sourceToAuthIndex.set(source, authIdx); - } - if (authIdx && !sourceToAuthFile.has(source)) { - const mapped = authFileMap.get(authIdx); - if (mapped) sourceToAuthFile.set(source, mapped); - } + row.total = row.success + row.failure; + row.successRate = row.total > 0 ? (row.success / row.total) * 100 : 100; + rowMap.set(key, row); }); - 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(); - 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, - }); - } - }); - - // 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 Array.from(rowMap.values()).sort((a, b) => b.total - a.total); + }, [authFileMap, sourceInfoMap, usage]); return ( @@ -275,51 +129,55 @@ export function CredentialStatsCard({
{t('common.loading')}
) : rows.length > 0 ? (
-
- - - - - - - - - - {rows.map((row) => ( - - - - +
+
{t('usage_stats.credential_name')}{t('usage_stats.requests_count')}{t('usage_stats.success_rate')}
- {row.displayName} - {row.type && ( - {row.type} - )} - - - {formatCompactNumber(row.total)} - - ({row.success.toLocaleString()}{' '} - {row.failure.toLocaleString()}) - - - - = 95 - ? styles.statSuccess - : row.successRate >= 80 - ? styles.statNeutral - : styles.statFailure - } - > - {row.successRate.toFixed(1)}% - -
+ + + + + - ))} - -
{t('usage_stats.credential_name')}{t('usage_stats.requests_count')}{t('usage_stats.success_rate')}
-
+ + + {rows.map((row) => ( + + + {row.displayName} + {row.type && {row.type}} + + + + {formatCompactNumber(row.total)} + + ( + + {row.success.toLocaleString()} + {' '} + + {row.failure.toLocaleString()} + + ) + + + + + = 95 + ? styles.statSuccess + : row.successRate >= 80 + ? styles.statNeutral + : styles.statFailure + } + > + {row.successRate.toFixed(1)}% + + + + ))} + + +
) : (
{t('usage_stats.no_data')}
diff --git a/src/components/usage/RequestEventsDetailsCard.tsx b/src/components/usage/RequestEventsDetailsCard.tsx index 2a69b22..b19404e 100644 --- a/src/components/usage/RequestEventsDetailsCard.tsx +++ b/src/components/usage/RequestEventsDetailsCard.tsx @@ -30,6 +30,7 @@ type RequestEventRow = { timestampMs: number; timestampLabel: string; model: string; + sourceKey: string; sourceRaw: string; source: string; sourceType: string; @@ -126,7 +127,7 @@ export function RequestEventsDetailsCard({ const rows = useMemo(() => { const details = collectUsageDetails(usage); - return details + const baseRows = details .map((detail, index) => { const timestamp = detail.timestamp; const timestampMs = @@ -147,6 +148,7 @@ export function RequestEventsDetailsCard({ authFileMap ); const source = sourceInfo.displayName; + const sourceKey = sourceInfo.identityKey ?? `source:${sourceRaw || source}`; const sourceType = sourceInfo.type; const model = String(detail.__modelName ?? '').trim() || '-'; const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0); @@ -163,11 +165,12 @@ export function RequestEventsDetailsCard({ const latencyMs = extractLatencyMs(detail); return { - id: `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`, + id: `${timestamp}-${model}-${sourceKey}-${authIndex}-${index}`, timestamp, timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs, timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-', model, + sourceKey, sourceRaw: sourceRaw || '-', source, sourceType, @@ -180,7 +183,41 @@ export function RequestEventsDetailsCard({ cachedTokens, totalTokens, }; - }) + }); + + const sourceLabelKeyMap = new Map>(); + baseRows.forEach((row) => { + const keys = sourceLabelKeyMap.get(row.source) ?? new Set(); + keys.add(row.sourceKey); + sourceLabelKeyMap.set(row.source, keys); + }); + + const buildDisambiguatedSourceLabel = (row: RequestEventRow) => { + const labelKeyCount = sourceLabelKeyMap.get(row.source)?.size ?? 0; + if (labelKeyCount <= 1) { + return row.source; + } + + if (row.authIndex !== '-') { + return `${row.source} · ${row.authIndex}`; + } + + if (row.sourceRaw !== '-' && row.sourceRaw !== row.source) { + return `${row.source} · ${row.sourceRaw}`; + } + + if (row.sourceType) { + return `${row.source} · ${row.sourceType}`; + } + + return `${row.source} · ${row.sourceKey}`; + }; + + return baseRows + .map((row) => ({ + ...row, + source: buildDisambiguatedSourceLabel(row), + })) .sort((a, b) => b.timestampMs - a.timestampMs); }, [authFileMap, i18n.language, sourceInfoMap, usage]); @@ -197,16 +234,22 @@ export function RequestEventsDetailsCard({ [rows, t] ); - const sourceOptions = useMemo( - () => [ + const sourceOptions = useMemo(() => { + const optionMap = new Map(); + rows.forEach((row) => { + if (!optionMap.has(row.sourceKey)) { + optionMap.set(row.sourceKey, row.source); + } + }); + + return [ { value: ALL_FILTER, label: t('usage_stats.filter_all') }, - ...Array.from(new Set(rows.map((row) => row.source))).map((source) => ({ - value: source, - label: source, + ...Array.from(optionMap.entries()).map(([value, label]) => ({ + value, + label, })), - ], - [rows, t] - ); + ]; + }, [rows, t]); const authIndexOptions = useMemo( () => [ @@ -244,7 +287,7 @@ export function RequestEventsDetailsCard({ const modelMatched = effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter; const sourceMatched = - effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter; + effectiveSourceFilter === ALL_FILTER || row.sourceKey === effectiveSourceFilter; const authIndexMatched = effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter; return modelMatched && sourceMatched && authIndexMatched; diff --git a/src/types/sourceInfo.ts b/src/types/sourceInfo.ts index 205e318..bd7d725 100644 --- a/src/types/sourceInfo.ts +++ b/src/types/sourceInfo.ts @@ -1,6 +1,7 @@ export type SourceInfo = { displayName: string; type: string; + identityKey?: string; }; export type CredentialInfo = { diff --git a/src/utils/sourceResolver.ts b/src/utils/sourceResolver.ts index 040b70c..8b0fd76 100644 --- a/src/utils/sourceResolver.ts +++ b/src/utils/sourceResolver.ts @@ -10,20 +10,64 @@ export interface SourceInfoMapInput { openaiCompatibility?: OpenAIProviderConfig[]; } -export function buildSourceInfoMap(input: SourceInfoMapInput): Map { - const map = new Map(); +type SourceInfoEntry = Required>; - const registerSource = (sourceId: string, displayName: string, type: string) => { - if (!sourceId || !displayName || map.has(sourceId)) return; - map.set(sourceId, { displayName, type }); - }; +export interface SourceInfoMap { + byAuthIndex: Map; + bySource: Map; +} - const registerCandidates = (displayName: string, type: string, candidates: string[]) => { - candidates.forEach((sourceId) => registerSource(sourceId, displayName, type)); +const buildProviderIdentityKey = (type: string, index: number) => `${type}:${index}`; + +const registerIdentity = ( + map: Map, + key: string | null | undefined, + entry: SourceInfoEntry +) => { + if (!key) return; + + const existing = map.get(key); + if (existing === undefined) { + map.set(key, entry); + return; + } + + if (existing === null) { + return; + } + + if (existing.identityKey === entry.identityKey) { + return; + } + + map.set(key, null); +}; + +const formatRawSourceDisplayName = (source: string) => { + if (!source) return '-'; + return source.startsWith('t:') ? source.slice(2) : source; +}; + +export function buildSourceInfoMap(input: SourceInfoMapInput): SourceInfoMap { + const byAuthIndex = new Map(); + const bySource = new Map(); + + const registerProvider = ( + entry: SourceInfoEntry, + authIndices: Array, + candidates: Iterable + ) => { + authIndices.forEach((authIndex) => { + registerIdentity(byAuthIndex, normalizeAuthIndex(authIndex), entry); + }); + + Array.from(candidates).forEach((candidate) => { + registerIdentity(bySource, candidate, entry); + }); }; const providers: Array<{ - items: Array<{ apiKey?: string; prefix?: string }>; + items: Array<{ apiKey?: string; prefix?: string; authIndex?: string }>; type: string; label: string; }> = [ @@ -35,49 +79,91 @@ export function buildSourceInfoMap(input: SourceInfoMapInput): Map { items.forEach((item, index) => { - const displayName = item.prefix?.trim() || `${label} #${index + 1}`; - registerCandidates( - displayName, - type, + registerProvider( + { + displayName: item.prefix?.trim() || `${label} #${index + 1}`, + type, + identityKey: buildProviderIdentityKey(type, index), + }, + [item.authIndex], buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix }) ); }); }); - // OpenAI 特殊处理:多 apiKeyEntries (input.openaiCompatibility || []).forEach((provider, providerIndex) => { - const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`; const candidates = new Set(); + const authIndices: Array = [provider.authIndex]; + buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((id) => candidates.add(id)); (provider.apiKeyEntries || []).forEach((entry) => { + authIndices.push(entry.authIndex); buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id)); }); - registerCandidates(displayName, 'openai', Array.from(candidates)); + + registerProvider( + { + displayName: provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`, + type: 'openai', + identityKey: buildProviderIdentityKey('openai', providerIndex), + }, + authIndices, + candidates + ); }); - return map; + return { byAuthIndex, bySource }; } export function resolveSourceDisplay( sourceRaw: string, authIndex: unknown, - sourceInfoMap: Map, + sourceInfoMap: SourceInfoMap, authFileMap: Map ): SourceInfo { const source = sourceRaw.trim(); - const matched = sourceInfoMap.get(source); - if (matched) return matched; - const authIndexKey = normalizeAuthIndex(authIndex); + if (authIndexKey) { + const matchedByAuthIndex = sourceInfoMap.byAuthIndex.get(authIndexKey); + if (matchedByAuthIndex) { + return matchedByAuthIndex; + } + const authInfo = authFileMap.get(authIndexKey); if (authInfo) { - return { displayName: authInfo.name || authIndexKey, type: authInfo.type }; + return { + displayName: authInfo.name || authIndexKey, + type: authInfo.type, + identityKey: `auth:${authIndexKey}`, + }; } } + const matchedBySource = source ? sourceInfoMap.bySource.get(source) : null; + if (matchedBySource) { + return matchedBySource; + } + + if (source) { + return { + displayName: formatRawSourceDisplayName(source), + type: '', + identityKey: `source:${source}`, + }; + } + + if (authIndexKey) { + return { + displayName: authIndexKey, + type: '', + identityKey: `auth:${authIndexKey}`, + }; + } + return { - displayName: source.startsWith('t:') ? source.slice(2) : source || '-', + displayName: '-', type: '', + identityKey: 'source:-', }; }