feat: 优化监控图表展示

- 增强 MonitorChart 组件功能和样式
- 优化 ResponseTimeChart 图表显示
- 添加可选择图例功能
- 统一图表插值和 connectNulls 配置
- 优化 MiniChart 和 MonitorCard 展示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yumusbandClaude Opus 4.6 committed 2026-04-10 12:53:04 +08:00
1 parent ffd1461b80
commit 85a55af6a5
11 files changed
+779 -186

No files matched your search

@@ -61,6 +61,7 @@ export const MiniChart = ({data, lastValue, id}: MiniChartProps) => {
strokeWidth={2}
filter={`url(#glow-${id})`}
isAnimationActive={false}
connectNulls
dot={false}
/>
</AreaChart>
@@ -27,31 +27,67 @@ const MonitorCard = ({monitor, displayMode}: {
staleTime: 30000,
});
// 转换时序数据为图表数据 - 保持原始 timestamp
// 转换时序数据为图表数据 - 使用统一格点对该对齐多探针数据
const chartData = useMemo(() => {
if (!historyData?.series || historyData.series.length === 0) {
return [];
}
// 按时间戳聚合多个探针的数据
const timeMap = new Map<number, number[]>();
const validSeries = historyData.series.filter(s => s.data && s.data.length > 0);
if (validSeries.length === 0) return [];
historyData.series.forEach(series => {
series.data?.forEach(point => {
if (!timeMap.has(point.timestamp)) {
timeMap.set(point.timestamp, []);
}
timeMap.get(point.timestamp)!.push(point.value);
});
// 确定全局时间范围
let minTime = Infinity, maxTime = -Infinity;
validSeries.forEach(s => {
minTime = Math.min(minTime, s.data![0].timestamp);
maxTime = Math.max(maxTime, s.data![s.data!.length - 1].timestamp);
});
// 根据显示模式计算聚合值
return Array.from(timeMap.entries()).map(([timestamp, values]) => ({
timestamp,
value: displayMode === 'avg'
? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
: Math.max(...values),
}));
if (minTime >= maxTime) return [];
// 定义目标采集点 (1小时数据,建议 60 个采集点)
const maxPoints = 60;
const timeStep = (maxTime - minTime) / (maxPoints - 1);
const targetTimestamps: number[] = [];
for (let i = 0; i < maxPoints; i++) {
targetTimestamps.push(minTime + i * timeStep);
}
// 线性插值函数
const interpolate = (data: Array<{ timestamp: number; value: number }>, targetTime: number): number | null => {
if (data.length === 0) return null;
if (data.length === 1) return data[0].timestamp === targetTime ? data[0].value : null;
if (targetTime < data[0].timestamp || targetTime > data[data.length - 1].timestamp) return null;
let left = 0, right = data.length - 1;
while (right - left > 1) {
const mid = Math.floor((left + right) / 2);
if (data[mid].timestamp <= targetTime) left = mid;
else right = mid;
}
const leftPoint = data[left];
const rightPoint = data[right];
const ratio = (targetTime - leftPoint.timestamp) / (rightPoint.timestamp - leftPoint.timestamp);
return leftPoint.value + ratio * (rightPoint.value - leftPoint.value);
};
// 对每个目标时间点,计算所有探针的聚合值
return targetTimestamps.map(timestamp => {
const values: number[] = [];
validSeries.forEach(s => {
const val = interpolate(s.data!, timestamp);
if (val !== null) values.push(val);
});
if (values.length === 0) return { timestamp, value: 0 };
return {
timestamp,
value: displayMode === 'avg'
? Math.round(values.reduce((a, b) => a + b, 0) / values.length)
: Math.max(...values),
};
});
}, [historyData, displayMode]);
const displayValue = displayMode === 'avg' ? monitor.responseTime : monitor.responseTimeMax;
@@ -1,8 +1,7 @@
import {useEffect, useMemo, useState} from 'react';
import {useQuery} from '@tanstack/react-query';
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {type GetMetricsResponse, getMonitorHistory} from '@/api/monitor';
import {AGENT_COLORS} from '@portal/constants/colors';
import {MONITOR_TIME_RANGE_OPTIONS} from '@portal/constants/time';
import {useIsMobile} from '@portal/hooks/use-mobile';
import type {AgentMonitorStat} from '@/types';
@@ -11,20 +10,112 @@ import {ChartPlaceholder} from "@portal/components/ChartPlaceholder";
import {CustomTooltip} from "@portal/components/CustomTooltip";
import {TimeRangeSelector} from "@portal/components/TimeRangeSelector";
import {formatChartTime} from '@/lib/format.ts';
import {RotateCcw, ChevronDown, ChevronUp} from 'lucide-react';
interface ResponseTimeChartProps {
monitorId: string;
monitorStats: AgentMonitorStat[];
}
// 移除 LTTB 降采样,改用统一格点对齐插值以获得更好的多序列对齐效果
/**
* 根据时间范围确定最大数据点数
*/
const getMaxDataPoints = (timeRange: string): number => {
switch (timeRange) {
case '15m':
case '1h':
return 200; // 短时间:详细数据
case '12h':
return 300;
case '24h':
return 400;
case '7d':
return 500;
case '30d':
return 600;
default:
return 400;
}
};
/**
* 生成不重复的颜色
* 使用 HSL 色轮均匀分布,支持无限数量的探针
*/
const generateColors = (count: number): string[] => {
const colors: string[] = [];
const hueStep = 360 / count;
for (let i = 0; i < count; i++) {
const hue = (i * hueStep) % 360;
const saturation = 65 + (i % 3) * 10;
const lightness = 45 + (i % 2) * 10;
colors.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);
}
return colors;
};
/**
* 自定义图例组件
*/
const CustomLegend = ({ onClick, selectedAgents, allAgents, colors, collapsed }: any) => {
if (!allAgents || allAgents.length === 0) return null;
if (collapsed) return null;
return (
<div className="flex flex-wrap justify-center gap-4 pt-4">
{allAgents.map((agent: { id: string; name: string }, index: number) => {
const isSelected = selectedAgents.has(agent.id);
const color = colors[index];
return (
<div
key={agent.id}
onClick={() => onClick(agent.id)}
className="flex items-center gap-2 cursor-pointer transition-opacity"
style={{
opacity: isSelected ? 1 : 0.4,
}}
>
<svg width="32" height="12" className="overflow-visible">
<line
x1="0"
y1="6"
x2="32"
y2="6"
stroke={isSelected ? color : '#9ca3af'}
strokeWidth="2"
/>
</svg>
<span
className="text-xs font-medium"
style={{
color: isSelected ? color : '#9ca3af',
}}
>
{agent.name}
</span>
</div>
);
})}
</div>
);
};
/**
* 响应时间趋势图表组件
* 显示监控各探针的响应时间变化
*/
export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartProps) => {
const [selectedAgent, setSelectedAgent] = useState<string>('all');
const [timeRange, setTimeRange] = useState<string>('12h'); // 对应后端 60 秒步长
const [selectedAgents, setSelectedAgents] = useState<Set<string>>(new Set());
const [timeRange, setTimeRange] = useState<string>('12h');
const [customRange, setCustomRange] = useState<{ start: number; end: number } | null>(null);
const [legendCollapsed, setLegendCollapsed] = useState(true); // 移动端默认收起
const isMobile = useIsMobile();
const customStart = timeRange === 'custom' ? customRange?.start : undefined;
const customEnd = timeRange === 'custom' ? customRange?.end : undefined;
@@ -55,67 +146,158 @@ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartPr
}));
}, [monitorStats]);
// 当可用探针列表变化时,检查当前选择的探针是否还存在
// 初始化选中所有探针
useEffect(() => {
if (selectedAgent === 'all') return;
if (!availableAgents.find(agent => agent.id === selectedAgent)) {
setSelectedAgent('all');
if (availableAgents.length > 0 && selectedAgents.size === 0) {
setSelectedAgents(new Set(availableAgents.map(a => a.id)));
}
}, [availableAgents, selectedAgent]);
}, [availableAgents, selectedAgents.size]);
// 生成图表数据
// 动态生成颜色
const colors = useMemo(() => {
return generateColors(availableAgents.length);
}, [availableAgents.length]);
// 点击图例切换选中状态
const handleLegendClick = (agentId: string) => {
const newSelected = new Set(selectedAgents);
// 判断是否是全选状态
const isAllSelected = selectedAgents.size === availableAgents.length;
if (isAllSelected) {
// 全选状态下,点击某个 → 只选这一个
setSelectedAgents(new Set([agentId]));
} else {
// 非全选状态下,点击切换
if (newSelected.has(agentId)) {
newSelected.delete(agentId);
} else {
newSelected.add(agentId);
}
setSelectedAgents(newSelected);
}
};
// 点击图表线条切换选中状态
const handleAreaClick = (data: any) => {
if (!data || !data.dataKey) return;
// 从 dataKey 中提取 agentId (格式: agent_${agentId})
const agentId = data.dataKey.replace('agent_', '');
const newSelected = new Set(selectedAgents);
// 判断是否是全选状态
const isAllSelected = selectedAgents.size === availableAgents.length;
if (isAllSelected) {
// 全选状态下,点击某个 → 只选这一个
setSelectedAgents(new Set([agentId]));
} else {
// 非全选状态下,点击切换
if (newSelected.has(agentId)) {
newSelected.delete(agentId);
} else {
newSelected.add(agentId);
}
setSelectedAgents(newSelected);
}
};
// 恢复全选
const handleSelectAll = () => {
setSelectedAgents(new Set(availableAgents.map(a => a.id)));
};
// 是否有探针未选中
const hasUnselected = selectedAgents.size < availableAgents.length;
// 切换图例显示/隐藏(仅移动端)
const toggleLegend = () => {
setLegendCollapsed(!legendCollapsed);
};
// 生成图表数据 - 使用统一时间网格对齐
const chartData = useMemo(() => {
if (!historyData?.series) return [];
// 过滤出响应时间指标的 series
const responseTimeSeries = historyData.series?.filter(s => s.name === 'response_time');
const seriesList = historyData.series.filter(s => s.name === 'response_time');
if (seriesList.length === 0) return [];
// 根据选择的探针过滤(使用 agent_id)
const filteredSeries = selectedAgent === 'all'
? responseTimeSeries
: responseTimeSeries.filter(s => s.labels?.agent_id === selectedAgent);
if (filteredSeries.length === 0) return [];
// 按时间戳分组数据
const grouped: Record<number, any> = {};
filteredSeries.forEach(series => {
// 使用 agent_id 作为标识符
const agentId = series.labels?.agent_id || 'unknown';
const agentKey = `agent_${agentId}`;
series.data.forEach(point => {
if (!grouped[point.timestamp]) {
grouped[point.timestamp] = {
timestamp: point.timestamp,
};
}
grouped[point.timestamp][agentKey] = point.value;
// 收集所有选中探针的数据
const selectedSeriesData: Array<{ key: string; data: Array<{ timestamp: number; value: number }> }> = [];
seriesList.forEach((s) => {
const agentId = s.labels?.agent_id || 'unknown';
if (!selectedAgents.has(agentId)) return;
if (!s.data || s.data.length === 0) return;
selectedSeriesData.push({
key: `agent_${agentId}`,
data: [...s.data].sort((a, b) => a.timestamp - b.timestamp)
});
});
// 按时间戳排序
return Object.values(grouped).sort((a, b) => a.timestamp - b.timestamp);
}, [historyData, selectedAgent, timeRange, customStart, customEnd]);
if (selectedSeriesData.length === 0) return [];
const visibleMonitorStats = useMemo(() => {
return monitorStats.filter(stat => selectedAgent === 'all' || stat.agentId === selectedAgent);
}, [monitorStats, selectedAgent]);
// 准备移动端图例数据
const legendItems = useMemo(() => {
return visibleMonitorStats.map((stat) => {
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length];
const label = stat.agentName || stat.agentId.substring(0, 8);
return {
key: stat.agentId,
label,
color,
};
// 确定全局时间范围
let minTime = Infinity, maxTime = -Infinity;
selectedSeriesData.forEach(s => {
minTime = Math.min(minTime, s.data[0].timestamp);
maxTime = Math.max(maxTime, s.data[s.data.length - 1].timestamp);
});
}, [visibleMonitorStats, monitorStats]);
if (minTime >= maxTime) return [];
// 生成均匀的目标时间点
const maxPoints = getMaxDataPoints(timeRange);
const timeStep = (maxTime - minTime) / (maxPoints - 1);
const targetTimestamps: number[] = [];
for (let i = 0; i < maxPoints; i++) {
targetTimestamps.push(minTime + i * timeStep);
}
// 线性插值函数
const interpolate = (data: Array<{ timestamp: number; value: number }>, targetTime: number): number | null => {
if (data.length === 0) return null;
if (data.length === 1) return data[0].timestamp === targetTime ? data[0].value : null;
// 超出范围不插值(防止产生虚假连线)
if (targetTime < data[0].timestamp || targetTime > data[data.length - 1].timestamp) {
// 如果距离最近的点足够近(比如小于两个采样间隔),可以考虑保留,否则返回null
return null;
}
// 二分查找
let left = 0, right = data.length - 1;
while (right - left > 1) {
const mid = Math.floor((left + right) / 2);
if (data[mid].timestamp <= targetTime) {
left = mid;
} else {
right = mid;
}
}
const leftPoint = data[left];
const rightPoint = data[right];
const ratio = (targetTime - leftPoint.timestamp) / (rightPoint.timestamp - leftPoint.timestamp);
return leftPoint.value + ratio * (rightPoint.value - leftPoint.value);
};
// 对齐所有探针数据
return targetTimestamps.map(timestamp => {
const dataPoint: any = { timestamp };
selectedSeriesData.forEach(s => {
const value = interpolate(s.data, timestamp);
if (value !== null) {
dataPoint[s.key] = Number(value.toFixed(2));
}
});
return dataPoint;
});
}, [historyData, selectedAgents, timeRange, customStart, customEnd]);
return (
<CyberCard className={'p-6'}>
@@ -135,37 +317,44 @@ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartPr
setCustomRange(range);
}}
/>
{availableAgents.length > 0 && (
<select
value={selectedAgent}
onChange={(e) => setSelectedAgent(e.target.value)}
className="rounded-lg border border-slate-200 dark:border-cyan-900/50 bg-white dark:bg-black/40 px-3 py-2 text-xs font-medium text-gray-700 dark:text-cyan-300 hover:border-slate-300 dark:hover:border-cyan-500/50 focus:border-slate-400 dark:focus:border-cyan-500 focus:outline-none focus:ring-2 focus:ring-slate-200 dark:focus:ring-cyan-500/20 transition-colors font-mono"
>
<option value="all">所有探针</option>
{availableAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.name}
</option>
))}
</select>
)}
</div>
</div>
{/* 使用提示和恢复按钮 */}
{availableAgents.length > 0 && (
<div className="mb-3 flex items-center justify-between">
<div className="text-xs text-gray-500 dark:text-cyan-600">
💡 点击图表线条或图例切换显示
</div>
{hasUnselected && (
<button
onClick={handleSelectAll}
className="p-1.5 rounded
text-gray-500 dark:text-cyan-500
hover:text-gray-700 dark:hover:text-cyan-400
hover:bg-gray-100 dark:hover:bg-cyan-900/30
transition-colors"
title="恢复全选"
>
<RotateCcw size={16} />
</button>
)}
</div>
)}
{chartData.length > 0 ? (
<div>
<ResponsiveContainer width="100%" height={360}>
<AreaChart data={chartData}>
<defs>
{visibleMonitorStats.map((stat) => {
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
const agentKey = `agent_${stat.agentId}`;
const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length];
{Array.from(selectedAgents).map((agentId, index) => {
const originalIndex = availableAgents.findIndex(a => a.id === agentId);
const agentKey = `agent_${agentId}`;
return (
<linearGradient key={agentKey} id={`gradient_${agentKey}`} x1="0" y1="0"
x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3}/>
<stop offset="95%" stopColor={color} stopOpacity={0}/>
<stop offset="5%" stopColor={colors[originalIndex]} stopOpacity={0.3}/>
<stop offset="95%" stopColor={colors[originalIndex]} stopOpacity={0}/>
</linearGradient>
);
})}
@@ -199,33 +388,59 @@ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartPr
content={<CustomTooltip unit={'ms'}/>}
wrapperStyle={{zIndex: 9999}}
/>
{!isMobile && (
<Legend
wrapperStyle={{paddingTop: '20px', zIndex: 1}}
iconType="circle"
/>
)}
{visibleMonitorStats.map((stat) => {
const originalIndex = monitorStats.findIndex(s => s.agentId === stat.agentId);
const agentKey = `agent_${stat.agentId}`;
const color = AGENT_COLORS[originalIndex % AGENT_COLORS.length];
const agentLabel = stat.agentName || stat.agentId.substring(0, 8);
{Array.from(selectedAgents).map((agentId) => {
const originalIndex = availableAgents.findIndex(a => a.id === agentId);
const agentKey = `agent_${agentId}`;
const agent = availableAgents.find(a => a.id === agentId);
return (
<Area
key={agentKey}
type="monotone"
dataKey={agentKey}
name={agentLabel}
stroke={color}
name={agent?.name || agentId.substring(0, 8)}
stroke={colors[originalIndex]}
strokeWidth={2}
fill={`url(#gradient_${agentKey})`}
activeDot={{r: 5, strokeWidth: 0}}
dot={false}
connectNulls
onClick={handleAreaClick}
style={{cursor: 'pointer'}}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
{/* 桌面端:直接显示图例 */}
{!isMobile && availableAgents.length > 0 && (
<CustomLegend
onClick={handleLegendClick}
selectedAgents={selectedAgents}
allAgents={availableAgents}
colors={colors}
/>
)}
{/* 移动端:可折叠图例 */}
{isMobile && availableAgents.length > 0 && (
<div className="pt-4">
<button
onClick={toggleLegend}
className="w-full flex items-center justify-center gap-2 py-2 text-xs text-gray-600 dark:text-cyan-400 hover:text-gray-900 dark:hover:text-cyan-300"
>
<span>{legendCollapsed ? '显示图例' : '收起图例'}</span>
{legendCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</button>
<CustomLegend
onClick={handleLegendClick}
selectedAgents={selectedAgents}
allAgents={availableAgents}
colors={colors}
collapsed={legendCollapsed}
/>
</div>
)}
</div>
) : (
<ChartPlaceholder
@@ -86,6 +86,7 @@ export const CpuChart = ({agentId, timeRange, start, end}: CpuChartProps) => {
strokeWidth={2}
fill="url(#cpuAreaGradient)"
activeDot={{r: 3}}
connectNulls
/>
</AreaChart>
</ResponsiveContainer>
@@ -109,6 +109,7 @@ export const DiskIOChart = ({agentId, timeRange, start, end}: DiskIOChartProps)
strokeWidth={2}
fill="url(#colorDiskRead)"
activeDot={{r: 3}}
connectNulls
/>
<Area
type="monotone"
@@ -118,6 +119,7 @@ export const DiskIOChart = ({agentId, timeRange, start, end}: DiskIOChartProps)
strokeWidth={2}
fill="url(#colorDiskWrite)"
activeDot={{r: 3}}
connectNulls
/>
</AreaChart>
</ResponsiveContainer>
@@ -113,6 +113,7 @@ export const GpuChart = ({agentId, timeRange, start, end}: GpuChartProps) => {
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
connectNulls
/>
<Line
yAxisId="right"
@@ -123,6 +124,7 @@ export const GpuChart = ({agentId, timeRange, start, end}: GpuChartProps) => {
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
@@ -86,6 +86,7 @@ export const MemoryChart = ({agentId, timeRange, start, end}: MemoryChartProps)
strokeWidth={2}
fill="url(#memoryAreaGradient)"
activeDot={{r: 3}}
connectNulls
/>
</AreaChart>
</ResponsiveContainer>
+382 -58
View File
@@ -1,6 +1,6 @@
import {useMemo} from 'react';
import {Activity} from 'lucide-react';
import {Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {useEffect, useMemo, useState} from 'react';
import {Activity, RotateCcw, ChevronDown, ChevronUp} from 'lucide-react';
import {Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
import {ChartPlaceholder} from '@portal/components/ChartPlaceholder';
import {CustomTooltip} from '@portal/components/CustomTooltip';
import {useMetricsQuery} from '@portal/hooks/server';
@@ -15,12 +15,150 @@ interface MonitorChartProps {
end?: number;
}
/**
* 降采样算法 - 使用LTTB (Largest Triangle Three Buckets)
* 确保输出精确的maxPoints个点,保留关键特征
*/
const downsampleData = (data: any[], maxPoints: number): any[] => {
// 边界检查
if (!data || data.length === 0) return [];
if (maxPoints < 2) maxPoints = 2;
if (data.length <= maxPoints) return [...data];
const result: any[] = [data[0]]; // 保留第一个点
// 桶大小
const bucketSize = (data.length - 2) / (maxPoints - 2);
for (let i = 0; i < maxPoints - 2; i++) {
// 计算当前桶的范围
const start = Math.floor((i + 0) * bucketSize) + 1;
const end = Math.floor((i + 1) * bucketSize) + 1;
// 计算前一个点和后一个点
const previousPoint = result[result.length - 1];
const nextPoint = data[Math.min(end, data.length - 1)];
// 在桶中选择与前后点形成的三角形面积最大的点
let maxArea = -1;
let selectedPoint = data[start];
for (let j = start; j < end && j < data.length - 1; j++) {
// 计算三角形面积
const area = Math.abs(
(previousPoint.timestamp - nextPoint.timestamp) * (data[j].value - previousPoint.value) -
(previousPoint.timestamp - data[j].timestamp) * (nextPoint.value - previousPoint.value)
);
if (area > maxArea) {
maxArea = area;
selectedPoint = data[j];
}
}
result.push(selectedPoint);
}
result.push(data[data.length - 1]); // 保留最后一个点
return result;
};
/**
* 根据时间范围确定最大数据点数
*/
const getMaxDataPoints = (timeRange: string): number => {
switch (timeRange) {
case '15m':
case '1h':
return 200; // 短时间:详细数据
case '12h':
return 300;
case '24h':
return 400;
case '7d':
return 500;
case '30d':
return 600;
default:
return 400;
}
};
/**
* 生成不重复的颜色
* 使用 HSL 色轮均匀分布,支持无限数量的监控项
*/
const generateColors = (count: number): string[] => {
const colors: string[] = [];
const hueStep = 360 / count; // 色相间隔
for (let i = 0; i < count; i++) {
const hue = (i * hueStep) % 360;
const saturation = 65 + (i % 3) * 10; // 65%, 75%, 85% 循环
const lightness = 45 + (i % 2) * 10; // 45%, 55% 循环
colors.push(`hsl(${hue}, ${saturation}%, ${lightness}%)`);
}
return colors;
};
/**
* 自定义图例组件
*/
const CustomLegend = ({ onClick, selectedMonitors, allMonitorKeys, colors, collapsed }: any) => {
if (!allMonitorKeys || allMonitorKeys.length === 0) return null;
if (collapsed) return null;
return (
<div className="flex flex-wrap justify-center gap-4 pt-4">
{allMonitorKeys.map((monitorKey: string, index: number) => {
const isSelected = selectedMonitors.has(monitorKey);
const color = colors[index];
return (
<div
key={monitorKey}
onClick={() => onClick({ value: monitorKey })}
className="flex items-center gap-2 cursor-pointer transition-opacity"
style={{
opacity: isSelected ? 1 : 0.4,
}}
>
<svg width="32" height="12" className="overflow-visible">
<line
x1="0"
y1="6"
x2="32"
y2="6"
stroke={isSelected ? color : '#9ca3af'}
strokeWidth="2"
/>
</svg>
<span
className="text-xs font-medium"
style={{
color: isSelected ? color : '#9ca3af',
}}
>
{monitorKey}
</span>
</div>
);
})}
</div>
);
};
/**
* 监控响应时间图表组件
*/
export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps) => {
const isMobile = useIsMobile();
const rangeMs = start !== undefined && end !== undefined ? end - start : undefined;
const [selectedMonitors, setSelectedMonitors] = useState<Set<string>>(new Set());
const [legendCollapsed, setLegendCollapsed] = useState(true); // 移动端默认收起
// 数据查询
const {data: metricsResponse, isLoading} = useMetricsQuery({
@@ -31,41 +169,172 @@ export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps
end,
});
// 数据转换 - 支持多个监控任务
const chartData = useMemo(() => {
const series = metricsResponse?.data.series || [];
if (series.length === 0) return [];
// 收集所有时间戳
const timestampMap = new Map<number, any>();
series.forEach((s) => {
s.data.forEach((point) => {
if (!timestampMap.has(point.timestamp)) {
timestampMap.set(point.timestamp, {
timestamp: point.timestamp,
});
}
const dataPoint = timestampMap.get(point.timestamp);
// 使用 monitor_name 作为 key,如果没有则使用 monitor_id
const monitorKey = s.labels?.monitor_name || s.labels?.monitor_id || s.name;
dataPoint[monitorKey] = Number(point.value.toFixed(2));
});
});
// 转换为数组并排序
return Array.from(timestampMap.values()).sort((a, b) => a.timestamp - b.timestamp);
}, [metricsResponse, timeRange, start, end]);
// 获取所有监控任务的列表(使用名称)
const monitorKeys = useMemo(() => {
const allMonitorKeys = useMemo(() => {
const series = metricsResponse?.data.series || [];
return series.map(s => s.labels?.monitor_name || s.labels?.monitor_id || s.name);
}, [metricsResponse]);
// 颜色配置
const colors = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
// 初始化选中所有监控任务
useEffect(() => {
if (allMonitorKeys.length > 0 && selectedMonitors.size === 0) {
setSelectedMonitors(new Set(allMonitorKeys));
}
}, [allMonitorKeys, selectedMonitors.size]);
// 过滤后的监控任务列表
const monitorKeys = useMemo(() => {
return allMonitorKeys.filter(key => selectedMonitors.has(key));
}, [allMonitorKeys, selectedMonitors]);
// 数据转换 - 支持多个监控任务(统一时间轴 + 线性插值)
const chartData = useMemo(() => {
const series = metricsResponse?.data.series || [];
if (series.length === 0) return [];
// 收集所有监控任务的数据
const seriesDataArray: Array<{ key: string; data: Array<{ timestamp: number; value: number }> }> = [];
series.forEach((s) => {
const monitorKey = s.labels?.monitor_name || s.labels?.monitor_id || s.name;
if (!selectedMonitors.has(monitorKey)) return;
if (!s.data || s.data.length === 0) return;
seriesDataArray.push({
key: monitorKey,
data: [...s.data].sort((a, b) => a.timestamp - b.timestamp)
});
});
if (seriesDataArray.length === 0) return [];
// 取所有监控任务时间范围的交集,确保每个时间点所有任务都有数据
let minTime = -Infinity, maxTime = Infinity;
seriesDataArray.forEach(s => {
if (s.data.length > 0) {
minTime = Math.max(minTime, s.data[0].timestamp);
maxTime = Math.min(maxTime, s.data[s.data.length - 1].timestamp);
}
});
// 如果没有交集,返回空数组
if (minTime >= maxTime) return [];
// 均匀生成目标时间点
const maxPoints = getMaxDataPoints(timeRange);
const timeStep = (maxTime - minTime) / (maxPoints - 1);
const targetTimestamps: number[] = [];
for (let i = 0; i < maxPoints; i++) {
targetTimestamps.push(minTime + i * timeStep);
}
// 线性插值函数
const interpolate = (data: Array<{ timestamp: number; value: number }>, targetTime: number): number | null => {
if (data.length === 0) return null;
if (data.length === 1) {
// 单点数据,只有精确匹配才返回
return data[0].timestamp === targetTime ? data[0].value : null;
}
// 如果目标时间在数据范围外,返回 null(断开折线)
if (targetTime < data[0].timestamp || targetTime > data[data.length - 1].timestamp) {
return null;
}
// 二分查找找到 targetTime 前后两个点
let left = 0, right = data.length - 1;
while (right - left > 1) {
const mid = Math.floor((left + right) / 2);
if (data[mid].timestamp <= targetTime) {
left = mid;
} else {
right = mid;
}
}
// 线性插值
const leftPoint = data[left];
const rightPoint = data[right];
const ratio = (targetTime - leftPoint.timestamp) / (rightPoint.timestamp - leftPoint.timestamp);
return leftPoint.value + ratio * (rightPoint.value - leftPoint.value);
};
// 对每个时间点,从每个监控任务中插值获取值
return targetTimestamps.map(timestamp => {
const dataPoint: any = { timestamp };
seriesDataArray.forEach(s => {
const value = interpolate(s.data, timestamp);
if (value !== null) {
dataPoint[s.key] = Number(value.toFixed(2));
}
});
return dataPoint;
});
}, [metricsResponse, selectedMonitors, timeRange, start, end]);
// 动态生成颜色(根据监控项数量)
const colors = useMemo(() => {
return generateColors(allMonitorKeys.length);
}, [allMonitorKeys.length]);
// 点击图表区域切换选中状态
const handleAreaClick = (data: any) => {
if (!data || !data.dataKey) return;
const monitorKey = data.dataKey;
const newSelected = new Set(selectedMonitors);
// 判断是否是全选状态
const isAllSelected = selectedMonitors.size === allMonitorKeys.length;
if (isAllSelected) {
// 全选状态下,点击某个 → 只选这一个
setSelectedMonitors(new Set([monitorKey]));
} else {
// 非全选状态下,点击切换
if (newSelected.has(monitorKey)) {
newSelected.delete(monitorKey);
} else {
newSelected.add(monitorKey);
}
setSelectedMonitors(newSelected);
}
};
// 点击图例切换选中状态
const handleLegendClick = (data: any) => {
const monitorKey = data.value;
const newSelected = new Set(selectedMonitors);
// 判断是否是全选状态
const isAllSelected = selectedMonitors.size === allMonitorKeys.length;
if (isAllSelected) {
// 全选状态下,点击某个 → 只选这一个
setSelectedMonitors(new Set([monitorKey]));
} else {
// 非全选状态下,点击切换
if (newSelected.has(monitorKey)) {
newSelected.delete(monitorKey);
} else {
newSelected.add(monitorKey);
}
setSelectedMonitors(newSelected);
}
};
// 恢复全选
const handleSelectAll = () => {
setSelectedMonitors(new Set(allMonitorKeys));
};
// 是否有监控项未选中(用于显示恢复按钮)
const hasUnselected = selectedMonitors.size < allMonitorKeys.length;
// 切换图例显示/隐藏(仅移动端)
const toggleLegend = () => {
setLegendCollapsed(!legendCollapsed);
};
// 如果没有数据且不是加载中,不渲染组件
if (!isLoading && chartData.length === 0) {
@@ -85,16 +354,41 @@ export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps
<ChartContainer title="监控响应时间" icon={Activity}>
{chartData.length > 0 ? (
<>
{/* 使用提示和恢复按钮 */}
{allMonitorKeys.length > 1 && (
<div className="mb-3 flex items-center justify-between">
<div className="text-xs text-gray-500 dark:text-cyan-600">
💡 点击图表线条或图例切换显示
</div>
{hasUnselected && (
<button
onClick={handleSelectAll}
className="p-1.5 rounded
text-gray-500 dark:text-cyan-500
hover:text-gray-700 dark:hover:text-cyan-400
hover:bg-gray-100 dark:hover:bg-cyan-900/30
transition-colors"
title="恢复全选"
>
<RotateCcw size={16} />
</button>
)}
</div>
)}
<ResponsiveContainer width="100%" height={250}>
<AreaChart data={chartData}>
<defs>
{monitorKeys.map((key, index) => (
<linearGradient key={key} id={`monitorAreaGradient-${index}`} x1="0" y1="0" x2="0"
y2="1">
<stop offset="5%" stopColor={colors[index % colors.length]} stopOpacity={0.4}/>
<stop offset="95%" stopColor={colors[index % colors.length]} stopOpacity={0}/>
</linearGradient>
))}
{monitorKeys.map((key, index) => {
const originalIndex = allMonitorKeys.indexOf(key);
return (
<linearGradient key={key} id={`monitorAreaGradient-${index}`} x1="0" y1="0" x2="0"
y2="1">
<stop offset="5%" stopColor={colors[originalIndex]} stopOpacity={0.4}/>
<stop offset="95%" stopColor={colors[originalIndex]} stopOpacity={0}/>
</linearGradient>
);
})}
</defs>
<CartesianGrid stroke="currentColor" strokeDasharray="4 4"
className="stroke-slate-200 dark:stroke-cyan-900/30"/>
@@ -119,26 +413,56 @@ export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps
content={<CustomTooltip unit="ms"/>}
wrapperStyle={{zIndex: 9999,}}
/>
{!isMobile && monitorKeys.length > 1 && (
<Legend
wrapperStyle={{fontSize: '12px'}}
iconType="line"
/>
)}
{monitorKeys.map((key, index) => (
<Area
key={key}
type="monotone"
dataKey={key}
name={key}
stroke={colors[index % colors.length]}
strokeWidth={2}
fill={`url(#monitorAreaGradient-${index})`}
activeDot={{r: 3}}
/>
))}
{monitorKeys.map((key, index) => {
const originalIndex = allMonitorKeys.indexOf(key);
return (
<Area
key={key}
type="monotone"
dataKey={key}
name={key}
stroke={colors[originalIndex]}
strokeWidth={2}
fill={`url(#monitorAreaGradient-${index})`}
activeDot={{r: 3}}
connectNulls
onClick={handleAreaClick}
style={{cursor: 'pointer'}}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
{/* 桌面端:直接显示图例 */}
{!isMobile && allMonitorKeys.length > 0 && (
<CustomLegend
onClick={handleLegendClick}
selectedMonitors={selectedMonitors}
allMonitorKeys={allMonitorKeys}
colors={colors}
/>
)}
{/* 移动端:可折叠图例 */}
{isMobile && allMonitorKeys.length > 0 && (
<div className="pt-4">
<button
onClick={toggleLegend}
className="w-full flex items-center justify-center gap-2 py-2 text-xs text-gray-600 dark:text-cyan-400 hover:text-gray-900 dark:hover:text-cyan-300"
>
<span>{legendCollapsed ? '显示图例' : '收起图例'}</span>
{legendCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</button>
<CustomLegend
onClick={handleLegendClick}
selectedMonitors={selectedMonitors}
allMonitorKeys={allMonitorKeys}
colors={colors}
collapsed={legendCollapsed}
/>
</div>
)}
</>
) : (
<ChartPlaceholder/>
@@ -141,6 +141,7 @@ export const NetworkChart = ({agentId, timeRange, start, end}: NetworkChartProps
strokeWidth={2}
fill="url(#color-upload)"
activeDot={{r: 3}}
connectNulls
/>
<Area
type="monotone"
@@ -150,6 +151,7 @@ export const NetworkChart = ({agentId, timeRange, start, end}: NetworkChartProps
strokeWidth={2}
fill="url(#color-download)"
activeDot={{r: 3}}
connectNulls
/>
</AreaChart>
</ResponsiveContainer>
@@ -98,6 +98,7 @@ export const NetworkConnectionChart = ({agentId, timeRange, start, end}: Network
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
connectNulls
/>
<Line
type="monotone"
@@ -107,6 +108,7 @@ export const NetworkConnectionChart = ({agentId, timeRange, start, end}: Network
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
connectNulls
/>
<Line
type="monotone"
@@ -116,6 +118,7 @@ export const NetworkConnectionChart = ({agentId, timeRange, start, end}: Network
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
connectNulls
/>
<Line
type="monotone"
@@ -125,6 +128,7 @@ export const NetworkConnectionChart = ({agentId, timeRange, start, end}: Network
strokeWidth={2}
dot={false}
activeDot={{r: 3}}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
+30 -25
View File
@@ -105,10 +105,35 @@ const ServerList = () => {
refetchInterval: 30000,
});
// 计算统计数据
// 计算所有标签(包括ALL和ONLINE/OFFLINE)
const allTags = useMemo(() => {
const tags = ['ALL', 'ONLINE', 'OFFLINE'];
if (tagsData && tagsData.length > 0) {
tagsData.forEach((tag: string) => {
if (!tags.includes(tag.toUpperCase())) {
tags.push(tag.toUpperCase());
}
});
}
return tags;
}, [tagsData]);
// 过滤逻辑
const displayAgents = useMemo(() => {
if (selectedTag === 'ONLINE') {
return agents.filter(a => a.status === 1);
} else if (selectedTag === 'OFFLINE') {
return agents.filter(a => a.status !== 1);
} else if (selectedTag && selectedTag !== 'ALL') {
return agents.filter(a => a.tags?.map(t => t.toUpperCase()).includes(selectedTag));
}
return agents;
}, [agents, selectedTag]);
// 计算统计数据(基于过滤后的 displayAgents)
const stats = useMemo(() => {
const total = agents.length;
const online = agents?.filter(a => a.status === 1).length;
const total = displayAgents.length;
const online = displayAgents.filter(a => a.status === 1).length;
const offline = total - online;
// 计算网络统计
@@ -117,7 +142,7 @@ const ServerList = () => {
let totalUploadTotal = 0;
let totalDownloadTotal = 0;
agents.forEach(agent => {
displayAgents.forEach(agent => {
if (agent.status === 1 && agent.metrics?.network) {
totalUploadRate += agent.metrics.network.totalBytesSentRate || 0;
totalDownloadRate += agent.metrics.network.totalBytesRecvRate || 0;
@@ -135,7 +160,7 @@ const ServerList = () => {
uploadTotal: totalUploadTotal,
downloadTotal: totalDownloadTotal
};
}, [agents]);
}, [displayAgents]);
const handleNavigate = (agentId: string) => {
navigate(`/servers/${agentId}`);
@@ -145,26 +170,6 @@ const ServerList = () => {
return <LoadingSpinner/>;
}
// 计算所有标签(包括ALL和ONLINE/OFFLINE)
const allTags = ['ALL', 'ONLINE', 'OFFLINE'];
if (tagsData && tagsData.length > 0) {
tagsData.forEach((tag: string) => {
if (!allTags.includes(tag.toUpperCase())) {
allTags.push(tag.toUpperCase());
}
});
}
// 过滤逻辑
let displayAgents = agents;
if (selectedTag === 'ONLINE') {
displayAgents = agents?.filter(a => a.status === 1);
} else if (selectedTag === 'OFFLINE') {
displayAgents = agents?.filter(a => a.status !== 1);
} else if (selectedTag && selectedTag !== 'ALL') {
displayAgents = agents?.filter(a => a.tags?.map(t => t.toUpperCase()).includes(selectedTag));
}
// debug
// displayAgents = Array.from({length:10}, ()=>displayAgents).flat();