Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5123d254f2 | ||
|
|
76bfa26d3e | ||
|
|
9805219fe8 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,3 +30,6 @@ settings.local.json
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
自定义 Web UI
|
||||||
|
tmpclaude*
|
||||||
|
.claude
|
||||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@uiw/react-codemirror": "^4.25.3",
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
@@ -71,6 +72,7 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -465,6 +467,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
|
||||||
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
|
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/state": "^6.5.0",
|
"@codemirror/state": "^6.5.0",
|
||||||
"crelt": "^1.0.6",
|
"crelt": "^1.0.6",
|
||||||
@@ -1865,6 +1868,33 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -1930,6 +1960,7 @@
|
|||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -2017,6 +2048,7 @@
|
|||||||
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
|
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.48.1",
|
"@typescript-eslint/scope-manager": "8.48.1",
|
||||||
"@typescript-eslint/types": "8.48.1",
|
"@typescript-eslint/types": "8.48.1",
|
||||||
@@ -2334,6 +2366,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2545,6 +2578,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kurkle/color": "^0.3.0"
|
"@kurkle/color": "^0.3.0"
|
||||||
},
|
},
|
||||||
@@ -2809,6 +2843,7 @@
|
|||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3285,6 +3320,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4"
|
"@babel/runtime": "^7.28.4"
|
||||||
},
|
},
|
||||||
@@ -3614,6 +3650,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3720,6 +3757,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||||
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -3737,6 +3775,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
||||||
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -3845,6 +3884,7 @@
|
|||||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -4027,6 +4067,7 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -4103,6 +4144,7 @@
|
|||||||
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -4244,6 +4286,7 @@
|
|||||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@uiw/react-codemirror": "^4.25.3",
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
IconShield,
|
IconShield,
|
||||||
IconSlidersHorizontal,
|
IconSlidersHorizontal,
|
||||||
IconTimer,
|
IconTimer,
|
||||||
|
IconActivity,
|
||||||
} from '@/components/ui/icons';
|
} from '@/components/ui/icons';
|
||||||
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
import { INLINE_LOGO_JPEG } from '@/assets/logoInline';
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +51,7 @@ const sidebarIcons: Record<string, ReactNode> = {
|
|||||||
config: <IconSettings size={18} />,
|
config: <IconSettings size={18} />,
|
||||||
logs: <IconScrollText size={18} />,
|
logs: <IconScrollText size={18} />,
|
||||||
system: <IconInfo size={18} />,
|
system: <IconInfo size={18} />,
|
||||||
|
monitor: <IconActivity size={18} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Header action icons - smaller size for header buttons
|
// 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: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }]
|
||||||
: []),
|
: []),
|
||||||
{ path: '/system', label: t('nav.system_info'), icon: sidebarIcons.system },
|
{ 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 navOrder = navItems.map((item) => item.path);
|
||||||
const getRouteOrder = (pathname: string) => {
|
const getRouteOrder = (pathname: string) => {
|
||||||
|
|||||||
409
src/components/monitor/ChannelStats.tsx
Normal file
409
src/components/monitor/ChannelStats.tsx
Normal file
@@ -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<string, string>;
|
||||||
|
providerModels: Record<string, Set<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, ModelStat>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelStats({ data, loading, providerMap, providerModels }: ChannelStatsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expandedChannel, setExpandedChannel] = useState<string | null>(null);
|
||||||
|
const [filterChannel, setFilterChannel] = useState('');
|
||||||
|
const [filterModel, setFilterModel] = useState('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState<'' | 'success' | 'failed'>('');
|
||||||
|
|
||||||
|
// 时间范围状态
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>(7);
|
||||||
|
const [customRange, setCustomRange] = useState<DateRange | undefined>();
|
||||||
|
|
||||||
|
// 使用禁用模型 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<string, ChannelStat> = {};
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
const modelSet = new Set<string>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={t('monitor.channel.title')}
|
||||||
|
subtitle={
|
||||||
|
<span>
|
||||||
|
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.channel.subtitle')}
|
||||||
|
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.channel.click_hint')}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<TimeRangeSelector
|
||||||
|
value={timeRange}
|
||||||
|
onChange={handleTimeRangeChange}
|
||||||
|
customRange={customRange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* 筛选器 */}
|
||||||
|
<div className={styles.logFilters}>
|
||||||
|
<select
|
||||||
|
className={styles.logSelect}
|
||||||
|
value={filterChannel}
|
||||||
|
onChange={(e) => setFilterChannel(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('monitor.channel.all_channels')}</option>
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<option key={channel} value={channel}>{channel}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className={styles.logSelect}
|
||||||
|
value={filterModel}
|
||||||
|
onChange={(e) => setFilterModel(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('monitor.channel.all_models')}</option>
|
||||||
|
{models.map((model) => (
|
||||||
|
<option key={model} value={model}>{model}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className={styles.logSelect}
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value as '' | 'success' | 'failed')}
|
||||||
|
>
|
||||||
|
<option value="">{t('monitor.channel.all_status')}</option>
|
||||||
|
<option value="success">{t('monitor.channel.only_success')}</option>
|
||||||
|
<option value="failed">{t('monitor.channel.only_failed')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.emptyState}>{t('common.loading')}</div>
|
||||||
|
) : filteredStats.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>{t('monitor.no_data')}</div>
|
||||||
|
) : (
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('monitor.channel.header_name')}</th>
|
||||||
|
<th>{t('monitor.channel.header_count')}</th>
|
||||||
|
<th>{t('monitor.channel.header_rate')}</th>
|
||||||
|
<th>{t('monitor.channel.header_recent')}</th>
|
||||||
|
<th>{t('monitor.channel.header_time')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredStats.map((stat) => (
|
||||||
|
<Fragment key={stat.displayName}>
|
||||||
|
<tr
|
||||||
|
className={styles.expandable}
|
||||||
|
onClick={() => toggleExpand(stat.displayName)}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
{stat.providerName ? (
|
||||||
|
<>
|
||||||
|
<span className={styles.channelName}>{stat.providerName}</span>
|
||||||
|
<span className={styles.channelSecret}> ({stat.maskedKey})</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
stat.maskedKey
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{stat.totalRequests.toLocaleString()}</td>
|
||||||
|
<td className={getRateClassName(stat.successRate, styles)}>
|
||||||
|
{stat.successRate.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={styles.statusBars}>
|
||||||
|
{stat.recentRequests.map((req, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{formatTimestamp(stat.lastRequestTime)}</td>
|
||||||
|
</tr>
|
||||||
|
{expandedChannel === stat.displayName && (
|
||||||
|
<tr key={`${stat.displayName}-detail`}>
|
||||||
|
<td colSpan={5} className={styles.expandDetail}>
|
||||||
|
<div className={styles.expandTableWrapper}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('monitor.channel.model')}</th>
|
||||||
|
<th>{t('monitor.channel.header_count')}</th>
|
||||||
|
<th>{t('monitor.channel.header_rate')}</th>
|
||||||
|
<th>{t('monitor.channel.success')}/{t('monitor.channel.failed')}</th>
|
||||||
|
<th>{t('monitor.channel.header_recent')}</th>
|
||||||
|
<th>{t('monitor.channel.header_time')}</th>
|
||||||
|
<th>{t('monitor.logs.header_actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr key={modelName} className={disabled ? styles.modelDisabled : ''}>
|
||||||
|
<td>{modelName}</td>
|
||||||
|
<td>{modelStat.requests.toLocaleString()}</td>
|
||||||
|
<td className={getRateClassName(modelStat.successRate, styles)}>
|
||||||
|
{modelStat.successRate.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.kpiSuccess}>{modelStat.success}</span>
|
||||||
|
{' / '}
|
||||||
|
<span className={styles.kpiFailure}>{modelStat.failed}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={styles.statusBars}>
|
||||||
|
{modelStat.recentRequests.map((req, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{formatTimestamp(modelStat.lastTimestamp)}</td>
|
||||||
|
<td>
|
||||||
|
{disabled ? (
|
||||||
|
<span className={styles.disabledLabel}>{t('monitor.logs.removed')}</span>
|
||||||
|
) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? (
|
||||||
|
<button
|
||||||
|
className={styles.disableBtn}
|
||||||
|
onClick={(e) => handleDisableClick(stat.source, modelName, e)}
|
||||||
|
>
|
||||||
|
{t('monitor.logs.disable')}
|
||||||
|
</button>
|
||||||
|
) : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 禁用确认弹窗 */}
|
||||||
|
<DisableModelModal
|
||||||
|
disableState={disableState}
|
||||||
|
disabling={disabling}
|
||||||
|
onConfirm={handleConfirmDisable}
|
||||||
|
onCancel={handleCancelDisable}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
src/components/monitor/DailyTrendChart.tsx
Normal file
279
src/components/monitor/DailyTrendChart.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
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;
|
||||||
|
successRequests: number;
|
||||||
|
failedRequests: 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<string, {
|
||||||
|
requests: number;
|
||||||
|
successRequests: number;
|
||||||
|
failedRequests: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
reasoningTokens: number;
|
||||||
|
cachedTokens: number;
|
||||||
|
}> = {};
|
||||||
|
|
||||||
|
// 辅助函数:获取本地日期字符串 YYYY-MM-DD
|
||||||
|
const getLocalDateString = (timestamp: string): string => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
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}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.values(data.apis).forEach((apiData) => {
|
||||||
|
Object.values(apiData.models).forEach((modelData) => {
|
||||||
|
modelData.details.forEach((detail) => {
|
||||||
|
// 使用本地日期而非 UTC 日期
|
||||||
|
const date = getLocalDateString(detail.timestamp);
|
||||||
|
if (!dailyStats[date]) {
|
||||||
|
dailyStats[date] = {
|
||||||
|
requests: 0,
|
||||||
|
successRequests: 0,
|
||||||
|
failedRequests: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
cachedTokens: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
dailyStats[date].requests++;
|
||||||
|
if (detail.failed) {
|
||||||
|
dailyStats[date].failedRequests++;
|
||||||
|
} else {
|
||||||
|
dailyStats[date].successRequests++;
|
||||||
|
// 只统计成功请求的 Token
|
||||||
|
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 (
|
||||||
|
<div className={styles.chartCard}>
|
||||||
|
<div className={styles.chartHeader}>
|
||||||
|
<div>
|
||||||
|
<h3 className={styles.chartTitle}>{t('monitor.trend.title')}</h3>
|
||||||
|
<p className={styles.chartSubtitle}>
|
||||||
|
{timeRangeLabel} · {t('monitor.trend.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.chartContent}>
|
||||||
|
{loading || dailyData.length === 0 ? (
|
||||||
|
<div className={styles.chartEmpty}>
|
||||||
|
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
src/components/monitor/DisableModelModal.tsx
Normal file
101
src/components/monitor/DisableModelModal.tsx
Normal file
@@ -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 (
|
||||||
|
<p style={{ marginBottom: 16, lineHeight: 1.6 }}>
|
||||||
|
{isZh ? '确定要禁用 ' : 'Are you sure you want to disable '}
|
||||||
|
<strong>{disableState.displayName}</strong>
|
||||||
|
{isZh ? ' 吗?' : '?'}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disableState.step === 2) {
|
||||||
|
return (
|
||||||
|
<p style={{ marginBottom: 16, lineHeight: 1.6, color: 'var(--warning-color, #f59e0b)' }}>
|
||||||
|
{isZh
|
||||||
|
? '⚠️ 警告:此操作将从配置中移除该模型映射!'
|
||||||
|
: '⚠️ Warning: this removes the model mapping from config!'}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p style={{ marginBottom: 16, lineHeight: 1.6, color: 'var(--danger-color, #ef4444)' }}>
|
||||||
|
{isZh
|
||||||
|
? '🚨 最后确认:禁用后需要手动重新添加才能恢复!'
|
||||||
|
: "🚨 Final confirmation: you'll need to add it back manually later!"}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取确认按钮文本
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
open={!!disableState}
|
||||||
|
onClose={onCancel}
|
||||||
|
title={t('monitor.logs.disable_confirm_title')}
|
||||||
|
width={400}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '16px 0' }}>
|
||||||
|
{getWarningContent()}
|
||||||
|
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={disabling}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={disabling}
|
||||||
|
>
|
||||||
|
{disabling ? t('monitor.logs.disabling') : getConfirmButtonText()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
420
src/components/monitor/FailureAnalysis.tsx
Normal file
420
src/components/monitor/FailureAnalysis.tsx
Normal file
@@ -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<string, string>;
|
||||||
|
providerModels: Record<string, Set<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, ModelFailureStat>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FailureAnalysis({ data, loading, providerMap, providerModels }: FailureAnalysisProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expandedChannel, setExpandedChannel] = useState<string | null>(null);
|
||||||
|
const [filterChannel, setFilterChannel] = useState('');
|
||||||
|
const [filterModel, setFilterModel] = useState('');
|
||||||
|
|
||||||
|
// 时间范围状态
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>(7);
|
||||||
|
const [customRange, setCustomRange] = useState<DateRange | undefined>();
|
||||||
|
|
||||||
|
// 使用禁用模型 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<string>();
|
||||||
|
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<string, FailureStat> = {};
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
const modelSet = new Set<string>();
|
||||||
|
|
||||||
|
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<string, ModelFailureStat>) => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={t('monitor.failure.title')}
|
||||||
|
subtitle={
|
||||||
|
<span>
|
||||||
|
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.failure.subtitle')}
|
||||||
|
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.failure.click_hint')}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<TimeRangeSelector
|
||||||
|
value={timeRange}
|
||||||
|
onChange={handleTimeRangeChange}
|
||||||
|
customRange={customRange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* 筛选器 */}
|
||||||
|
<div className={styles.logFilters}>
|
||||||
|
<select
|
||||||
|
className={styles.logSelect}
|
||||||
|
value={filterChannel}
|
||||||
|
onChange={(e) => setFilterChannel(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('monitor.channel.all_channels')}</option>
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<option key={channel} value={channel}>{channel}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className={styles.logSelect}
|
||||||
|
value={filterModel}
|
||||||
|
onChange={(e) => setFilterModel(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('monitor.channel.all_models')}</option>
|
||||||
|
{models.map((model) => (
|
||||||
|
<option key={model} value={model}>{model}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.emptyState}>{t('common.loading')}</div>
|
||||||
|
) : filteredStats.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>{t('monitor.failure.no_failures')}</div>
|
||||||
|
) : (
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('monitor.failure.header_name')}</th>
|
||||||
|
<th>{t('monitor.failure.header_count')}</th>
|
||||||
|
<th>{t('monitor.failure.header_time')}</th>
|
||||||
|
<th>{t('monitor.failure.header_models')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredStats.map((stat) => {
|
||||||
|
const topModels = getTopFailedModels(stat.source, stat.models);
|
||||||
|
const totalFailedModels = Object.values(stat.models).filter(m => m.failure > 0).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={stat.displayName}>
|
||||||
|
<tr
|
||||||
|
className={styles.expandable}
|
||||||
|
onClick={() => toggleExpand(stat.displayName)}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
{stat.providerName ? (
|
||||||
|
<>
|
||||||
|
<span className={styles.channelName}>{stat.providerName}</span>
|
||||||
|
<span className={styles.channelSecret}> ({stat.maskedKey})</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
stat.maskedKey
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className={styles.kpiFailure}>{stat.failedCount.toLocaleString()}</td>
|
||||||
|
<td>{formatTimestamp(stat.lastFailTime)}</td>
|
||||||
|
<td>
|
||||||
|
{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 (
|
||||||
|
<span
|
||||||
|
key={model}
|
||||||
|
className={`${styles.failureModelTag} ${disabled ? styles.modelDisabled : ''}`}
|
||||||
|
title={`${model}: ${modelStat.failure} (${percent}%)${disabled ? ` - ${t('monitor.logs.removed')}` : ''}`}
|
||||||
|
>
|
||||||
|
{shortModel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{totalFailedModels > 2 && (
|
||||||
|
<span className={styles.moreModelsHint}>
|
||||||
|
+{totalFailedModels - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expandedChannel === stat.displayName && (
|
||||||
|
<tr key={`${stat.displayName}-detail`}>
|
||||||
|
<td colSpan={4} className={styles.expandDetail}>
|
||||||
|
<div className={styles.expandTableWrapper}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('monitor.channel.model')}</th>
|
||||||
|
<th>{t('monitor.channel.header_count')}</th>
|
||||||
|
<th>{t('monitor.channel.header_rate')}</th>
|
||||||
|
<th>{t('monitor.channel.success')}/{t('monitor.channel.failed')}</th>
|
||||||
|
<th>{t('monitor.channel.header_recent')}</th>
|
||||||
|
<th>{t('monitor.channel.header_time')}</th>
|
||||||
|
<th>{t('monitor.logs.header_actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr key={modelName} className={disabled ? styles.modelDisabled : ''}>
|
||||||
|
<td>{modelName}</td>
|
||||||
|
<td>{modelStat.total.toLocaleString()}</td>
|
||||||
|
<td className={getRateClassName(modelStat.successRate, styles)}>
|
||||||
|
{modelStat.successRate.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.kpiSuccess}>{modelStat.success}</span>
|
||||||
|
{' / '}
|
||||||
|
<span className={styles.kpiFailure}>{modelStat.failure}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={styles.statusBars}>
|
||||||
|
{modelStat.recentRequests.map((req, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{formatTimestamp(modelStat.lastTimestamp)}</td>
|
||||||
|
<td>
|
||||||
|
{disabled ? (
|
||||||
|
<span className={styles.disabledLabel}>{t('monitor.logs.removed')}</span>
|
||||||
|
) : stat.source && stat.source !== '-' && stat.source !== 'unknown' ? (
|
||||||
|
<button
|
||||||
|
className={styles.disableBtn}
|
||||||
|
onClick={(e) => handleDisableClick(stat.source, modelName, e)}
|
||||||
|
>
|
||||||
|
{t('monitor.logs.disable')}
|
||||||
|
</button>
|
||||||
|
) : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 禁用确认弹窗 */}
|
||||||
|
<DisableModelModal
|
||||||
|
disableState={disableState}
|
||||||
|
disabling={disabling}
|
||||||
|
onConfirm={handleConfirmDisable}
|
||||||
|
onCancel={handleCancelDisable}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
314
src/components/monitor/HourlyModelChart.tsx
Normal file
314
src/components/monitor/HourlyModelChart.tsx
Normal file
@@ -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<HourRange>(12);
|
||||||
|
|
||||||
|
// 按小时聚合数据
|
||||||
|
const hourlyData = useMemo(() => {
|
||||||
|
if (!data?.apis) return { hours: [], models: [], modelData: {} as Record<string, number[]>, 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<string, Record<string, { success: number; failed: number }>> = {};
|
||||||
|
const modelSet = new Set<string>();
|
||||||
|
|
||||||
|
// 初始化所有小时
|
||||||
|
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<string, number> = {};
|
||||||
|
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<string, number[]> = {};
|
||||||
|
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:00Z'); // 添加 Z 表示 UTC 时间,确保正确转换为本地时间显示
|
||||||
|
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 (
|
||||||
|
<div className={styles.chartCard}>
|
||||||
|
<div className={styles.chartHeader}>
|
||||||
|
<div>
|
||||||
|
<h3 className={styles.chartTitle}>{t('monitor.hourly_model.title')}</h3>
|
||||||
|
<p className={styles.chartSubtitle}>
|
||||||
|
{hourRangeLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartControls}>
|
||||||
|
<button
|
||||||
|
className={`${styles.chartControlBtn} ${hourRange === 6 ? styles.active : ''}`}
|
||||||
|
onClick={() => setHourRange(6)}
|
||||||
|
>
|
||||||
|
{t('monitor.hourly.last_6h')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.chartControlBtn} ${hourRange === 12 ? styles.active : ''}`}
|
||||||
|
onClick={() => setHourRange(12)}
|
||||||
|
>
|
||||||
|
{t('monitor.hourly.last_12h')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.chartControlBtn} ${hourRange === 24 ? styles.active : ''}`}
|
||||||
|
onClick={() => setHourRange(24)}
|
||||||
|
>
|
||||||
|
{t('monitor.hourly.last_24h')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.chartContent}>
|
||||||
|
{loading || hourlyData.hours.length === 0 ? (
|
||||||
|
<div className={styles.chartEmpty}>
|
||||||
|
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
src/components/monitor/HourlyTokenChart.tsx
Normal file
274
src/components/monitor/HourlyTokenChart.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
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<HourRange>(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<string, {
|
||||||
|
total: number;
|
||||||
|
input: number;
|
||||||
|
output: number;
|
||||||
|
reasoning: number;
|
||||||
|
cached: number;
|
||||||
|
}> = {};
|
||||||
|
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) => {
|
||||||
|
// 跳过失败请求,失败请求的 Token 数据不准确
|
||||||
|
if (detail.failed) return;
|
||||||
|
|
||||||
|
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:00Z'); // 添加 Z 表示 UTC 时间,确保正确转换为本地时间显示
|
||||||
|
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 (
|
||||||
|
<div className={styles.chartCard}>
|
||||||
|
<div className={styles.chartHeader}>
|
||||||
|
<div>
|
||||||
|
<h3 className={styles.chartTitle}>{t('monitor.hourly_token.title')}</h3>
|
||||||
|
<p className={styles.chartSubtitle}>
|
||||||
|
{hourRangeLabel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartControls}>
|
||||||
|
<button
|
||||||
|
className={`${styles.chartControlBtn} ${hourRange === 6 ? styles.active : ''}`}
|
||||||
|
onClick={() => setHourRange(6)}
|
||||||
|
>
|
||||||
|
{t('monitor.hourly.last_6h')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.chartControlBtn} ${hourRange === 12 ? styles.active : ''}`}
|
||||||
|
onClick={() => setHourRange(12)}
|
||||||
|
>
|
||||||
|
{t('monitor.hourly.last_12h')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.chartControlBtn} ${hourRange === 24 ? styles.active : ''}`}
|
||||||
|
onClick={() => setHourRange(24)}
|
||||||
|
>
|
||||||
|
{t('monitor.hourly.last_24h')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.chartContent}>
|
||||||
|
{loading || hourlyData.hours.length === 0 ? (
|
||||||
|
<div className={styles.chartEmpty}>
|
||||||
|
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Chart type="bar" data={chartData} options={chartOptions} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
201
src/components/monitor/KpiCards.tsx
Normal file
201
src/components/monitor/KpiCards.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={styles.kpiGrid}>
|
||||||
|
{/* 请求数 */}
|
||||||
|
<div className={styles.kpiCard}>
|
||||||
|
<div className={styles.kpiTitle}>
|
||||||
|
<span className={styles.kpiLabel}>{t('monitor.kpi.requests')}</span>
|
||||||
|
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiValue}>
|
||||||
|
{loading ? '--' : formatNumber(stats.totalRequests)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiMeta}>
|
||||||
|
<span className={styles.kpiSuccess}>
|
||||||
|
{t('monitor.kpi.success')}: {loading ? '--' : stats.successRequests.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className={styles.kpiFailure}>
|
||||||
|
{t('monitor.kpi.failed')}: {loading ? '--' : stats.failedRequests.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t('monitor.kpi.rate')}: {loading ? '--' : stats.successRate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tokens */}
|
||||||
|
<div className={`${styles.kpiCard} ${styles.green}`}>
|
||||||
|
<div className={styles.kpiTitle}>
|
||||||
|
<span className={styles.kpiLabel}>{t('monitor.kpi.tokens')}</span>
|
||||||
|
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiValue}>
|
||||||
|
{loading ? '--' : formatNumber(stats.totalTokens)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiMeta}>
|
||||||
|
<span>{t('monitor.kpi.input')}: {loading ? '--' : formatNumber(stats.inputTokens)}</span>
|
||||||
|
<span>{t('monitor.kpi.output')}: {loading ? '--' : formatNumber(stats.outputTokens)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 平均 TPM */}
|
||||||
|
<div className={`${styles.kpiCard} ${styles.purple}`}>
|
||||||
|
<div className={styles.kpiTitle}>
|
||||||
|
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_tpm')}</span>
|
||||||
|
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiValue}>
|
||||||
|
{loading ? '--' : formatNumber(stats.avgTpm)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiMeta}>
|
||||||
|
<span>{t('monitor.kpi.tokens_per_minute')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 平均 RPM */}
|
||||||
|
<div className={`${styles.kpiCard} ${styles.orange}`}>
|
||||||
|
<div className={styles.kpiTitle}>
|
||||||
|
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_rpm')}</span>
|
||||||
|
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiValue}>
|
||||||
|
{loading ? '--' : stats.avgRpm.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiMeta}>
|
||||||
|
<span>{t('monitor.kpi.requests_per_minute')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 日均 RPD */}
|
||||||
|
<div className={`${styles.kpiCard} ${styles.cyan}`}>
|
||||||
|
<div className={styles.kpiTitle}>
|
||||||
|
<span className={styles.kpiLabel}>{t('monitor.kpi.avg_rpd')}</span>
|
||||||
|
<span className={styles.kpiTag}>{timeRangeLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiValue}>
|
||||||
|
{loading ? '--' : formatNumber(stats.avgRpd)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.kpiMeta}>
|
||||||
|
<span>{t('monitor.kpi.requests_per_day')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
src/components/monitor/ModelDistributionChart.tsx
Normal file
205
src/components/monitor/ModelDistributionChart.tsx
Normal file
@@ -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<ViewMode>('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<string, { requests: number; tokens: number }> = {};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={styles.chartCard}>
|
||||||
|
<div className={styles.chartHeader}>
|
||||||
|
<div>
|
||||||
|
<h3 className={styles.chartTitle}>{t('monitor.distribution.title')}</h3>
|
||||||
|
<p className={styles.chartSubtitle}>
|
||||||
|
{timeRangeLabel} · {viewMode === 'request' ? t('monitor.distribution.by_requests') : t('monitor.distribution.by_tokens')}
|
||||||
|
{' · Top 10'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.chartControls}>
|
||||||
|
<button
|
||||||
|
className={`${styles.chartControlBtn} ${viewMode === 'request' ? styles.active : ''}`}
|
||||||
|
onClick={() => setViewMode('request')}
|
||||||
|
>
|
||||||
|
{t('monitor.distribution.requests')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.chartControlBtn} ${viewMode === 'token' ? styles.active : ''}`}
|
||||||
|
onClick={() => setViewMode('token')}
|
||||||
|
>
|
||||||
|
{t('monitor.distribution.tokens')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading || distributionData.length === 0 ? (
|
||||||
|
<div className={styles.chartContent}>
|
||||||
|
<div className={styles.chartEmpty}>
|
||||||
|
{loading ? t('common.loading') : t('monitor.no_data')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.distributionContent}>
|
||||||
|
<div className={styles.donutWrapper}>
|
||||||
|
<Doughnut data={chartData} options={chartOptions} />
|
||||||
|
<div className={styles.donutCenter}>
|
||||||
|
<div className={styles.donutLabel}>
|
||||||
|
{viewMode === 'request' ? t('monitor.distribution.request_share') : t('monitor.distribution.token_share')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.legendList}>
|
||||||
|
{distributionData.map((item, index) => {
|
||||||
|
const value = viewMode === 'request' ? item.requests : item.tokens;
|
||||||
|
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0';
|
||||||
|
return (
|
||||||
|
<div key={item.name} className={styles.legendItem}>
|
||||||
|
<span
|
||||||
|
className={styles.legendDot}
|
||||||
|
style={{ backgroundColor: COLORS[index] }}
|
||||||
|
/>
|
||||||
|
<span className={styles.legendName} title={item.name}>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className={styles.legendValue}>
|
||||||
|
{formatValue(value)} ({percentage}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
643
src/components/monitor/RequestLogs.tsx
Normal file
643
src/components/monitor/RequestLogs.tsx
Normal file
@@ -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<string, string>;
|
||||||
|
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<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
// 用 ref 存储 fetchLogData,避免作为定时器 useEffect 的依赖
|
||||||
|
const fetchLogDataRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||||
|
|
||||||
|
// 虚拟滚动容器 ref
|
||||||
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
// 固定表头容器 ref
|
||||||
|
const headerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 同步表头和内容的水平滚动
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
if (tableContainerRef.current && headerRef.current) {
|
||||||
|
headerRef.current.scrollLeft = tableContainerRef.current.scrollLeft;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 时间范围状态
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>(7);
|
||||||
|
const [customRange, setCustomRange] = useState<DateRange | undefined>();
|
||||||
|
|
||||||
|
// 日志独立数据状态
|
||||||
|
const [logData, setLogData] = useState<UsageData | null>(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<string, { details: UsageData['apis'][string]['models'][string]['details'] }> = {};
|
||||||
|
|
||||||
|
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<string, PrecomputedStats>();
|
||||||
|
|
||||||
|
// 首先按渠道+模型分组,并按时间排序
|
||||||
|
const channelModelGroups: Record<string, { entry: LogEntry; index: number }[]> = {};
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
const modelSet = new Set<string>();
|
||||||
|
const sourceSet = new Set<string>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<td>{entry.authIndex}</td>
|
||||||
|
<td title={entry.apiKey}>
|
||||||
|
{maskSecret(entry.apiKey)}
|
||||||
|
</td>
|
||||||
|
<td title={entry.model}>
|
||||||
|
{entry.model}
|
||||||
|
</td>
|
||||||
|
<td title={entry.source}>
|
||||||
|
{entry.providerName ? (
|
||||||
|
<>
|
||||||
|
<span className={styles.channelName}>{entry.providerName}</span>
|
||||||
|
<span className={styles.channelSecret}> ({entry.maskedKey})</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
entry.maskedKey
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`${styles.statusPill} ${entry.failed ? styles.failed : styles.success}`}>
|
||||||
|
{entry.failed ? t('monitor.logs.failed') : t('monitor.logs.success')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={styles.statusBars}>
|
||||||
|
{stats.recentRequests.map((req, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`${styles.statusBar} ${req.failed ? styles.failure : styles.success}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className={getRateClassName(rateValue, styles)}>
|
||||||
|
{stats.successRate}%
|
||||||
|
</td>
|
||||||
|
<td>{formatNumber(stats.totalCount)}</td>
|
||||||
|
<td>{formatNumber(entry.inputTokens)}</td>
|
||||||
|
<td>{formatNumber(entry.outputTokens)}</td>
|
||||||
|
<td>{formatNumber(entry.totalTokens)}</td>
|
||||||
|
<td>{formatTimestamp(entry.timestamp)}</td>
|
||||||
|
<td>
|
||||||
|
{entry.source && entry.source !== '-' && entry.source !== 'unknown' ? (
|
||||||
|
disabled ? (
|
||||||
|
<span className={styles.disabledLabel}>
|
||||||
|
{t('monitor.logs.disabled')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={styles.disableBtn}
|
||||||
|
title={t('monitor.logs.disable_model')}
|
||||||
|
onClick={() => handleDisableClick(entry.source, entry.model)}
|
||||||
|
>
|
||||||
|
{t('monitor.logs.disable')}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card
|
||||||
|
title={t('monitor.logs.title')}
|
||||||
|
subtitle={
|
||||||
|
<span>
|
||||||
|
{formatTimeRangeCaption(timeRange, customRange, t)} · {t('monitor.logs.total_count', { count: logEntries.length })}
|
||||||
|
<span style={{ color: 'var(--text-tertiary)' }}> · {t('monitor.logs.scroll_hint')}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<TimeRangeSelector
|
||||||
|
value={timeRange}
|
||||||
|
onChange={handleTimeRangeChange}
|
||||||
|
customRange={customRange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* 筛选器 */}
|
||||||
|
<div className={styles.logFilters}>
|
||||||
|
<select
|
||||||
|
className={styles.logSelect}
|
||||||
|
value={filterApi}
|
||||||
|
onChange={(e) => setFilterApi(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('monitor.logs.all_apis')}</option>
|
||||||
|
{apis.map((api) => (
|
||||||
|
<option key={api} value={api}>
|
||||||
|
{maskSecret(api)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className={styles.logSelect}
|
||||||
|
value={filterModel}
|
||||||
|
onChange={(e) => setFilterModel(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('monitor.logs.all_models')}</option>
|
||||||
|
{models.map((model) => (
|
||||||
|
<option key={model} value={model}>{model}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className={styles.logSelect}
|
||||||
|
value={filterSource}
|
||||||
|
onChange={(e) => setFilterSource(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{t('monitor.logs.all_sources')}</option>
|
||||||
|
{sources.map((source) => (
|
||||||
|
<option key={source} value={source}>
|
||||||
|
{formatProviderDisplay(source, providerMap)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className={styles.logSelect}
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value as '' | 'success' | 'failed')}
|
||||||
|
>
|
||||||
|
<option value="">{t('monitor.logs.all_status')}</option>
|
||||||
|
<option value="success">{t('monitor.logs.success')}</option>
|
||||||
|
<option value="failed">{t('monitor.logs.failed')}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<span className={styles.logLastUpdate}>
|
||||||
|
{getCountdownText()}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className={styles.logSelect}
|
||||||
|
value={autoRefresh}
|
||||||
|
onChange={(e) => setAutoRefresh(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value="0">{t('monitor.logs.manual_refresh')}</option>
|
||||||
|
<option value="5">{t('monitor.logs.refresh_5s')}</option>
|
||||||
|
<option value="10">{t('monitor.logs.refresh_10s')}</option>
|
||||||
|
<option value="15">{t('monitor.logs.refresh_15s')}</option>
|
||||||
|
<option value="30">{t('monitor.logs.refresh_30s')}</option>
|
||||||
|
<option value="60">{t('monitor.logs.refresh_60s')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 虚拟滚动表格 */}
|
||||||
|
<div className={styles.tableWrapper}>
|
||||||
|
{showLoading ? (
|
||||||
|
<div className={styles.emptyState}>{t('common.loading')}</div>
|
||||||
|
) : filteredEntries.length === 0 ? (
|
||||||
|
<div className={styles.emptyState}>{t('monitor.no_data')}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 固定表头 */}
|
||||||
|
<div ref={headerRef} className={styles.stickyHeader}>
|
||||||
|
<table className={`${styles.table} ${styles.virtualTable}`}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('monitor.logs.header_auth')}</th>
|
||||||
|
<th>{t('monitor.logs.header_api')}</th>
|
||||||
|
<th>{t('monitor.logs.header_model')}</th>
|
||||||
|
<th>{t('monitor.logs.header_source')}</th>
|
||||||
|
<th>{t('monitor.logs.header_status')}</th>
|
||||||
|
<th>{t('monitor.logs.header_recent')}</th>
|
||||||
|
<th>{t('monitor.logs.header_rate')}</th>
|
||||||
|
<th>{t('monitor.logs.header_count')}</th>
|
||||||
|
<th>{t('monitor.logs.header_input')}</th>
|
||||||
|
<th>{t('monitor.logs.header_output')}</th>
|
||||||
|
<th>{t('monitor.logs.header_total')}</th>
|
||||||
|
<th>{t('monitor.logs.header_time')}</th>
|
||||||
|
<th>{t('monitor.logs.header_actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 虚拟滚动容器 */}
|
||||||
|
<div
|
||||||
|
ref={tableContainerRef}
|
||||||
|
className={styles.virtualScrollContainer}
|
||||||
|
style={{
|
||||||
|
height: 'calc(100vh - 420px)',
|
||||||
|
minHeight: '360px',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table className={`${styles.table} ${styles.virtualTable}`}>
|
||||||
|
<tbody>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const entry = filteredEntries[virtualRow.index];
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={entry.id}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: `${virtualRow.size}px`,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
display: 'table',
|
||||||
|
tableLayout: 'fixed',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderRow(entry)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计信息 */}
|
||||||
|
{filteredEntries.length > 0 && (
|
||||||
|
<div style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||||
|
{t('monitor.logs.total_count', { count: filteredEntries.length })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 禁用确认弹窗 */}
|
||||||
|
<DisableModelModal
|
||||||
|
disableState={disableState}
|
||||||
|
disabling={disabling}
|
||||||
|
onConfirm={handleConfirmDisable}
|
||||||
|
onCancel={handleCancelDisable}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/components/monitor/TimeRangeSelector.tsx
Normal file
158
src/components/monitor/TimeRangeSelector.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={styles.timeRangeSelector}>
|
||||||
|
<div className={styles.timeButtons}>
|
||||||
|
{([1, 7, 14, 30, 'custom'] as TimeRange[]).map((range) => (
|
||||||
|
<button
|
||||||
|
key={range}
|
||||||
|
className={`${styles.timeButton} ${value === range ? styles.active : ''}`}
|
||||||
|
onClick={() => handleTimeClick(range)}
|
||||||
|
>
|
||||||
|
{range === 1
|
||||||
|
? t('monitor.time.today')
|
||||||
|
: range === 'custom'
|
||||||
|
? t('monitor.time.custom')
|
||||||
|
: t('monitor.time.last_n_days', { n: range })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showCustom && (
|
||||||
|
<div className={styles.customDatePicker}>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={styles.dateInput}
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className={styles.dateSeparator}>{t('monitor.time.to')}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={styles.dateInput}
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className={styles.dateApplyBtn} onClick={handleApplyCustom}>
|
||||||
|
{t('monitor.time.apply')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T extends { timestamp?: string }>(
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
8
src/components/monitor/index.ts
Normal file
8
src/components/monitor/index.ts
Normal file
@@ -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';
|
||||||
@@ -2,16 +2,20 @@ import type { PropsWithChildren, ReactNode } from 'react';
|
|||||||
|
|
||||||
interface CardProps {
|
interface CardProps {
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
extra?: ReactNode;
|
extra?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ title, extra, children, className }: PropsWithChildren<CardProps>) {
|
export function Card({ title, subtitle, extra, children, className }: PropsWithChildren<CardProps>) {
|
||||||
return (
|
return (
|
||||||
<div className={className ? `card ${className}` : 'card'}>
|
<div className={className ? `card ${className}` : 'card'}>
|
||||||
{(title || extra) && (
|
{(title || extra) && (
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<div className="title">{title}</div>
|
<div className="card-title-group">
|
||||||
|
<div className="title">{title}</div>
|
||||||
|
{subtitle && <div className="subtitle">{subtitle}</div>}
|
||||||
|
</div>
|
||||||
{extra}
|
{extra}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -314,3 +314,11 @@ export function IconLayoutDashboard({ size = 20, ...props }: IconProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function IconActivity({ size = 20, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
|
<path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ export { useInterval } from './useInterval';
|
|||||||
export { useMediaQuery } from './useMediaQuery';
|
export { useMediaQuery } from './useMediaQuery';
|
||||||
export { usePagination } from './usePagination';
|
export { usePagination } from './usePagination';
|
||||||
export { useHeaderRefresh } from './useHeaderRefresh';
|
export { useHeaderRefresh } from './useHeaderRefresh';
|
||||||
|
export { useDisableModel } from './useDisableModel';
|
||||||
|
export type { UseDisableModelOptions, UseDisableModelReturn } from './useDisableModel';
|
||||||
|
|||||||
164
src/hooks/useDisableModel.ts
Normal file
164
src/hooks/useDisableModel.ts
Normal file
@@ -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<string, string>;
|
||||||
|
providerModels?: Record<string, Set<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDisableModelReturn {
|
||||||
|
/** 当前禁用状态 */
|
||||||
|
disableState: DisableState | null;
|
||||||
|
/** 是否正在禁用中 */
|
||||||
|
disabling: boolean;
|
||||||
|
/** 开始禁用流程 */
|
||||||
|
handleDisableClick: (source: string, model: string) => void;
|
||||||
|
/** 确认禁用(需要点击3次) */
|
||||||
|
handleConfirmDisable: () => Promise<void>;
|
||||||
|
/** 取消禁用 */
|
||||||
|
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<DisableState | null>(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<OpenAIProviderConfig>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记为已禁用(全局状态)
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -95,7 +95,8 @@
|
|||||||
"usage_stats": "Usage Statistics",
|
"usage_stats": "Usage Statistics",
|
||||||
"config_management": "Config Management",
|
"config_management": "Config Management",
|
||||||
"logs": "Logs Viewer",
|
"logs": "Logs Viewer",
|
||||||
"system_info": "Management Center Info"
|
"system_info": "Management Center Info",
|
||||||
|
"monitor": "Monitor Center"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -906,5 +907,160 @@
|
|||||||
"build_date": "Build Time",
|
"build_date": "Build Time",
|
||||||
"version": "Management UI Version",
|
"version": "Management UI Version",
|
||||||
"author": "Author"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,8 @@
|
|||||||
"usage_stats": "使用统计",
|
"usage_stats": "使用统计",
|
||||||
"config_management": "配置管理",
|
"config_management": "配置管理",
|
||||||
"logs": "日志查看",
|
"logs": "日志查看",
|
||||||
"system_info": "中心信息"
|
"system_info": "中心信息",
|
||||||
|
"monitor": "监控中心"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "仪表盘",
|
"title": "仪表盘",
|
||||||
@@ -906,5 +907,160 @@
|
|||||||
"build_date": "构建时间",
|
"build_date": "构建时间",
|
||||||
"version": "管理中心版本",
|
"version": "管理中心版本",
|
||||||
"author": "作者"
|
"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}} 渠道不支持禁用操作"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1082
src/pages/MonitorPage.module.scss
Normal file
1082
src/pages/MonitorPage.module.scss
Normal file
File diff suppressed because it is too large
Load Diff
301
src/pages/MonitorPage.tsx
Normal file
301
src/pages/MonitorPage.tsx
Normal file
@@ -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<string, {
|
||||||
|
models: Record<string, {
|
||||||
|
details: UsageDetail[];
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(null);
|
||||||
|
const [usageData, setUsageData] = useState<UsageData | null>(null);
|
||||||
|
const [timeRange, setTimeRange] = useState<TimeRange>(7);
|
||||||
|
const [apiFilter, setApiFilter] = useState('');
|
||||||
|
const [providerMap, setProviderMap] = useState<Record<string, string>>({});
|
||||||
|
const [providerModels, setProviderModels] = useState<Record<string, Set<string>>>({});
|
||||||
|
|
||||||
|
// 加载渠道名称映射(参照原始 Web UI 的映射方式)
|
||||||
|
const loadProviderMap = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const providers = await providersApi.getOpenAIProviders();
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
const modelsMap: Record<string, Set<string>> = {};
|
||||||
|
providers.forEach((provider) => {
|
||||||
|
// 使用 X-Provider header 或 name 作为渠道名称
|
||||||
|
const providerName = provider.headers?.['X-Provider'] || provider.name || 'unknown';
|
||||||
|
// 存储每个渠道的可用模型(使用 alias 和 name 作为标识)
|
||||||
|
const modelSet = new Set<string>();
|
||||||
|
(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<string, { details: UsageDetail[] }> = {};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{loading && !usageData && (
|
||||||
|
<div className={styles.loadingOverlay} aria-busy="true">
|
||||||
|
<div className={styles.loadingOverlayContent}>
|
||||||
|
<LoadingSpinner size={28} className={styles.loadingOverlaySpinner} />
|
||||||
|
<span className={styles.loadingOverlayText}>{t('common.loading')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h1 className={styles.pageTitle}>{t('monitor.title')}</h1>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? t('common.loading') : t('common.refresh')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && <div className={styles.errorBox}>{error}</div>}
|
||||||
|
|
||||||
|
{/* 时间范围和 API 过滤 */}
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<div className={styles.filterGroup}>
|
||||||
|
<span className={styles.filterLabel}>{t('monitor.time_range')}</span>
|
||||||
|
<div className={styles.timeButtons}>
|
||||||
|
{([1, 7, 14, 30] as TimeRange[]).map((range) => (
|
||||||
|
<button
|
||||||
|
key={range}
|
||||||
|
className={`${styles.timeButton} ${timeRange === range ? styles.active : ''}`}
|
||||||
|
onClick={() => handleTimeRangeChange(range)}
|
||||||
|
>
|
||||||
|
{range === 1 ? t('monitor.today') : t('monitor.last_n_days', { n: range })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.filterGroup}>
|
||||||
|
<span className={styles.filterLabel}>{t('monitor.api_filter')}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.filterInput}
|
||||||
|
placeholder={t('monitor.api_filter_placeholder')}
|
||||||
|
value={apiFilter}
|
||||||
|
onChange={(e) => setApiFilter(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" size="sm" onClick={handleApiFilterApply}>
|
||||||
|
{t('monitor.apply')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI 卡片 */}
|
||||||
|
<KpiCards data={filteredData} loading={loading} timeRange={timeRange} />
|
||||||
|
|
||||||
|
{/* 图表区域 */}
|
||||||
|
<div className={styles.chartsGrid}>
|
||||||
|
<ModelDistributionChart data={filteredData} loading={loading} isDark={isDark} timeRange={timeRange} />
|
||||||
|
<DailyTrendChart data={filteredData} loading={loading} isDark={isDark} timeRange={timeRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 小时级图表 */}
|
||||||
|
<HourlyModelChart data={filteredData} loading={loading} isDark={isDark} />
|
||||||
|
<HourlyTokenChart data={filteredData} loading={loading} isDark={isDark} />
|
||||||
|
|
||||||
|
{/* 统计表格 */}
|
||||||
|
<div className={styles.statsGrid}>
|
||||||
|
<ChannelStats data={filteredData} loading={loading} providerMap={providerMap} providerModels={providerModels} />
|
||||||
|
<FailureAnalysis data={filteredData} loading={loading} providerMap={providerMap} providerModels={providerModels} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 请求日志 */}
|
||||||
|
<RequestLogs
|
||||||
|
data={filteredData}
|
||||||
|
loading={loading}
|
||||||
|
providerMap={providerMap}
|
||||||
|
apiFilter={apiFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { UsagePage } from '@/pages/UsagePage';
|
|||||||
import { ConfigPage } from '@/pages/ConfigPage';
|
import { ConfigPage } from '@/pages/ConfigPage';
|
||||||
import { LogsPage } from '@/pages/LogsPage';
|
import { LogsPage } from '@/pages/LogsPage';
|
||||||
import { SystemPage } from '@/pages/SystemPage';
|
import { SystemPage } from '@/pages/SystemPage';
|
||||||
|
import { MonitorPage } from '@/pages/MonitorPage';
|
||||||
|
|
||||||
const mainRoutes = [
|
const mainRoutes = [
|
||||||
{ path: '/', element: <DashboardPage /> },
|
{ path: '/', element: <DashboardPage /> },
|
||||||
@@ -24,6 +25,7 @@ const mainRoutes = [
|
|||||||
{ path: '/config', element: <ConfigPage /> },
|
{ path: '/config', element: <ConfigPage /> },
|
||||||
{ path: '/logs', element: <LogsPage /> },
|
{ path: '/logs', element: <LogsPage /> },
|
||||||
{ path: '/system', element: <SystemPage /> },
|
{ path: '/system', element: <SystemPage /> },
|
||||||
|
{ path: '/monitor', element: <MonitorPage /> },
|
||||||
{ path: '*', element: <Navigate to="/" replace /> },
|
{ path: '*', element: <Navigate to="/" replace /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -194,5 +194,14 @@ export const providersApi = {
|
|||||||
apiClient.patch('/openai-compatibility', { index, value: serializeOpenAIProvider(value) }),
|
apiClient.patch('/openai-compatibility', { index, value: serializeOpenAIProvider(value) }),
|
||||||
|
|
||||||
deleteOpenAIProvider: (name: string) =>
|
deleteOpenAIProvider: (name: string) =>
|
||||||
apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`)
|
apiClient.delete(`/openai-compatibility?name=${encodeURIComponent(name)}`),
|
||||||
|
|
||||||
|
// 通过 name 更新 OpenAI 兼容提供商(用于禁用模型)
|
||||||
|
patchOpenAIProviderByName: (name: string, value: Partial<OpenAIProviderConfig>) => {
|
||||||
|
const payload: Record<string, any> = {};
|
||||||
|
if (value.models !== undefined) {
|
||||||
|
payload.models = serializeModelAliases(value.models);
|
||||||
|
}
|
||||||
|
return apiClient.patch('/openai-compatibility', { name, value: payload });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export { useAuthStore } from './useAuthStore';
|
|||||||
export { useConfigStore } from './useConfigStore';
|
export { useConfigStore } from './useConfigStore';
|
||||||
export { useModelsStore } from './useModelsStore';
|
export { useModelsStore } from './useModelsStore';
|
||||||
export { useQuotaStore } from './useQuotaStore';
|
export { useQuotaStore } from './useQuotaStore';
|
||||||
|
export { useDisabledModelsStore } from './useDisabledModelsStore';
|
||||||
|
|||||||
50
src/stores/useDisabledModelsStore.ts
Normal file
50
src/stores/useDisabledModelsStore.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 禁用模型状态管理
|
||||||
|
* 全局管理已禁用的模型,确保所有组件状态同步
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface DisabledModelsState {
|
||||||
|
/** 已禁用的模型集合,格式:`${source}|||${model}` */
|
||||||
|
disabledModels: Set<string>;
|
||||||
|
/** 添加禁用模型 */
|
||||||
|
addDisabledModel: (source: string, model: string) => void;
|
||||||
|
/** 移除禁用模型(恢复) */
|
||||||
|
removeDisabledModel: (source: string, model: string) => void;
|
||||||
|
/** 检查模型是否已禁用 */
|
||||||
|
isDisabled: (source: string, model: string) => boolean;
|
||||||
|
/** 清空所有禁用状态 */
|
||||||
|
clearAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDisabledModelsStore = create<DisabledModelsState>()((set, get) => ({
|
||||||
|
disabledModels: new Set<string>(),
|
||||||
|
|
||||||
|
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() });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -116,14 +116,42 @@ textarea {
|
|||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: $spacing-md;
|
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 {
|
.title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--text-primary);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
264
src/utils/monitor.ts
Normal file
264
src/utils/monitor.ts
Normal file
@@ -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, string>
|
||||||
|
): 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, string>
|
||||||
|
): 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<string, string>
|
||||||
|
): { 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, string>
|
||||||
|
): 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<string, Set<string>>
|
||||||
|
): 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<string>,
|
||||||
|
providerModels: Record<string, Set<string>>
|
||||||
|
): 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<string, string>
|
||||||
|
): 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<string, { details: UsageData['apis'][string]['models'][string]['details'] }> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user