diff --git a/.gitignore b/.gitignore index ffaaf5c..84c834a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ settings.local.json *.njsproj *.sln *.sw? +自定义 Web UI +tmpclaude* +.claude \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index fcad3ab..4a2c9c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@codemirror/lang-yaml": "^6.1.2", + "@tanstack/react-virtual": "^3.13.18", "@uiw/react-codemirror": "^4.25.3", "axios": "^1.13.2", "chart.js": "^4.5.1", @@ -71,6 +72,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -465,6 +467,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1865,6 +1868,33 @@ "win32" ] }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz", + "integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz", + "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1930,6 +1960,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2017,6 +2048,7 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -2334,6 +2366,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2545,6 +2578,7 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -2809,6 +2843,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3285,6 +3320,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -3614,6 +3650,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3720,6 +3757,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3737,6 +3775,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3845,6 +3884,7 @@ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -4027,6 +4067,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4103,6 +4144,7 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4244,6 +4286,7 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 52b65e3..9a85661 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@codemirror/lang-yaml": "^6.1.2", + "@tanstack/react-virtual": "^3.13.18", "@uiw/react-codemirror": "^4.25.3", "axios": "^1.13.2", "chart.js": "^4.5.1", diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 4226607..875b879 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -26,6 +26,7 @@ import { IconShield, IconSlidersHorizontal, IconTimer, + IconActivity, } from '@/components/ui/icons'; import { INLINE_LOGO_JPEG } from '@/assets/logoInline'; import { @@ -50,6 +51,7 @@ const sidebarIcons: Record = { config: , logs: , system: , + monitor: , }; // Header action icons - smaller size for header buttons @@ -369,6 +371,7 @@ export function MainLayout() { ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }] : []), { path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system }, + { path: '/monitor', label: t('nav.monitor'), icon: sidebarIcons.monitor }, ]; const navOrder = navItems.map((item) => item.path); const getRouteOrder = (pathname: string) => { diff --git a/src/components/monitor/ChannelStats.tsx b/src/components/monitor/ChannelStats.tsx new file mode 100644 index 0000000..7911058 --- /dev/null +++ b/src/components/monitor/ChannelStats.tsx @@ -0,0 +1,409 @@ +import { useMemo, useState, useCallback, Fragment } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card } from '@/components/ui/Card'; +import { useDisableModel } from '@/hooks'; +import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector'; +import { DisableModelModal } from './DisableModelModal'; +import { + formatTimestamp, + getRateClassName, + filterDataByTimeRange, + getProviderDisplayParts, + type DateRange, +} from '@/utils/monitor'; +import type { UsageData } from '@/pages/MonitorPage'; +import styles from '@/pages/MonitorPage.module.scss'; + +interface ChannelStatsProps { + data: UsageData | null; + loading: boolean; + providerMap: Record; + providerModels: Record>; +} + +interface ModelStat { + requests: number; + success: number; + failed: number; + successRate: number; + recentRequests: { failed: boolean; timestamp: number }[]; + lastTimestamp: number; +} + +interface ChannelStat { + source: string; + displayName: string; + providerName: string | null; + maskedKey: string; + totalRequests: number; + successRequests: number; + failedRequests: number; + successRate: number; + lastRequestTime: number; + recentRequests: { failed: boolean; timestamp: number }[]; + models: Record; +} + +export function ChannelStats({ data, loading, providerMap, providerModels }: ChannelStatsProps) { + const { t } = useTranslation(); + const [expandedChannel, setExpandedChannel] = useState(null); + const [filterChannel, setFilterChannel] = useState(''); + const [filterModel, setFilterModel] = useState(''); + const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>(''); + + // 时间范围状态 + const [timeRange, setTimeRange] = useState(7); + const [customRange, setCustomRange] = useState(); + + // 使用禁用模型 Hook + const { + disableState, + disabling, + isModelDisabled, + handleDisableClick: onDisableClick, + handleConfirmDisable, + handleCancelDisable, + } = useDisableModel({ providerMap, providerModels }); + + // 处理时间范围变化 + const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => { + setTimeRange(range); + if (custom) { + setCustomRange(custom); + } + }, []); + + // 根据时间范围过滤数据 + const timeFilteredData = useMemo(() => { + return filterDataByTimeRange(data, timeRange, customRange); + }, [data, timeRange, customRange]); + + // 计算渠道统计数据 + const channelStats = useMemo(() => { + if (!timeFilteredData?.apis) return []; + + const stats: Record = {}; + + Object.values(timeFilteredData.apis).forEach((apiData) => { + Object.entries(apiData.models).forEach(([modelName, modelData]) => { + modelData.details.forEach((detail) => { + const source = detail.source || 'unknown'; + // 获取渠道显示信息 + const { provider, masked } = getProviderDisplayParts(source, providerMap); + // 只统计在 providerMap 中存在的渠道 + if (!provider) return; + + const displayName = `${provider} (${masked})`; + const timestamp = detail.timestamp ? new Date(detail.timestamp).getTime() : 0; + + if (!stats[displayName]) { + stats[displayName] = { + source, + displayName, + providerName: provider, + maskedKey: masked, + totalRequests: 0, + successRequests: 0, + failedRequests: 0, + successRate: 0, + lastRequestTime: 0, + recentRequests: [], + models: {}, + }; + } + + stats[displayName].totalRequests++; + if (detail.failed) { + stats[displayName].failedRequests++; + } else { + stats[displayName].successRequests++; + } + + // 更新最近请求时间 + if (timestamp > stats[displayName].lastRequestTime) { + stats[displayName].lastRequestTime = timestamp; + } + + // 收集请求状态 + stats[displayName].recentRequests.push({ failed: detail.failed, timestamp }); + + // 模型统计 + if (!stats[displayName].models[modelName]) { + stats[displayName].models[modelName] = { + requests: 0, + success: 0, + failed: 0, + successRate: 0, + recentRequests: [], + lastTimestamp: 0, + }; + } + stats[displayName].models[modelName].requests++; + if (detail.failed) { + stats[displayName].models[modelName].failed++; + } else { + stats[displayName].models[modelName].success++; + } + stats[displayName].models[modelName].recentRequests.push({ failed: detail.failed, timestamp }); + if (timestamp > stats[displayName].models[modelName].lastTimestamp) { + stats[displayName].models[modelName].lastTimestamp = timestamp; + } + }); + }); + }); + + // 计算成功率并排序请求 + Object.values(stats).forEach((stat) => { + stat.successRate = stat.totalRequests > 0 + ? (stat.successRequests / stat.totalRequests) * 100 + : 0; + // 按时间排序,取最近12个 + stat.recentRequests.sort((a, b) => a.timestamp - b.timestamp); + stat.recentRequests = stat.recentRequests.slice(-12); + + Object.values(stat.models).forEach((model) => { + model.successRate = model.requests > 0 + ? (model.success / model.requests) * 100 + : 0; + model.recentRequests.sort((a, b) => a.timestamp - b.timestamp); + model.recentRequests = model.recentRequests.slice(-12); + }); + }); + + return Object.values(stats) + .filter((stat) => stat.totalRequests > 0) + .sort((a, b) => b.totalRequests - a.totalRequests) + .slice(0, 10); + }, [timeFilteredData, providerMap]); + + // 获取所有渠道和模型列表 + const { channels, models } = useMemo(() => { + const channelSet = new Set(); + const modelSet = new Set(); + + channelStats.forEach((stat) => { + channelSet.add(stat.displayName); + Object.keys(stat.models).forEach((model) => modelSet.add(model)); + }); + + return { + channels: Array.from(channelSet).sort(), + models: Array.from(modelSet).sort(), + }; + }, [channelStats]); + + // 过滤后的数据 + const filteredStats = useMemo(() => { + return channelStats.filter((stat) => { + if (filterChannel && stat.displayName !== filterChannel) return false; + if (filterModel && !stat.models[filterModel]) return false; + if (filterStatus === 'success' && stat.failedRequests > 0) return false; + if (filterStatus === 'failed' && stat.failedRequests === 0) return false; + return true; + }); + }, [channelStats, filterChannel, filterModel, filterStatus]); + + // 切换展开状态 + const toggleExpand = (displayName: string) => { + setExpandedChannel(expandedChannel === displayName ? null : displayName); + }; + + // 开始禁用流程(阻止事件冒泡) + const handleDisableClick = (source: string, model: string, e: React.MouseEvent) => { + e.stopPropagation(); + onDisableClick(source, model); + }; + + return ( + <> + + {formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.channel.subtitle')} + · {t('monitor.channel.click_hint')} + + } + extra={ + + } + > + {/* 筛选器 */} +
+ + + +
+ + {/* 表格 */} +
+ {loading ? ( +
{t('common.loading')}
+ ) : filteredStats.length === 0 ? ( +
{t('monitor.no_data')}
+ ) : ( + + + + + + + + + + + + {filteredStats.map((stat) => ( + + toggleExpand(stat.displayName)} + > + + + + + + + {expandedChannel === stat.displayName && ( + + + + )} + + ))} + +
{t('monitor.channel.header_name')}{t('monitor.channel.header_count')}{t('monitor.channel.header_rate')}{t('monitor.channel.header_recent')}{t('monitor.channel.header_time')}
+ {stat.providerName ? ( + <> + {stat.providerName} + ({stat.maskedKey}) + + ) : ( + stat.maskedKey + )} + {stat.totalRequests.toLocaleString()} + {stat.successRate.toFixed(1)}% + +
+ {stat.recentRequests.map((req, i) => ( +
+ ))} +
+
{formatTimestamp(stat.lastRequestTime)}
+
+ + + + + + + + + + + + + + {Object.entries(stat.models) + .sort((a, b) => { + const aDisabled = isModelDisabled(stat.source, a[0]); + const bDisabled = isModelDisabled(stat.source, b[0]); + // 已禁用的排在后面 + if (aDisabled !== bDisabled) { + return aDisabled ? 1 : -1; + } + // 然后按请求数降序 + return b[1].requests - a[1].requests; + }) + .map(([modelName, modelStat]) => { + const disabled = isModelDisabled(stat.source, modelName); + return ( + + + + + + + + + + ); + })} + +
{t('monitor.channel.model')}{t('monitor.channel.header_count')}{t('monitor.channel.header_rate')}{t('monitor.channel.success')}/{t('monitor.channel.failed')}{t('monitor.channel.header_recent')}{t('monitor.channel.header_time')}{t('monitor.logs.header_actions')}
{modelName}{modelStat.requests.toLocaleString()} + {modelStat.successRate.toFixed(1)}% + + {modelStat.success} + {' / '} + {modelStat.failed} + +
+ {modelStat.recentRequests.map((req, i) => ( +
+ ))} +
+
{formatTimestamp(modelStat.lastTimestamp)} + {disabled ? ( + {t('monitor.logs.removed')} + ) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? ( + + ) : '-'} +
+
+
+ )} +
+
+ + {/* 禁用确认弹窗 */} + + + ); +} diff --git a/src/components/monitor/DailyTrendChart.tsx b/src/components/monitor/DailyTrendChart.tsx new file mode 100644 index 0000000..46d33b3 --- /dev/null +++ b/src/components/monitor/DailyTrendChart.tsx @@ -0,0 +1,257 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Chart } from 'react-chartjs-2'; +import type { UsageData } from '@/pages/MonitorPage'; +import styles from '@/pages/MonitorPage.module.scss'; + +interface DailyTrendChartProps { + data: UsageData | null; + loading: boolean; + isDark: boolean; + timeRange: number; +} + +interface DailyStat { + date: string; + requests: number; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cachedTokens: number; +} + +export function DailyTrendChart({ data, loading, isDark, timeRange }: DailyTrendChartProps) { + const { t } = useTranslation(); + + // 按日期聚合数据 + const dailyData = useMemo((): DailyStat[] => { + if (!data?.apis) return []; + + const dailyStats: Record = {}; + + Object.values(data.apis).forEach((apiData) => { + Object.values(apiData.models).forEach((modelData) => { + modelData.details.forEach((detail) => { + const date = new Date(detail.timestamp).toISOString().split('T')[0]; + if (!dailyStats[date]) { + dailyStats[date] = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cachedTokens: 0, + }; + } + dailyStats[date].requests++; + dailyStats[date].inputTokens += detail.tokens.input_tokens || 0; + dailyStats[date].outputTokens += detail.tokens.output_tokens || 0; + dailyStats[date].reasoningTokens += detail.tokens.reasoning_tokens || 0; + dailyStats[date].cachedTokens += detail.tokens.cached_tokens || 0; + }); + }); + }); + + // 转换为数组并按日期排序 + return Object.entries(dailyStats) + .map(([date, stats]) => ({ date, ...stats })) + .sort((a, b) => a.date.localeCompare(b.date)); + }, [data]); + + // 图表数据 + const chartData = useMemo(() => { + const labels = dailyData.map((item) => { + const date = new Date(item.date); + return `${date.getMonth() + 1}/${date.getDate()}`; + }); + + return { + labels, + datasets: [ + { + type: 'line' as const, + label: t('monitor.trend.requests'), + data: dailyData.map((item) => item.requests), + borderColor: '#3b82f6', + backgroundColor: '#3b82f6', + borderWidth: 3, + fill: false, + tension: 0.35, + yAxisID: 'y1', + order: 0, + pointRadius: 3, + pointBackgroundColor: '#3b82f6', + }, + { + type: 'bar' as const, + label: t('monitor.trend.input_tokens'), + data: dailyData.map((item) => item.inputTokens / 1000), + backgroundColor: 'rgba(34, 197, 94, 0.7)', + borderColor: 'rgba(34, 197, 94, 0.7)', + borderWidth: 1, + borderRadius: 0, + yAxisID: 'y', + order: 1, + stack: 'tokens', + }, + { + type: 'bar' as const, + label: t('monitor.trend.output_tokens'), + data: dailyData.map((item) => item.outputTokens / 1000), + backgroundColor: 'rgba(249, 115, 22, 0.7)', + borderColor: 'rgba(249, 115, 22, 0.7)', + borderWidth: 1, + borderRadius: 4, + yAxisID: 'y', + order: 1, + stack: 'tokens', + }, + ], + }; + }, [dailyData, t]); + + // 图表配置 + const chartOptions = useMemo(() => ({ + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index' as const, + intersect: false, + }, + plugins: { + legend: { + display: true, + position: 'bottom' as const, + labels: { + color: isDark ? '#9ca3af' : '#6b7280', + usePointStyle: true, + padding: 16, + font: { + size: 11, + }, + generateLabels: (chart: any) => { + return chart.data.datasets.map((dataset: any, i: number) => { + const isLine = dataset.type === 'line'; + return { + text: dataset.label, + fillStyle: dataset.backgroundColor, + strokeStyle: dataset.borderColor, + lineWidth: 0, + hidden: !chart.isDatasetVisible(i), + datasetIndex: i, + pointStyle: isLine ? 'circle' : 'rect', + }; + }); + }, + }, + }, + tooltip: { + backgroundColor: isDark ? '#374151' : '#ffffff', + titleColor: isDark ? '#f3f4f6' : '#111827', + bodyColor: isDark ? '#d1d5db' : '#4b5563', + borderColor: isDark ? '#4b5563' : '#e5e7eb', + borderWidth: 1, + padding: 12, + callbacks: { + label: (context: any) => { + const label = context.dataset.label || ''; + const value = context.raw; + if (context.dataset.yAxisID === 'y1') { + return `${label}: ${value.toLocaleString()}`; + } + return `${label}: ${value.toFixed(1)}K`; + }, + }, + }, + }, + scales: { + x: { + grid: { + color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)', + }, + ticks: { + color: isDark ? '#9ca3af' : '#6b7280', + font: { + size: 11, + }, + }, + }, + y: { + type: 'linear' as const, + position: 'left' as const, + stacked: true, + grid: { + color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)', + }, + ticks: { + color: isDark ? '#9ca3af' : '#6b7280', + font: { + size: 11, + }, + callback: (value: string | number) => `${value}K`, + }, + title: { + display: true, + text: 'Tokens (K)', + color: isDark ? '#9ca3af' : '#6b7280', + font: { + size: 11, + }, + }, + }, + y1: { + type: 'linear' as const, + position: 'right' as const, + grid: { + drawOnChartArea: false, + }, + ticks: { + color: isDark ? '#9ca3af' : '#6b7280', + font: { + size: 11, + }, + }, + title: { + display: true, + text: t('monitor.trend.requests'), + color: isDark ? '#9ca3af' : '#6b7280', + font: { + size: 11, + }, + }, + }, + }, + }), [isDark, t]); + + const timeRangeLabel = timeRange === 1 + ? t('monitor.today') + : t('monitor.last_n_days', { n: timeRange }); + + return ( +
+
+
+

{t('monitor.trend.title')}

+

+ {timeRangeLabel} · {t('monitor.trend.subtitle')} +

+
+
+ +
+ {loading || dailyData.length === 0 ? ( +
+ {loading ? t('common.loading') : t('monitor.no_data')} +
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/components/monitor/DisableModelModal.tsx b/src/components/monitor/DisableModelModal.tsx new file mode 100644 index 0000000..52f6eea --- /dev/null +++ b/src/components/monitor/DisableModelModal.tsx @@ -0,0 +1,101 @@ +/** + * 禁用模型确认弹窗组件 + * 封装三次确认的 UI 逻辑 + */ + +import { useTranslation } from 'react-i18next'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +import type { DisableState } from '@/utils/monitor'; + +interface DisableModelModalProps { + /** 禁用状态 */ + disableState: DisableState | null; + /** 是否正在禁用中 */ + disabling: boolean; + /** 确认回调 */ + onConfirm: () => void; + /** 取消回调 */ + onCancel: () => void; +} + +export function DisableModelModal({ + disableState, + disabling, + onConfirm, + onCancel, +}: DisableModelModalProps) { + const { t, i18n } = useTranslation(); + const isZh = i18n.language === 'zh-CN' || i18n.language === 'zh'; + + // 获取警告内容 + const getWarningContent = () => { + if (!disableState) return null; + + if (disableState.step === 1) { + return ( +

+ {isZh ? '确定要禁用 ' : 'Are you sure you want to disable '} + {disableState.displayName} + {isZh ? ' 吗?' : '?'} +

+ ); + } + + if (disableState.step === 2) { + return ( +

+ {isZh + ? '⚠️ 警告:此操作将从配置中移除该模型映射!' + : '⚠️ Warning: this removes the model mapping from config!'} +

+ ); + } + + return ( +

+ {isZh + ? '🚨 最后确认:禁用后需要手动重新添加才能恢复!' + : "🚨 Final confirmation: you'll need to add it back manually later!"} +

+ ); + }; + + // 获取确认按钮文本 + const getConfirmButtonText = () => { + if (!disableState) return ''; + const btnTexts = isZh + ? ['确认禁用 (3)', '我确定 (2)', '立即禁用 (1)'] + : ['Confirm (3)', "I'm sure (2)", 'Disable now (1)']; + return btnTexts[disableState.step - 1] || btnTexts[0]; + }; + + return ( + +
+ {getWarningContent()} +
+ + +
+
+
+ ); +} diff --git a/src/components/monitor/FailureAnalysis.tsx b/src/components/monitor/FailureAnalysis.tsx new file mode 100644 index 0000000..366c580 --- /dev/null +++ b/src/components/monitor/FailureAnalysis.tsx @@ -0,0 +1,420 @@ +import { useMemo, useState, useCallback, Fragment } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card } from '@/components/ui/Card'; +import { useDisableModel } from '@/hooks'; +import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector'; +import { DisableModelModal } from './DisableModelModal'; +import { + formatTimestamp, + getRateClassName, + filterDataByTimeRange, + getProviderDisplayParts, + type DateRange, +} from '@/utils/monitor'; +import type { UsageData } from '@/pages/MonitorPage'; +import styles from '@/pages/MonitorPage.module.scss'; + +interface FailureAnalysisProps { + data: UsageData | null; + loading: boolean; + providerMap: Record; + providerModels: Record>; +} + +interface ModelFailureStat { + success: number; + failure: number; + total: number; + successRate: number; + recentRequests: { failed: boolean; timestamp: number }[]; + lastTimestamp: number; +} + +interface FailureStat { + source: string; + displayName: string; + providerName: string | null; + maskedKey: string; + failedCount: number; + lastFailTime: number; + models: Record; +} + +export function FailureAnalysis({ data, loading, providerMap, providerModels }: FailureAnalysisProps) { + const { t } = useTranslation(); + const [expandedChannel, setExpandedChannel] = useState(null); + const [filterChannel, setFilterChannel] = useState(''); + const [filterModel, setFilterModel] = useState(''); + + // 时间范围状态 + const [timeRange, setTimeRange] = useState(7); + const [customRange, setCustomRange] = useState(); + + // 使用禁用模型 Hook + const { + disableState, + disabling, + isModelDisabled, + handleDisableClick: onDisableClick, + handleConfirmDisable, + handleCancelDisable, + } = useDisableModel({ providerMap, providerModels }); + + // 处理时间范围变化 + const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => { + setTimeRange(range); + if (custom) { + setCustomRange(custom); + } + }, []); + + // 根据时间范围过滤数据 + const timeFilteredData = useMemo(() => { + return filterDataByTimeRange(data, timeRange, customRange); + }, [data, timeRange, customRange]); + + // 计算失败统计数据 + const failureStats = useMemo(() => { + if (!timeFilteredData?.apis) return []; + + // 首先收集有失败记录的渠道 + const failedSources = new Set(); + Object.values(timeFilteredData.apis).forEach((apiData) => { + Object.values(apiData.models).forEach((modelData) => { + modelData.details.forEach((detail) => { + if (detail.failed) { + const source = detail.source || 'unknown'; + const { provider } = getProviderDisplayParts(source, providerMap); + if (provider) { + failedSources.add(source); + } + } + }); + }); + }); + + // 统计这些渠道的所有请求 + const stats: Record = {}; + + Object.values(timeFilteredData.apis).forEach((apiData) => { + Object.entries(apiData.models).forEach(([modelName, modelData]) => { + modelData.details.forEach((detail) => { + const source = detail.source || 'unknown'; + // 只统计有失败记录的渠道 + if (!failedSources.has(source)) return; + + const { provider, masked } = getProviderDisplayParts(source, providerMap); + const displayName = provider ? `${provider} (${masked})` : masked; + const timestamp = detail.timestamp ? new Date(detail.timestamp).getTime() : 0; + + if (!stats[displayName]) { + stats[displayName] = { + source, + displayName, + providerName: provider, + maskedKey: masked, + failedCount: 0, + lastFailTime: 0, + models: {}, + }; + } + + if (detail.failed) { + stats[displayName].failedCount++; + if (timestamp > stats[displayName].lastFailTime) { + stats[displayName].lastFailTime = timestamp; + } + } + + // 按模型统计 + if (!stats[displayName].models[modelName]) { + stats[displayName].models[modelName] = { + success: 0, + failure: 0, + total: 0, + successRate: 0, + recentRequests: [], + lastTimestamp: 0, + }; + } + stats[displayName].models[modelName].total++; + if (detail.failed) { + stats[displayName].models[modelName].failure++; + } else { + stats[displayName].models[modelName].success++; + } + stats[displayName].models[modelName].recentRequests.push({ failed: detail.failed, timestamp }); + if (timestamp > stats[displayName].models[modelName].lastTimestamp) { + stats[displayName].models[modelName].lastTimestamp = timestamp; + } + }); + }); + }); + + // 计算成功率并排序请求 + Object.values(stats).forEach((stat) => { + Object.values(stat.models).forEach((model) => { + model.successRate = model.total > 0 + ? (model.success / model.total) * 100 + : 0; + model.recentRequests.sort((a, b) => a.timestamp - b.timestamp); + model.recentRequests = model.recentRequests.slice(-12); + }); + }); + + return Object.values(stats) + .filter((stat) => stat.failedCount > 0) + .sort((a, b) => b.failedCount - a.failedCount) + .slice(0, 10); + }, [timeFilteredData, providerMap]); + + // 获取所有渠道和模型列表 + const { channels, models } = useMemo(() => { + const channelSet = new Set(); + const modelSet = new Set(); + + failureStats.forEach((stat) => { + channelSet.add(stat.displayName); + Object.keys(stat.models).forEach((model) => modelSet.add(model)); + }); + + return { + channels: Array.from(channelSet).sort(), + models: Array.from(modelSet).sort(), + }; + }, [failureStats]); + + // 过滤后的数据 + const filteredStats = useMemo(() => { + return failureStats.filter((stat) => { + if (filterChannel && stat.displayName !== filterChannel) return false; + if (filterModel && !stat.models[filterModel]) return false; + return true; + }); + }, [failureStats, filterChannel, filterModel]); + + // 切换展开状态 + const toggleExpand = (displayName: string) => { + setExpandedChannel(expandedChannel === displayName ? null : displayName); + }; + + // 获取主要失败模型(前2个,已禁用的排在后面) + const getTopFailedModels = (source: string, modelsMap: Record) => { + return Object.entries(modelsMap) + .filter(([, stat]) => stat.failure > 0) + .sort((a, b) => { + const aDisabled = isModelDisabled(source, a[0]); + const bDisabled = isModelDisabled(source, b[0]); + // 已禁用的排在后面 + if (aDisabled !== bDisabled) { + return aDisabled ? 1 : -1; + } + // 然后按失败数降序 + return b[1].failure - a[1].failure; + }) + .slice(0, 2); + }; + + // 开始禁用流程(阻止事件冒泡) + const handleDisableClick = (source: string, model: string, e: React.MouseEvent) => { + e.stopPropagation(); + onDisableClick(source, model); + }; + + return ( + <> + + {formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.failure.subtitle')} + · {t('monitor.failure.click_hint')} + + } + extra={ + + } + > + {/* 筛选器 */} +
+ + +
+ + {/* 表格 */} +
+ {loading ? ( +
{t('common.loading')}
+ ) : filteredStats.length === 0 ? ( +
{t('monitor.failure.no_failures')}
+ ) : ( + + + + + + + + + + + {filteredStats.map((stat) => { + const topModels = getTopFailedModels(stat.source, stat.models); + const totalFailedModels = Object.values(stat.models).filter(m => m.failure > 0).length; + + return ( + + toggleExpand(stat.displayName)} + > + + + + + + {expandedChannel === stat.displayName && ( + + + + )} + + ); + })} + +
{t('monitor.failure.header_name')}{t('monitor.failure.header_count')}{t('monitor.failure.header_time')}{t('monitor.failure.header_models')}
+ {stat.providerName ? ( + <> + {stat.providerName} + ({stat.maskedKey}) + + ) : ( + stat.maskedKey + )} + {stat.failedCount.toLocaleString()}{formatTimestamp(stat.lastFailTime)} + {topModels.map(([model, modelStat]) => { + const percent = ((modelStat.failure / stat.failedCount) * 100).toFixed(0); + const shortModel = model.length > 16 ? model.slice(0, 13) + '...' : model; + const disabled = isModelDisabled(stat.source, model); + return ( + + {shortModel} + + ); + })} + {totalFailedModels > 2 && ( + + +{totalFailedModels - 2} + + )} +
+
+ + + + + + + + + + + + + + {Object.entries(stat.models) + .filter(([, m]) => m.failure > 0) + .sort((a, b) => { + const aDisabled = isModelDisabled(stat.source, a[0]); + const bDisabled = isModelDisabled(stat.source, b[0]); + // 已禁用的排在后面 + if (aDisabled !== bDisabled) { + return aDisabled ? 1 : -1; + } + // 然后按失败数降序 + return b[1].failure - a[1].failure; + }) + .map(([modelName, modelStat]) => { + const disabled = isModelDisabled(stat.source, modelName); + return ( + + + + + + + + + + ); + })} + +
{t('monitor.channel.model')}{t('monitor.channel.header_count')}{t('monitor.channel.header_rate')}{t('monitor.channel.success')}/{t('monitor.channel.failed')}{t('monitor.channel.header_recent')}{t('monitor.channel.header_time')}{t('monitor.logs.header_actions')}
{modelName}{modelStat.total.toLocaleString()} + {modelStat.successRate.toFixed(1)}% + + {modelStat.success} + {' / '} + {modelStat.failure} + +
+ {modelStat.recentRequests.map((req, i) => ( +
+ ))} +
+
{formatTimestamp(modelStat.lastTimestamp)} + {disabled ? ( + {t('monitor.logs.removed')} + ) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? ( + + ) : '-'} +
+
+
+ )} +
+
+ + {/* 禁用确认弹窗 */} + + + ); +} diff --git a/src/components/monitor/HourlyModelChart.tsx b/src/components/monitor/HourlyModelChart.tsx new file mode 100644 index 0000000..ad40429 --- /dev/null +++ b/src/components/monitor/HourlyModelChart.tsx @@ -0,0 +1,314 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Chart } from 'react-chartjs-2'; +import type { UsageData } from '@/pages/MonitorPage'; +import styles from '@/pages/MonitorPage.module.scss'; + +interface HourlyModelChartProps { + data: UsageData | null; + loading: boolean; + isDark: boolean; +} + +// 颜色调色板 +const COLORS = [ + 'rgba(59, 130, 246, 0.7)', // 蓝色 + 'rgba(34, 197, 94, 0.7)', // 绿色 + 'rgba(249, 115, 22, 0.7)', // 橙色 + 'rgba(139, 92, 246, 0.7)', // 紫色 + 'rgba(236, 72, 153, 0.7)', // 粉色 + 'rgba(6, 182, 212, 0.7)', // 青色 +]; + +type HourRange = 6 | 12 | 24; + +export function HourlyModelChart({ data, loading, isDark }: HourlyModelChartProps) { + const { t } = useTranslation(); + const [hourRange, setHourRange] = useState(12); + + // 按小时聚合数据 + const hourlyData = useMemo(() => { + if (!data?.apis) return { hours: [], models: [], modelData: {} as Record, successRates: [] }; + + const now = new Date(); + let cutoffTime: Date; + let hoursCount: number; + + cutoffTime = new Date(now.getTime() - hourRange * 60 * 60 * 1000); + cutoffTime.setMinutes(0, 0, 0); + hoursCount = hourRange + 1; + + // 生成所有小时的时间点 + const allHours: string[] = []; + for (let i = 0; i < hoursCount; i++) { + const hourTime = new Date(cutoffTime.getTime() + i * 60 * 60 * 1000); + const hourKey = hourTime.toISOString().slice(0, 13); // YYYY-MM-DDTHH + allHours.push(hourKey); + } + + // 收集每小时每个模型的请求数 + const hourlyStats: Record> = {}; + const modelSet = new Set(); + + // 初始化所有小时 + allHours.forEach((hour) => { + hourlyStats[hour] = {}; + }); + + Object.values(data.apis).forEach((apiData) => { + Object.entries(apiData.models).forEach(([modelName, modelData]) => { + modelSet.add(modelName); + modelData.details.forEach((detail) => { + const timestamp = new Date(detail.timestamp); + if (timestamp < cutoffTime) return; + + const hourKey = timestamp.toISOString().slice(0, 13); // YYYY-MM-DDTHH + if (!hourlyStats[hourKey]) { + hourlyStats[hourKey] = {}; + } + if (!hourlyStats[hourKey][modelName]) { + hourlyStats[hourKey][modelName] = { success: 0, failed: 0 }; + } + if (detail.failed) { + hourlyStats[hourKey][modelName].failed++; + } else { + hourlyStats[hourKey][modelName].success++; + } + }); + }); + }); + + // 获取排序后的小时列表 + const hours = allHours.sort(); + + // 计算每个模型的总请求数,取 Top 6 + const modelTotals: Record = {}; + hours.forEach((hour) => { + Object.entries(hourlyStats[hour]).forEach(([model, stats]) => { + modelTotals[model] = (modelTotals[model] || 0) + stats.success + stats.failed; + }); + }); + + const topModels = Object.entries(modelTotals) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([name]) => name); + + // 构建每个模型的数据数组 + const modelData: Record = {}; + topModels.forEach((model) => { + modelData[model] = hours.map((hour) => { + const stats = hourlyStats[hour][model]; + return stats ? stats.success + stats.failed : 0; + }); + }); + + // 计算每小时的成功率 + const successRates = hours.map((hour) => { + let totalSuccess = 0; + let totalRequests = 0; + Object.values(hourlyStats[hour]).forEach((stats) => { + totalSuccess += stats.success; + totalRequests += stats.success + stats.failed; + }); + return totalRequests > 0 ? (totalSuccess / totalRequests) * 100 : 0; + }); + + return { hours, models: topModels, modelData, successRates }; + }, [data, hourRange]); + + // 获取时间范围标签 + const hourRangeLabel = useMemo(() => { + if (hourRange === 6) return t('monitor.hourly.last_6h'); + if (hourRange === 12) return t('monitor.hourly.last_12h'); + return t('monitor.hourly.last_24h'); + }, [hourRange, t]); + + // 图表数据 + const chartData = useMemo(() => { + const labels = hourlyData.hours.map((hour) => { + const date = new Date(hour + ':00:00'); + return `${date.getHours()}:00`; + }); + + // 成功率折线放在最前面 + const datasets: any[] = [{ + type: 'line' as const, + label: t('monitor.hourly.success_rate'), + data: hourlyData.successRates, + borderColor: '#4ef0c3', + backgroundColor: '#4ef0c3', + borderWidth: 2.5, + tension: 0.4, + yAxisID: 'y1', + stack: '', + pointRadius: 3, + pointBackgroundColor: '#4ef0c3', + pointBorderColor: '#4ef0c3', + }]; + + // 添加模型柱状图 + hourlyData.models.forEach((model, index) => { + datasets.push({ + type: 'bar' as const, + label: model, + data: hourlyData.modelData[model], + backgroundColor: COLORS[index % COLORS.length], + borderColor: COLORS[index % COLORS.length], + borderWidth: 1, + borderRadius: 4, + stack: 'models', + yAxisID: 'y', + }); + }); + + return { labels, datasets }; + }, [hourlyData, t]); + + // 图表配置 + const chartOptions = useMemo(() => ({ + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index' as const, + intersect: false, + }, + plugins: { + legend: { + display: true, + position: 'bottom' as const, + labels: { + color: isDark ? '#9ca3af' : '#6b7280', + usePointStyle: true, + padding: 12, + font: { + size: 11, + }, + generateLabels: (chart: any) => { + return chart.data.datasets.map((dataset: any, i: number) => { + const isLine = dataset.type === 'line'; + return { + text: dataset.label, + fillStyle: dataset.backgroundColor, + strokeStyle: dataset.borderColor, + lineWidth: 0, + hidden: !chart.isDatasetVisible(i), + datasetIndex: i, + pointStyle: isLine ? 'circle' : 'rect', + }; + }); + }, + }, + }, + tooltip: { + backgroundColor: isDark ? '#374151' : '#ffffff', + titleColor: isDark ? '#f3f4f6' : '#111827', + bodyColor: isDark ? '#d1d5db' : '#4b5563', + borderColor: isDark ? '#4b5563' : '#e5e7eb', + borderWidth: 1, + padding: 12, + }, + }, + scales: { + x: { + stacked: true, + grid: { + color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)', + }, + ticks: { + color: isDark ? '#9ca3af' : '#6b7280', + font: { + size: 11, + }, + }, + }, + y: { + stacked: true, + position: 'left' as const, + grid: { + color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)', + }, + ticks: { + color: isDark ? '#9ca3af' : '#6b7280', + font: { + size: 11, + }, + }, + title: { + display: true, + text: t('monitor.hourly.requests'), + color: isDark ? '#9ca3af' : '#6b7280', + font: { + size: 11, + }, + }, + }, + y1: { + position: 'right' as const, + min: 0, + max: 100, + grid: { + drawOnChartArea: false, + }, + ticks: { + color: isDark ? '#9ca3af' : '#6b7280', + font: { + size: 11, + }, + callback: (value: string | number) => `${value}%`, + }, + title: { + display: true, + text: t('monitor.hourly.success_rate'), + color: isDark ? '#9ca3af' : '#6b7280', + font: { + size: 11, + }, + }, + }, + }, + }), [isDark, t]); + + return ( +
+
+
+

