feat(provider-stats): add total stats calculation and display for API keys in resource detail view

This commit is contained in:
LTbinglingfeng
2026-05-26 04:17:06 +08:00
parent d64210fd58
commit 87702bb5a0
7 changed files with 269 additions and 6 deletions
+31
View File
@@ -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
@@ -417,6 +417,7 @@ export function ProvidersWorkbenchPage() {
workbench={workbench}
onCreated={handleCreated}
onUpdated={handleUpdated}
usageByProvider={usageByProvider}
/>
</div>
);
@@ -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);
@@ -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({
<div className={styles.statusCell}>
{renderStatus(resource)}
{usageByProvider && resource.brand !== 'ampcode' ? (
<ProviderStatusBar
statusData={resolveStatusBarData(resource, usageByProvider)}
styles={statusBarStyles}
/>
<>
{(() => {
const stats = resolveTotalStats(resource, usageByProvider);
return (
<div className={styles.stats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
);
})()}
<ProviderStatusBar
statusData={resolveStatusBarData(resource, usageByProvider)}
styles={statusBarStyles}
/>
</>
) : null}
</div>
</TableCell>
@@ -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<ProviderSheetHandle>;
}
@@ -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 <ResourceDetailView resource={state.resource} />;
return <ResourceDetailView resource={state.resource} usageByProvider={usageByProvider} />;
}
const formKey = `${state.brand}:${state.resource?.id ?? 'new'}:${state.mode}`;
if (isAmpcode) {
@@ -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 (
<div>
<div className={styles.detailHeader}>
@@ -42,6 +56,50 @@ export function ResourceDetailView({ resource }: ResourceDetailViewProps) {
))}
</dl>
{openaiConfig && apiKeyEntries.length > 0 ? (
<div className={styles.apiKeyEntriesSection}>
<div className={styles.apiKeyEntriesLabel}>
{t('providersPage.form.apiKeyEntriesSection')}: {apiKeyEntries.length}
</div>
<div className={styles.apiKeyEntryList}>
{apiKeyEntries.map((entry, entryIndex) => {
const entryStats = usageByProvider
? getProviderTotalStats(
usageByProvider,
openaiConfig.name,
entry.apiKey,
openaiConfig.baseUrl
)
: { success: 0, failure: 0 };
return (
<div
key={`${entry.apiKey}-${entryIndex}`}
className={styles.apiKeyEntryCard}
>
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
{entry.proxyUrl ? (
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
) : null}
<div className={styles.apiKeyEntryStats}>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
>
<IconCheck size={12} /> {entryStats.success}
</span>
<span
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
>
<IconX size={12} /> {entryStats.failure}
</span>
</div>
</div>
);
})}
</div>
</div>
) : null}
<div style={{ marginTop: 16 }}>
<Collapsible label={t('providersPage.detail.metadataTitle')}>
<dl className={styles.dl}>
@@ -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);
}