Files
Cli-Proxy-API-Management-Ce…/src/components/monitor/HourlyTokenChart.tsx
kongkongyo 290ce72948 fix(monitor): correct timezone conversion in monitor charts
Changes:
- Correct time label parsing in hourly model request distribution chart
- Correct time label parsing in hourly token usage chart
- Mark ISO time strings as UTC to enable proper browser conversion to user's local time

Modified files:
- src/components/monitor/HourlyModelChart.tsx (modified)
- src/components/monitor/HourlyTokenChart.tsx (modified)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 18:19:40 +08:00

272 lines
8.4 KiB
TypeScript

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) => {
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>
);
}