From b3808add0f99a4d9e6271d50fb97a46a95d6f6d3 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 27 Dec 2025 15:02:32 +0800 Subject: [PATCH] feat(providers): add visual status bar for API key health monitoring --- src/pages/AiProvidersPage.module.scss | 92 +++++++++++++++++++++++++ src/pages/AiProvidersPage.tsx | 92 ++++++++++++++++++++++++- src/utils/usage.ts | 97 +++++++++++++++++++++++++++ 3 files changed, 278 insertions(+), 3 deletions(-) diff --git a/src/pages/AiProvidersPage.module.scss b/src/pages/AiProvidersPage.module.scss index 8282e03..eff7935 100644 --- a/src/pages/AiProvidersPage.module.scss +++ b/src/pages/AiProvidersPage.module.scss @@ -397,6 +397,79 @@ line-height: 1.5; } +// 状态监测栏 +.statusBar { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + padding: 8px 0; + max-width: 280px; +} + +.statusBlocks { + display: flex; + gap: 2px; + flex: 1; + min-width: 180px; +} + +.statusBlock { + flex: 1; + height: 8px; + border-radius: 2px; + min-width: 6px; + transition: transform 0.15s ease, opacity 0.15s ease; + + &:hover { + transform: scaleY(1.5); + opacity: 0.85; + } +} + +.statusBlockSuccess { + background-color: var(--success-color, #22c55e); +} + +.statusBlockFailure { + background-color: var(--danger-color, #ef4444); +} + +.statusBlockMixed { + background-color: var(--warning-color, #f59e0b); +} + +.statusBlockIdle { + background-color: var(--border-secondary, #e5e7eb); +} + +.statusRate { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + padding: 4px 8px; + border-radius: 6px; + background: var(--bg-tertiary); +} + +.statusRateHigh { + color: var(--success-badge-text, #065f46); + background: var(--success-badge-bg, #d1fae5); +} + +.statusRateMedium { + color: var(--warning-text, #92400e); + background: var(--warning-bg, #fef3c7); +} + +.statusRateLow { + color: var(--failure-badge-text, #991b1b); + background: var(--failure-badge-bg, #fee2e2); +} + // 暗色主题适配 :global([data-theme='dark']) { .headerBadge { @@ -436,4 +509,23 @@ .apiKeyEntryIndex { background: var(--primary-color); } + + .statusBlockIdle { + background-color: var(--border-primary, #374151); + } + + .statusRateHigh { + background: rgba(34, 197, 94, 0.2); + color: #86efac; + } + + .statusRateMedium { + background: rgba(251, 191, 36, 0.2); + color: #fde68a; + } + + .statusRateLow { + background: rgba(239, 68, 68, 0.2); + color: #fca5a5; + } } diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index 7f931ee..3b7e8a9 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -24,7 +24,8 @@ import type { AmpcodeConfig, AmpcodeModelMapping, } from '@/types'; -import type { KeyStats, KeyStatBucket } from '@/utils/usage'; +import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage'; +import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage'; import type { ModelInfo } from '@/utils/models'; import { headersToEntries, buildHeaderObject, type HeaderEntry } from '@/utils/headers'; import { maskApiKey } from '@/utils/format'; @@ -202,6 +203,7 @@ export function AiProvidersPage() { const [claudeConfigs, setClaudeConfigs] = useState([]); const [openaiProviders, setOpenaiProviders] = useState([]); const [keyStats, setKeyStats] = useState({ bySource: {}, byAuthIndex: {} }); + const [usageDetails, setUsageDetails] = useState([]); const [modal, setModal] = useState(null); @@ -273,11 +275,16 @@ export function AiProvidersPage() { [openaiForm.modelEntries] ); - // 加载 key 统计 + // 加载 key 统计和 usage 明细 const loadKeyStats = useCallback(async () => { try { - const stats = await usageApi.getKeyStats(); + const usageResponse = await usageApi.getUsage(); + const usageData = usageResponse?.usage ?? usageResponse; + const stats = await usageApi.getKeyStats(usageData); setKeyStats(stats); + // 收集 usage 明细用于状态栏 + const details = collectUsageDetails(usageData); + setUsageDetails(details); } catch { // 静默失败 } @@ -1090,6 +1097,77 @@ export function AiProvidersPage() { ); }; + // 渲染状态监测栏 + const renderStatusBar = (apiKey: string) => { + const statusData = calculateStatusBarData(usageDetails, apiKey); + const hasData = statusData.totalSuccess + statusData.totalFailure > 0; + const rateClass = !hasData + ? '' + : statusData.successRate >= 90 + ? styles.statusRateHigh + : statusData.successRate >= 50 + ? styles.statusRateMedium + : styles.statusRateLow; + + return ( +
+
+ {statusData.blocks.map((state, idx) => { + const blockClass = + state === 'success' + ? styles.statusBlockSuccess + : state === 'failure' + ? styles.statusBlockFailure + : state === 'mixed' + ? styles.statusBlockMixed + : styles.statusBlockIdle; + return
; + })} +
+ + {hasData ? `${statusData.successRate.toFixed(1)}%` : '--'} + +
+ ); + }; + + // 渲染 OpenAI 提供商的状态栏(汇总多个 apiKey) + const renderOpenAIStatusBar = (apiKeyEntries: ApiKeyEntry[] | undefined) => { + // 合并所有 apiKey 的 usage details + const allKeys = (apiKeyEntries || []).map((e) => e.apiKey).filter(Boolean); + const filteredDetails = usageDetails.filter((detail) => allKeys.includes(detail.source)); + const statusData = calculateStatusBarData(filteredDetails); + const hasData = statusData.totalSuccess + statusData.totalFailure > 0; + const rateClass = !hasData + ? '' + : statusData.successRate >= 90 + ? styles.statusRateHigh + : statusData.successRate >= 50 + ? styles.statusRateMedium + : styles.statusRateLow; + + return ( +
+
+ {statusData.blocks.map((state, idx) => { + const blockClass = + state === 'success' + ? styles.statusBlockSuccess + : state === 'failure' + ? styles.statusBlockFailure + : state === 'mixed' + ? styles.statusBlockMixed + : styles.statusBlockIdle; + return
; + })} +
+ + {hasData ? `${statusData.successRate.toFixed(1)}%` : '--'} + +
+ ); + }; + const renderList = ( items: T[], keyField: (item: T) => string, @@ -1254,6 +1332,8 @@ export function AiProvidersPage() { {t('stats.failure')}: {stats.failure}
+ {/* 状态监测栏 */} + {renderStatusBar(item.apiKey)} ); }, @@ -1370,6 +1450,8 @@ export function AiProvidersPage() { {t('stats.failure')}: {stats.failure}
+ {/* 状态监测栏 */} + {renderStatusBar(item.apiKey)} ); }, @@ -1502,6 +1584,8 @@ export function AiProvidersPage() { {t('stats.failure')}: {stats.failure} + {/* 状态监测栏 */} + {renderStatusBar(item.apiKey)} ); }, @@ -1721,6 +1805,8 @@ export function AiProvidersPage() { {t('stats.failure')}: {stats.failure} + {/* 状态监测栏(汇总) */} + {renderOpenAIStatusBar(item.apiKeyEntries)} ); }, diff --git a/src/utils/usage.ts b/src/utils/usage.ts index e33d2cc..1b4d5b3 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -754,6 +754,103 @@ export function buildChartData( /** * 依据 usage 数据计算密钥使用统计 */ +/** + * 状态栏单个格子的状态 + */ +export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle'; + +/** + * 状态栏数据 + */ +export interface StatusBarData { + blocks: StatusBlockState[]; + successRate: number; + totalSuccess: number; + totalFailure: number; +} + +/** + * 计算状态栏数据(最近1小时,分为20个5分钟的时间块) + * 注意:20个块 × 5分钟 = 100分钟,但我们只使用最近60分钟的数据 + * 所以实际只有最后12个块可能有数据,前8个块将始终为 idle + */ +export function calculateStatusBarData( + usageDetails: UsageDetail[], + sourceFilter?: string, + authIndexFilter?: number +): StatusBarData { + const BLOCK_COUNT = 20; + const BLOCK_DURATION_MS = 5 * 60 * 1000; // 5 minutes + const HOUR_MS = 60 * 60 * 1000; + + const now = Date.now(); + const hourAgo = now - HOUR_MS; + + // Initialize blocks + const blockStats: Array<{ success: number; failure: number }> = Array.from( + { length: BLOCK_COUNT }, + () => ({ success: 0, failure: 0 }) + ); + + let totalSuccess = 0; + let totalFailure = 0; + + // Filter and bucket the usage details + usageDetails.forEach((detail) => { + const timestamp = Date.parse(detail.timestamp); + if (Number.isNaN(timestamp) || timestamp < hourAgo || timestamp > now) { + return; + } + + // Apply filters if provided + if (sourceFilter !== undefined && detail.source !== sourceFilter) { + return; + } + if (authIndexFilter !== undefined && detail.auth_index !== authIndexFilter) { + return; + } + + // Calculate which block this falls into (0 = oldest, 19 = newest) + const ageMs = now - timestamp; + const blockIndex = BLOCK_COUNT - 1 - Math.floor(ageMs / BLOCK_DURATION_MS); + + if (blockIndex >= 0 && blockIndex < BLOCK_COUNT) { + if (detail.failed) { + blockStats[blockIndex].failure += 1; + totalFailure += 1; + } else { + blockStats[blockIndex].success += 1; + totalSuccess += 1; + } + } + }); + + // Convert stats to block states + const blocks: StatusBlockState[] = blockStats.map((stat) => { + if (stat.success === 0 && stat.failure === 0) { + return 'idle'; + } + if (stat.failure === 0) { + return 'success'; + } + if (stat.success === 0) { + return 'failure'; + } + return 'mixed'; + }); + + // Calculate success rate + const total = totalSuccess + totalFailure; + const successRate = total > 0 ? (totalSuccess / total) * 100 : 100; + + return { + blocks, + successRate, + totalSuccess, + totalFailure + }; +} + export function computeKeyStats(usageData: any, masker: (val: string) => string = maskApiKey): KeyStats { if (!usageData) { return { bySource: {}, byAuthIndex: {} };