mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(provider-stats): add total stats calculation and display for API keys in resource detail view
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user