{t('monitor.hourly_model.title')}

+

+ {hourRangeLabel} +

+
+
+ + + +
+
+ +
+ {loading || hourlyData.hours.length === 0 ? ( +
+ {loading ? t('common.loading') : t('monitor.no_data')} +
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/components/monitor/HourlyTokenChart.tsx b/src/components/monitor/HourlyTokenChart.tsx new file mode 100644 index 0000000..3c57ea1 --- /dev/null +++ b/src/components/monitor/HourlyTokenChart.tsx @@ -0,0 +1,271 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Chart } from 'react-chartjs-2'; +import type { UsageData } from '@/pages/MonitorPage'; +import styles from '@/pages/MonitorPage.module.scss'; + +interface HourlyTokenChartProps { + data: UsageData | null; + loading: boolean; + isDark: boolean; +} + +type HourRange = 6 | 12 | 24; + +export function HourlyTokenChart({ data, loading, isDark }: HourlyTokenChartProps) { + const { t } = useTranslation(); + const [hourRange, setHourRange] = useState(12); + + // 按小时聚合 Token 数据 + const hourlyData = useMemo(() => { + if (!data?.apis) return { hours: [], totalTokens: [], inputTokens: [], outputTokens: [], reasoningTokens: [], cachedTokens: [] }; + + const now = new Date(); + let cutoffTime: Date; + let hoursCount: number; + + cutoffTime = new Date(now.getTime() - hourRange * 60 * 60 * 1000); + cutoffTime.setMinutes(0, 0, 0); + hoursCount = hourRange + 1; + + // 生成所有小时的时间点 + const allHours: string[] = []; + for (let i = 0; i < hoursCount; i++) { + const hourTime = new Date(cutoffTime.getTime() + i * 60 * 60 * 1000); + const hourKey = hourTime.toISOString().slice(0, 13); // YYYY-MM-DDTHH + allHours.push(hourKey); + } + + // 初始化所有小时的数据为0 + const hourlyStats: Record = {}; + allHours.forEach((hour) => { + hourlyStats[hour] = { total: 0, input: 0, output: 0, reasoning: 0, cached: 0 }; + }); + + // 收集每小时的 Token 数据 + Object.values(data.apis).forEach((apiData) => { + Object.values(apiData.models).forEach((modelData) => { + modelData.details.forEach((detail) => { + const timestamp = new Date(detail.timestamp); + if (timestamp < cutoffTime) return; + + const hourKey = timestamp.toISOString().slice(0, 13); // YYYY-MM-DDTHH + if (!hourlyStats[hourKey]) { + hourlyStats[hourKey] = { total: 0, input: 0, output: 0, reasoning: 0, cached: 0 }; + } + hourlyStats[hourKey].total += detail.tokens.total_tokens || 0; + hourlyStats[hourKey].input += detail.tokens.input_tokens || 0; + hourlyStats[hourKey].output += detail.tokens.output_tokens || 0; + hourlyStats[hourKey].reasoning += detail.tokens.reasoning_tokens || 0; + hourlyStats[hourKey].cached += detail.tokens.cached_tokens || 0; + }); + }); + }); + + // 获取排序后的小时列表 + const hours = allHours.sort(); + + return { + hours, + totalTokens: hours.map((h) => (hourlyStats[h]?.total || 0) / 1000), + inputTokens: hours.map((h) => (hourlyStats[h]?.input || 0) / 1000), + outputTokens: hours.map((h) => (hourlyStats[h]?.output || 0) / 1000), + reasoningTokens: hours.map((h) => (hourlyStats[h]?.reasoning || 0) / 1000), + cachedTokens: hours.map((h) => (hourlyStats[h]?.cached || 0) / 1000), + }; + }, [data, hourRange]); + + // 获取时间范围标签 + const hourRangeLabel = useMemo(() => { + if (hourRange === 6) return t('monitor.hourly.last_6h'); + if (hourRange === 12) return t('monitor.hourly.last_12h'); + return t('monitor.hourly.last_24h'); + }, [hourRange, t]); + + // 图表数据 + const chartData = useMemo(() => { + const labels = hourlyData.hours.map((hour) => { + const date = new Date(hour + ':00:00'); + return `${date.getHours()}:00`; + }); + + return { + labels, + datasets: [ + { + type: 'line' as const, + label: t('monitor.hourly_token.input'), + data: hourlyData.inputTokens, + borderColor: '#22c55e', + backgroundColor: '#22c55e', + borderWidth: 2, + tension: 0.4, + yAxisID: 'y', + order: 0, + pointRadius: 3, + pointBackgroundColor: '#22c55e', + }, + { + type: 'line' as const, + label: t('monitor.hourly_token.output'), + data: hourlyData.outputTokens, + borderColor: '#f97316', + backgroundColor: '#f97316', + borderWidth: 2, + tension: 0.4, + yAxisID: 'y', + order: 0, + pointRadius: 3, + pointBackgroundColor: '#f97316', + }, + { + type: 'bar' as const, + label: t('monitor.hourly_token.total'), + data: hourlyData.totalTokens, + backgroundColor: 'rgba(59, 130, 246, 0.6)', + borderColor: 'rgba(59, 130, 246, 0.6)', + borderWidth: 1, + borderRadius: 4, + yAxisID: 'y', + order: 1, + }, + ], + }; + }, [hourlyData, t]); + + // 图表配置 + const chartOptions = useMemo(() => ({ + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index' as const, + intersect: false, + }, + plugins: { + legend: { + display: true, + position: 'bottom' as const, + labels: { + color: isDark ? '#9ca3af' : '#6b7280', + usePointStyle: true, + padding: 12, + font: { + size: 11, + }, + generateLabels: (chart: any) => { + return chart.data.datasets.map((dataset: any, i: number) => { + const isLine = dataset.type === 'line'; + return { + text: dataset.label, + fillStyle: dataset.backgroundColor, + strokeStyle: dataset.borderColor, + lineWidth: 0, + hidden: !chart.isDatasetVisible(i), + datasetIndex: i, + pointStyle: isLine ? 'circle' : 'rect', + }; + }); + }, + }, + }, + tooltip: { + backgroundColor: isDark ? '#374151' : '#ffffff', + titleColor: isDark ? '#f3f4f6' : '#111827', + bodyColor: isDark ? '#d1d5db' : '#4b5563', + borderColor: isDark ? '#4b5563' : '#e5e7eb', + borderWidth: 1, + padding: 12, + callbacks: { + label: (context: any) => { + const label = context.dataset.label || ''; + const value = context.raw; + return `${label}: ${value.toFixed(1)}K`; + }, + }, + }, + }, + scales: { + x: { + grid: { + color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)', + }, + ticks: { + color: isDark ? '#9ca3af' : '#6b7280', + font: { + size: 11, + }, + }, + }, + y: { + position: 'left' as const, + grid: { + color: isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(0, 0, 0, 0.06)', + }, + ticks: { + color: isDark ? '#9ca3af' : '#6b7280', + font: { + size: 11, + }, + callback: (value: string | number) => `${value}K`, + }, + title: { + display: true, + text: 'Tokens (K)', + color: isDark ? '#9ca3af' : '#6b7280', + font: { + size: 11, + }, + }, + }, + }, + }), [isDark]); + + return ( +
+
+
+

{t('monitor.hourly_token.title')}

+

+ {hourRangeLabel} +

+
+
+ + + +
+
+ +
+ {loading || hourlyData.hours.length === 0 ? ( +
+ {loading ? t('common.loading') : t('monitor.no_data')} +
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/components/monitor/KpiCards.tsx b/src/components/monitor/KpiCards.tsx new file mode 100644 index 0000000..f44b1f2 --- /dev/null +++ b/src/components/monitor/KpiCards.tsx @@ -0,0 +1,201 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { UsageData } from '@/pages/MonitorPage'; +import styles from '@/pages/MonitorPage.module.scss'; + +interface KpiCardsProps { + data: UsageData | null; + loading: boolean; + timeRange: number; +} + +// 格式化数字 +function formatNumber(num: number): string { + if (num >= 1000000000) { + return (num / 1000000000).toFixed(2) + 'B'; + } + if (num >= 1000000) { + return (num / 1000000).toFixed(2) + 'M'; + } + if (num >= 1000) { + return (num / 1000).toFixed(2) + 'K'; + } + return num.toLocaleString(); +} + +export function KpiCards({ data, loading, timeRange }: KpiCardsProps) { + const { t } = useTranslation(); + + // 计算统计数据 + const stats = useMemo(() => { + if (!data?.apis) { + return { + totalRequests: 0, + successRequests: 0, + failedRequests: 0, + successRate: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cachedTokens: 0, + avgTpm: 0, + avgRpm: 0, + avgRpd: 0, + }; + } + + let totalRequests = 0; + let successRequests = 0; + let failedRequests = 0; + let totalTokens = 0; + let inputTokens = 0; + let outputTokens = 0; + let reasoningTokens = 0; + let cachedTokens = 0; + + // 收集所有时间戳用于计算 TPM/RPM + const timestamps: number[] = []; + + Object.values(data.apis).forEach((apiData) => { + Object.values(apiData.models).forEach((modelData) => { + modelData.details.forEach((detail) => { + totalRequests++; + if (detail.failed) { + failedRequests++; + } else { + successRequests++; + } + + totalTokens += detail.tokens.total_tokens || 0; + inputTokens += detail.tokens.input_tokens || 0; + outputTokens += detail.tokens.output_tokens || 0; + reasoningTokens += detail.tokens.reasoning_tokens || 0; + cachedTokens += detail.tokens.cached_tokens || 0; + + timestamps.push(new Date(detail.timestamp).getTime()); + }); + }); + }); + + const successRate = totalRequests > 0 ? (successRequests / totalRequests) * 100 : 0; + + // 计算 TPM 和 RPM(基于实际时间跨度) + let avgTpm = 0; + let avgRpm = 0; + let avgRpd = 0; + + if (timestamps.length > 0) { + const minTime = Math.min(...timestamps); + const maxTime = Math.max(...timestamps); + const timeSpanMinutes = Math.max((maxTime - minTime) / (1000 * 60), 1); + const timeSpanDays = Math.max(timeSpanMinutes / (60 * 24), 1); + + avgTpm = Math.round(totalTokens / timeSpanMinutes); + avgRpm = Math.round(totalRequests / timeSpanMinutes * 10) / 10; + avgRpd = Math.round(totalRequests / timeSpanDays); + } + + return { + totalRequests, + successRequests, + failedRequests, + successRate, + totalTokens, + inputTokens, + outputTokens, + reasoningTokens, + cachedTokens, + avgTpm, + avgRpm, + avgRpd, + }; + }, [data]); + + const timeRangeLabel = timeRange === 1 + ? t('monitor.today') + : t('monitor.last_n_days', { n: timeRange }); + + return ( +
+ {/* 请求数 */} +
+
+ {t('monitor.kpi.requests')} + {timeRangeLabel} +
+
+ {loading ? '--' : formatNumber(stats.totalRequests)} +
+
+ + {t('monitor.kpi.success')}: {loading ? '--' : stats.successRequests.toLocaleString()} + + + {t('monitor.kpi.failed')}: {loading ? '--' : stats.failedRequests.toLocaleString()} + + + {t('monitor.kpi.rate')}: {loading ? '--' : stats.successRate.toFixed(1)}% + +
+
+ + {/* Tokens */} +
+
+ {t('monitor.kpi.tokens')} + {timeRangeLabel} +
+
+ {loading ? '--' : formatNumber(stats.totalTokens)} +
+
+ {t('monitor.kpi.input')}: {loading ? '--' : formatNumber(stats.inputTokens)} + {t('monitor.kpi.output')}: {loading ? '--' : formatNumber(stats.outputTokens)} +
+
+ + {/* 平均 TPM */} +
+
+ {t('monitor.kpi.avg_tpm')} + {timeRangeLabel} +
+
+ {loading ? '--' : formatNumber(stats.avgTpm)} +
+
+ {t('monitor.kpi.tokens_per_minute')} +
+
+ + {/* 平均 RPM */} +
+
+ {t('monitor.kpi.avg_rpm')} + {timeRangeLabel} +
+
+ {loading ? '--' : stats.avgRpm.toFixed(1)} +
+
+ {t('monitor.kpi.requests_per_minute')} +
+
+ + {/* 日均 RPD */} +
+
+ {t('monitor.kpi.avg_rpd')} + {timeRangeLabel} +
+
+ {loading ? '--' : formatNumber(stats.avgRpd)} +
+
+ {t('monitor.kpi.requests_per_day')} +
+
+
+ ); +} diff --git a/src/components/monitor/ModelDistributionChart.tsx b/src/components/monitor/ModelDistributionChart.tsx new file mode 100644 index 0000000..f758400 --- /dev/null +++ b/src/components/monitor/ModelDistributionChart.tsx @@ -0,0 +1,205 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Doughnut } from 'react-chartjs-2'; +import type { UsageData } from '@/pages/MonitorPage'; +import styles from '@/pages/MonitorPage.module.scss'; + +interface ModelDistributionChartProps { + data: UsageData | null; + loading: boolean; + isDark: boolean; + timeRange: number; +} + +// 颜色调色板 +const COLORS = [ + '#3b82f6', // 蓝色 + '#22c55e', // 绿色 + '#f97316', // 橙色 + '#8b5cf6', // 紫色 + '#ec4899', // 粉色 + '#06b6d4', // 青色 + '#eab308', // 黄色 + '#ef4444', // 红色 + '#14b8a6', // 青绿 + '#6366f1', // 靛蓝 +]; + +type ViewMode = 'request' | 'token'; + +export function ModelDistributionChart({ data, loading, isDark, timeRange }: ModelDistributionChartProps) { + const { t } = useTranslation(); + const [viewMode, setViewMode] = useState('request'); + + const timeRangeLabel = timeRange === 1 + ? t('monitor.today') + : t('monitor.last_n_days', { n: timeRange }); + + // 计算模型分布数据 + const distributionData = useMemo(() => { + if (!data?.apis) return []; + + const modelStats: Record = {}; + + Object.values(data.apis).forEach((apiData) => { + Object.entries(apiData.models).forEach(([modelName, modelData]) => { + if (!modelStats[modelName]) { + modelStats[modelName] = { requests: 0, tokens: 0 }; + } + modelData.details.forEach((detail) => { + modelStats[modelName].requests++; + modelStats[modelName].tokens += detail.tokens.total_tokens || 0; + }); + }); + }); + + // 转换为数组并排序 + const sorted = Object.entries(modelStats) + .map(([name, stats]) => ({ + name, + requests: stats.requests, + tokens: stats.tokens, + })) + .sort((a, b) => { + if (viewMode === 'request') { + return b.requests - a.requests; + } + return b.tokens - a.tokens; + }); + + // 取 Top 10 + return sorted.slice(0, 10); + }, [data, viewMode]); + + // 计算总数 + const total = useMemo(() => { + return distributionData.reduce((sum, item) => { + return sum + (viewMode === 'request' ? item.requests : item.tokens); + }, 0); + }, [distributionData, viewMode]); + + // 图表数据 + const chartData = useMemo(() => { + return { + labels: distributionData.map((item) => item.name), + datasets: [ + { + data: distributionData.map((item) => + viewMode === 'request' ? item.requests : item.tokens + ), + backgroundColor: COLORS.slice(0, distributionData.length), + borderColor: isDark ? '#1f2937' : '#ffffff', + borderWidth: 2, + }, + ], + }; + }, [distributionData, viewMode, isDark]); + + // 图表配置 + const chartOptions = useMemo(() => ({ + responsive: true, + maintainAspectRatio: false, + cutout: '65%', + plugins: { + legend: { + display: false, + }, + tooltip: { + backgroundColor: isDark ? '#374151' : '#ffffff', + titleColor: isDark ? '#f3f4f6' : '#111827', + bodyColor: isDark ? '#d1d5db' : '#4b5563', + borderColor: isDark ? '#4b5563' : '#e5e7eb', + borderWidth: 1, + padding: 12, + callbacks: { + label: (context: any) => { + const value = context.raw; + const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0; + if (viewMode === 'request') { + return `${value.toLocaleString()} ${t('monitor.requests')} (${percentage}%)`; + } + return `${value.toLocaleString()} tokens (${percentage}%)`; + }, + }, + }, + }, + }), [isDark, total, viewMode, t]); + + // 格式化数值 + const formatValue = (value: number) => { + if (value >= 1000000) { + return (value / 1000000).toFixed(1) + 'M'; + } + if (value >= 1000) { + return (value / 1000).toFixed(1) + 'K'; + } + return value.toString(); + }; + + return ( +
+
+
+

{t('monitor.distribution.title')}

+

+ {timeRangeLabel} · {viewMode === 'request' ? t('monitor.distribution.by_requests') : t('monitor.distribution.by_tokens')} + {' · Top 10'} +

+
+
+ + +
+
+ + {loading || distributionData.length === 0 ? ( +
+
+ {loading ? t('common.loading') : t('monitor.no_data')} +
+
+ ) : ( +
+
+ +
+
+ {viewMode === 'request' ? t('monitor.distribution.request_share') : t('monitor.distribution.token_share')} +
+
+
+
+ {distributionData.map((item, index) => { + const value = viewMode === 'request' ? item.requests : item.tokens; + const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0'; + return ( +
+ + + {item.name} + + + {formatValue(value)} ({percentage}%) + +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/src/components/monitor/RequestLogs.tsx b/src/components/monitor/RequestLogs.tsx new file mode 100644 index 0000000..688a59f --- /dev/null +++ b/src/components/monitor/RequestLogs.tsx @@ -0,0 +1,643 @@ +import { useMemo, useState, useEffect, useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { Card } from '@/components/ui/Card'; +import { usageApi } from '@/services/api'; +import { useDisableModel } from '@/hooks'; +import { TimeRangeSelector, formatTimeRangeCaption, type TimeRange } from './TimeRangeSelector'; +import { DisableModelModal } from './DisableModelModal'; +import { + maskSecret, + formatProviderDisplay, + formatTimestamp, + getRateClassName, + getProviderDisplayParts, + type DateRange, +} from '@/utils/monitor'; +import type { UsageData } from '@/pages/MonitorPage'; +import styles from '@/pages/MonitorPage.module.scss'; + +interface RequestLogsProps { + data: UsageData | null; + loading: boolean; + providerMap: Record; + apiFilter: string; +} + +interface LogEntry { + id: string; + timestamp: string; + timestampMs: number; + apiKey: string; + model: string; + source: string; + displayName: string; + providerName: string | null; + maskedKey: string; + authIndex: string; + failed: boolean; + inputTokens: number; + outputTokens: number; + totalTokens: number; +} + +interface ChannelModelRequest { + failed: boolean; + timestamp: number; +} + +// 预计算的统计数据缓存 +interface PrecomputedStats { + recentRequests: ChannelModelRequest[]; + successRate: string; + totalCount: number; +} + +// 虚拟滚动行高 +const ROW_HEIGHT = 40; + +export function RequestLogs({ data, loading: parentLoading, providerMap, apiFilter }: RequestLogsProps) { + const { t } = useTranslation(); + const [filterApi, setFilterApi] = useState(''); + const [filterModel, setFilterModel] = useState(''); + const [filterSource, setFilterSource] = useState(''); + const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>(''); + const [autoRefresh, setAutoRefresh] = useState(10); + const [countdown, setCountdown] = useState(0); + const countdownRef = useRef | null>(null); + // 用 ref 存储 fetchLogData,避免作为定时器 useEffect 的依赖 + const fetchLogDataRef = useRef<() => Promise>(() => Promise.resolve()); + + // 虚拟滚动容器 ref + const tableContainerRef = useRef(null); + // 固定表头容器 ref + const headerRef = useRef(null); + + // 同步表头和内容的水平滚动 + const handleScroll = useCallback(() => { + if (tableContainerRef.current && headerRef.current) { + headerRef.current.scrollLeft = tableContainerRef.current.scrollLeft; + } + }, []); + + // 时间范围状态 + const [timeRange, setTimeRange] = useState(7); + const [customRange, setCustomRange] = useState(); + + // 日志独立数据状态 + const [logData, setLogData] = useState(null); + const [logLoading, setLogLoading] = useState(false); + const [isFirstLoad, setIsFirstLoad] = useState(true); + + // 使用禁用模型 Hook + const { + disableState, + disabling, + isModelDisabled, + handleDisableClick, + handleConfirmDisable, + handleCancelDisable, + } = useDisableModel({ providerMap }); + + // 处理时间范围变化 + const handleTimeRangeChange = useCallback((range: TimeRange, custom?: DateRange) => { + setTimeRange(range); + if (custom) { + setCustomRange(custom); + } + }, []); + + // 使用日志独立数据或父组件数据 + const effectiveData = logData || data; + // 只在首次加载且没有数据时显示 loading 状态 + const showLoading = (parentLoading && isFirstLoad && !effectiveData) || (logLoading && !effectiveData); + + // 当父组件数据加载完成时,标记首次加载完成 + useEffect(() => { + if (!parentLoading && data) { + setIsFirstLoad(false); + } + }, [parentLoading, data]); + + // 独立获取日志数据 + const fetchLogData = useCallback(async () => { + setLogLoading(true); + try { + const response = await usageApi.getUsage(); + const usageData = response?.usage ?? response; + + // 应用时间范围过滤 + if (usageData?.apis) { + const apis = usageData.apis as UsageData['apis']; + const now = new Date(); + let cutoffStart: Date; + let cutoffEnd: Date = new Date(now.getTime()); + cutoffEnd.setHours(23, 59, 59, 999); + + if (timeRange === 'custom' && customRange) { + cutoffStart = customRange.start; + cutoffEnd = customRange.end; + } else if (typeof timeRange === 'number') { + cutoffStart = new Date(now.getTime() - timeRange * 24 * 60 * 60 * 1000); + cutoffStart.setHours(0, 0, 0, 0); + } else { + cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + cutoffStart.setHours(0, 0, 0, 0); + } + + const filtered: UsageData = { apis: {} }; + + Object.entries(apis).forEach(([apiKey, apiData]) => { + // 如果有 API 过滤器,检查是否匹配 + if (apiFilter && !apiKey.toLowerCase().includes(apiFilter.toLowerCase())) { + return; + } + + if (!apiData?.models) return; + + const filteredModels: Record = {}; + + Object.entries(apiData.models).forEach(([modelName, modelData]) => { + if (!modelData?.details || !Array.isArray(modelData.details)) return; + + const filteredDetails = modelData.details.filter((detail) => { + const timestamp = new Date(detail.timestamp); + return timestamp >= cutoffStart && timestamp <= cutoffEnd; + }); + + if (filteredDetails.length > 0) { + filteredModels[modelName] = { details: filteredDetails }; + } + }); + + if (Object.keys(filteredModels).length > 0) { + filtered.apis[apiKey] = { models: filteredModels }; + } + }); + + setLogData(filtered); + } + } catch (err) { + console.error('日志刷新失败:', err); + } finally { + setLogLoading(false); + } + }, [timeRange, customRange, apiFilter]); + + // 同步 fetchLogData 到 ref,确保定时器始终调用最新版本 + useEffect(() => { + fetchLogDataRef.current = fetchLogData; + }, [fetchLogData]); + + // 统一的自动刷新定时器管理 + useEffect(() => { + // 清理旧定时器 + if (countdownRef.current) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + + // 禁用自动刷新时 + if (autoRefresh <= 0) { + setCountdown(0); + return; + } + + // 设置初始倒计时 + setCountdown(autoRefresh); + + // 创建新定时器 + countdownRef.current = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + // 倒计时结束,触发刷新并重置倒计时 + fetchLogDataRef.current(); + return autoRefresh; + } + return prev - 1; + }); + }, 1000); + + // 组件卸载或 autoRefresh 变化时清理 + return () => { + if (countdownRef.current) { + clearInterval(countdownRef.current); + countdownRef.current = null; + } + }; + }, [autoRefresh]); + + // 时间范围变化时立即刷新数据 + useEffect(() => { + fetchLogData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [timeRange, customRange]); + + // 获取倒计时显示文本 + const getCountdownText = () => { + if (logLoading) { + return t('monitor.logs.refreshing'); + } + if (autoRefresh === 0) { + return t('monitor.logs.manual_refresh'); + } + if (countdown > 0) { + return t('monitor.logs.refresh_in_seconds', { seconds: countdown }); + } + return t('monitor.logs.refreshing'); + }; + + // 将数据转换为日志条目 + const logEntries = useMemo(() => { + if (!effectiveData?.apis) return []; + + const entries: LogEntry[] = []; + let idCounter = 0; + + Object.entries(effectiveData.apis).forEach(([apiKey, apiData]) => { + Object.entries(apiData.models).forEach(([modelName, modelData]) => { + modelData.details.forEach((detail) => { + const source = detail.source || 'unknown'; + const { provider, masked } = getProviderDisplayParts(source, providerMap); + const displayName = provider ? `${provider} (${masked})` : masked; + const timestampMs = detail.timestamp ? new Date(detail.timestamp).getTime() : 0; + entries.push({ + id: `${idCounter++}`, + timestamp: detail.timestamp, + timestampMs, + apiKey, + model: modelName, + source, + displayName, + providerName: provider, + maskedKey: masked, + authIndex: detail.auth_index || '--', + failed: detail.failed, + inputTokens: detail.tokens.input_tokens || 0, + outputTokens: detail.tokens.output_tokens || 0, + totalTokens: detail.tokens.total_tokens || 0, + }); + }); + }); + }); + + // 按时间倒序排序 + return entries.sort((a, b) => b.timestampMs - a.timestampMs); + }, [effectiveData, providerMap]); + + // 预计算所有条目的统计数据(一次性计算,避免渲染时重复计算) + const precomputedStats = useMemo(() => { + const statsMap = new Map(); + + // 首先按渠道+模型分组,并按时间排序 + const channelModelGroups: Record = {}; + + logEntries.forEach((entry, index) => { + const key = `${entry.source}|||${entry.model}`; + if (!channelModelGroups[key]) { + channelModelGroups[key] = []; + } + channelModelGroups[key].push({ entry, index }); + }); + + // 对每个分组按时间正序排序(用于计算累计统计) + Object.values(channelModelGroups).forEach((group) => { + group.sort((a, b) => a.entry.timestampMs - b.entry.timestampMs); + }); + + // 计算每个条目的统计数据 + Object.entries(channelModelGroups).forEach(([, group]) => { + let successCount = 0; + let totalCount = 0; + const recentRequests: ChannelModelRequest[] = []; + + group.forEach(({ entry }) => { + totalCount++; + if (!entry.failed) { + successCount++; + } + + // 维护最近 10 次请求 + recentRequests.push({ failed: entry.failed, timestamp: entry.timestampMs }); + if (recentRequests.length > 10) { + recentRequests.shift(); + } + + // 计算成功率 + const successRate = totalCount > 0 ? ((successCount / totalCount) * 100).toFixed(1) : '0.0'; + + // 存储该条目的统计数据 + statsMap.set(entry.id, { + recentRequests: [...recentRequests], + successRate, + totalCount, + }); + }); + }); + + return statsMap; + }, [logEntries]); + + // 获取筛选选项 + const { apis, models, sources } = useMemo(() => { + const apiSet = new Set(); + const modelSet = new Set(); + const sourceSet = new Set(); + + logEntries.forEach((entry) => { + apiSet.add(entry.apiKey); + modelSet.add(entry.model); + sourceSet.add(entry.source); + }); + + return { + apis: Array.from(apiSet).sort(), + models: Array.from(modelSet).sort(), + sources: Array.from(sourceSet).sort(), + }; + }, [logEntries]); + + // 过滤后的数据 + const filteredEntries = useMemo(() => { + return logEntries.filter((entry) => { + if (filterApi && entry.apiKey !== filterApi) return false; + if (filterModel && entry.model !== filterModel) return false; + if (filterSource && entry.source !== filterSource) return false; + if (filterStatus === 'success' && entry.failed) return false; + if (filterStatus === 'failed' && !entry.failed) return false; + return true; + }); + }, [logEntries, filterApi, filterModel, filterSource, filterStatus]); + + // 虚拟滚动配置 + const rowVirtualizer = useVirtualizer({ + count: filteredEntries.length, + getScrollElement: () => tableContainerRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 10, // 预渲染上下各 10 行 + }); + + // 格式化数字 + const formatNumber = (num: number) => { + return num.toLocaleString('zh-CN'); + }; + + // 获取预计算的统计数据 + const getStats = (entry: LogEntry): PrecomputedStats => { + return precomputedStats.get(entry.id) || { + recentRequests: [], + successRate: '0.0', + totalCount: 0, + }; + }; + + // 渲染单行 + const renderRow = (entry: LogEntry) => { + const stats = getStats(entry); + const rateValue = parseFloat(stats.successRate); + const disabled = isModelDisabled(entry.source, entry.model); + + return ( + <> + {entry.authIndex} + + {maskSecret(entry.apiKey)} + + + {entry.model} + + + {entry.providerName ? ( + <> + {entry.providerName} + ({entry.maskedKey}) + + ) : ( + entry.maskedKey + )} + + + + {entry.failed ? t('monitor.logs.failed') : t('monitor.logs.success')} + + + +
+ {stats.recentRequests.map((req, idx) => ( +
+ ))} +
+ + + {stats.successRate}% + + {formatNumber(stats.totalCount)} + {formatNumber(entry.inputTokens)} + {formatNumber(entry.outputTokens)} + {formatNumber(entry.totalTokens)} + {formatTimestamp(entry.timestamp)} + + {entry.source && entry.source !== '-' && entry.source !== 'unknown' ? ( + disabled ? ( + + {t('monitor.logs.disabled')} + + ) : ( + + ) + ) : ( + '-' + )} + + + ); + }; + + return ( + <> + + {formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.logs.total_count', { count: logEntries.length })} + · {t('monitor.logs.scroll_hint')} + + } + extra={ + + } + > + {/* 筛选器 */} +
+ + + + + + + {getCountdownText()} + + + +
+ + {/* 虚拟滚动表格 */} +
+ {showLoading ? ( +
{t('common.loading')}
+ ) : filteredEntries.length === 0 ? ( +
{t('monitor.no_data')}
+ ) : ( + <> + {/* 固定表头 */} +
+ + + + + + + + + + + + + + + + + + +
{t('monitor.logs.header_auth')}{t('monitor.logs.header_api')}{t('monitor.logs.header_model')}{t('monitor.logs.header_source')}{t('monitor.logs.header_status')}{t('monitor.logs.header_recent')}{t('monitor.logs.header_rate')}{t('monitor.logs.header_count')}{t('monitor.logs.header_input')}{t('monitor.logs.header_output')}{t('monitor.logs.header_total')}{t('monitor.logs.header_time')}{t('monitor.logs.header_actions')}
+
+ + {/* 虚拟滚动容器 */} +
+
+ + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const entry = filteredEntries[virtualRow.index]; + return ( + + {renderRow(entry)} + + ); + })} + +
+
+
+ + )} +
+ + {/* 统计信息 */} + {filteredEntries.length > 0 && ( +
+ {t('monitor.logs.total_count', { count: filteredEntries.length })} +
+ )} +
+ + {/* 禁用确认弹窗 */} + + + ); +} diff --git a/src/components/monitor/TimeRangeSelector.tsx b/src/components/monitor/TimeRangeSelector.tsx new file mode 100644 index 0000000..df9a305 --- /dev/null +++ b/src/components/monitor/TimeRangeSelector.tsx @@ -0,0 +1,158 @@ +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from '@/pages/MonitorPage.module.scss'; + +export type TimeRange = 1 | 7 | 14 | 30 | 'custom'; + +interface DateRange { + start: Date; + end: Date; +} + +interface TimeRangeSelectorProps { + value: TimeRange; + onChange: (range: TimeRange, customRange?: DateRange) => void; + customRange?: DateRange; +} + +export function TimeRangeSelector({ value, onChange, customRange }: TimeRangeSelectorProps) { + const { t } = useTranslation(); + const [showCustom, setShowCustom] = useState(value === 'custom'); + const [startDate, setStartDate] = useState(() => { + if (customRange?.start) { + return formatDateForInput(customRange.start); + } + const date = new Date(); + date.setDate(date.getDate() - 7); + return formatDateForInput(date); + }); + const [endDate, setEndDate] = useState(() => { + if (customRange?.end) { + return formatDateForInput(customRange.end); + } + return formatDateForInput(new Date()); + }); + + const handleTimeClick = useCallback((range: TimeRange) => { + if (range === 'custom') { + setShowCustom(true); + onChange(range); + } else { + setShowCustom(false); + onChange(range); + } + }, [onChange]); + + const handleApplyCustom = useCallback(() => { + if (startDate && endDate) { + const start = new Date(startDate); + start.setHours(0, 0, 0, 0); + const end = new Date(endDate); + end.setHours(23, 59, 59, 999); + + if (start <= end) { + onChange('custom', { start, end }); + } + } + }, [startDate, endDate, onChange]); + + return ( +
+
+ {([1, 7, 14, 30, 'custom'] as TimeRange[]).map((range) => ( + + ))} +
+ {showCustom && ( +
+ setStartDate(e.target.value)} + /> + {t('monitor.time.to')} + setEndDate(e.target.value)} + /> + +
+ )} +
+ ); +} + +function formatDateForInput(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +// 根据时间范围过滤数据的工具函数 +export function filterByTimeRange( + items: T[], + range: TimeRange, + customRange?: DateRange +): T[] { + const now = new Date(); + let cutoffStart: Date; + let cutoffEnd: Date = new Date(now.getTime()); + cutoffEnd.setHours(23, 59, 59, 999); + + if (range === 'custom' && customRange) { + cutoffStart = customRange.start; + cutoffEnd = customRange.end; + } else if (typeof range === 'number') { + cutoffStart = new Date(now.getTime() - range * 24 * 60 * 60 * 1000); + cutoffStart.setHours(0, 0, 0, 0); + } else { + // 默认7天 + cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + cutoffStart.setHours(0, 0, 0, 0); + } + + return items.filter((item) => { + if (!item.timestamp) return false; + const timestamp = new Date(item.timestamp); + return timestamp >= cutoffStart && timestamp <= cutoffEnd; + }); +} + +// 格式化时间范围显示 +export function formatTimeRangeCaption( + range: TimeRange, + customRange?: DateRange, + t?: (key: string, options?: any) => string +): string { + if (range === 'custom' && customRange) { + const startStr = formatDateForDisplay(customRange.start); + const endStr = formatDateForDisplay(customRange.end); + return `${startStr} - ${endStr}`; + } + if (range === 1) { + return t ? t('monitor.time.today') : '今天'; + } + return t ? t('monitor.time.last_n_days', { n: range }) : `最近 ${range} 天`; +} + +function formatDateForDisplay(date: Date): string { + const month = date.getMonth() + 1; + const day = date.getDate(); + return `${month}/${day}`; +} diff --git a/src/components/monitor/index.ts b/src/components/monitor/index.ts new file mode 100644 index 0000000..aa6f704 --- /dev/null +++ b/src/components/monitor/index.ts @@ -0,0 +1,8 @@ +export { KpiCards } from './KpiCards'; +export { ModelDistributionChart } from './ModelDistributionChart'; +export { DailyTrendChart } from './DailyTrendChart'; +export { HourlyModelChart } from './HourlyModelChart'; +export { HourlyTokenChart } from './HourlyTokenChart'; +export { ChannelStats } from './ChannelStats'; +export { FailureAnalysis } from './FailureAnalysis'; +export { RequestLogs } from './RequestLogs'; diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx index 4427978..437610a 100644 --- a/src/components/ui/Card.tsx +++ b/src/components/ui/Card.tsx @@ -2,16 +2,20 @@ import type { PropsWithChildren, ReactNode } from 'react'; interface CardProps { title?: ReactNode; + subtitle?: ReactNode; extra?: ReactNode; className?: string; } -export function Card({ title, extra, children, className }: PropsWithChildren) { +export function Card({ title, subtitle, extra, children, className }: PropsWithChildren) { return (
{(title || extra) && (
-
{title}
+
+
{title}
+ {subtitle &&
{subtitle}
} +
{extra}
)} diff --git a/src/components/ui/icons.tsx b/src/components/ui/icons.tsx index de658a8..4e2eba8 100644 --- a/src/components/ui/icons.tsx +++ b/src/components/ui/icons.tsx @@ -314,3 +314,11 @@ export function IconLayoutDashboard({ size = 20, ...props }: IconProps) { ); } + +export function IconActivity({ size = 20, ...props }: IconProps) { + return ( + + + + ); +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ee756c7..05d89b8 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -9,3 +9,5 @@ export { useInterval } from './useInterval'; export { useMediaQuery } from './useMediaQuery'; export { usePagination } from './usePagination'; export { useHeaderRefresh } from './useHeaderRefresh'; +export { useDisableModel } from './useDisableModel'; +export type { UseDisableModelOptions, UseDisableModelReturn } from './useDisableModel'; diff --git a/src/hooks/useDisableModel.ts b/src/hooks/useDisableModel.ts new file mode 100644 index 0000000..06edaa9 --- /dev/null +++ b/src/hooks/useDisableModel.ts @@ -0,0 +1,164 @@ +/** + * 禁用模型 Hook + * 封装禁用模型的状态管理和业务逻辑 + */ + +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { providersApi } from '@/services/api'; +import { useDisabledModelsStore } from '@/stores'; +import { + resolveProvider, + createDisableState, + type DisableState, +} from '@/utils/monitor'; +import type { OpenAIProviderConfig } from '@/types'; + +// 不支持禁用的渠道类型 +const UNSUPPORTED_PROVIDERS = ['claude', 'gemini', 'codex']; + +export interface UseDisableModelOptions { + providerMap: Record; + providerModels?: Record>; +} + +export interface UseDisableModelReturn { + /** 当前禁用状态 */ + disableState: DisableState | null; + /** 是否正在禁用中 */ + disabling: boolean; + /** 开始禁用流程 */ + handleDisableClick: (source: string, model: string) => void; + /** 确认禁用(需要点击3次) */ + handleConfirmDisable: () => Promise; + /** 取消禁用 */ + handleCancelDisable: () => void; + /** 检查模型是否已禁用 */ + isModelDisabled: (source: string, model: string) => boolean; +} + +/** + * 禁用模型 Hook + * @param options 配置选项 + * @returns 禁用模型相关的状态和方法 + */ +export function useDisableModel(options: UseDisableModelOptions): UseDisableModelReturn { + const { providerMap, providerModels } = options; + const { t } = useTranslation(); + + // 使用全局 store 管理禁用状态 + const { addDisabledModel, isDisabled } = useDisabledModelsStore(); + + const [disableState, setDisableState] = useState(null); + const [disabling, setDisabling] = useState(false); + + // 开始禁用流程 + const handleDisableClick = useCallback((source: string, model: string) => { + setDisableState(createDisableState(source, model, providerMap)); + }, [providerMap]); + + // 确认禁用(需要点击3次) + const handleConfirmDisable = useCallback(async () => { + if (!disableState) return; + + // 前两次点击只增加步骤 + if (disableState.step < 3) { + setDisableState({ + ...disableState, + step: disableState.step + 1, + }); + return; + } + + // 第3次点击,执行禁用 + setDisabling(true); + try { + const providerName = resolveProvider(disableState.source, providerMap); + if (!providerName) { + throw new Error(t('monitor.logs.disable_error_no_provider')); + } + + // 检查是否为不支持的渠道类型 + const lowerName = providerName.toLowerCase(); + if (UNSUPPORTED_PROVIDERS.includes(lowerName)) { + throw new Error(t('monitor.logs.disable_not_supported', { provider: providerName })); + } + + // 获取当前配置 + const providers = await providersApi.getOpenAIProviders(); + const targetProvider = providers.find( + (p) => p.name && p.name.toLowerCase() === providerName.toLowerCase() + ); + + if (!targetProvider) { + throw new Error(t('monitor.logs.disable_error_provider_not_found', { provider: providerName })); + } + + const originalModels = targetProvider.models || []; + const modelAlias = disableState.model; + + // 过滤掉匹配的模型 + const filteredModels = originalModels.filter( + (m) => m.alias !== modelAlias && m.name !== modelAlias + ); + + // 只有当模型确实被过滤掉时才调用 API + if (filteredModels.length < originalModels.length) { + await providersApi.patchOpenAIProviderByName(targetProvider.name, { + models: filteredModels, + } as Partial); + } + + // 标记为已禁用(全局状态) + addDisabledModel(disableState.source, disableState.model); + setDisableState(null); + } catch (err) { + console.error('禁用模型失败:', err); + alert(err instanceof Error ? err.message : t('monitor.logs.disable_error')); + } finally { + setDisabling(false); + } + }, [disableState, providerMap, t, addDisabledModel]); + + // 取消禁用 + const handleCancelDisable = useCallback(() => { + setDisableState(null); + }, []); + + // 检查模型是否已禁用 + const isModelDisabled = useCallback((source: string, model: string): boolean => { + // 首先检查全局状态中是否已禁用 + if (isDisabled(source, model)) { + return true; + } + + // 如果提供了 providerModels,检查配置中是否已移除 + if (providerModels) { + if (!source || !model) return false; + + // 首先尝试完全匹配 + if (providerModels[source]) { + return !providerModels[source].has(model); + } + + // 然后尝试前缀匹配 + const entries = Object.entries(providerModels); + for (const [key, modelSet] of entries) { + if (source.startsWith(key) || key.startsWith(source)) { + return !modelSet.has(model); + } + } + } + + return false; + }, [isDisabled, providerModels]); + + return { + disableState, + disabling, + handleDisableClick, + handleConfirmDisable, + handleCancelDisable, + isModelDisabled, + }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 86b2684..07ece72 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -95,7 +95,8 @@ "usage_stats": "Usage Statistics", "config_management": "Config Management", "logs": "Logs Viewer", - "system_info": "Management Center Info" + "system_info": "Management Center Info", + "monitor": "Monitor Center" }, "dashboard": { "title": "Dashboard", @@ -906,5 +907,160 @@ "build_date": "Build Time", "version": "Management UI Version", "author": "Author" + }, + "monitor": { + "title": "Monitor Center", + "time_range": "Time Range", + "today": "Today", + "last_n_days": "Last {{n}} Days", + "api_filter": "API Query", + "api_filter_placeholder": "Query API data", + "apply": "Apply", + "no_data": "No data available", + "requests": "Requests", + "kpi": { + "requests": "Requests", + "success": "Success", + "failed": "Failed", + "rate": "Success Rate", + "tokens": "Tokens", + "input": "Input", + "output": "Output", + "reasoning": "Reasoning", + "cached": "Cached", + "avg_tpm": "Avg TPM", + "avg_rpm": "Avg RPM", + "avg_rpd": "Avg RPD", + "tokens_per_minute": "Tokens per minute", + "requests_per_minute": "Requests per minute", + "requests_per_day": "Requests per day" + }, + "distribution": { + "title": "Model Usage Distribution", + "by_requests": "By Requests", + "by_tokens": "By Tokens", + "requests": "Requests", + "tokens": "Tokens", + "request_share": "Request Share", + "token_share": "Token Share" + }, + "trend": { + "title": "Daily Usage Trend", + "subtitle": "Requests and Token usage trend", + "requests": "Requests", + "input_tokens": "Input Tokens", + "output_tokens": "Output Tokens", + "reasoning_tokens": "Reasoning Tokens", + "cached_tokens": "Cached Tokens" + }, + "hourly": { + "last_6h": "Last 6 Hours", + "last_12h": "Last 12 Hours", + "last_24h": "Last 24 Hours", + "all": "All", + "requests": "Requests", + "success_rate": "Success Rate" + }, + "hourly_model": { + "title": "Hourly Model Request Distribution", + "models": "Models" + }, + "hourly_token": { + "title": "Hourly Token Usage", + "subtitle": "By Hour", + "total": "Total Tokens", + "input": "Input", + "output": "Output", + "reasoning": "Reasoning", + "cached": "Cached" + }, + "channel": { + "title": "Channel Statistics", + "subtitle": "Grouped by source channel", + "click_hint": "Click row to expand model details", + "all_channels": "All Channels", + "all_models": "All Models", + "all_status": "All Status", + "only_success": "Success Only", + "only_failed": "Failed Only", + "header_name": "Channel", + "header_count": "Requests", + "header_rate": "Success Rate", + "header_recent": "Recent Status", + "header_time": "Last Request", + "model_details": "Model Details", + "model": "Model", + "success": "Success", + "failed": "Failed" + }, + "time": { + "today": "Today", + "last_n_days": "{{n}} Days", + "custom": "Custom", + "to": "to", + "apply": "Apply" + }, + "failure": { + "title": "Failure Analysis", + "subtitle": "Locate issues by source channel", + "click_hint": "Click row to expand details", + "no_failures": "No failure data", + "header_name": "Channel", + "header_count": "Failures", + "header_time": "Last Failure", + "header_models": "Top Failed Models", + "all_failed_models": "All Failed Models" + }, + "logs": { + "title": "Request Logs", + "total_count": "{{count}} records", + "sort_hint": "Auto sorted by time desc", + "scroll_hint": "Scroll to browse all data", + "virtual_scroll_info": "Showing {{visible}} rows, {{total}} records total", + "all_apis": "All APIs", + "all_models": "All Models", + "all_sources": "All Sources", + "all_status": "All Status", + "success": "Success", + "failed": "Failed", + "last_update": "Last Update", + "manual_refresh": "Manual Refresh", + "refresh_5s": "5s Refresh", + "refresh_10s": "10s Refresh", + "refresh_15s": "15s Refresh", + "refresh_30s": "30s Refresh", + "refresh_60s": "60s Refresh", + "refresh_in_seconds": "Refresh in {{seconds}}s", + "refreshing": "Refreshing...", + "header_auth": "Auth Index", + "header_api": "API", + "header_model": "Model", + "header_source": "Source", + "header_status": "Status", + "header_recent": "Recent Status", + "header_rate": "Success Rate", + "header_count": "Requests", + "header_input": "Input", + "header_output": "Output", + "header_total": "Total Tokens", + "header_time": "Time", + "header_actions": "Actions", + "showing": "Showing {{start}}-{{end}} of {{total}}", + "page_info": "Page {{current}}/{{total}}", + "first_page": "First", + "prev_page": "Prev", + "next_page": "Next", + "last_page": "Last", + "disable": "Disable", + "disable_model": "Disable this model", + "disabled": "Disabled", + "removed": "Removed", + "disabling": "Disabling...", + "disable_confirm_title": "Confirm Disable Model", + "disable_error": "Disable failed", + "disable_error_no_provider": "Cannot identify provider", + "disable_error_provider_not_found": "Provider config not found: {{provider}}", + "disable_not_supported": "{{provider}} provider does not support disable operation" + } } } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f126768..87a8c48 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -95,7 +95,8 @@ "usage_stats": "使用统计", "config_management": "配置管理", "logs": "日志查看", - "system_info": "中心信息" + "system_info": "中心信息", + "monitor": "监控中心" }, "dashboard": { "title": "仪表盘", @@ -906,5 +907,160 @@ "build_date": "构建时间", "version": "管理中心版本", "author": "作者" + }, + "monitor": { + "title": "监控中心", + "time_range": "时间范围", + "today": "今天", + "last_n_days": "最近 {{n}} 天", + "api_filter": "API 查询", + "api_filter_placeholder": "查询对应 API 数据", + "apply": "查看", + "no_data": "暂无数据", + "requests": "请求", + "kpi": { + "requests": "请求数", + "success": "成功", + "failed": "失败", + "rate": "成功率", + "tokens": "Tokens", + "input": "输入", + "output": "输出", + "reasoning": "思考", + "cached": "缓存", + "avg_tpm": "平均 TPM", + "avg_rpm": "平均 RPM", + "avg_rpd": "日均 RPD", + "tokens_per_minute": "每分钟 Token", + "requests_per_minute": "每分钟请求", + "requests_per_day": "每日请求数" + }, + "distribution": { + "title": "模型用量分布", + "by_requests": "按请求数", + "by_tokens": "按 Token 数", + "requests": "请求", + "tokens": "Token", + "request_share": "请求占比", + "token_share": "Token 占比" + }, + "trend": { + "title": "每日用量趋势", + "subtitle": "请求数与 Token 用量趋势", + "requests": "请求数", + "input_tokens": "输入 Token", + "output_tokens": "输出 Token", + "reasoning_tokens": "思考 Token", + "cached_tokens": "缓存 Token" + }, + "hourly": { + "last_6h": "最近 6 小时", + "last_12h": "最近 12 小时", + "last_24h": "最近 24 小时", + "all": "全部", + "requests": "请求数", + "success_rate": "成功率" + }, + "hourly_model": { + "title": "每小时模型请求分布", + "models": "模型" + }, + "hourly_token": { + "title": "每小时 Token 用量", + "subtitle": "按小时显示", + "total": "总 Token", + "input": "输入", + "output": "输出", + "reasoning": "思考", + "cached": "缓存" + }, + "channel": { + "title": "渠道统计", + "subtitle": "按来源渠道分类", + "click_hint": "单击行展开模型详情", + "all_channels": "全部渠道", + "all_models": "全部模型", + "all_status": "全部状态", + "only_success": "仅成功", + "only_failed": "仅失败", + "header_name": "渠道", + "header_count": "请求数", + "header_rate": "成功率", + "header_recent": "最近请求状态", + "header_time": "最近请求时间", + "model_details": "模型详情", + "model": "模型", + "success": "成功", + "failed": "失败" + }, + "time": { + "today": "今天", + "last_n_days": "{{n}} 天", + "custom": "自定义", + "to": "至", + "apply": "应用" + }, + "failure": { + "title": "失败来源分析", + "subtitle": "从来源渠道定位异常", + "click_hint": "单击行展开详情", + "no_failures": "暂无失败数据", + "header_name": "渠道", + "header_count": "失败数", + "header_time": "最近失败", + "header_models": "主要失败模型", + "all_failed_models": "所有失败模型" + }, + "logs": { + "title": "请求日志", + "total_count": "共 {{count}} 条", + "sort_hint": "自动按时间倒序", + "scroll_hint": "滚动浏览全部数据", + "virtual_scroll_info": "当前显示 {{visible}} 行,共 {{total}} 条记录", + "all_apis": "全部 API", + "all_models": "全部模型", + "all_sources": "全部来源渠道", + "all_status": "全部状态", + "success": "成功", + "failed": "失败", + "last_update": "最后更新", + "manual_refresh": "手动刷新", + "refresh_5s": "5秒刷新", + "refresh_10s": "10秒刷新", + "refresh_15s": "15秒刷新", + "refresh_30s": "30秒刷新", + "refresh_60s": "60秒刷新", + "refresh_in_seconds": "{{seconds}}秒后刷新", + "refreshing": "刷新中...", + "header_auth": "认证索引", + "header_api": "API", + "header_model": "模型", + "header_source": "请求渠道", + "header_status": "状态", + "header_recent": "最近请求状态", + "header_rate": "成功率", + "header_count": "请求数", + "header_input": "输入", + "header_output": "输出", + "header_total": "总 Token", + "header_time": "时间", + "header_actions": "操作", + "showing": "显示 {{start}}-{{end}} 条,共 {{total}} 条", + "page_info": "第 {{current}}/{{total}} 页", + "first_page": "首页", + "prev_page": "上一页", + "next_page": "下一页", + "last_page": "末页", + "disable": "禁用", + "disable_model": "禁用此模型", + "disabled": "已禁用", + "removed": "已移除", + "disabling": "禁用中...", + "disable_confirm_title": "确认禁用模型", + "disable_error": "禁用失败", + "disable_error_no_provider": "无法识别渠道", + "disable_error_provider_not_found": "未找到渠道配置:{{provider}}", + "disable_not_supported": "{{provider}} 渠道不支持禁用操作" + } } } diff --git a/src/pages/MonitorPage.module.scss b/src/pages/MonitorPage.module.scss new file mode 100644 index 0000000..a3db997 --- /dev/null +++ b/src/pages/MonitorPage.module.scss @@ -0,0 +1,1082 @@ +@use '../styles/variables' as *; +@use '../styles/mixins' as *; + +.container { + width: 100%; + min-height: 100%; + display: flex; + flex-direction: column; + gap: 20px; + position: relative; + + @include mobile { + gap: 16px; + } +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px; + + @include mobile { + flex-direction: column; + align-items: flex-start; + } +} + +.headerActions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.pageTitle { + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + margin: 0; + + @include mobile { + font-size: 22px; + } +} + +.errorBox { + padding: 10px; + background-color: rgba(239, 68, 68, 0.1); + border: 1px solid var(--error-color); + border-radius: $radius-sm; + color: var(--error-color); + font-size: 12px; +} + +.loadingOverlay { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + background: rgba(243, 244, 246, 0.75); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +:global([data-theme='dark']) .loadingOverlay { + background: rgba(25, 25, 25, 0.72); +} + +.loadingOverlayContent { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-radius: $radius-lg; + border: 1px solid var(--border-color); + background: var(--bg-primary); + box-shadow: var(--shadow-lg); +} + +.loadingOverlaySpinner { + border-color: rgba(59, 130, 246, 0.25); + border-top-color: var(--primary-color); + box-shadow: 0 0 10px rgba(59, 130, 246, 0.25); +} + +.loadingOverlayText { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +// 过滤器区域 +.filters { + display: flex; + flex-wrap: wrap; + gap: 16px; + padding: 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: $radius-lg; + align-items: center; +} + +.filterGroup { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.filterLabel { + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; + white-space: nowrap; +} + +.timeButtons { + display: flex; + gap: 4px; + background: var(--bg-tertiary); + border-radius: $radius-full; + padding: 4px; +} + +.timeButton { + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + padding: 8px 16px; + border-radius: $radius-full; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + + &:hover { + color: var(--text-primary); + } + + &.active { + background: var(--primary-color); + color: #fff; + font-weight: 600; + } +} + +.filterInput { + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + min-width: 160px; + + &::placeholder { + color: var(--text-tertiary); + } + + &:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + } +} + +// KPI 卡片网格 +.kpiGrid { + display: grid; + gap: 14px; + grid-template-columns: repeat(5, 1fr); + + @include tablet { + grid-template-columns: repeat(3, 1fr); + } + + @include mobile { + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } +} + +.kpiCard { + --accent: #3b82f6; + --accent-soft: rgba(59, 130, 246, 0.18); + --accent-border: rgba(59, 130, 246, 0.35); + + position: relative; + padding: 18px; + background: + radial-gradient(120% 140% at 12% 0%, var(--accent-soft) 0%, rgba(0, 0, 0, 0) 62%), + linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)), + var(--bg-primary); + border-radius: $radius-lg; + border: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 8px; + min-height: 140px; + box-shadow: var(--shadow-lg); + transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast; + overflow: hidden; + + @include mobile { + padding: 14px; + min-height: 110px; + gap: 6px; + } + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 3px; + width: 100%; + background: linear-gradient(90deg, var(--accent), rgba(0, 0, 0, 0)); + opacity: 0.95; + } + + &:hover { + transform: translateY(-2px); + border-color: var(--accent-border); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.22); + } + + &.green { + --accent: #22c55e; + --accent-soft: rgba(34, 197, 94, 0.18); + --accent-border: rgba(34, 197, 94, 0.35); + } + + &.purple { + --accent: #8b5cf6; + --accent-soft: rgba(139, 92, 246, 0.18); + --accent-border: rgba(139, 92, 246, 0.35); + } + + &.orange { + --accent: #f97316; + --accent-soft: rgba(249, 115, 22, 0.18); + --accent-border: rgba(249, 115, 22, 0.35); + } + + &.cyan { + --accent: #06b6d4; + --accent-soft: rgba(6, 182, 212, 0.18); + --accent-border: rgba(6, 182, 212, 0.35); + } +} + +.kpiTitle { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} + +.kpiLabel { + font-size: 12px; + color: var(--text-tertiary); + font-weight: 600; + letter-spacing: 0.02em; + + @include mobile { + font-size: 11px; + } +} + +.kpiTag { + font-size: 11px; + padding: 2px 8px; + border-radius: $radius-full; + background: var(--bg-tertiary); + color: var(--text-secondary); + + @include mobile { + font-size: 10px; + padding: 2px 6px; + } +} + +.kpiValue { + font-size: 28px; + font-weight: 800; + color: var(--text-primary); + line-height: 1.2; + font-variant-numeric: tabular-nums; + + @include mobile { + font-size: 22px; + } +} + +.kpiMeta { + font-size: 12px; + color: var(--text-secondary); + display: flex; + flex-wrap: wrap; + gap: 6px; + + @include mobile { + font-size: 10px; + gap: 4px; + } +} + +.kpiSuccess { + color: var(--success-color, #22c55e); +} + +.kpiFailure { + color: var(--danger-color, #ef4444); +} + +// 图表网格 +.chartsGrid { + display: grid; + gap: 20px; + grid-template-columns: 1fr 2fr; + + @include tablet { + grid-template-columns: 1fr; + } + + @include mobile { + grid-template-columns: 1fr; + gap: 16px; + } +} + +// 统计表格网格 +.statsGrid { + display: grid; + gap: 20px; + grid-template-columns: repeat(2, 1fr); + + @include tablet { + grid-template-columns: 1fr; + } + + @include mobile { + gap: 16px; + } +} + +// 图表卡片 +.chartCard { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: $radius-lg; + padding: 20px; + box-shadow: var(--shadow-lg); + + @include mobile { + padding: 14px; + border-radius: $radius-md; + } +} + +.chartHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + gap: 12px; + flex-wrap: wrap; + + @include mobile { + flex-direction: column; + gap: 10px; + margin-bottom: 12px; + } +} + +.chartTitle { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0; + + @include mobile { + font-size: 14px; + } +} + +.chartSubtitle { + font-size: 12px; + color: var(--text-secondary); + margin-top: 4px; + + @include mobile { + font-size: 11px; + } +} + +.chartControls { + display: flex; + gap: 4px; + background: var(--bg-tertiary); + border-radius: $radius-full; + padding: 3px; + flex-wrap: wrap; + + @include mobile { + width: 100%; + justify-content: center; + } +} + +.chartControlBtn { + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + padding: 6px 12px; + border-radius: $radius-full; + cursor: pointer; + transition: all 0.2s ease; + + @include mobile { + font-size: 11px; + padding: 5px 10px; + } + + &:hover { + color: var(--text-primary); + } + + &.active { + background: var(--primary-color); + color: #fff; + font-weight: 600; + } +} + +.chartContent { + position: relative; + min-height: 280px; + + @include mobile { + min-height: 220px; + } +} + +.chartEmpty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + font-size: 13px; +} + +// 分布图特殊布局 +.distributionContent { + display: flex; + gap: 16px; + align-items: flex-start; + + @include mobile { + flex-direction: column; + align-items: center; + } +} + +.donutWrapper { + width: 160px; + height: 160px; + flex-shrink: 0; + position: relative; + + @include mobile { + width: 140px; + height: 140px; + } +} + +.donutCenter { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; +} + +.donutLabel { + font-size: 11px; + color: var(--text-tertiary); +} + +.legendList { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.legendItem { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + font-size: 12px; + color: var(--text-secondary); +} + +.legendDot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.legendName { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.legendValue { + font-weight: 600; + color: var(--text-primary); +} + +// 表格样式 +.tableWrapper { + overflow-x: auto; + max-height: 400px; + -webkit-overflow-scrolling: touch; + + @include mobile { + max-height: 350px; + } +} + +// 虚拟滚动容器 +.virtualScrollContainer { + overflow: auto; + -webkit-overflow-scrolling: touch; + + // 自定义滚动条 + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + &::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; + + &:hover { + background: var(--text-tertiary); + } + } +} + +// 固定表头容器 +.stickyHeader { + position: sticky; + top: 0; + z-index: 10; + background-color: var(--bg-secondary); + overflow-x: auto; + + // 隐藏滚动条但保持可滚动 + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } +} + +// 虚拟滚动表格固定列宽 +.virtualTable { + table-layout: fixed; + min-width: 1200px; + + th, td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + // 认证索引 + th:nth-child(1), td:nth-child(1) { width: 100px; } + // API + th:nth-child(2), td:nth-child(2) { width: 110px; } + // 模型 + th:nth-child(3), td:nth-child(3) { width: 120px; } + // 请求渠道 + th:nth-child(4), td:nth-child(4) { width: 140px; } + // 状态 + th:nth-child(5), td:nth-child(5) { width: 60px; } + // 最近请求状态 + th:nth-child(6), td:nth-child(6) { width: 100px; } + // 成功率 + th:nth-child(7), td:nth-child(7) { width: 70px; } + // 请求数 + th:nth-child(8), td:nth-child(8) { width: 70px; } + // 输入 + th:nth-child(9), td:nth-child(9) { width: 60px; } + // 输出 + th:nth-child(10), td:nth-child(10) { width: 60px; } + // 总 Token + th:nth-child(11), td:nth-child(11) { width: 70px; } + // 时间 + th:nth-child(12), td:nth-child(12) { width: 150px; } + // 操作 + th:nth-child(13), td:nth-child(13) { width: 60px; } +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + min-width: 600px; + + @include mobile { + font-size: 11px; + min-width: 500px; + } + + th, td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--border-color); + + @include mobile { + padding: 8px 10px; + } + } + + th { + position: sticky; + top: 0; + font-weight: 600; + color: var(--text-tertiary); + background-color: var(--bg-secondary); + white-space: nowrap; + z-index: 1; + } + + td { + color: var(--text-primary); + } + + tbody tr { + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--bg-tertiary); + } + + &.expandable { + cursor: pointer; + } + } + + // 移动端固定首列 + @include mobile { + th:first-child, + td:first-child { + position: sticky; + left: 0; + z-index: 2; + background-color: var(--bg-secondary); + min-width: 120px; + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 8px; + background: linear-gradient(to right, transparent, var(--bg-secondary)); + pointer-events: none; + } + } + + td:first-child { + background-color: var(--bg-primary); + } + + tbody tr:hover td:first-child { + background-color: var(--bg-tertiary); + } + + th:first-child { + z-index: 3; + } + } +} + +// 状态条 +.statusBars { + display: flex; + gap: 2px; +} + +.statusBar { + width: 4px; + height: 20px; + border-radius: 2px; + + &.success { + background: var(--success-color, #22c55e); + } + + &.failure { + background: var(--danger-color, #ef4444); + } +} + +// 状态标签 +.statusPill { + display: inline-block; + padding: 4px 10px; + border-radius: $radius-full; + font-size: 11px; + font-weight: 600; + + &.success { + background: rgba(34, 197, 94, 0.15); + color: var(--success-color, #22c55e); + } + + &.failed { + background: rgba(239, 68, 68, 0.15); + color: var(--danger-color, #ef4444); + } +} + +// 成功率颜色 +.rateHigh { + color: var(--success-color, #22c55e) !important; + font-weight: 600; +} + +.rateMedium { + color: var(--warning-color, #f59e0b) !important; + font-weight: 600; +} + +.rateLow { + color: var(--danger-color, #ef4444) !important; + font-weight: 600; +} + +// 展开详情 +.expandDetail { + padding: 12px 16px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-color); + + @include mobile { + padding: 10px 12px; + } + + // 嵌套表格支持横向滚动 + .table { + min-width: 700px; + + @include mobile { + min-width: 600px; + } + } +} + +// 展开详情内的表格包装器 +.expandTableWrapper { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin: 0 -12px; + padding: 0 12px; + + @include mobile { + margin: 0 -10px; + padding: 0 10px; + } +} + +.expandIcon { + font-size: 10px; + color: var(--text-tertiary); + transition: transform 0.2s ease; + + &.expanded { + transform: rotate(180deg); + } +} + +// 日志筛选 +.logFilters { + display: flex; + gap: 10px; + padding: 12px 0; + flex-wrap: wrap; + align-items: center; + + @include mobile { + gap: 8px; + padding: 10px 0; + } +} + +.logSelect { + padding: 6px 10px; + border-radius: $radius-md; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 12px; + min-width: 120px; + cursor: pointer; + + @include mobile { + font-size: 11px; + min-width: 100px; + padding: 5px 8px; + flex: 1 1 calc(50% - 4px); + } + + &:focus { + outline: none; + border-color: var(--primary-color); + } +} + +.logLastUpdate { + margin-left: auto; + font-size: 12px; + color: var(--text-tertiary); + + @include mobile { + width: 100%; + margin-left: 0; + text-align: center; + font-size: 11px; + } +} + +// 分页 +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 6px; + padding: 16px 0; + flex-wrap: wrap; + + @include mobile { + gap: 4px; + padding: 12px 0; + } +} + +.pageBtn { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + padding: 6px 12px; + border-radius: $radius-md; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; + + @include mobile { + padding: 5px 10px; + font-size: 12px; + } + + &:hover:not(:disabled) { + border-color: var(--primary-color); + color: var(--text-primary); + } + + &.active { + background: var(--primary-color); + border-color: var(--primary-color); + color: #fff; + font-weight: 600; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +// 空状态 +.emptyState { + text-align: center; + color: var(--text-tertiary); + font-size: 13px; + padding: 40px 0; +} + +// 详情按钮 +.detailBtn { + padding: 4px 10px; + border-radius: $radius-md; + border: 1px solid rgba(239, 68, 68, 0.4); + background: rgba(239, 68, 68, 0.1); + color: var(--danger-color, #ef4444); + font-size: 11px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: rgba(239, 68, 68, 0.2); + border-color: var(--danger-color, #ef4444); + } +} + +// 禁用按钮 +.disableBtn { + padding: 4px 10px; + border-radius: $radius-md; + border: 1px solid rgba(239, 68, 68, 0.4); + background: rgba(239, 68, 68, 0.1); + color: var(--danger-color, #ef4444); + font-size: 11px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + + &:hover { + background: rgba(239, 68, 68, 0.2); + border-color: var(--danger-color, #ef4444); + } +} + +// 已禁用标签 +.disabledLabel { + display: inline-block; + padding: 4px 10px; + border-radius: $radius-md; + border: 1px solid var(--border-color); + background: var(--bg-tertiary); + color: var(--text-tertiary); + font-size: 11px; + white-space: nowrap; +} + +// 渠道名称显示 +.channelName { + display: inline; +} + +.channelSecret { + color: var(--text-tertiary); + font-size: 0.9em; +} + +// 已禁用模型行 +.modelDisabled { + opacity: 0.4; +} + +// 失败模型标签 +.failureModelTag { + display: inline-block; + padding: 2px 8px; + margin-right: 4px; + border-radius: $radius-md; + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--danger-color, #ef4444); + font-size: 11px; + + // 已移除模型的弱化样式 + &.modelDisabled { + opacity: 0.55; + background: rgba(128, 128, 128, 0.15); + border: 1px dashed rgba(128, 128, 128, 0.5); + color: var(--text-secondary); + } +} + +// 更多模型提示 +.moreModelsHint { + color: var(--text-tertiary); + font-size: 11px; + margin-left: 4px; +} + +// 展开详情头部 +.expandHeader { + margin-bottom: 8px; + color: var(--text-secondary); +} + +// 时间范围选择器 +.timeRangeSelector { + display: flex; + flex-direction: column; + gap: 8px; +} + +// 自定义日期选择器 +.customDatePicker { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: $radius-lg; + flex-wrap: wrap; + + @include mobile { + padding: 10px 12px; + gap: 6px; + } +} + +.dateInput { + padding: 8px 12px; + border-radius: $radius-md; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + + @include mobile { + padding: 6px 10px; + font-size: 12px; + flex: 1; + min-width: 0; + } + + &:focus { + outline: none; + border-color: var(--primary-color); + } +} + +.dateSeparator { + color: var(--text-tertiary); + font-size: 13px; +} + +.dateApplyBtn { + padding: 8px 16px; + border-radius: $radius-md; + border: 1px solid var(--primary-color); + background: var(--primary-color); + color: white; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; + + @include mobile { + padding: 6px 14px; + font-size: 12px; + width: 100%; + margin-top: 4px; + } + + &:hover { + opacity: 0.9; + } +} diff --git a/src/pages/MonitorPage.tsx b/src/pages/MonitorPage.tsx new file mode 100644 index 0000000..c48c8d6 --- /dev/null +++ b/src/pages/MonitorPage.tsx @@ -0,0 +1,301 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + BarController, + LineController, + ArcElement, + Title, + Tooltip, + Legend, + Filler +} from 'chart.js'; +import { Button } from '@/components/ui/Button'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; +import { useThemeStore } from '@/stores'; +import { usageApi, providersApi } from '@/services/api'; +import { KpiCards } from '@/components/monitor/KpiCards'; +import { ModelDistributionChart } from '@/components/monitor/ModelDistributionChart'; +import { DailyTrendChart } from '@/components/monitor/DailyTrendChart'; +import { HourlyModelChart } from '@/components/monitor/HourlyModelChart'; +import { HourlyTokenChart } from '@/components/monitor/HourlyTokenChart'; +import { ChannelStats } from '@/components/monitor/ChannelStats'; +import { FailureAnalysis } from '@/components/monitor/FailureAnalysis'; +import { RequestLogs } from '@/components/monitor/RequestLogs'; +import styles from './MonitorPage.module.scss'; + +// 注册 Chart.js 组件 +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + BarController, + LineController, + ArcElement, + Title, + Tooltip, + Legend, + Filler +); + +// 时间范围选项 +type TimeRange = 1 | 7 | 14 | 30; + +export interface UsageDetail { + timestamp: string; + failed: boolean; + source: string; + auth_index: string; + tokens: { + input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + cached_tokens: number; + total_tokens: number; + }; +} + +export interface UsageData { + apis: Record; + }>; +} + +export function MonitorPage() { + const { t } = useTranslation(); + const resolvedTheme = useThemeStore((state) => state.resolvedTheme); + const isDark = resolvedTheme === 'dark'; + + // 状态 + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [usageData, setUsageData] = useState(null); + const [timeRange, setTimeRange] = useState(7); + const [apiFilter, setApiFilter] = useState(''); + const [providerMap, setProviderMap] = useState>({}); + const [providerModels, setProviderModels] = useState>>({}); + + // 加载渠道名称映射(参照原始 Web UI 的映射方式) + const loadProviderMap = useCallback(async () => { + try { + const providers = await providersApi.getOpenAIProviders(); + const map: Record = {}; + const modelsMap: Record> = {}; + providers.forEach((provider) => { + // 使用 X-Provider header 或 name 作为渠道名称 + const providerName = provider.headers?.['X-Provider'] || provider.name || 'unknown'; + // 存储每个渠道的可用模型(使用 alias 和 name 作为标识) + const modelSet = new Set(); + (provider.models || []).forEach((m) => { + if (m.alias) modelSet.add(m.alias); + if (m.name) modelSet.add(m.name); + }); + // 遍历 api-key-entries,将每个 api-key 映射到 provider 名称和模型集合 + const apiKeyEntries = provider.apiKeyEntries || []; + apiKeyEntries.forEach((entry) => { + const apiKey = entry.apiKey; + if (apiKey) { + map[apiKey] = providerName; + modelsMap[apiKey] = modelSet; + } + }); + // 也用 name 作为 key(备用) + if (provider.name) { + map[provider.name] = providerName; + modelsMap[provider.name] = modelSet; + } + }); + setProviderMap(map); + setProviderModels(modelsMap); + } catch (err) { + console.warn('Monitor: Failed to load provider map:', err); + } + }, []); + + // 加载数据 + const loadData = useCallback(async () => { + setLoading(true); + setError(null); + try { + // 并行加载使用数据和渠道映射 + const [response] = await Promise.all([ + usageApi.getUsage(), + loadProviderMap() + ]); + // API 返回的数据可能在 response.usage 或直接在 response 中 + const data = response?.usage ?? response; + setUsageData(data as UsageData); + } catch (err) { + const message = err instanceof Error ? err.message : t('common.unknown_error'); + console.error('Monitor: Error loading data:', err); + setError(message); + } finally { + setLoading(false); + } + }, [t, loadProviderMap]); + + // 初始加载 + useEffect(() => { + loadData(); + }, [loadData]); + + // 响应头部刷新 + useHeaderRefresh(loadData); + + // 根据时间范围过滤数据 + const filteredData = useMemo(() => { + if (!usageData?.apis) { + return null; + } + + const now = new Date(); + const cutoffTime = new Date(now.getTime() - timeRange * 24 * 60 * 60 * 1000); + + const filtered: UsageData = { apis: {} }; + + Object.entries(usageData.apis).forEach(([apiKey, apiData]) => { + // 如果有 API 过滤器,检查是否匹配 + if (apiFilter && !apiKey.toLowerCase().includes(apiFilter.toLowerCase())) { + return; + } + + // 检查 apiData 是否有 models 属性 + if (!apiData?.models) { + return; + } + + const filteredModels: Record = {}; + + Object.entries(apiData.models).forEach(([modelName, modelData]) => { + // 检查 modelData 是否有 details 属性 + if (!modelData?.details || !Array.isArray(modelData.details)) { + return; + } + + const filteredDetails = modelData.details.filter((detail) => { + const timestamp = new Date(detail.timestamp); + return timestamp >= cutoffTime; + }); + + if (filteredDetails.length > 0) { + filteredModels[modelName] = { details: filteredDetails }; + } + }); + + if (Object.keys(filteredModels).length > 0) { + filtered.apis[apiKey] = { models: filteredModels }; + } + }); + + return filtered; + }, [usageData, timeRange, apiFilter]); + + // 处理时间范围变化 + const handleTimeRangeChange = (range: TimeRange) => { + setTimeRange(range); + }; + + // 处理 API 过滤应用(触发数据刷新) + const handleApiFilterApply = () => { + loadData(); + }; + + return ( +
+ {loading && !usageData && ( +
+
+ + {t('common.loading')} +
+
+ )} + + {/* 页面标题 */} +
+

{t('monitor.title')}

+
+ +
+
+ + {/* 错误提示 */} + {error &&
{error}
} + + {/* 时间范围和 API 过滤 */} +
+
+ {t('monitor.time_range')} +
+ {([1, 7, 14, 30] as TimeRange[]).map((range) => ( + + ))} +
+
+
+ {t('monitor.api_filter')} + setApiFilter(e.target.value)} + /> + +
+
+ + {/* KPI 卡片 */} + + + {/* 图表区域 */} +
+ + +
+ + {/* 小时级图表 */} + + + + {/* 统计表格 */} +
+ + +
+ + {/* 请求日志 */} + +
+ ); +} diff --git a/src/router/MainRoutes.tsx b/src/router/MainRoutes.tsx index ee01d80..ca1081b 100644 --- a/src/router/MainRoutes.tsx +++ b/src/router/MainRoutes.tsx @@ -10,6 +10,7 @@ import { UsagePage } from '@/pages/UsagePage'; import { ConfigPage } from '@/pages/ConfigPage'; import { LogsPage } from '@/pages/LogsPage'; import { SystemPage } from '@/pages/SystemPage'; +import { MonitorPage } from '@/pages/MonitorPage'; const mainRoutes = [ { path: '/', element: }, @@ -24,6 +25,7 @@ const mainRoutes = [ { path: '/config', element: }, { path: '/logs', element: }, { path: '/system', element: }, + { path: '/monitor', element: }, { path: '*', element: }, ]; diff --git a/src/services/api/providers.ts b/src/services/api/providers.ts index 960852e..a1cd891 100644 --- a/src/services/api/providers.ts +++ b/src/services/api/providers.ts @@ -194,5 +194,14 @@ export const providersApi = { apiClient.patch('/openai-compatibility', { index, value: serializeOpenAIProvider(value) }), deleteOpenAIProvider: (name: string) => - apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`) + apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`), + + // 通过 name 更新 OpenAI 兼容提供商(用于禁用模型) + patchOpenAIProviderByName: (name: string, value: Partial) => { + const payload: Record = {}; + if (value.models !== undefined) { + payload.models = serializeModelAliases(value.models); + } + return apiClient.patch('/openai-compatibility', { name, value: payload }); + } }; diff --git a/src/stores/index.ts b/src/stores/index.ts index 7432fe0..79a098d 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -9,3 +9,4 @@ export { useAuthStore } from './useAuthStore'; export { useConfigStore } from './useConfigStore'; export { useModelsStore } from './useModelsStore'; export { useQuotaStore } from './useQuotaStore'; +export { useDisabledModelsStore } from './useDisabledModelsStore'; diff --git a/src/stores/useDisabledModelsStore.ts b/src/stores/useDisabledModelsStore.ts new file mode 100644 index 0000000..efc8904 --- /dev/null +++ b/src/stores/useDisabledModelsStore.ts @@ -0,0 +1,50 @@ +/** + * 禁用模型状态管理 + * 全局管理已禁用的模型,确保所有组件状态同步 + */ + +import { create } from 'zustand'; + +interface DisabledModelsState { + /** 已禁用的模型集合,格式:`${source}|||${model}` */ + disabledModels: Set; + /** 添加禁用模型 */ + addDisabledModel: (source: string, model: string) => void; + /** 移除禁用模型(恢复) */ + removeDisabledModel: (source: string, model: string) => void; + /** 检查模型是否已禁用 */ + isDisabled: (source: string, model: string) => boolean; + /** 清空所有禁用状态 */ + clearAll: () => void; +} + +export const useDisabledModelsStore = create()((set, get) => ({ + disabledModels: new Set(), + + addDisabledModel: (source, model) => { + const key = `${source}|||${model}`; + set((state) => { + const newSet = new Set(state.disabledModels); + newSet.add(key); + return { disabledModels: newSet }; + }); + }, + + removeDisabledModel: (source, model) => { + const key = `${source}|||${model}`; + set((state) => { + const newSet = new Set(state.disabledModels); + newSet.delete(key); + return { disabledModels: newSet }; + }); + }, + + isDisabled: (source, model) => { + const key = `${source}|||${model}`; + return get().disabledModels.has(key); + }, + + clearAll: () => { + set({ disabledModels: new Set() }); + }, +})); diff --git a/src/styles/components.scss b/src/styles/components.scss index 656de5d..c436eee 100644 --- a/src/styles/components.scss +++ b/src/styles/components.scss @@ -116,14 +116,42 @@ textarea { .card-header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; margin-bottom: $spacing-md; + gap: 12px; + flex-wrap: wrap; + + @media (max-width: 768px) { + flex-direction: column; + gap: 10px; + } + + .card-title-group { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + flex: 1; + } .title { font-size: 18px; font-weight: 700; color: var(--text-primary); + + @media (max-width: 768px) { + font-size: 16px; + } + } + + .subtitle { + font-size: 13px; + color: var(--text-secondary); + + @media (max-width: 768px) { + font-size: 12px; + } } } diff --git a/src/utils/monitor.ts b/src/utils/monitor.ts new file mode 100644 index 0000000..4162406 --- /dev/null +++ b/src/utils/monitor.ts @@ -0,0 +1,264 @@ +/** + * 监控中心公共工具函数 + */ + +import type { UsageData } from '@/pages/MonitorPage'; + +/** + * 日期范围接口 + */ +export interface DateRange { + start: Date; + end: Date; +} + +/** + * 禁用模型状态接口 + */ +export interface DisableState { + source: string; + model: string; + displayName: string; + step: number; +} + +/** + * 脱敏 API Key + * @param key API Key 字符串 + * @returns 脱敏后的字符串 + */ +export function maskSecret(key: string): string { + if (!key || key === '-' || key === 'unknown') return key || '-'; + if (key.length <= 8) { + return `${key.slice(0, 4)}***`; + } + return `${key.slice(0, 4)}***${key.slice(-4)}`; +} + +/** + * 解析渠道名称(返回 provider 名称) + * @param source 来源标识 + * @param providerMap 渠道映射表 + * @returns provider 名称或 null + */ +export function resolveProvider( + source: string, + providerMap: Record +): string | null { + if (!source || source === '-' || source === 'unknown') return null; + + // 首先尝试完全匹配 + if (providerMap[source]) { + return providerMap[source]; + } + + // 然后尝试前缀匹配(双向) + const entries = Object.entries(providerMap); + for (const [key, provider] of entries) { + if (source.startsWith(key) || key.startsWith(source)) { + return provider; + } + } + + return null; +} + +/** + * 格式化渠道显示名称:渠道名 (脱敏后的api-key) + * @param source 来源标识 + * @param providerMap 渠道映射表 + * @returns 格式化后的显示名称 + */ +export function formatProviderDisplay( + source: string, + providerMap: Record +): string { + if (!source || source === '-' || source === 'unknown') { + return source || '-'; + } + const provider = resolveProvider(source, providerMap); + const masked = maskSecret(source); + if (!provider) return masked; + return `${provider} (${masked})`; +} + +/** + * 获取渠道显示信息(分离渠道名和秘钥) + * @param source 来源标识 + * @param providerMap 渠道映射表 + * @returns 包含渠道名和秘钥的对象 + */ +export function getProviderDisplayParts( + source: string, + providerMap: Record +): { provider: string | null; masked: string } { + if (!source || source === '-' || source === 'unknown') { + return { provider: null, masked: source || '-' }; + } + const provider = resolveProvider(source, providerMap); + const masked = maskSecret(source); + return { provider, masked }; +} + +/** + * 格式化时间戳为日期时间字符串 + * @param timestamp 时间戳(毫秒数或 ISO 字符串) + * @returns 格式化后的日期时间字符串 + */ +export function formatTimestamp(timestamp: number | string): string { + if (!timestamp) return '-'; + const date = typeof timestamp === 'string' ? new Date(timestamp) : new Date(timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +/** + * 获取成功率对应的样式类名 + * @param rate 成功率(0-100) + * @param styles 样式模块对象 + * @returns 样式类名 + */ +export function getRateClassName( + rate: number, + styles: Record +): string { + if (rate >= 90) return styles.rateHigh || ''; + if (rate >= 70) return styles.rateMedium || ''; + return styles.rateLow || ''; +} + +/** + * 检查模型是否在配置中可用(未被移除) + * @param source 来源标识 + * @param modelAlias 模型别名 + * @param providerModels 渠道模型映射表 + * @returns 是否可用 + */ +export function isModelEnabled( + source: string, + modelAlias: string, + providerModels: Record> +): boolean { + if (!source || !modelAlias) return true; // 无法判断时默认显示 + // 首先尝试完全匹配 + if (providerModels[source]) { + return providerModels[source].has(modelAlias); + } + // 然后尝试前缀匹配 + const entries = Object.entries(providerModels); + for (const [key, modelSet] of entries) { + if (source.startsWith(key) || key.startsWith(source)) { + return modelSet.has(modelAlias); + } + } + return true; // 找不到渠道配置时默认显示 +} + +/** + * 检查模型是否已禁用(会话中禁用或配置中已移除) + * @param source 来源标识 + * @param model 模型名称 + * @param disabledModels 已禁用模型集合 + * @param providerModels 渠道模型映射表 + * @returns 是否已禁用 + */ +export function isModelDisabled( + source: string, + model: string, + disabledModels: Set, + providerModels: Record> +): boolean { + // 首先检查会话中是否已禁用 + if (disabledModels.has(`${source}|||${model}`)) { + return true; + } + // 然后检查配置中是否已移除 + return !isModelEnabled(source, model, providerModels); +} + +/** + * 创建禁用状态对象 + * @param source 来源标识 + * @param model 模型名称 + * @param providerMap 渠道映射表 + * @returns 禁用状态对象 + */ +export function createDisableState( + source: string, + model: string, + providerMap: Record +): DisableState { + const providerName = resolveProvider(source, providerMap); + const displayName = providerName + ? `${providerName} / ${model}` + : `${maskSecret(source)} / ${model}`; + return { source, model, displayName, step: 1 }; +} + +/** + * 时间范围类型 + */ +export type TimeRangeValue = number | 'custom'; + +/** + * 根据时间范围过滤数据 + * @param data 原始数据 + * @param timeRange 时间范围(天数或 'custom') + * @param customRange 自定义日期范围 + * @returns 过滤后的数据 + */ +export function filterDataByTimeRange( + data: UsageData | null, + timeRange: TimeRangeValue, + customRange?: DateRange +): UsageData | null { + if (!data?.apis) return null; + + const now = new Date(); + let cutoffStart: Date; + let cutoffEnd: Date = new Date(now.getTime()); + cutoffEnd.setHours(23, 59, 59, 999); + + if (timeRange === 'custom' && customRange) { + cutoffStart = customRange.start; + cutoffEnd = customRange.end; + } else if (typeof timeRange === 'number') { + cutoffStart = new Date(now.getTime() - timeRange * 24 * 60 * 60 * 1000); + cutoffStart.setHours(0, 0, 0, 0); + } else { + cutoffStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + cutoffStart.setHours(0, 0, 0, 0); + } + + const filtered: UsageData = { apis: {} }; + + Object.entries(data.apis).forEach(([apiKey, apiData]) => { + if (!apiData?.models) return; + + const filteredModels: Record = {}; + + Object.entries(apiData.models).forEach(([modelName, modelData]) => { + if (!modelData?.details || !Array.isArray(modelData.details)) return; + + const filteredDetails = modelData.details.filter((detail) => { + const timestamp = new Date(detail.timestamp); + return timestamp >= cutoffStart && timestamp <= cutoffEnd; + }); + + if (filteredDetails.length > 0) { + filteredModels[modelName] = { details: filteredDetails }; + } + }); + + if (Object.keys(filteredModels).length > 0) { + filtered.apis[apiKey] = { models: filteredModels }; + } + }); + + return filtered; +}