diff --git a/web/src/portal/components/monitor/MiniChart.tsx b/web/src/portal/components/monitor/MiniChart.tsx index cc0f9cf..dee1cc3 100644 --- a/web/src/portal/components/monitor/MiniChart.tsx +++ b/web/src/portal/components/monitor/MiniChart.tsx @@ -61,6 +61,7 @@ export const MiniChart = ({data, lastValue, id}: MiniChartProps) => { strokeWidth={2} filter={`url(#glow-${id})`} isAnimationActive={false} + connectNulls dot={false} /> diff --git a/web/src/portal/components/monitor/MonitorCard.tsx b/web/src/portal/components/monitor/MonitorCard.tsx index 4181505..2af79e0 100644 --- a/web/src/portal/components/monitor/MonitorCard.tsx +++ b/web/src/portal/components/monitor/MonitorCard.tsx @@ -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(); + 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; diff --git a/web/src/portal/components/monitor/ResponseTimeChart.tsx b/web/src/portal/components/monitor/ResponseTimeChart.tsx index df8f6e1..971d7b1 100644 --- a/web/src/portal/components/monitor/ResponseTimeChart.tsx +++ b/web/src/portal/components/monitor/ResponseTimeChart.tsx @@ -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 ( +
+ {allAgents.map((agent: { id: string; name: string }, index: number) => { + const isSelected = selectedAgents.has(agent.id); + const color = colors[index]; + + return ( +
onClick(agent.id)} + className="flex items-center gap-2 cursor-pointer transition-opacity" + style={{ + opacity: isSelected ? 1 : 0.4, + }} + > + + + + + {agent.name} + +
+ ); + })} +
+ ); +}; + /** * 响应时间趋势图表组件 * 显示监控各探针的响应时间变化 */ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartProps) => { - const [selectedAgent, setSelectedAgent] = useState('all'); - const [timeRange, setTimeRange] = useState('12h'); // 对应后端 60 秒步长 + const [selectedAgents, setSelectedAgents] = useState>(new Set()); + const [timeRange, setTimeRange] = useState('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 = {}; - - 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 ( @@ -135,37 +317,44 @@ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartPr setCustomRange(range); }} /> - {availableAgents.length > 0 && ( - - )} + {/* 使用提示和恢复按钮 */} + {availableAgents.length > 0 && ( +
+
+ 💡 点击图表线条或图例切换显示 +
+ {hasUnselected && ( + + )} +
+ )} + {chartData.length > 0 ? (
- {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 ( - - + + ); })} @@ -199,33 +388,59 @@ export const ResponseTimeChart = ({monitorId, monitorStats}: ResponseTimeChartPr content={} wrapperStyle={{zIndex: 9999}} /> - {!isMobile && ( - - )} - {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 ( ); })} + + {/* 桌面端:直接显示图例 */} + {!isMobile && availableAgents.length > 0 && ( + + )} + + {/* 移动端:可折叠图例 */} + {isMobile && availableAgents.length > 0 && ( +
+ + +
+ )}
) : ( { strokeWidth={2} fill="url(#cpuAreaGradient)" activeDot={{r: 3}} + connectNulls /> diff --git a/web/src/portal/components/server/DiskIOChart.tsx b/web/src/portal/components/server/DiskIOChart.tsx index 48bec12..df05d2f 100644 --- a/web/src/portal/components/server/DiskIOChart.tsx +++ b/web/src/portal/components/server/DiskIOChart.tsx @@ -109,6 +109,7 @@ export const DiskIOChart = ({agentId, timeRange, start, end}: DiskIOChartProps) strokeWidth={2} fill="url(#colorDiskRead)" activeDot={{r: 3}} + connectNulls /> diff --git a/web/src/portal/components/server/GpuChart.tsx b/web/src/portal/components/server/GpuChart.tsx index c87f964..0a7481d 100644 --- a/web/src/portal/components/server/GpuChart.tsx +++ b/web/src/portal/components/server/GpuChart.tsx @@ -113,6 +113,7 @@ export const GpuChart = ({agentId, timeRange, start, end}: GpuChartProps) => { strokeWidth={2} dot={false} activeDot={{r: 3}} + connectNulls /> { strokeWidth={2} dot={false} activeDot={{r: 3}} + connectNulls /> diff --git a/web/src/portal/components/server/MemoryChart.tsx b/web/src/portal/components/server/MemoryChart.tsx index 4ed100f..6cab0a8 100644 --- a/web/src/portal/components/server/MemoryChart.tsx +++ b/web/src/portal/components/server/MemoryChart.tsx @@ -86,6 +86,7 @@ export const MemoryChart = ({agentId, timeRange, start, end}: MemoryChartProps) strokeWidth={2} fill="url(#memoryAreaGradient)" activeDot={{r: 3}} + connectNulls /> diff --git a/web/src/portal/components/server/MonitorChart.tsx b/web/src/portal/components/server/MonitorChart.tsx index b48717b..6a8d0e6 100644 --- a/web/src/portal/components/server/MonitorChart.tsx +++ b/web/src/portal/components/server/MonitorChart.tsx @@ -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 ( +
+ {allMonitorKeys.map((monitorKey: string, index: number) => { + const isSelected = selectedMonitors.has(monitorKey); + const color = colors[index]; + + return ( +
onClick({ value: monitorKey })} + className="flex items-center gap-2 cursor-pointer transition-opacity" + style={{ + opacity: isSelected ? 1 : 0.4, + }} + > + + + + + {monitorKey} + +
+ ); + })} +
+ ); +}; + /** * 监控响应时间图表组件 */ export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps) => { const isMobile = useIsMobile(); const rangeMs = start !== undefined && end !== undefined ? end - start : undefined; + const [selectedMonitors, setSelectedMonitors] = useState>(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(); - - 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 {chartData.length > 0 ? ( <> + {/* 使用提示和恢复按钮 */} + {allMonitorKeys.length > 1 && ( +
+
+ 💡 点击图表线条或图例切换显示 +
+ {hasUnselected && ( + + )} +
+ )} + - {monitorKeys.map((key, index) => ( - - - - - ))} + {monitorKeys.map((key, index) => { + const originalIndex = allMonitorKeys.indexOf(key); + return ( + + + + + ); + })} @@ -119,26 +413,56 @@ export const MonitorChart = ({agentId, timeRange, start, end}: MonitorChartProps content={} wrapperStyle={{zIndex: 9999,}} /> - {!isMobile && monitorKeys.length > 1 && ( - - )} - {monitorKeys.map((key, index) => ( - - ))} + {monitorKeys.map((key, index) => { + const originalIndex = allMonitorKeys.indexOf(key); + return ( + + ); + })} + + {/* 桌面端:直接显示图例 */} + {!isMobile && allMonitorKeys.length > 0 && ( + + )} + + {/* 移动端:可折叠图例 */} + {isMobile && allMonitorKeys.length > 0 && ( +
+ + +
+ )} ) : ( diff --git a/web/src/portal/components/server/NetworkChart.tsx b/web/src/portal/components/server/NetworkChart.tsx index 6ddf512..6099d79 100644 --- a/web/src/portal/components/server/NetworkChart.tsx +++ b/web/src/portal/components/server/NetworkChart.tsx @@ -141,6 +141,7 @@ export const NetworkChart = ({agentId, timeRange, start, end}: NetworkChartProps strokeWidth={2} fill="url(#color-upload)" activeDot={{r: 3}} + connectNulls /> diff --git a/web/src/portal/components/server/NetworkConnectionChart.tsx b/web/src/portal/components/server/NetworkConnectionChart.tsx index c4fe154..c1e4cbc 100644 --- a/web/src/portal/components/server/NetworkConnectionChart.tsx +++ b/web/src/portal/components/server/NetworkConnectionChart.tsx @@ -98,6 +98,7 @@ export const NetworkConnectionChart = ({agentId, timeRange, start, end}: Network strokeWidth={2} dot={false} activeDot={{r: 3}} + connectNulls /> diff --git a/web/src/portal/pages/ServerList.tsx b/web/src/portal/pages/ServerList.tsx index 05eb530..52299fc 100644 --- a/web/src/portal/pages/ServerList.tsx +++ b/web/src/portal/pages/ServerList.tsx @@ -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 ; } - // 计算所有标签(包括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();