From 321be986a6b1fe61bd592973873f300d64368fa2 Mon Sep 17 00:00:00 2001 From: shaw Date: Wed, 30 Jul 2025 15:07:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=BB=AA=E8=A1=A8?= =?UTF-8?q?=E7=9B=98=E6=95=B0=E6=8D=AE=E5=B1=95=E7=A4=BA=E5=92=8C=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化实时TPM显示格式,添加智能单位(K/M) - 折线图tooltip添加token数单位格式化 - API Keys使用趋势图增加USD金额计算和显示 - 实现鼠标悬浮时数据倒序展示,前3名添加🥇🥈🥉标识 - 后端API添加费用计算支持 - 删除测试脚本,保持代码整洁 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- scripts/test-realtime-metrics.js | 69 ---------------------- src/models/redis.js | 2 +- src/routes/admin.js | 44 ++++++++++---- web/admin-spa/src/views/DashboardView.vue | 71 +++++++++++++++++++++-- 4 files changed, 102 insertions(+), 84 deletions(-) delete mode 100644 scripts/test-realtime-metrics.js diff --git a/scripts/test-realtime-metrics.js b/scripts/test-realtime-metrics.js deleted file mode 100644 index ba236822..00000000 --- a/scripts/test-realtime-metrics.js +++ /dev/null @@ -1,69 +0,0 @@ -const redis = require('../src/models/redis'); -const logger = require('../src/utils/logger'); - -async function testRealtimeMetrics() { - try { - // 连接Redis - await redis.connect(); - - // 获取当前时间戳 - const now = new Date(); - const currentMinute = Math.floor(now.getTime() / 60000); - - console.log('=== 时间戳测试 ==='); - console.log('当前时间:', now.toISOString()); - console.log('当前分钟时间戳:', currentMinute); - console.log(''); - - // 检查最近5分钟的键 - console.log('=== 检查Redis键 ==='); - const client = redis.getClient(); - for (let i = 0; i < 5; i++) { - const minuteKey = `system:metrics:minute:${currentMinute - i}`; - const exists = await client.exists(minuteKey); - const data = await client.hgetall(minuteKey); - - console.log(`键: ${minuteKey}`); - console.log(` 存在: ${exists ? '是' : '否'}`); - if (exists && data) { - console.log(` 数据: requests=${data.requests}, totalTokens=${data.totalTokens}`); - } - console.log(''); - } - - // 调用getRealtimeSystemMetrics - console.log('=== 调用 getRealtimeSystemMetrics ==='); - const metrics = await redis.getRealtimeSystemMetrics(); - console.log('结果:', JSON.stringify(metrics, null, 2)); - - // 列出所有system:metrics:minute:*键 - console.log('\n=== 所有系统指标键 ==='); - const allKeys = await client.keys('system:metrics:minute:*'); - console.log('找到的键数量:', allKeys.length); - if (allKeys.length > 0) { - // 排序并显示最新的10个 - allKeys.sort((a, b) => { - const aNum = parseInt(a.split(':').pop()); - const bNum = parseInt(b.split(':').pop()); - return bNum - aNum; - }); - - console.log('最新的10个键:'); - for (let i = 0; i < Math.min(10, allKeys.length); i++) { - const key = allKeys[i]; - const timestamp = parseInt(key.split(':').pop()); - const timeDiff = currentMinute - timestamp; - console.log(` ${key} (${timeDiff}分钟前)`); - } - } - - } catch (error) { - console.error('测试失败:', error); - } finally { - await redis.disconnect(); - process.exit(0); - } -} - -// 运行测试 -testRealtimeMetrics(); \ No newline at end of file diff --git a/src/models/redis.js b/src/models/redis.js index 659432db..5d934750 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1073,7 +1073,7 @@ class RedisClient { totalCacheReadTokens }; - logger.debug(`🔍 Realtime metrics - Final result:`, result); + logger.debug('🔍 Realtime metrics - Final result:', result); return result; } catch (error) { diff --git a/src/routes/admin.js b/src/routes/admin.js index de0a4be4..213efd54 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1986,15 +1986,27 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { const data = await client.hgetall(key); if (data && apiKeyMap.has(apiKeyId)) { - const totalTokens = (parseInt(data.inputTokens) || 0) + - (parseInt(data.outputTokens) || 0) + - (parseInt(data.cacheCreateTokens) || 0) + - (parseInt(data.cacheReadTokens) || 0); + const inputTokens = parseInt(data.inputTokens) || 0; + const outputTokens = parseInt(data.outputTokens) || 0; + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0; + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0; + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + + // 计算费用 - 使用默认模型价格,因为小时级别的数据没有模型信息 + const usage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + }; + const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022'); hourData.apiKeys[apiKeyId] = { name: apiKeyMap.get(apiKeyId).name, tokens: totalTokens, - requests: parseInt(data.requests) || 0 + requests: parseInt(data.requests) || 0, + cost: costResult.costs.total, + formattedCost: costResult.formatted.total }; } } @@ -2031,15 +2043,27 @@ router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { const data = await client.hgetall(key); if (data && apiKeyMap.has(apiKeyId)) { - const totalTokens = (parseInt(data.inputTokens) || 0) + - (parseInt(data.outputTokens) || 0) + - (parseInt(data.cacheCreateTokens) || 0) + - (parseInt(data.cacheReadTokens) || 0); + const inputTokens = parseInt(data.inputTokens) || 0; + const outputTokens = parseInt(data.outputTokens) || 0; + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0; + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0; + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + + // 计算费用 - 使用默认模型价格,因为日级别的汇总数据没有模型信息 + const usage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + }; + const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022'); dayData.apiKeys[apiKeyId] = { name: apiKeyMap.get(apiKeyId).name, tokens: totalTokens, - requests: parseInt(data.requests) || 0 + requests: parseInt(data.requests) || 0, + cost: costResult.costs.total, + formattedCost: costResult.formatted.total }; } } diff --git a/web/admin-spa/src/views/DashboardView.vue b/web/admin-spa/src/views/DashboardView.vue index 7820ed5f..5888b843 100644 --- a/web/admin-spa/src/views/DashboardView.vue +++ b/web/admin-spa/src/views/DashboardView.vue @@ -176,7 +176,7 @@ 实时TPM ({{ dashboardData.metricsWindow }}分钟)

-

{{ dashboardData.realtimeTPM || 0 }}

+

{{ formatNumber(dashboardData.realtimeTPM || 0) }}

每分钟Token数 @@ -618,6 +618,22 @@ function createUsageTrendChart() { tooltip: { mode: 'index', intersect: false, + itemSort: function(a, b) { + // 按值倒序排列,费用和请求数特殊处理 + const aLabel = a.dataset.label || '' + const bLabel = b.dataset.label || '' + + // 费用和请求数使用不同的轴,单独处理 + if (aLabel === '费用 (USD)' || bLabel === '费用 (USD)') { + return aLabel === '费用 (USD)' ? -1 : 1 + } + if (aLabel === '请求数' || bLabel === '请求数') { + return aLabel === '请求数' ? 1 : -1 + } + + // 其他按token值倒序 + return b.parsed.y - a.parsed.y + }, callbacks: { label: function(context) { const label = context.dataset.label || '' @@ -633,7 +649,14 @@ function createUsageTrendChart() { } else if (label === '请求数') { return label + ': ' + value.toLocaleString() + ' 次' } else { - return label + ': ' + value.toLocaleString() + ' tokens' + // 格式化token数显示 + if (value >= 1000000) { + return label + ': ' + (value / 1000000).toFixed(2) + 'M tokens' + } else if (value >= 1000) { + return label + ': ' + (value / 1000).toFixed(2) + 'K tokens' + } else { + return label + ': ' + value.toLocaleString() + ' tokens' + } } } } @@ -781,12 +804,52 @@ function createApiKeysUsageTrendChart() { tooltip: { mode: 'index', intersect: false, + itemSort: function(a, b) { + // 按值倒序排列 + return b.parsed.y - a.parsed.y + }, callbacks: { label: function(context) { const label = context.dataset.label || '' const value = context.parsed.y - const unit = apiKeysTrendMetric.value === 'tokens' ? ' tokens' : ' 次' - return label + ': ' + value.toLocaleString() + unit + const dataIndex = context.dataIndex + const dataPoint = apiKeysTrendData.value.data[dataIndex] + + // 获取所有数据集在这个时间点的值,用于排名 + const allValues = context.chart.data.datasets.map((dataset, idx) => ({ + value: dataset.data[dataIndex] || 0, + index: idx + })).sort((a, b) => b.value - a.value) + + // 找出当前数据集的排名 + const rank = allValues.findIndex(item => item.index === context.datasetIndex) + 1 + + // 准备排名标识 + let rankIcon = '' + if (rank === 1) rankIcon = '🥇 ' + else if (rank === 2) rankIcon = '🥈 ' + else if (rank === 3) rankIcon = '🥉 ' + + if (apiKeysTrendMetric.value === 'tokens') { + // 格式化token显示 + let formattedValue = '' + if (value >= 1000000) { + formattedValue = (value / 1000000).toFixed(2) + 'M' + } else if (value >= 1000) { + formattedValue = (value / 1000).toFixed(2) + 'K' + } else { + formattedValue = value.toLocaleString() + } + + // 获取对应API Key的费用信息 + const apiKeyId = apiKeysTrendData.value.topApiKeys[context.datasetIndex] + const apiKeyData = dataPoint?.apiKeys?.[apiKeyId] + const cost = apiKeyData?.formattedCost || '$0.00' + + return `${rankIcon}${label}: ${formattedValue} tokens (${cost})` + } else { + return `${rankIcon}${label}: ${value.toLocaleString()} 次` + } } } }