feat: 优化监控图表展示
- 增强 MonitorChart 组件功能和样式 - 优化 ResponseTimeChart 图表显示 - 添加可选择图例功能 - 统一图表插值和 connectNulls 配置 - 优化 MiniChart 和 MonitorCard 展示 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user