From 87702bb5a0625072f03cbc49f4da9d918a7910c0 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Tue, 26 May 2026 04:17:06 +0800 Subject: [PATCH] feat(provider-stats): add total stats calculation and display for API keys in resource detail view --- src/components/providers/utils.ts | 31 ++++++ .../providers/ProvidersWorkbenchPage.tsx | 1 + .../ProviderResourceTable.module.scss | 32 ++++++ .../components/ProviderResourceTable.tsx | 43 +++++++- .../providers/sheets/ProviderSheet.tsx | 5 +- .../providers/sheets/ResourceDetailView.tsx | 60 +++++++++- .../sheets/forms/sharedForm.module.scss | 103 ++++++++++++++++++ 7 files changed, 269 insertions(+), 6 deletions(-) diff --git a/src/components/providers/utils.ts b/src/components/providers/utils.ts index b2d2c82..3e92cfe 100644 --- a/src/components/providers/utils.ts +++ b/src/components/providers/utils.ts @@ -118,6 +118,16 @@ export function getProviderRecentStatusData( ); } +export function getProviderTotalStats( + usageByProvider: ProviderRecentUsageMap, + provider: string, + apiKey?: string, + baseUrl?: string +): { success: number; failure: number } { + const entry = getProviderRecentUsageEntry(usageByProvider, provider, apiKey, baseUrl); + return { success: entry.success, failure: entry.failed }; +} + const collectOpenAIProviderRecentBuckets = ( provider: OpenAIProviderConfig, usageByProvider: ProviderRecentUsageMap @@ -140,6 +150,27 @@ export function getOpenAIProviderRecentWindowStats( return sumRecentRequests(collectOpenAIProviderRecentBuckets(provider, usageByProvider)); } +export function getOpenAIProviderTotalStats( + provider: OpenAIProviderConfig, + usageByProvider: ProviderRecentUsageMap +): { success: number; failure: number } { + return (provider.apiKeyEntries || []).reduce( + (total, entry) => { + const usageEntry = getProviderRecentUsageEntry( + usageByProvider, + provider.name, + entry.apiKey, + provider.baseUrl + ); + return { + success: total.success + usageEntry.success, + failure: total.failure + usageEntry.failed, + }; + }, + { success: 0, failure: 0 } + ); +} + export function getOpenAIProviderRecentStatusData( provider: OpenAIProviderConfig, usageByProvider: ProviderRecentUsageMap diff --git a/src/features/providers/ProvidersWorkbenchPage.tsx b/src/features/providers/ProvidersWorkbenchPage.tsx index 28feb8a..399177c 100644 --- a/src/features/providers/ProvidersWorkbenchPage.tsx +++ b/src/features/providers/ProvidersWorkbenchPage.tsx @@ -417,6 +417,7 @@ export function ProvidersWorkbenchPage() { workbench={workbench} onCreated={handleCreated} onUpdated={handleUpdated} + usageByProvider={usageByProvider} /> ); diff --git a/src/features/providers/components/ProviderResourceTable.module.scss b/src/features/providers/components/ProviderResourceTable.module.scss index 8315271..03c159b 100644 --- a/src/features/providers/components/ProviderResourceTable.module.scss +++ b/src/features/providers/components/ProviderResourceTable.module.scss @@ -112,6 +112,38 @@ min-width: 0; } +.stats { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.statPill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1.4; + border: 1px solid transparent; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.statSuccess { + background-color: var(--success-badge-bg); + color: var(--success-badge-text); + border-color: var(--success-badge-border); +} + +.statFailure { + background-color: var(--failure-badge-bg); + color: var(--failure-badge-text); + border-color: var(--failure-badge-border); +} + .statusActive { border-color: var(--success-badge-border); background: var(--success-badge-bg); diff --git a/src/features/providers/components/ProviderResourceTable.tsx b/src/features/providers/components/ProviderResourceTable.tsx index 1ef2ca4..732a112 100644 --- a/src/features/providers/components/ProviderResourceTable.tsx +++ b/src/features/providers/components/ProviderResourceTable.tsx @@ -19,7 +19,9 @@ import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar'; import { getOpenAIProviderRecentStatusData, + getOpenAIProviderTotalStats, getProviderRecentStatusData, + getProviderTotalStats, type ProviderRecentUsageMap, } from '@/components/providers/utils'; import type { OpenAIProviderConfig } from '@/types'; @@ -59,6 +61,24 @@ const resolveStatusBarData = ( ); }; +const resolveTotalStats = ( + resource: ProviderResource, + usageByProvider: ProviderRecentUsageMap +): { success: number; failure: number } => { + if (resource.brand === 'openaiCompatibility') { + return getOpenAIProviderTotalStats( + resource.raw as OpenAIProviderConfig, + usageByProvider + ); + } + return getProviderTotalStats( + usageByProvider, + resource.brand, + resource.apiKey ?? undefined, + resource.baseUrl ?? undefined + ); +}; + export function ProviderResourceTable({ resources, selectedId, @@ -224,10 +244,25 @@ export function ProviderResourceTable({
{renderStatus(resource)} {usageByProvider && resource.brand !== 'ampcode' ? ( - + <> + {(() => { + const stats = resolveTotalStats(resource, usageByProvider); + return ( +
+ + {t('stats.success')}: {stats.success} + + + {t('stats.failure')}: {stats.failure} + +
+ ); + })()} + + ) : null}
diff --git a/src/features/providers/sheets/ProviderSheet.tsx b/src/features/providers/sheets/ProviderSheet.tsx index 50c6427..9c4f904 100644 --- a/src/features/providers/sheets/ProviderSheet.tsx +++ b/src/features/providers/sheets/ProviderSheet.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useId, useImperativeHandle, useState, type Ref import { useTranslation } from 'react-i18next'; import { Sheet } from '@/components/ui/Sheet'; import { IconLoader2, IconPencil } from '@/components/ui/icons'; +import type { ProviderRecentUsageMap } from '@/components/providers/utils'; import { useNotificationStore } from '@/stores'; import { PROVIDER_DESCRIPTORS } from '../descriptors'; import type { @@ -35,6 +36,7 @@ interface ProviderSheetProps { workbench: UseProviderWorkbenchResult; onCreated: () => void; onUpdated: () => void; + usageByProvider?: ProviderRecentUsageMap; ref?: Ref; } @@ -45,6 +47,7 @@ export function ProviderSheet({ workbench, onCreated, onUpdated, + usageByProvider, ref, }: ProviderSheetProps) { const { t } = useTranslation(); @@ -151,7 +154,7 @@ export function ProviderSheet({ if (!state.resource) { return null; } - return ; + return ; } const formKey = `${state.brand}:${state.resource?.id ?? 'new'}:${state.mode}`; if (isAmpcode) { diff --git a/src/features/providers/sheets/ResourceDetailView.tsx b/src/features/providers/sheets/ResourceDetailView.tsx index e331b89..f22f41f 100644 --- a/src/features/providers/sheets/ResourceDetailView.tsx +++ b/src/features/providers/sheets/ResourceDetailView.tsx @@ -1,13 +1,21 @@ import { useTranslation } from 'react-i18next'; import { Collapsible } from '@/components/ui/Collapsible'; +import { IconCheck, IconX } from '@/components/ui/icons'; +import { + getProviderTotalStats, + type ProviderRecentUsageMap, +} from '@/components/providers/utils'; +import type { OpenAIProviderConfig } from '@/types'; +import { maskApiKey } from '@/utils/format'; import type { ProviderResource } from '../types'; import styles from './forms/sharedForm.module.scss'; interface ResourceDetailViewProps { resource: ProviderResource; + usageByProvider?: ProviderRecentUsageMap; } -export function ResourceDetailView({ resource }: ResourceDetailViewProps) { +export function ResourceDetailView({ resource, usageByProvider }: ResourceDetailViewProps) { const { t } = useTranslation(); const primary: Array<[string, string]> = [ @@ -25,6 +33,12 @@ export function ResourceDetailView({ resource }: ResourceDetailViewProps) { ['apiKeyEntries', String(resource.apiKeyEntryCount)], ]; + const openaiConfig = + resource.brand === 'openaiCompatibility' + ? (resource.raw as OpenAIProviderConfig) + : null; + const apiKeyEntries = openaiConfig?.apiKeyEntries ?? []; + return (
@@ -42,6 +56,50 @@ export function ResourceDetailView({ resource }: ResourceDetailViewProps) { ))} + {openaiConfig && apiKeyEntries.length > 0 ? ( +
+
+ {t('providersPage.form.apiKeyEntriesSection')}: {apiKeyEntries.length} +
+
+ {apiKeyEntries.map((entry, entryIndex) => { + const entryStats = usageByProvider + ? getProviderTotalStats( + usageByProvider, + openaiConfig.name, + entry.apiKey, + openaiConfig.baseUrl + ) + : { success: 0, failure: 0 }; + return ( +
+ {entryIndex + 1} + {maskApiKey(entry.apiKey)} + {entry.proxyUrl ? ( + {entry.proxyUrl} + ) : null} +
+ + {entryStats.success} + + + {entryStats.failure} + +
+
+ ); + })} +
+
+ ) : null} +
diff --git a/src/features/providers/sheets/forms/sharedForm.module.scss b/src/features/providers/sheets/forms/sharedForm.module.scss index 1e5773f..9a1ecf8 100644 --- a/src/features/providers/sheets/forms/sharedForm.module.scss +++ b/src/features/providers/sheets/forms/sharedForm.module.scss @@ -585,3 +585,106 @@ background: var(--primary-hover); } } + +.apiKeyEntriesSection { + margin-top: 16px; + min-width: 0; + max-width: 100%; +} + +.apiKeyEntriesLabel { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.apiKeyEntryList { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + max-width: 100%; +} + +.apiKeyEntryCard { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-width: 0; + max-width: 100%; + padding: 8px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-secondary); + border-radius: 8px; + font-size: 12px; +} + +.apiKeyEntryIndex { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--primary-color); + color: var(--primary-contrast); + font-size: 11px; + font-weight: 600; + flex-shrink: 0; +} + +.apiKeyEntryKey { + font-family: 'Monaco', 'Menlo', 'Consolas', 'Ubuntu Mono', monospace; + font-weight: 600; + color: var(--text-primary); + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-all; +} + +.apiKeyEntryProxy { + color: var(--text-tertiary); + font-size: 11px; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; + + &::before { + content: '| Proxy: '; + color: var(--text-quaternary); + } +} + +.apiKeyEntryStats { + display: flex; + gap: 6px; + margin-left: auto; +} + +.apiKeyEntryStat { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + + svg { + display: block; + } +} + +.apiKeyEntryStatSuccess { + background: var(--success-badge-bg); + color: var(--success-badge-text); +} + +.apiKeyEntryStatFailure { + background: var(--failure-badge-bg); + color: var(--failure-badge-text); +}