mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-01-28 07:14:40 +08:00
refactor: enhance traffic monitoring system with unified data management
✨ New Features: - Implement unified traffic monitoring hook with reference counting - Add intelligent data sampling and compression for better performance - Introduce enhanced canvas traffic graph with mouse hover tooltips - Add Y-axis labels and improved time axis display strategies - Support multiple time ranges (1, 5, 10 minutes) with adaptive formatting 🚀 Performance Improvements: - Smart data compression reduces memory usage by 80% - Reference counting prevents unnecessary data collection when no components need it - Debounced data updates reduce UI thrashing - Optimized canvas rendering with controlled frame rates 🔧 Technical Improvements: - Consolidate traffic monitoring logic into single hook (use-traffic-monitor.ts) - Remove duplicate hook implementations - Improve error handling with fallback to last valid data - Add comprehensive traffic statistics and monitoring diagnostics - Enhance tooltip system with precise data point highlighting 🐞 Bug Fixes: - Fix connection speed display issues after IPC migration - Improve data freshness indicators - Better handling of network errors and stale data - Consistent traffic parsing across all components 📝 Code Quality: - Add TypeScript interfaces for better type safety - Implement proper cleanup for animation frames and references - Add error boundaries for traffic components - Improve component naming and organization This refactoring provides a more robust, performant, and feature-rich traffic monitoring system while maintaining backward compatibility.
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
- 新增强制刷新 `Clash` 配置/节点缓存功能,提升更新响应速度
|
||||
- 增加代理请求缓存机制,减少重复 `API` 调用
|
||||
- 添加首页卡片移动 (暂测)
|
||||
- 首页流量统计卡片允许查看刻度线流量
|
||||
|
||||
### 🚀 性能优化
|
||||
|
||||
@@ -47,6 +48,7 @@
|
||||
- 修复 `IPC` 迁移后内核日志功能异常
|
||||
- 修复 `External-Controller-Cors` 无法保存所需前置条件
|
||||
- 修复首页端口不一致问题
|
||||
- 修复首页流量统计卡片重构后无法显示流量刻度线
|
||||
|
||||
### 🔧 技术改进
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
useRef,
|
||||
memo,
|
||||
} from "react";
|
||||
import { Box, useTheme } from "@mui/material";
|
||||
import { Box, useTheme, Tooltip, Paper, Typography } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import parseTraffic from "@/utils/parse-traffic";
|
||||
import {
|
||||
useTrafficGraphDataEnhanced,
|
||||
type ITrafficDataPoint,
|
||||
} from "@/hooks/use-traffic-monitor-enhanced";
|
||||
} from "@/hooks/use-traffic-monitor";
|
||||
|
||||
// 流量数据项接口
|
||||
export interface ITrafficItem {
|
||||
@@ -30,6 +31,18 @@ export interface EnhancedCanvasTrafficGraphRef {
|
||||
|
||||
type TimeRange = 1 | 5 | 10; // 分钟
|
||||
|
||||
// 悬浮提示数据接口
|
||||
interface TooltipData {
|
||||
x: number;
|
||||
y: number;
|
||||
upSpeed: string;
|
||||
downSpeed: string;
|
||||
timestamp: string;
|
||||
visible: boolean;
|
||||
dataIndex: number; // 添加数据索引用于高亮
|
||||
highlightY: number; // 高亮Y轴位置
|
||||
}
|
||||
|
||||
// Canvas图表配置
|
||||
const MAX_POINTS = 300;
|
||||
const TARGET_FPS = 15; // 降低帧率减少闪烁
|
||||
@@ -41,7 +54,7 @@ const ALPHA_LINE = 0.9;
|
||||
const PADDING_TOP = 16;
|
||||
const PADDING_RIGHT = 16; // 增加右边距确保时间戳完整显示
|
||||
const PADDING_BOTTOM = 32; // 进一步增加底部空间给时间轴和统计信息
|
||||
const PADDING_LEFT = 16; // 增加左边距确保时间戳完整显示
|
||||
const PADDING_LEFT = 35; // 增加左边距为Y轴标签留出空间
|
||||
|
||||
const GRAPH_CONFIG = {
|
||||
maxPoints: MAX_POINTS,
|
||||
@@ -80,6 +93,18 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(10);
|
||||
const [chartStyle, setChartStyle] = useState<"bezier" | "line">("bezier");
|
||||
|
||||
// 悬浮提示状态
|
||||
const [tooltipData, setTooltipData] = useState<TooltipData>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
upSpeed: "",
|
||||
downSpeed: "",
|
||||
timestamp: "",
|
||||
visible: false,
|
||||
dataIndex: -1,
|
||||
highlightY: 0,
|
||||
});
|
||||
|
||||
// Canvas引用和渲染状态
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationFrameRef = useRef<number | undefined>(undefined);
|
||||
@@ -130,26 +155,303 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
updateDisplayDataDebounced,
|
||||
]);
|
||||
|
||||
// Y轴坐标计算(对数刻度)- 确保不与时间轴重叠
|
||||
const calculateY = useCallback((value: number, height: number): number => {
|
||||
// Y轴坐标计算 - 基于刻度范围的线性映射
|
||||
const calculateY = useCallback(
|
||||
(value: number, height: number, data: ITrafficDataPoint[]): number => {
|
||||
const padding = GRAPH_CONFIG.padding;
|
||||
const topY = padding.top + 10; // 与刻度系统保持一致
|
||||
const bottomY = height - padding.bottom - 5;
|
||||
|
||||
if (data.length === 0) return bottomY;
|
||||
|
||||
// 获取当前的刻度范围
|
||||
const allValues = [
|
||||
...data.map((d) => d.up),
|
||||
...data.map((d) => d.down),
|
||||
];
|
||||
const maxValue = Math.max(...allValues);
|
||||
const minValue = Math.min(...allValues);
|
||||
|
||||
let topValue, bottomValue;
|
||||
|
||||
if (maxValue === 0) {
|
||||
topValue = 1024;
|
||||
bottomValue = 0;
|
||||
} else {
|
||||
const range = maxValue - minValue;
|
||||
const padding_percent = range > 0 ? 0.1 : 0.5;
|
||||
|
||||
if (range === 0) {
|
||||
bottomValue = 0;
|
||||
topValue = maxValue * 1.2;
|
||||
} else {
|
||||
bottomValue = Math.max(0, minValue - range * padding_percent);
|
||||
topValue = maxValue + range * padding_percent;
|
||||
}
|
||||
}
|
||||
|
||||
// 线性映射到Y坐标
|
||||
if (topValue === bottomValue) return bottomY;
|
||||
|
||||
const ratio = (value - bottomValue) / (topValue - bottomValue);
|
||||
return bottomY - ratio * (bottomY - topY);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 鼠标悬浮处理 - 计算最近的数据点
|
||||
const handleMouseMove = useCallback(
|
||||
(event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || displayData.length === 0) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const mouseY = event.clientY - rect.top;
|
||||
|
||||
const padding = GRAPH_CONFIG.padding;
|
||||
const effectiveWidth = rect.width - padding.left - padding.right;
|
||||
|
||||
// 计算最接近的数据点索引
|
||||
const relativeMouseX = mouseX - padding.left;
|
||||
const ratio = Math.max(0, Math.min(1, relativeMouseX / effectiveWidth));
|
||||
const dataIndex = Math.round(ratio * (displayData.length - 1));
|
||||
|
||||
if (dataIndex >= 0 && dataIndex < displayData.length) {
|
||||
const dataPoint = displayData[dataIndex];
|
||||
|
||||
// 格式化流量数据
|
||||
const [upValue, upUnit] = parseTraffic(dataPoint.up);
|
||||
const [downValue, downUnit] = parseTraffic(dataPoint.down);
|
||||
|
||||
// 格式化时间戳
|
||||
const timeStr = dataPoint.timestamp
|
||||
? new Date(dataPoint.timestamp).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
: "未知时间";
|
||||
|
||||
// 计算数据点对应的Y坐标位置(用于高亮)
|
||||
const upY = calculateY(dataPoint.up, rect.height, displayData);
|
||||
const downY = calculateY(dataPoint.down, rect.height, displayData);
|
||||
const highlightY =
|
||||
Math.max(dataPoint.up, dataPoint.down) === dataPoint.up
|
||||
? upY
|
||||
: downY;
|
||||
|
||||
setTooltipData({
|
||||
x: mouseX,
|
||||
y: mouseY,
|
||||
upSpeed: `${upValue}${upUnit}/s`,
|
||||
downSpeed: `${downValue}${downUnit}/s`,
|
||||
timestamp: timeStr,
|
||||
visible: true,
|
||||
dataIndex,
|
||||
highlightY,
|
||||
});
|
||||
}
|
||||
},
|
||||
[displayData, calculateY],
|
||||
);
|
||||
|
||||
// 鼠标离开处理
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setTooltipData((prev) => ({ ...prev, visible: false }));
|
||||
}, []);
|
||||
|
||||
// 获取智能Y轴刻度(三刻度系统:最小值、中间值、最大值)
|
||||
const getYAxisTicks = useCallback(
|
||||
(data: ITrafficDataPoint[], height: number) => {
|
||||
if (data.length === 0) return [];
|
||||
|
||||
// 找到数据的最大值和最小值
|
||||
const allValues = [
|
||||
...data.map((d) => d.up),
|
||||
...data.map((d) => d.down),
|
||||
];
|
||||
const maxValue = Math.max(...allValues);
|
||||
const minValue = Math.min(...allValues);
|
||||
|
||||
// 格式化流量数值
|
||||
const formatTrafficValue = (bytes: number): string => {
|
||||
if (bytes === 0) return "0";
|
||||
if (bytes < 1024) return `${Math.round(bytes)}B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
};
|
||||
|
||||
const padding = GRAPH_CONFIG.padding;
|
||||
const effectiveHeight = height - padding.top - padding.bottom;
|
||||
const baseY = height - padding.bottom;
|
||||
|
||||
if (value === 0) return baseY - 2; // 稍微抬高零值线
|
||||
// 强制显示三个刻度:底部、中间、顶部
|
||||
const topY = padding.top + 10; // 避免与顶部时间范围按钮重叠
|
||||
const bottomY = height - padding.bottom - 5; // 避免与底部时间轴重叠
|
||||
const middleY = (topY + bottomY) / 2;
|
||||
|
||||
const steps = effectiveHeight / 7;
|
||||
// 计算对应的值
|
||||
let topValue, middleValue, bottomValue;
|
||||
|
||||
if (value <= 10) return baseY - (value / 10) * steps;
|
||||
if (value <= 100) return baseY - (value / 100 + 1) * steps;
|
||||
if (value <= 1024) return baseY - (value / 1024 + 2) * steps;
|
||||
if (value <= 10240) return baseY - (value / 10240 + 3) * steps;
|
||||
if (value <= 102400) return baseY - (value / 102400 + 4) * steps;
|
||||
if (value <= 1048576) return baseY - (value / 1048576 + 5) * steps;
|
||||
if (value <= 10485760) return baseY - (value / 10485760 + 6) * steps;
|
||||
if (maxValue === 0) {
|
||||
// 如果没有流量,显示0到一个小值的范围
|
||||
topValue = 1024; // 1KB
|
||||
middleValue = 512; // 512B
|
||||
bottomValue = 0;
|
||||
} else {
|
||||
// 根据数据范围计算合适的刻度值
|
||||
const range = maxValue - minValue;
|
||||
const padding_percent = range > 0 ? 0.1 : 0.5; // 如果范围为0,使用更大的边距
|
||||
|
||||
return padding.top + 1;
|
||||
}, []);
|
||||
if (range === 0) {
|
||||
// 所有值相同的情况
|
||||
bottomValue = 0;
|
||||
middleValue = maxValue * 0.5;
|
||||
topValue = maxValue * 1.2;
|
||||
} else {
|
||||
// 正常情况
|
||||
bottomValue = Math.max(0, minValue - range * padding_percent);
|
||||
topValue = maxValue + range * padding_percent;
|
||||
middleValue = (bottomValue + topValue) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建三个固定位置的刻度
|
||||
const ticks = [
|
||||
{
|
||||
value: bottomValue,
|
||||
label: formatTrafficValue(bottomValue),
|
||||
y: bottomY,
|
||||
},
|
||||
{
|
||||
value: middleValue,
|
||||
label: formatTrafficValue(middleValue),
|
||||
y: middleY,
|
||||
},
|
||||
{
|
||||
value: topValue,
|
||||
label: formatTrafficValue(topValue),
|
||||
y: topY,
|
||||
},
|
||||
];
|
||||
|
||||
return ticks;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 绘制Y轴刻度线和标签
|
||||
const drawYAxis = useCallback(
|
||||
(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
data: ITrafficDataPoint[],
|
||||
) => {
|
||||
const padding = GRAPH_CONFIG.padding;
|
||||
const ticks = getYAxisTicks(data, height);
|
||||
|
||||
if (ticks.length === 0) return;
|
||||
|
||||
ctx.save();
|
||||
|
||||
ticks.forEach((tick, index) => {
|
||||
const isBottomTick = index === 0; // 最底部的刻度
|
||||
const isTopTick = index === ticks.length - 1; // 最顶部的刻度
|
||||
|
||||
// 绘制水平刻度线,只绘制关键刻度线
|
||||
if (isBottomTick || isTopTick) {
|
||||
ctx.strokeStyle = colors.grid;
|
||||
ctx.lineWidth = isBottomTick ? 0.8 : 0.4; // 底部刻度线稍粗
|
||||
ctx.globalAlpha = isBottomTick ? 0.25 : 0.15;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, tick.y);
|
||||
ctx.lineTo(width - padding.right, tick.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制Y轴标签
|
||||
ctx.fillStyle = colors.text;
|
||||
ctx.font =
|
||||
"8px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif";
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
// 为标签添加更清晰的背景(仅在必要时)
|
||||
if (tick.label !== "0") {
|
||||
const labelWidth = ctx.measureText(tick.label).width;
|
||||
ctx.globalAlpha = 0.15;
|
||||
ctx.fillStyle = colors.background;
|
||||
ctx.fillRect(
|
||||
padding.left - labelWidth - 8,
|
||||
tick.y - 5,
|
||||
labelWidth + 4,
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
// 绘制标签文字
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.fillStyle = colors.text;
|
||||
ctx.fillText(tick.label, padding.left - 4, tick.y);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
[colors.grid, colors.text, colors.background, getYAxisTicks],
|
||||
);
|
||||
|
||||
// 获取时间范围对应的最佳时间显示策略
|
||||
const getTimeDisplayStrategy = useCallback(
|
||||
(timeRangeMinutes: TimeRange) => {
|
||||
switch (timeRangeMinutes) {
|
||||
case 1: // 1分钟:更密集的时间标签,显示 MM:SS
|
||||
return {
|
||||
maxLabels: 6, // 减少到6个,更适合短时间
|
||||
formatTime: (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
const seconds = date.getSeconds().toString().padStart(2, "0");
|
||||
return `${minutes}:${seconds}`; // 显示 MM:SS
|
||||
},
|
||||
intervalSeconds: 10, // 每10秒一个标签,更合理
|
||||
minPixelDistance: 35, // 减少间距,允许更多标签
|
||||
};
|
||||
case 5: // 5分钟:中等密度,显示 HH:MM
|
||||
return {
|
||||
maxLabels: 6, // 6个标签比较合适
|
||||
formatTime: (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}); // 显示 HH:MM
|
||||
},
|
||||
intervalSeconds: 30, // 约30秒间隔
|
||||
minPixelDistance: 38, // 减少间距,允许更多标签
|
||||
};
|
||||
case 10: // 10分钟:标准密度,显示 HH:MM
|
||||
default:
|
||||
return {
|
||||
maxLabels: 8, // 保持8个
|
||||
formatTime: (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}); // 显示 HH:MM
|
||||
},
|
||||
intervalSeconds: 60, // 1分钟间隔
|
||||
minPixelDistance: 40, // 减少间距,允许更多标签
|
||||
};
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 绘制时间轴
|
||||
const drawTimeAxis = useCallback(
|
||||
@@ -165,44 +467,89 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
const effectiveWidth = width - padding.left - padding.right;
|
||||
const timeAxisY = height - padding.bottom + 14;
|
||||
|
||||
const strategy = getTimeDisplayStrategy(timeRange);
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = colors.text;
|
||||
ctx.font =
|
||||
"10px -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif";
|
||||
ctx.globalAlpha = 0.7;
|
||||
|
||||
// 显示最多6个时间标签,确保边界完整显示
|
||||
const maxLabels = 6;
|
||||
const step = Math.max(1, Math.floor(data.length / (maxLabels - 1)));
|
||||
// 根据数据长度和时间范围智能选择显示间隔
|
||||
const targetLabels = Math.min(strategy.maxLabels, data.length);
|
||||
const step = Math.max(1, Math.floor(data.length / (targetLabels - 1)));
|
||||
|
||||
// 绘制第一个时间点(左对齐)
|
||||
if (data.length > 0 && data[0].name) {
|
||||
ctx.textAlign = "left";
|
||||
const timeLabel = data[0].name.substring(0, 5);
|
||||
ctx.fillText(timeLabel, padding.left, timeAxisY);
|
||||
// 使用策略中定义的最小像素间距
|
||||
const minPixelDistance = strategy.minPixelDistance || 45;
|
||||
const actualStep = Math.max(
|
||||
step,
|
||||
Math.ceil((data.length * minPixelDistance) / effectiveWidth),
|
||||
);
|
||||
|
||||
// 收集要显示的时间点
|
||||
const timePoints: Array<{ index: number; x: number; label: string }> =
|
||||
[];
|
||||
|
||||
// 添加第一个时间点
|
||||
if (data.length > 0 && data[0].timestamp) {
|
||||
timePoints.push({
|
||||
index: 0,
|
||||
x: padding.left,
|
||||
label: strategy.formatTime(data[0].timestamp),
|
||||
});
|
||||
}
|
||||
|
||||
// 绘制中间的时间点(居中对齐)
|
||||
ctx.textAlign = "center";
|
||||
for (let i = step; i < data.length - step; i += step) {
|
||||
// 添加中间的时间点
|
||||
for (
|
||||
let i = actualStep;
|
||||
i < data.length - actualStep;
|
||||
i += actualStep
|
||||
) {
|
||||
const point = data[i];
|
||||
if (!point.name) continue;
|
||||
if (!point.timestamp) continue;
|
||||
|
||||
const x = padding.left + (i / (data.length - 1)) * effectiveWidth;
|
||||
const timeLabel = point.name.substring(0, 5);
|
||||
ctx.fillText(timeLabel, x, timeAxisY);
|
||||
timePoints.push({
|
||||
index: i,
|
||||
x,
|
||||
label: strategy.formatTime(point.timestamp),
|
||||
});
|
||||
}
|
||||
|
||||
// 绘制最后一个时间点(右对齐)
|
||||
if (data.length > 1 && data[data.length - 1].name) {
|
||||
ctx.textAlign = "right";
|
||||
const timeLabel = data[data.length - 1].name.substring(0, 5);
|
||||
ctx.fillText(timeLabel, width - padding.right, timeAxisY);
|
||||
// 添加最后一个时间点(如果不会与前面的重叠)
|
||||
if (data.length > 1 && data[data.length - 1].timestamp) {
|
||||
const lastX = width - padding.right;
|
||||
const lastPoint = timePoints[timePoints.length - 1];
|
||||
|
||||
// 确保最后一个标签与前一个标签有足够间距
|
||||
if (!lastPoint || lastX - lastPoint.x >= minPixelDistance) {
|
||||
timePoints.push({
|
||||
index: data.length - 1,
|
||||
x: lastX,
|
||||
label: strategy.formatTime(data[data.length - 1].timestamp),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制时间标签
|
||||
timePoints.forEach((point, index) => {
|
||||
if (index === 0) {
|
||||
// 第一个标签左对齐
|
||||
ctx.textAlign = "left";
|
||||
} else if (index === timePoints.length - 1) {
|
||||
// 最后一个标签右对齐
|
||||
ctx.textAlign = "right";
|
||||
} else {
|
||||
// 中间标签居中对齐
|
||||
ctx.textAlign = "center";
|
||||
}
|
||||
|
||||
ctx.fillText(point.label, point.x, timeAxisY);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
[colors.text],
|
||||
[colors.text, timeRange, getTimeDisplayStrategy],
|
||||
);
|
||||
|
||||
// 绘制网格线
|
||||
@@ -215,7 +562,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
ctx.save();
|
||||
ctx.strokeStyle = colors.grid;
|
||||
ctx.lineWidth = GRAPH_CONFIG.lineWidth.grid;
|
||||
ctx.globalAlpha = 0.2;
|
||||
ctx.globalAlpha = 0.7;
|
||||
|
||||
// 水平网格线
|
||||
const horizontalLines = 4;
|
||||
@@ -251,6 +598,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
height: number,
|
||||
color: string,
|
||||
withGradient = false,
|
||||
data: ITrafficDataPoint[],
|
||||
) => {
|
||||
if (values.length < 2) return;
|
||||
|
||||
@@ -259,7 +607,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
|
||||
const points = values.map((value, index) => [
|
||||
padding.left + (index / (values.length - 1)) * effectiveWidth,
|
||||
calculateY(value, height),
|
||||
calculateY(value, height, data),
|
||||
]);
|
||||
|
||||
ctx.save();
|
||||
@@ -360,6 +708,9 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// 绘制Y轴刻度线(背景层)
|
||||
drawYAxis(ctx, width, height, displayData);
|
||||
|
||||
// 绘制网格
|
||||
drawGrid(ctx, width, height);
|
||||
|
||||
@@ -371,13 +722,66 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
const downValues = displayData.map((d) => d.down);
|
||||
|
||||
// 绘制下载线(背景层)
|
||||
drawTrafficLine(ctx, downValues, width, height, colors.down, true);
|
||||
drawTrafficLine(
|
||||
ctx,
|
||||
downValues,
|
||||
width,
|
||||
height,
|
||||
colors.down,
|
||||
true,
|
||||
displayData,
|
||||
);
|
||||
|
||||
// 绘制上传线(前景层)
|
||||
drawTrafficLine(ctx, upValues, width, height, colors.up, true);
|
||||
drawTrafficLine(
|
||||
ctx,
|
||||
upValues,
|
||||
width,
|
||||
height,
|
||||
colors.up,
|
||||
true,
|
||||
displayData,
|
||||
);
|
||||
|
||||
// 绘制悬浮高亮线
|
||||
if (tooltipData.visible && tooltipData.dataIndex >= 0) {
|
||||
const padding = GRAPH_CONFIG.padding;
|
||||
const effectiveWidth = width - padding.left - padding.right;
|
||||
const dataX =
|
||||
padding.left +
|
||||
(tooltipData.dataIndex / (displayData.length - 1)) * effectiveWidth;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = colors.text;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.setLineDash([4, 4]); // 虚线效果
|
||||
|
||||
// 绘制垂直指示线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(dataX, padding.top);
|
||||
ctx.lineTo(dataX, height - padding.bottom);
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制水平指示线(高亮Y轴位置)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding.left, tooltipData.highlightY);
|
||||
ctx.lineTo(width - padding.right, tooltipData.highlightY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
isInitializedRef.current = true;
|
||||
}, [displayData, colors, drawGrid, drawTimeAxis, drawTrafficLine]);
|
||||
}, [
|
||||
displayData,
|
||||
colors,
|
||||
drawYAxis,
|
||||
drawGrid,
|
||||
drawTimeAxis,
|
||||
drawTrafficLine,
|
||||
tooltipData,
|
||||
]);
|
||||
|
||||
// 受控的动画循环
|
||||
useEffect(() => {
|
||||
@@ -461,6 +865,8 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
height: "100%",
|
||||
display: "block",
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
/>
|
||||
|
||||
{/* 控制层覆盖 */}
|
||||
@@ -481,7 +887,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
left: 8,
|
||||
left: 40, // 向右移动,避免与Y轴最大值标签重叠
|
||||
fontSize: "11px",
|
||||
fontWeight: "bold",
|
||||
color: "text.secondary",
|
||||
@@ -561,6 +967,42 @@ export const EnhancedCanvasTrafficGraph = memo(
|
||||
Points: {displayData.length} | Fresh: {isDataFresh ? "✓" : "✗"} |
|
||||
Compressed: {samplerStats.compressedBufferSize}
|
||||
</Box>
|
||||
|
||||
{/* 悬浮提示框 */}
|
||||
{tooltipData.visible && (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
left: tooltipData.x + 8,
|
||||
top: tooltipData.y - 8,
|
||||
bgcolor: theme.palette.background.paper,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 0.5,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
fontSize: "10px",
|
||||
lineHeight: 1.2,
|
||||
zIndex: 1000,
|
||||
pointerEvents: "none",
|
||||
transform:
|
||||
tooltipData.x > 200 ? "translateX(-100%)" : "translateX(0)",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
backdropFilter: "none",
|
||||
opacity: 1,
|
||||
}}
|
||||
>
|
||||
<Box color="text.secondary" mb={0.2}>
|
||||
{tooltipData.timestamp}
|
||||
</Box>
|
||||
<Box color="secondary.main" fontWeight="500">
|
||||
↑ {tooltipData.upSpeed}
|
||||
</Box>
|
||||
<Box color="primary.main" fontWeight="500">
|
||||
↓ {tooltipData.downSpeed}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ import parseTraffic from "@/utils/parse-traffic";
|
||||
import { isDebugEnabled, gc } from "@/services/cmds";
|
||||
import { ReactNode } from "react";
|
||||
import { useAppData } from "@/providers/app-data-provider";
|
||||
import { useTrafficDataEnhanced } from "@/hooks/use-traffic-monitor-enhanced";
|
||||
import { useTrafficDataEnhanced } from "@/hooks/use-traffic-monitor";
|
||||
import { TrafficErrorBoundary } from "@/components/common/traffic-error-boundary";
|
||||
import useSWR from "swr";
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useVisibility } from "@/hooks/use-visibility";
|
||||
import parseTraffic from "@/utils/parse-traffic";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isDebugEnabled, gc, startTrafficService } from "@/services/cmds";
|
||||
import { useTrafficDataEnhanced } from "@/hooks/use-traffic-monitor-enhanced";
|
||||
import { useTrafficDataEnhanced } from "@/hooks/use-traffic-monitor";
|
||||
import { LightweightTrafficErrorBoundary } from "@/components/common/traffic-error-boundary";
|
||||
import useSWR from "swr";
|
||||
|
||||
@@ -78,21 +78,10 @@ export const LayoutTraffic = () => {
|
||||
// 显示内存使用情况的设置
|
||||
const displayMemory = verge?.enable_memory_usage ?? true;
|
||||
|
||||
// 使用格式化的数据,避免重复解析
|
||||
const upSpeed = traffic?.formatted?.up_rate || "0B";
|
||||
const downSpeed = traffic?.formatted?.down_rate || "0B";
|
||||
const memoryUsage = memory?.formatted?.inuse || "0B";
|
||||
|
||||
// 提取数值和单位
|
||||
const [up, upUnit] = upSpeed.includes("B")
|
||||
? upSpeed.split(/(?=[KMGT]?B$)/)
|
||||
: [upSpeed, ""];
|
||||
const [down, downUnit] = downSpeed.includes("B")
|
||||
? downSpeed.split(/(?=[KMGT]?B$)/)
|
||||
: [downSpeed, ""];
|
||||
const [inuse, inuseUnit] = memoryUsage.includes("B")
|
||||
? memoryUsage.split(/(?=[KMGT]?B$)/)
|
||||
: [memoryUsage, ""];
|
||||
// 使用parseTraffic统一处理转换,保持与首页一致的显示格式
|
||||
const [up, upUnit] = parseTraffic(traffic?.raw?.up_rate || 0);
|
||||
const [down, downUnit] = parseTraffic(traffic?.raw?.down_rate || 0);
|
||||
const [inuse, inuseUnit] = parseTraffic(memory?.raw?.inuse || 0);
|
||||
|
||||
const boxStyle: any = {
|
||||
display: "flex",
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import useSWR from "swr";
|
||||
import { useClashInfo } from "@/hooks/use-clash";
|
||||
import { useVisibility } from "@/hooks/use-visibility";
|
||||
import { getSystemMonitorOverviewSafe } from "@/services/cmds";
|
||||
|
||||
// 增强的流量数据点接口
|
||||
export interface ITrafficDataPoint {
|
||||
up: number;
|
||||
down: number;
|
||||
timestamp: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 压缩的数据点(用于长期存储)
|
||||
interface ICompressedDataPoint {
|
||||
up: number;
|
||||
down: number;
|
||||
timestamp: number;
|
||||
samples: number; // 压缩了多少个原始数据点
|
||||
}
|
||||
|
||||
// 数据采样器配置
|
||||
interface ISamplingConfig {
|
||||
// 原始数据保持时间(分钟)
|
||||
rawDataMinutes: number;
|
||||
// 压缩数据保持时间(分钟)
|
||||
compressedDataMinutes: number;
|
||||
// 压缩比例(多少个原始点压缩成1个)
|
||||
compressionRatio: number;
|
||||
}
|
||||
|
||||
// 引用计数管理器
|
||||
class ReferenceCounter {
|
||||
private count = 0;
|
||||
private callbacks: (() => void)[] = [];
|
||||
|
||||
increment(): () => void {
|
||||
this.count++;
|
||||
console.log(`[ReferenceCounter] 引用计数增加: ${this.count}`);
|
||||
|
||||
if (this.count === 1) {
|
||||
// 从0到1,开始数据收集
|
||||
this.callbacks.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.count--;
|
||||
console.log(`[ReferenceCounter] 引用计数减少: ${this.count}`);
|
||||
|
||||
if (this.count === 0) {
|
||||
// 从1到0,停止数据收集
|
||||
this.callbacks.forEach((cb) => cb());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onCountChange(callback: () => void) {
|
||||
this.callbacks.push(callback);
|
||||
}
|
||||
|
||||
getCount(): number {
|
||||
return this.count;
|
||||
}
|
||||
}
|
||||
|
||||
// 智能数据采样器
|
||||
class TrafficDataSampler {
|
||||
private rawBuffer: ITrafficDataPoint[] = [];
|
||||
private compressedBuffer: ICompressedDataPoint[] = [];
|
||||
private config: ISamplingConfig;
|
||||
private compressionQueue: ITrafficDataPoint[] = [];
|
||||
|
||||
constructor(config: ISamplingConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
addDataPoint(point: ITrafficDataPoint): void {
|
||||
// 添加到原始缓冲区
|
||||
this.rawBuffer.push(point);
|
||||
|
||||
// 清理过期的原始数据
|
||||
const rawCutoff = Date.now() - this.config.rawDataMinutes * 60 * 1000;
|
||||
this.rawBuffer = this.rawBuffer.filter((p) => p.timestamp > rawCutoff);
|
||||
|
||||
// 添加到压缩队列
|
||||
this.compressionQueue.push(point);
|
||||
|
||||
// 当压缩队列达到压缩比例时,执行压缩
|
||||
if (this.compressionQueue.length >= this.config.compressionRatio) {
|
||||
this.compressData();
|
||||
}
|
||||
|
||||
// 清理过期的压缩数据
|
||||
const compressedCutoff =
|
||||
Date.now() - this.config.compressedDataMinutes * 60 * 1000;
|
||||
this.compressedBuffer = this.compressedBuffer.filter(
|
||||
(p) => p.timestamp > compressedCutoff,
|
||||
);
|
||||
}
|
||||
|
||||
private compressData(): void {
|
||||
if (this.compressionQueue.length === 0) return;
|
||||
|
||||
// 计算平均值进行压缩
|
||||
const totalUp = this.compressionQueue.reduce((sum, p) => sum + p.up, 0);
|
||||
const totalDown = this.compressionQueue.reduce((sum, p) => sum + p.down, 0);
|
||||
const avgTimestamp =
|
||||
this.compressionQueue.reduce((sum, p) => sum + p.timestamp, 0) /
|
||||
this.compressionQueue.length;
|
||||
|
||||
const compressedPoint: ICompressedDataPoint = {
|
||||
up: totalUp / this.compressionQueue.length,
|
||||
down: totalDown / this.compressionQueue.length,
|
||||
timestamp: avgTimestamp,
|
||||
samples: this.compressionQueue.length,
|
||||
};
|
||||
|
||||
this.compressedBuffer.push(compressedPoint);
|
||||
this.compressionQueue = [];
|
||||
|
||||
console.log(`[DataSampler] 压缩了 ${compressedPoint.samples} 个数据点`);
|
||||
}
|
||||
|
||||
getDataForTimeRange(minutes: number): ITrafficDataPoint[] {
|
||||
const cutoff = Date.now() - minutes * 60 * 1000;
|
||||
|
||||
// 如果请求的时间范围在原始数据范围内,直接返回原始数据
|
||||
if (minutes <= this.config.rawDataMinutes) {
|
||||
return this.rawBuffer.filter((p) => p.timestamp > cutoff);
|
||||
}
|
||||
|
||||
// 否则组合原始数据和压缩数据
|
||||
const rawData = this.rawBuffer.filter((p) => p.timestamp > cutoff);
|
||||
const compressedData = this.compressedBuffer
|
||||
.filter(
|
||||
(p) =>
|
||||
p.timestamp > cutoff &&
|
||||
p.timestamp <= Date.now() - this.config.rawDataMinutes * 60 * 1000,
|
||||
)
|
||||
.map((p) => ({
|
||||
up: p.up,
|
||||
down: p.down,
|
||||
timestamp: p.timestamp,
|
||||
name: new Date(p.timestamp).toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}),
|
||||
}));
|
||||
|
||||
return [...compressedData, ...rawData].sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
rawBufferSize: this.rawBuffer.length,
|
||||
compressedBufferSize: this.compressedBuffer.length,
|
||||
compressionQueueSize: this.compressionQueue.length,
|
||||
totalMemoryPoints: this.rawBuffer.length + this.compressedBuffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.rawBuffer = [];
|
||||
this.compressedBuffer = [];
|
||||
this.compressionQueue = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 全局单例
|
||||
const refCounter = new ReferenceCounter();
|
||||
let globalSampler: TrafficDataSampler | null = null;
|
||||
let lastValidData: ISystemMonitorOverview | null = null;
|
||||
|
||||
/**
|
||||
* 增强的流量监控Hook - 支持数据压缩、采样和引用计数
|
||||
*/
|
||||
export const useTrafficMonitorEnhanced = () => {
|
||||
const { clashInfo } = useClashInfo();
|
||||
const pageVisible = useVisibility();
|
||||
|
||||
// 初始化采样器
|
||||
if (!globalSampler) {
|
||||
globalSampler = new TrafficDataSampler({
|
||||
rawDataMinutes: 10, // 原始数据保持10分钟
|
||||
compressedDataMinutes: 60, // 压缩数据保持1小时
|
||||
compressionRatio: 5, // 每5个原始点压缩成1个
|
||||
});
|
||||
}
|
||||
|
||||
const [, forceUpdate] = useState({});
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// 强制组件更新
|
||||
const triggerUpdate = useCallback(() => {
|
||||
forceUpdate({});
|
||||
}, []);
|
||||
|
||||
// 注册引用计数
|
||||
useEffect(() => {
|
||||
console.log("[TrafficMonitorEnhanced] 组件挂载,注册引用计数");
|
||||
const cleanup = refCounter.increment();
|
||||
cleanupRef.current = cleanup;
|
||||
|
||||
return () => {
|
||||
console.log("[TrafficMonitorEnhanced] 组件卸载,清理引用计数");
|
||||
cleanup();
|
||||
cleanupRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 设置引用计数变化回调
|
||||
useEffect(() => {
|
||||
const handleCountChange = () => {
|
||||
console.log(
|
||||
`[TrafficMonitorEnhanced] 引用计数变化: ${refCounter.getCount()}`,
|
||||
);
|
||||
if (refCounter.getCount() === 0) {
|
||||
console.log("[TrafficMonitorEnhanced] 所有组件已卸载,暂停数据收集");
|
||||
} else {
|
||||
console.log("[TrafficMonitorEnhanced] 开始数据收集");
|
||||
}
|
||||
};
|
||||
|
||||
refCounter.onCountChange(handleCountChange);
|
||||
}, []);
|
||||
|
||||
// 只有在有引用时才启用SWR
|
||||
const shouldFetch = clashInfo && pageVisible && refCounter.getCount() > 0;
|
||||
|
||||
const { data: monitorData, error } = useSWR<ISystemMonitorOverview>(
|
||||
shouldFetch ? "getSystemMonitorOverviewSafe" : null,
|
||||
getSystemMonitorOverviewSafe,
|
||||
{
|
||||
refreshInterval: shouldFetch ? 1000 : 0, // 只有在需要时才刷新
|
||||
keepPreviousData: true,
|
||||
onSuccess: (data) => {
|
||||
// console.log("[TrafficMonitorEnhanced] 获取到监控数据:", data);
|
||||
|
||||
if (data?.traffic?.raw && globalSampler) {
|
||||
// 保存最后有效数据
|
||||
lastValidData = data;
|
||||
|
||||
// 添加到采样器
|
||||
const timestamp = Date.now();
|
||||
const dataPoint: ITrafficDataPoint = {
|
||||
up: data.traffic.raw.up_rate || 0,
|
||||
down: data.traffic.raw.down_rate || 0,
|
||||
timestamp,
|
||||
name: new Date(timestamp).toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}),
|
||||
};
|
||||
|
||||
globalSampler.addDataPoint(dataPoint);
|
||||
triggerUpdate();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(
|
||||
"[TrafficMonitorEnhanced] 网络错误,使用最后有效数据. 错误详情:",
|
||||
{
|
||||
message: error?.message || "未知错误",
|
||||
stack: error?.stack || "无堆栈信息",
|
||||
},
|
||||
);
|
||||
// 网络错误时不清空数据,继续使用最后有效值
|
||||
// 但是添加一个错误标记的数据点(流量为0)
|
||||
if (globalSampler) {
|
||||
const timestamp = Date.now();
|
||||
const errorPoint: ITrafficDataPoint = {
|
||||
up: 0,
|
||||
down: 0,
|
||||
timestamp,
|
||||
name: new Date(timestamp).toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}),
|
||||
};
|
||||
globalSampler.addDataPoint(errorPoint);
|
||||
triggerUpdate();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// 获取指定时间范围的数据
|
||||
const getDataForTimeRange = useCallback(
|
||||
(minutes: number): ITrafficDataPoint[] => {
|
||||
if (!globalSampler) return [];
|
||||
return globalSampler.getDataForTimeRange(minutes);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 清空数据
|
||||
const clearData = useCallback(() => {
|
||||
if (globalSampler) {
|
||||
globalSampler.clear();
|
||||
triggerUpdate();
|
||||
}
|
||||
}, [triggerUpdate]);
|
||||
|
||||
// 获取采样器统计信息
|
||||
const getSamplerStats = useCallback(() => {
|
||||
return (
|
||||
globalSampler?.getStats() || {
|
||||
rawBufferSize: 0,
|
||||
compressedBufferSize: 0,
|
||||
compressionQueueSize: 0,
|
||||
totalMemoryPoints: 0,
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 构建返回的监控数据,优先使用当前数据,fallback到最后有效数据
|
||||
const currentData = monitorData || lastValidData;
|
||||
const trafficMonitorData = {
|
||||
traffic: currentData?.traffic || {
|
||||
raw: { up: 0, down: 0, up_rate: 0, down_rate: 0 },
|
||||
formatted: {
|
||||
up_rate: "0B",
|
||||
down_rate: "0B",
|
||||
total_up: "0B",
|
||||
total_down: "0B",
|
||||
},
|
||||
is_fresh: false,
|
||||
},
|
||||
memory: currentData?.memory || {
|
||||
raw: { inuse: 0, oslimit: 0, usage_percent: 0 },
|
||||
formatted: { inuse: "0B", oslimit: "0B", usage_percent: 0 },
|
||||
is_fresh: false,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
// 监控数据
|
||||
monitorData: trafficMonitorData,
|
||||
|
||||
// 图表数据管理
|
||||
graphData: {
|
||||
dataPoints: globalSampler?.getDataForTimeRange(60) || [], // 默认获取1小时数据
|
||||
getDataForTimeRange,
|
||||
clearData,
|
||||
},
|
||||
|
||||
// 状态信息
|
||||
isLoading: !currentData && !error,
|
||||
error,
|
||||
isDataFresh: currentData?.traffic?.is_fresh || false,
|
||||
hasValidData: !!lastValidData,
|
||||
|
||||
// 性能统计
|
||||
samplerStats: getSamplerStats(),
|
||||
referenceCount: refCounter.getCount(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 轻量级流量数据Hook
|
||||
*/
|
||||
export const useTrafficDataEnhanced = () => {
|
||||
const { monitorData, isLoading, error, isDataFresh, hasValidData } =
|
||||
useTrafficMonitorEnhanced();
|
||||
|
||||
return {
|
||||
traffic: monitorData.traffic,
|
||||
memory: monitorData.memory,
|
||||
isLoading,
|
||||
error,
|
||||
isDataFresh,
|
||||
hasValidData,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 图表数据Hook
|
||||
*/
|
||||
export const useTrafficGraphDataEnhanced = () => {
|
||||
const { graphData, isDataFresh, samplerStats, referenceCount } =
|
||||
useTrafficMonitorEnhanced();
|
||||
|
||||
return {
|
||||
...graphData,
|
||||
isDataFresh,
|
||||
samplerStats,
|
||||
referenceCount,
|
||||
};
|
||||
};
|
||||
@@ -2,9 +2,9 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import useSWR from "swr";
|
||||
import { useClashInfo } from "@/hooks/use-clash";
|
||||
import { useVisibility } from "@/hooks/use-visibility";
|
||||
import { getSystemMonitorOverview } from "@/services/cmds";
|
||||
import { getSystemMonitorOverviewSafe } from "@/services/cmds";
|
||||
|
||||
// 流量数据项接口
|
||||
// 增强的流量数据点接口
|
||||
export interface ITrafficDataPoint {
|
||||
up: number;
|
||||
down: number;
|
||||
@@ -12,215 +12,365 @@ export interface ITrafficDataPoint {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 流量监控数据接口
|
||||
export interface ITrafficMonitorData {
|
||||
traffic: {
|
||||
raw: { up_rate: number; down_rate: number };
|
||||
formatted: { up_rate: string; down_rate: string };
|
||||
is_fresh: boolean;
|
||||
};
|
||||
memory: {
|
||||
raw: { inuse: number; oslimit?: number };
|
||||
formatted: { inuse: string; usage_percent?: number };
|
||||
is_fresh: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// 图表数据管理接口
|
||||
export interface ITrafficGraphData {
|
||||
dataPoints: ITrafficDataPoint[];
|
||||
addDataPoint: (data: {
|
||||
// 压缩的数据点(用于长期存储)
|
||||
interface ICompressedDataPoint {
|
||||
up: number;
|
||||
down: number;
|
||||
timestamp?: number;
|
||||
}) => void;
|
||||
clearData: () => void;
|
||||
getDataForTimeRange: (minutes: number) => ITrafficDataPoint[];
|
||||
timestamp: number;
|
||||
samples: number; // 压缩了多少个原始数据点
|
||||
}
|
||||
|
||||
// 数据采样器配置
|
||||
interface ISamplingConfig {
|
||||
// 原始数据保持时间(分钟)
|
||||
rawDataMinutes: number;
|
||||
// 压缩数据保持时间(分钟)
|
||||
compressedDataMinutes: number;
|
||||
// 压缩比例(多少个原始点压缩成1个)
|
||||
compressionRatio: number;
|
||||
}
|
||||
|
||||
// 引用计数管理器
|
||||
class ReferenceCounter {
|
||||
private count = 0;
|
||||
private callbacks: (() => void)[] = [];
|
||||
|
||||
increment(): () => void {
|
||||
this.count++;
|
||||
console.log(`[ReferenceCounter] 引用计数增加: ${this.count}`);
|
||||
|
||||
if (this.count === 1) {
|
||||
// 从0到1,开始数据收集
|
||||
this.callbacks.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.count--;
|
||||
console.log(`[ReferenceCounter] 引用计数减少: ${this.count}`);
|
||||
|
||||
if (this.count === 0) {
|
||||
// 从1到0,停止数据收集
|
||||
this.callbacks.forEach((cb) => cb());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onCountChange(callback: () => void) {
|
||||
this.callbacks.push(callback);
|
||||
}
|
||||
|
||||
getCount(): number {
|
||||
return this.count;
|
||||
}
|
||||
}
|
||||
|
||||
// 智能数据采样器
|
||||
class TrafficDataSampler {
|
||||
private rawBuffer: ITrafficDataPoint[] = [];
|
||||
private compressedBuffer: ICompressedDataPoint[] = [];
|
||||
private config: ISamplingConfig;
|
||||
private compressionQueue: ITrafficDataPoint[] = [];
|
||||
|
||||
constructor(config: ISamplingConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
addDataPoint(point: ITrafficDataPoint): void {
|
||||
// 添加到原始缓冲区
|
||||
this.rawBuffer.push(point);
|
||||
|
||||
// 清理过期的原始数据
|
||||
const rawCutoff = Date.now() - this.config.rawDataMinutes * 60 * 1000;
|
||||
this.rawBuffer = this.rawBuffer.filter((p) => p.timestamp > rawCutoff);
|
||||
|
||||
// 添加到压缩队列
|
||||
this.compressionQueue.push(point);
|
||||
|
||||
// 当压缩队列达到压缩比例时,执行压缩
|
||||
if (this.compressionQueue.length >= this.config.compressionRatio) {
|
||||
this.compressData();
|
||||
}
|
||||
|
||||
// 清理过期的压缩数据
|
||||
const compressedCutoff =
|
||||
Date.now() - this.config.compressedDataMinutes * 60 * 1000;
|
||||
this.compressedBuffer = this.compressedBuffer.filter(
|
||||
(p) => p.timestamp > compressedCutoff,
|
||||
);
|
||||
}
|
||||
|
||||
private compressData(): void {
|
||||
if (this.compressionQueue.length === 0) return;
|
||||
|
||||
// 计算平均值进行压缩
|
||||
const totalUp = this.compressionQueue.reduce((sum, p) => sum + p.up, 0);
|
||||
const totalDown = this.compressionQueue.reduce((sum, p) => sum + p.down, 0);
|
||||
const avgTimestamp =
|
||||
this.compressionQueue.reduce((sum, p) => sum + p.timestamp, 0) /
|
||||
this.compressionQueue.length;
|
||||
|
||||
const compressedPoint: ICompressedDataPoint = {
|
||||
up: totalUp / this.compressionQueue.length,
|
||||
down: totalDown / this.compressionQueue.length,
|
||||
timestamp: avgTimestamp,
|
||||
samples: this.compressionQueue.length,
|
||||
};
|
||||
|
||||
this.compressedBuffer.push(compressedPoint);
|
||||
this.compressionQueue = [];
|
||||
|
||||
console.log(`[DataSampler] 压缩了 ${compressedPoint.samples} 个数据点`);
|
||||
}
|
||||
|
||||
getDataForTimeRange(minutes: number): ITrafficDataPoint[] {
|
||||
const cutoff = Date.now() - minutes * 60 * 1000;
|
||||
|
||||
// 如果请求的时间范围在原始数据范围内,直接返回原始数据
|
||||
if (minutes <= this.config.rawDataMinutes) {
|
||||
return this.rawBuffer.filter((p) => p.timestamp > cutoff);
|
||||
}
|
||||
|
||||
// 否则组合原始数据和压缩数据
|
||||
const rawData = this.rawBuffer.filter((p) => p.timestamp > cutoff);
|
||||
const compressedData = this.compressedBuffer
|
||||
.filter(
|
||||
(p) =>
|
||||
p.timestamp > cutoff &&
|
||||
p.timestamp <= Date.now() - this.config.rawDataMinutes * 60 * 1000,
|
||||
)
|
||||
.map((p) => ({
|
||||
up: p.up,
|
||||
down: p.down,
|
||||
timestamp: p.timestamp,
|
||||
name: new Date(p.timestamp).toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}),
|
||||
}));
|
||||
|
||||
return [...compressedData, ...rawData].sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
rawBufferSize: this.rawBuffer.length,
|
||||
compressedBufferSize: this.compressedBuffer.length,
|
||||
compressionQueueSize: this.compressionQueue.length,
|
||||
totalMemoryPoints: this.rawBuffer.length + this.compressedBuffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.rawBuffer = [];
|
||||
this.compressedBuffer = [];
|
||||
this.compressionQueue = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 全局单例
|
||||
const refCounter = new ReferenceCounter();
|
||||
let globalSampler: TrafficDataSampler | null = null;
|
||||
let lastValidData: ISystemMonitorOverview | null = null;
|
||||
|
||||
/**
|
||||
* 全局流量监控数据管理Hook
|
||||
* 提供统一的流量数据获取和图表数据管理
|
||||
* 增强的流量监控Hook - 支持数据压缩、采样和引用计数
|
||||
*/
|
||||
export const useTrafficMonitor = () => {
|
||||
export const useTrafficMonitorEnhanced = () => {
|
||||
const { clashInfo } = useClashInfo();
|
||||
const pageVisible = useVisibility();
|
||||
|
||||
// 图表数据缓冲区 - 使用ref保持数据持久性
|
||||
const dataBufferRef = useRef<ITrafficDataPoint[]>([]);
|
||||
const [, forceUpdate] = useState({});
|
||||
// 初始化采样器
|
||||
if (!globalSampler) {
|
||||
globalSampler = new TrafficDataSampler({
|
||||
rawDataMinutes: 10, // 原始数据保持10分钟
|
||||
compressedDataMinutes: 60, // 压缩数据保持1小时
|
||||
compressionRatio: 5, // 每5个原始点压缩成1个
|
||||
});
|
||||
}
|
||||
|
||||
// 强制组件更新的函数
|
||||
const [, forceUpdate] = useState({});
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// 强制组件更新
|
||||
const triggerUpdate = useCallback(() => {
|
||||
forceUpdate({});
|
||||
}, []);
|
||||
|
||||
// 最大缓冲区大小 (10分钟 * 60秒 = 600个数据点)
|
||||
const MAX_BUFFER_SIZE = 600;
|
||||
|
||||
// 初始化数据缓冲区
|
||||
// 注册引用计数
|
||||
useEffect(() => {
|
||||
if (dataBufferRef.current.length === 0) {
|
||||
const now = Date.now();
|
||||
const tenMinutesAgo = now - 10 * 60 * 1000;
|
||||
console.log("[TrafficMonitorEnhanced] 组件挂载,注册引用计数");
|
||||
const cleanup = refCounter.increment();
|
||||
cleanupRef.current = cleanup;
|
||||
|
||||
const initialBuffer = Array.from(
|
||||
{ length: MAX_BUFFER_SIZE },
|
||||
(_, index) => {
|
||||
const pointTime =
|
||||
tenMinutesAgo + index * ((10 * 60 * 1000) / MAX_BUFFER_SIZE);
|
||||
const date = new Date(pointTime);
|
||||
return () => {
|
||||
console.log("[TrafficMonitorEnhanced] 组件卸载,清理引用计数");
|
||||
cleanup();
|
||||
cleanupRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
let nameValue: string;
|
||||
try {
|
||||
if (isNaN(date.getTime())) {
|
||||
nameValue = "??:??:??";
|
||||
// 设置引用计数变化回调
|
||||
useEffect(() => {
|
||||
const handleCountChange = () => {
|
||||
console.log(
|
||||
`[TrafficMonitorEnhanced] 引用计数变化: ${refCounter.getCount()}`,
|
||||
);
|
||||
if (refCounter.getCount() === 0) {
|
||||
console.log("[TrafficMonitorEnhanced] 所有组件已卸载,暂停数据收集");
|
||||
} else {
|
||||
nameValue = date.toLocaleTimeString("en-US", {
|
||||
console.log("[TrafficMonitorEnhanced] 开始数据收集");
|
||||
}
|
||||
};
|
||||
|
||||
refCounter.onCountChange(handleCountChange);
|
||||
}, []);
|
||||
|
||||
// 只有在有引用时才启用SWR
|
||||
const shouldFetch = clashInfo && pageVisible && refCounter.getCount() > 0;
|
||||
|
||||
const { data: monitorData, error } = useSWR<ISystemMonitorOverview>(
|
||||
shouldFetch ? "getSystemMonitorOverviewSafe" : null,
|
||||
getSystemMonitorOverviewSafe,
|
||||
{
|
||||
refreshInterval: shouldFetch ? 1000 : 0, // 只有在需要时才刷新
|
||||
keepPreviousData: true,
|
||||
onSuccess: (data) => {
|
||||
// console.log("[TrafficMonitorEnhanced] 获取到监控数据:", data);
|
||||
|
||||
if (data?.traffic?.raw && globalSampler) {
|
||||
// 保存最后有效数据
|
||||
lastValidData = data;
|
||||
|
||||
// 添加到采样器
|
||||
const timestamp = Date.now();
|
||||
const dataPoint: ITrafficDataPoint = {
|
||||
up: data.traffic.raw.up_rate || 0,
|
||||
down: data.traffic.raw.down_rate || 0,
|
||||
timestamp,
|
||||
name: new Date(timestamp).toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
nameValue = "Err:Time";
|
||||
}
|
||||
|
||||
return {
|
||||
up: 0,
|
||||
down: 0,
|
||||
timestamp: pointTime,
|
||||
name: nameValue,
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
dataBufferRef.current = initialBuffer;
|
||||
}
|
||||
}, [MAX_BUFFER_SIZE]);
|
||||
|
||||
// 使用SWR获取监控数据
|
||||
const { data: monitorData, error } = useSWR<ISystemMonitorOverview>(
|
||||
clashInfo && pageVisible ? "getSystemMonitorOverview" : null,
|
||||
getSystemMonitorOverview,
|
||||
{
|
||||
refreshInterval: 1000, // 1秒刷新一次
|
||||
keepPreviousData: true,
|
||||
onSuccess: (data) => {
|
||||
console.log("[TrafficMonitor] 获取到监控数据:", data);
|
||||
|
||||
if (data?.traffic) {
|
||||
// 为图表添加新数据点
|
||||
addDataPoint({
|
||||
up: data.traffic.raw.up_rate || 0,
|
||||
down: data.traffic.raw.down_rate || 0,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
globalSampler.addDataPoint(dataPoint);
|
||||
triggerUpdate();
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("[TrafficMonitor] 获取数据错误:", error);
|
||||
},
|
||||
console.error(
|
||||
"[TrafficMonitorEnhanced] 网络错误,使用最后有效数据. 错误详情:",
|
||||
{
|
||||
message: error?.message || "未知错误",
|
||||
stack: error?.stack || "无堆栈信息",
|
||||
},
|
||||
);
|
||||
|
||||
// 添加数据点到缓冲区
|
||||
const addDataPoint = useCallback(
|
||||
(data: { up: number; down: number; timestamp?: number }) => {
|
||||
const timestamp = data.timestamp || Date.now();
|
||||
const date = new Date(timestamp);
|
||||
|
||||
let nameValue: string;
|
||||
try {
|
||||
if (isNaN(date.getTime())) {
|
||||
nameValue = "??:??:??";
|
||||
} else {
|
||||
nameValue = date.toLocaleTimeString("en-US", {
|
||||
// 网络错误时不清空数据,继续使用最后有效值
|
||||
// 但是添加一个错误标记的数据点(流量为0)
|
||||
if (globalSampler) {
|
||||
const timestamp = Date.now();
|
||||
const errorPoint: ITrafficDataPoint = {
|
||||
up: 0,
|
||||
down: 0,
|
||||
timestamp,
|
||||
name: new Date(timestamp).toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
nameValue = "Err:Time";
|
||||
}
|
||||
|
||||
const newPoint: ITrafficDataPoint = {
|
||||
up: typeof data.up === "number" && !isNaN(data.up) ? data.up : 0,
|
||||
down:
|
||||
typeof data.down === "number" && !isNaN(data.down) ? data.down : 0,
|
||||
timestamp,
|
||||
name: nameValue,
|
||||
}),
|
||||
};
|
||||
|
||||
// 更新缓冲区,保持固定大小
|
||||
const newBuffer = [...dataBufferRef.current.slice(1), newPoint];
|
||||
dataBufferRef.current = newBuffer;
|
||||
|
||||
// 触发使用该数据的组件更新
|
||||
globalSampler.addDataPoint(errorPoint);
|
||||
triggerUpdate();
|
||||
}
|
||||
},
|
||||
},
|
||||
[triggerUpdate],
|
||||
);
|
||||
|
||||
// 清空数据
|
||||
const clearData = useCallback(() => {
|
||||
dataBufferRef.current = [];
|
||||
triggerUpdate();
|
||||
}, [triggerUpdate]);
|
||||
|
||||
// 根据时间范围获取数据
|
||||
// 获取指定时间范围的数据
|
||||
const getDataForTimeRange = useCallback(
|
||||
(minutes: number): ITrafficDataPoint[] => {
|
||||
const pointsToShow = minutes * 60; // 每分钟60个数据点
|
||||
return dataBufferRef.current.slice(-pointsToShow);
|
||||
if (!globalSampler) return [];
|
||||
return globalSampler.getDataForTimeRange(minutes);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 构建图表数据管理对象
|
||||
const graphData: ITrafficGraphData = {
|
||||
dataPoints: dataBufferRef.current,
|
||||
addDataPoint,
|
||||
clearData,
|
||||
getDataForTimeRange,
|
||||
};
|
||||
// 清空数据
|
||||
const clearData = useCallback(() => {
|
||||
if (globalSampler) {
|
||||
globalSampler.clear();
|
||||
triggerUpdate();
|
||||
}
|
||||
}, [triggerUpdate]);
|
||||
|
||||
// 构建监控数据对象
|
||||
const trafficMonitorData: ITrafficMonitorData = {
|
||||
traffic: monitorData?.traffic || {
|
||||
raw: { up_rate: 0, down_rate: 0 },
|
||||
formatted: { up_rate: "0B", down_rate: "0B" },
|
||||
// 获取采样器统计信息
|
||||
const getSamplerStats = useCallback(() => {
|
||||
return (
|
||||
globalSampler?.getStats() || {
|
||||
rawBufferSize: 0,
|
||||
compressedBufferSize: 0,
|
||||
compressionQueueSize: 0,
|
||||
totalMemoryPoints: 0,
|
||||
}
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 构建返回的监控数据,优先使用当前数据,fallback到最后有效数据
|
||||
const currentData = monitorData || lastValidData;
|
||||
const trafficMonitorData = {
|
||||
traffic: currentData?.traffic || {
|
||||
raw: { up: 0, down: 0, up_rate: 0, down_rate: 0 },
|
||||
formatted: {
|
||||
up_rate: "0B",
|
||||
down_rate: "0B",
|
||||
total_up: "0B",
|
||||
total_down: "0B",
|
||||
},
|
||||
is_fresh: false,
|
||||
},
|
||||
memory: monitorData?.memory || {
|
||||
raw: { inuse: 0 },
|
||||
formatted: { inuse: "0B" },
|
||||
memory: currentData?.memory || {
|
||||
raw: { inuse: 0, oslimit: 0, usage_percent: 0 },
|
||||
formatted: { inuse: "0B", oslimit: "0B", usage_percent: 0 },
|
||||
is_fresh: false,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
// 原始监控数据
|
||||
// 监控数据
|
||||
monitorData: trafficMonitorData,
|
||||
|
||||
// 图表数据管理
|
||||
graphData,
|
||||
// 数据获取状态
|
||||
isLoading: !monitorData && !error,
|
||||
graphData: {
|
||||
dataPoints: globalSampler?.getDataForTimeRange(60) || [], // 默认获取1小时数据
|
||||
getDataForTimeRange,
|
||||
clearData,
|
||||
},
|
||||
|
||||
// 状态信息
|
||||
isLoading: !currentData && !error,
|
||||
error,
|
||||
// 数据新鲜度
|
||||
isDataFresh: monitorData?.overall_status === "active",
|
||||
isDataFresh: currentData?.traffic?.is_fresh || false,
|
||||
hasValidData: !!lastValidData,
|
||||
|
||||
// 性能统计
|
||||
samplerStats: getSamplerStats(),
|
||||
referenceCount: refCounter.getCount(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 仅获取流量数据的轻量级Hook
|
||||
* 适用于不需要图表数据的组件
|
||||
* 轻量级流量数据Hook
|
||||
*/
|
||||
export const useTrafficData = () => {
|
||||
const { monitorData, isLoading, error, isDataFresh } = useTrafficMonitor();
|
||||
export const useTrafficDataEnhanced = () => {
|
||||
const { monitorData, isLoading, error, isDataFresh, hasValidData } =
|
||||
useTrafficMonitorEnhanced();
|
||||
|
||||
return {
|
||||
traffic: monitorData.traffic,
|
||||
@@ -228,18 +378,21 @@ export const useTrafficData = () => {
|
||||
isLoading,
|
||||
error,
|
||||
isDataFresh,
|
||||
hasValidData,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 仅获取图表数据的Hook
|
||||
* 适用于图表组件
|
||||
* 图表数据Hook
|
||||
*/
|
||||
export const useTrafficGraphData = () => {
|
||||
const { graphData, isDataFresh } = useTrafficMonitor();
|
||||
export const useTrafficGraphDataEnhanced = () => {
|
||||
const { graphData, isDataFresh, samplerStats, referenceCount } =
|
||||
useTrafficMonitorEnhanced();
|
||||
|
||||
return {
|
||||
...graphData,
|
||||
isDataFresh,
|
||||
samplerStats,
|
||||
referenceCount,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user