From b1426ccefc5f4ddd711273006145d3b92ce4eedb Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sun, 21 Dec 2025 16:06:33 +0800 Subject: [PATCH] feat(dashboard): enhance dashboard with provider breakdown and usage stats --- src/i18n/locales/en.json | 14 +- src/i18n/locales/zh-CN.json | 14 +- src/pages/DashboardPage.module.scss | 89 +++++++++++++ src/pages/DashboardPage.tsx | 194 +++++++++++++++++++++++++--- 4 files changed, 294 insertions(+), 17 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 51b0159..12041b5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -97,7 +97,19 @@ "subtitle": "Welcome to CLI Proxy API Management Center", "openai_providers": "OpenAI Providers", "quick_actions": "Quick Actions", - "current_config": "Current Configuration" + "current_config": "Current Configuration", + "management_keys": "Management Keys", + "provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}", + "oauth_credentials": "OAuth Credentials", + "usage_overview": "Usage Overview", + "total_requests": "Total Requests", + "total_tokens": "Total Tokens", + "rpm_30min": "RPM (30min)", + "tpm_30min": "TPM (30min)", + "models_used": "Models Used", + "no_usage_data": "No usage data available", + "view_detailed_usage": "View Detailed Stats", + "edit_settings": "Edit Settings" }, "basic_settings": { "title": "Basic Settings", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 17cc218..90647d5 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -97,7 +97,19 @@ "subtitle": "欢迎使用 CLI Proxy API 管理中心", "openai_providers": "OpenAI 提供商", "quick_actions": "快捷操作", - "current_config": "当前配置" + "current_config": "当前配置", + "management_keys": "管理密钥", + "provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} O:{{openai}}", + "oauth_credentials": "OAuth 凭证", + "usage_overview": "使用概览", + "total_requests": "总请求数", + "total_tokens": "总 Token 数", + "rpm_30min": "RPM (30分钟)", + "tpm_30min": "TPM (30分钟)", + "models_used": "使用模型数", + "no_usage_data": "暂无使用数据", + "view_detailed_usage": "查看详细统计", + "edit_settings": "编辑设置" }, "basic_settings": { "title": "基础设置", diff --git a/src/pages/DashboardPage.module.scss b/src/pages/DashboardPage.module.scss index b9f6b38..305de0f 100644 --- a/src/pages/DashboardPage.module.scss +++ b/src/pages/DashboardPage.module.scss @@ -18,6 +18,7 @@ font-weight: 800; color: var(--text-primary); margin: 0; + line-height: 1.4; } .subtitle { @@ -105,6 +106,11 @@ border-radius: $radius-full; } +.buildDate { + font-size: 12px; + color: var(--text-secondary); +} + .statsGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); @@ -157,6 +163,13 @@ color: var(--text-secondary); } +.statSublabel { + font-size: 11px; + color: var(--text-secondary); + opacity: 0.8; + margin-top: 2px; +} + .section { display: flex; flex-direction: column; @@ -184,6 +197,17 @@ display: inline-flex; align-items: center; gap: $spacing-sm; + + // Button 内部的 span 需要 flex 对齐图标和文字 + > span { + display: inline-flex; + align-items: center; + gap: $spacing-sm; + } + + svg { + flex-shrink: 0; + } } .configGrid { @@ -221,3 +245,68 @@ color: var(--text-secondary); } } + +.configValueMono { + font-size: 12px; + font-family: $font-mono; + color: var(--text-secondary); + word-break: break-all; +} + +.configItemFull { + grid-column: 1 / -1; +} + +// Usage stats section +.usageGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: $spacing-sm; +} + +.usageCard { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: $spacing-md; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: $radius-md; + text-align: center; +} + +.usageValue { + font-size: 22px; + font-weight: 800; + color: var(--primary-color); +} + +.usageLabel { + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; +} + +.usageLoading, +.usageEmpty { + padding: $spacing-lg; + text-align: center; + color: var(--text-secondary); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: $radius-md; +} + +.viewMoreLink { + display: inline-flex; + align-items: center; + font-size: 13px; + color: var(--primary-color); + text-decoration: none; + margin-top: $spacing-xs; + + &:hover { + text-decoration: underline; + } +} diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 87dad54..534b624 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -2,9 +2,19 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; -import { IconKey, IconBot, IconFileText, IconChartLine, IconSettings, IconShield } from '@/components/ui/icons'; +import { + IconKey, + IconBot, + IconFileText, + IconChartLine, + IconSettings, + IconShield, + IconScrollText, + IconInfo +} from '@/components/ui/icons'; import { useAuthStore, useConfigStore } from '@/stores'; -import { apiKeysApi, providersApi, authFilesApi } from '@/services/api'; +import { apiKeysApi, providersApi, authFilesApi, usageApi } from '@/services/api'; +import { collectUsageDetails, extractTotalTokens, calculateRecentPerMinuteRates, formatCompactNumber } from '@/utils/usage'; import styles from './DashboardPage.module.scss'; interface QuickStat { @@ -13,51 +23,129 @@ interface QuickStat { icon: React.ReactNode; path: string; loading?: boolean; + sublabel?: string; +} + +interface ProviderStats { + gemini: number | null; + codex: number | null; + claude: number | null; + openai: number | null; +} + +interface UsageStats { + totalRequests: number; + totalTokens: number; + rpm: number; + tpm: number; + modelsUsed: number; } export function DashboardPage() { const { t } = useTranslation(); const connectionStatus = useAuthStore((state) => state.connectionStatus); const serverVersion = useAuthStore((state) => state.serverVersion); + const serverBuildDate = useAuthStore((state) => state.serverBuildDate); const apiBase = useAuthStore((state) => state.apiBase); const config = useConfigStore((state) => state.config); const [stats, setStats] = useState<{ apiKeys: number | null; - providers: number | null; authFiles: number | null; }>({ apiKeys: null, - providers: null, authFiles: null }); + const [providerStats, setProviderStats] = useState({ + gemini: null, + codex: null, + claude: null, + openai: null + }); + + const [usageStats, setUsageStats] = useState(null); const [loading, setLoading] = useState(true); + const [usageLoading, setUsageLoading] = useState(true); useEffect(() => { const fetchStats = async () => { setLoading(true); try { - const [keysRes, providersRes, filesRes] = await Promise.allSettled([ + const [keysRes, filesRes, geminiRes, codexRes, claudeRes, openaiRes] = await Promise.allSettled([ apiKeysApi.list(), - providersApi.getOpenAIProviders(), - authFilesApi.list() + authFilesApi.list(), + providersApi.getGeminiKeys(), + providersApi.getCodexConfigs(), + providersApi.getClaudeConfigs(), + providersApi.getOpenAIProviders() ]); setStats({ apiKeys: keysRes.status === 'fulfilled' ? keysRes.value.length : null, - providers: providersRes.status === 'fulfilled' ? providersRes.value.length : null, authFiles: filesRes.status === 'fulfilled' ? filesRes.value.files.length : null }); + + setProviderStats({ + gemini: geminiRes.status === 'fulfilled' ? geminiRes.value.length : null, + codex: codexRes.status === 'fulfilled' ? codexRes.value.length : null, + claude: claudeRes.status === 'fulfilled' ? claudeRes.value.length : null, + openai: openaiRes.status === 'fulfilled' ? openaiRes.value.length : null + }); } finally { setLoading(false); } }; + const fetchUsage = async () => { + if (!config?.usageStatisticsEnabled) { + setUsageLoading(false); + return; + } + setUsageLoading(true); + try { + const response = await usageApi.getUsage(); + const usageData = response?.usage ?? response; + + if (usageData) { + const details = collectUsageDetails(usageData); + const totalRequests = details.length; + const totalTokens = details.reduce((sum, d) => sum + extractTotalTokens(d), 0); + const rateStats = calculateRecentPerMinuteRates(30, usageData); + + // Count unique models + const modelSet = new Set(); + details.forEach(d => { + if (d.__modelName) modelSet.add(d.__modelName); + }); + + setUsageStats({ + totalRequests, + totalTokens, + rpm: rateStats.rpm, + tpm: rateStats.tpm, + modelsUsed: modelSet.size + }); + } + } catch { + // Ignore usage fetch errors + } finally { + setUsageLoading(false); + } + }; + if (connectionStatus === 'connected') { fetchStats(); + fetchUsage(); } - }, [connectionStatus]); + }, [connectionStatus, config?.usageStatisticsEnabled]); + + // Calculate total provider keys + const totalProviderKeys = + (providerStats.gemini ?? 0) + + (providerStats.codex ?? 0) + + (providerStats.claude ?? 0) + + (providerStats.openai ?? 0); const quickStats: QuickStat[] = [ { @@ -65,21 +153,29 @@ export function DashboardPage() { value: stats.apiKeys ?? '-', icon: , path: '/api-keys', - loading: loading && stats.apiKeys === null + loading: loading && stats.apiKeys === null, + sublabel: t('dashboard.management_keys') }, { - label: t('dashboard.openai_providers'), - value: stats.providers ?? '-', + label: t('nav.ai_providers'), + value: loading ? '-' : totalProviderKeys, icon: , path: '/ai-providers', - loading: loading && stats.providers === null + loading: loading, + sublabel: t('dashboard.provider_keys_detail', { + gemini: providerStats.gemini ?? 0, + codex: providerStats.codex ?? 0, + claude: providerStats.claude ?? 0, + openai: providerStats.openai ?? 0 + }) }, { label: t('nav.auth_files'), value: stats.authFiles ?? '-', icon: , path: '/auth-files', - loading: loading && stats.authFiles === null + loading: loading && stats.authFiles === null, + sublabel: t('dashboard.oauth_credentials') } ]; @@ -87,7 +183,9 @@ export function DashboardPage() { { label: t('nav.basic_settings'), icon: , path: '/settings' }, { label: t('nav.ai_providers'), icon: , path: '/ai-providers' }, { label: t('nav.oauth'), icon: , path: '/oauth' }, - { label: t('nav.usage_stats'), icon: , path: '/usage' } + { label: t('nav.usage_stats'), icon: , path: '/usage' }, + ...(config?.loggingToFile ? [{ label: t('nav.logs'), icon: , path: '/logs' }] : []), + { label: t('nav.system_info'), icon: , path: '/system' } ]; return ( @@ -121,6 +219,11 @@ export function DashboardPage() {
{apiBase || '-'} {serverVersion && v{serverVersion}} + {serverBuildDate && ( + + {new Date(serverBuildDate).toLocaleDateString()} + + )}
@@ -131,11 +234,51 @@ export function DashboardPage() {
{stat.loading ? '...' : stat.value} {stat.label} + {stat.sublabel && !stat.loading && ( + {stat.sublabel} + )}
))} + {config?.usageStatisticsEnabled && ( +
+

{t('dashboard.usage_overview')}

+ {usageLoading ? ( +
{t('common.loading')}
+ ) : usageStats ? ( +
+
+ {formatCompactNumber(usageStats.totalRequests)} + {t('dashboard.total_requests')} +
+
+ {formatCompactNumber(usageStats.totalTokens)} + {t('dashboard.total_tokens')} +
+
+ {usageStats.rpm.toFixed(1)} + {t('dashboard.rpm_30min')} +
+
+ {formatCompactNumber(usageStats.tpm)} + {t('dashboard.tpm_30min')} +
+
+ {usageStats.modelsUsed} + {t('dashboard.models_used')} +
+
+ ) : ( +
{t('dashboard.no_usage_data')}
+ )} + + {t('dashboard.view_detailed_usage')} → + +
+ )} +

{t('dashboard.quick_actions')}

@@ -172,11 +315,32 @@ export function DashboardPage() { {config.loggingToFile ? t('common.yes') : t('common.no')}
+
+ {t('basic_settings.request_log_enable')} + + {config.requestLog ? t('common.yes') : t('common.no')} + +
{t('basic_settings.retry_count_label')} {config.requestRetry ?? 0}
+
+ {t('basic_settings.ws_auth_enable')} + + {config.wsAuth ? t('common.yes') : t('common.no')} + +
+ {config.proxyUrl && ( +
+ {t('basic_settings.proxy_url_label')} + {config.proxyUrl} +
+ )}
+ + {t('dashboard.edit_settings')} → + )}