feat: migrate mihomo to use kode-bridge IPC on Windows and Unix (#4051)

* Refactor Mihomo API integration and remove crate_mihomo_api

- Removed the `mihomo_api` crate and its dependencies from the project.
- Introduced `IpcManager` for handling IPC communication with Mihomo.
- Implemented IPC methods for managing proxies, connections, and configurations.
- Updated `MihomoManager` to utilize `IpcManager` instead of the removed crate.
- Added platform-specific IPC socket path handling for macOS, Linux, and Windows.
- Cleaned up related tests and configuration files.

* fix: remove duplicate permission entry in desktop capabilities

* refactor: replace MihomoManager with IpcManager and remove Mihomo module

* fix: restore tempfile dependency in dev-dependencies

* fix: update kode-bridge dependency to use git source from the dev branch

* feat: migrate mihomo to use kode-bridge IPC on Windows

This commit implements a comprehensive migration from legacy service IPC to the kode-bridge library for Windows IPC communication. Key changes include:

Replace service_ipc with kode-bridge IpcManager for all mihomo communications
Simplify proxy commands using new caching mechanism with ProxyRequestCache
Add Windows named pipe (\.\pipe\mihomo) and Unix socket IPC endpoint configuration
Update Tauri permissions and dependencies (dashmap, tauri-plugin-notification)
Add IPC logging support and improve error handling
Fix Windows IPC path handling in directory utilities
This migration enables better cross-platform IPC support and improved performance for mihomo proxy core communication.

* doc: add IPC communication with Mihomo kernel, removing Restful API dependency

* fix: standardize logging type naming from IPC to Ipc for consistency

* refactor: clean up and optimize code structure across multiple components and services

- Removed unnecessary comments and whitespace in various files.
- Improved code readability and maintainability by restructuring functions and components.
- Updated localization files for consistency and accuracy.
- Enhanced performance by optimizing hooks and utility functions.
- General code cleanup in settings, pages, and services to adhere to best practices.

* fix: simplify URL formatting in test_proxy_delay method

* fix: update kode-bridge dependency to version 0.1.3 and change source to crates.io

* fix: update macOS target versions in development workflow

* Revert "fix: update macOS target versions in development workflow"

This reverts commit b9831357e462e0f308d11a9a53cb718f98ae1295.

* feat: enhance IPC path handling for Unix systems and improve directory safety checks

* feat: add conditional compilation for Unix-specific IPC path handling

* chore: update cagro.lock

* feat: add external controller configuration and UI support

* Refactor proxy and connection management to use IPC-based commands

- Updated `get_proxies` function in `proxy.rs` to call the new IPC command.
- Renamed `get_refresh_proxies` to `get_proxies` in `ipc/general.rs` for consistency.
- Added new IPC commands for managing proxies, connections, and configurations in `cmds.ts`.
- Refactored API calls in various components to use the new IPC commands instead of HTTP requests.
- Improved error handling and response management in the new IPC functions.
- Cleaned up unused API functions in `api.ts` and redirected relevant calls to `cmds.ts`.
- Enhanced connection management features including health checks and updates for proxy providers.

* chore: update dependencies and improve error handling in IPC manager

* fix: downgrade zip dependency from 4.3.0 to 4.2.0

* feat: Implement traffic and memory data monitoring service

- Added `TrafficService` and `TrafficManager` to manage traffic and memory data collection.
- Introduced commands to get traffic and memory data, start and stop the traffic service.
- Integrated IPC calls for traffic and memory data retrieval in the frontend.
- Updated `AppDataProvider` and `EnhancedTrafficStats` components to utilize new data fetching methods.
- Removed WebSocket connections for traffic and memory data, replaced with IPC polling.
- Added logging for better traceability of data fetching and service status.

* refactor: unify external controller handling and improve IPC path resolution

* fix: replace direct IPC path retrieval with guard function for external controller

* fix: convert external controller IPC path to string for proper insertion in config map

* fix: update dependencies and improve IPC response handling

* fix: remove unnecessary unix conditional for ipc path import

* Refactor traffic and memory monitoring to use IPC stream; remove TrafficService and TrafficManager. Introduce new IPC-based data retrieval methods for traffic and memory, including formatted data and system overview. Update frontend components to utilize new APIs for enhanced data display and management.

* chore: bump crate rand version to 0.9.2

* feat: Implement enhanced traffic monitoring system with data compression and sampling

- Introduced `useTrafficMonitorEnhanced` hook for advanced traffic data management.
- Added `TrafficDataSampler` class for handling raw and compressed traffic data.
- Implemented reference counting to manage data collection based on component usage.
- Enhanced data validation with `SystemMonitorValidator` for API responses.
- Created diagnostic tools for monitoring performance and error tracking.
- Updated existing hooks to utilize the new enhanced monitoring features.
- Added utility functions for generating and formatting diagnostic reports.

* feat(ipc): improve URL encoding and error handling for IPC requests

- Add percent-encoding for URL paths to handle special characters properly
- Enhance error handling in update_proxy with proper logging
- Remove excessive debug logging to reduce noise
- Update kode-bridge dependency to v0.1.5
- Fix JSON parsing error handling in PUT requests

Changes include:
- Proper URL encoding for connection IDs, proxy names, and test URLs
- Enhanced error handling with fallback responses in updateProxy
- Comment out verbose debug logs in traffic monitoring and data validation
- Update dependency version for improved IPC functionality

* feat: major improvements in architecture, traffic monitoring, and data validation

* Refactor traffic graph components: Replace EnhancedTrafficGraph with EnhancedCanvasTrafficGraph, improve rendering performance, and enhance visual elements. Remove deprecated code and ensure compatibility with global data management.

* chore: update UPDATELOG.md for v2.4.0 release, refine traffic monitoring system details, and enhance IPC functionality

* chore: update UPDATELOG.md to reflect removal of deprecated MihomoManager and unify IPC control

* refactor: remove global traffic service testing method from cmds.ts

* Update src/components/home/enhanced-canvas-traffic-graph.tsx

* Update src/hooks/use-traffic-monitor-enhanced.ts

* Update src/components/layout/layout-traffic.tsx

* refactor: remove debug state management from LayoutTraffic component

---------
This commit is contained in:
Tunglies
2025-07-24 00:47:42 +08:00
committed by Tunglies
parent f580409ade
commit 15a1770ee9
62 changed files with 4029 additions and 1762 deletions

View File

@@ -0,0 +1,342 @@
import React, { Component, ErrorInfo, ReactNode } from "react";
import { Box, Typography, Button, Alert, Collapse } from "@mui/material";
import {
ErrorOutlineRounded,
RefreshRounded,
BugReportRounded,
} from "@mui/icons-material";
import { useTranslation } from "react-i18next";
interface Props {
children: ReactNode;
fallbackComponent?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
showDetails: boolean;
}
/**
* 流量统计专用错误边界组件
* 处理图表和流量统计组件的错误,提供优雅的降级体验
*/
export class TrafficErrorBoundary extends Component<Props, State> {
private retryCount = 0;
private maxRetries = 3;
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
showDetails: false,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
// 更新状态以显示降级UI
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("[TrafficErrorBoundary] 捕获到组件错误:", error, errorInfo);
this.setState({
error,
errorInfo,
});
// 调用错误回调
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
// 发送错误到监控系统(如果有的话)
this.reportError(error, errorInfo);
}
private reportError = (error: Error, errorInfo: ErrorInfo) => {
// 这里可以集成错误监控服务
const errorReport = {
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href,
};
console.log("[TrafficErrorBoundary] 错误报告:", errorReport);
// TODO: 发送到错误监控服务
// sendErrorReport(errorReport);
};
private handleRetry = () => {
if (this.retryCount < this.maxRetries) {
this.retryCount++;
console.log(
`[TrafficErrorBoundary] 尝试重试 (${this.retryCount}/${this.maxRetries})`,
);
this.setState({
hasError: false,
error: null,
errorInfo: null,
showDetails: false,
});
} else {
console.warn("[TrafficErrorBoundary] 已达到最大重试次数");
}
};
private handleRefresh = () => {
window.location.reload();
};
private toggleDetails = () => {
this.setState((prev) => ({ showDetails: !prev.showDetails }));
};
render() {
if (this.state.hasError) {
// 如果提供了自定义降级组件,使用它
if (this.props.fallbackComponent) {
return this.props.fallbackComponent;
}
// 默认错误UI
return (
<TrafficErrorFallback
error={this.state.error}
errorInfo={this.state.errorInfo}
showDetails={this.state.showDetails}
canRetry={this.retryCount < this.maxRetries}
retryCount={this.retryCount}
maxRetries={this.maxRetries}
onRetry={this.handleRetry}
onRefresh={this.handleRefresh}
onToggleDetails={this.toggleDetails}
/>
);
}
return this.props.children;
}
}
/**
* 错误降级UI组件
*/
interface TrafficErrorFallbackProps {
error: Error | null;
errorInfo: ErrorInfo | null;
showDetails: boolean;
canRetry: boolean;
retryCount: number;
maxRetries: number;
onRetry: () => void;
onRefresh: () => void;
onToggleDetails: () => void;
}
const TrafficErrorFallback: React.FC<TrafficErrorFallbackProps> = ({
error,
errorInfo,
showDetails,
canRetry,
retryCount,
maxRetries,
onRetry,
onRefresh,
onToggleDetails,
}) => {
const { t } = useTranslation();
return (
<Box
sx={{
p: 2,
minHeight: 200,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
border: "1px dashed",
borderColor: "error.main",
borderRadius: 2,
bgcolor: "error.light",
color: "error.contrastText",
}}
>
<ErrorOutlineRounded sx={{ fontSize: 48, mb: 2, color: "error.main" }} />
<Typography variant="h6" gutterBottom>
{t("Traffic Statistics Error")}
</Typography>
<Typography
variant="body2"
color="text.secondary"
textAlign="center"
sx={{ mb: 2 }}
>
{t(
"The traffic statistics component encountered an error and has been disabled to prevent crashes.",
)}
</Typography>
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
<Typography variant="body2">
<strong>Error:</strong> {error?.message || "Unknown error"}
</Typography>
{retryCount > 0 && (
<Typography variant="caption" display="block" sx={{ mt: 1 }}>
{t("Retry attempts")}: {retryCount}/{maxRetries}
</Typography>
)}
</Alert>
<Box sx={{ display: "flex", gap: 1, mb: 2 }}>
{canRetry && (
<Button
variant="contained"
color="primary"
startIcon={<RefreshRounded />}
onClick={onRetry}
size="small"
>
{t("Retry")}
</Button>
)}
<Button variant="outlined" onClick={onRefresh} size="small">
{t("Refresh Page")}
</Button>
<Button
variant="text"
startIcon={<BugReportRounded />}
onClick={onToggleDetails}
size="small"
>
{showDetails ? t("Hide Details") : t("Show Details")}
</Button>
</Box>
<Collapse in={showDetails} sx={{ width: "100%", maxWidth: 600 }}>
<Box
sx={{
p: 2,
bgcolor: "background.paper",
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
}}
>
<Typography variant="subtitle2" gutterBottom>
Error Details:
</Typography>
<Typography
variant="caption"
component="pre"
sx={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "monospace",
fontSize: "0.75rem",
color: "text.secondary",
}}
>
{error?.stack}
</Typography>
{errorInfo?.componentStack && (
<>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
Component Stack:
</Typography>
<Typography
variant="caption"
component="pre"
sx={{
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "monospace",
fontSize: "0.75rem",
color: "text.secondary",
}}
>
{errorInfo.componentStack}
</Typography>
</>
)}
</Box>
</Collapse>
</Box>
);
};
/**
* 轻量级流量统计错误边界
* 用于小型流量显示组件提供最小化的错误UI
*/
export const LightweightTrafficErrorBoundary: React.FC<{
children: ReactNode;
}> = ({ children }) => {
return (
<TrafficErrorBoundary
fallbackComponent={
<Box
sx={{
p: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 60,
bgcolor: "error.light",
borderRadius: 1,
color: "error.contrastText",
}}
>
<ErrorOutlineRounded sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="caption">Traffic data unavailable</Typography>
</Box>
}
>
{children}
</TrafficErrorBoundary>
);
};
/**
* HOC为任何组件添加流量错误边界
*/
export function withTrafficErrorBoundary<P extends object>(
WrappedComponent: React.ComponentType<P>,
options?: {
lightweight?: boolean;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
},
) {
const WithErrorBoundaryComponent = (props: P) => {
const ErrorBoundaryComponent = options?.lightweight
? LightweightTrafficErrorBoundary
: TrafficErrorBoundary;
return (
<ErrorBoundaryComponent onError={options?.onError}>
<WrappedComponent {...props} />
</ErrorBoundaryComponent>
);
};
WithErrorBoundaryComponent.displayName = `withTrafficErrorBoundary(${WrappedComponent.displayName || WrappedComponent.name})`;
return WithErrorBoundaryComponent;
}

View File

@@ -2,7 +2,7 @@ import dayjs from "dayjs";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks";
import { Box, Button, Snackbar, useTheme } from "@mui/material";
import { deleteConnection } from "@/services/api";
import { deleteConnection } from "@/services/cmds";
import parseTraffic from "@/utils/parse-traffic";
import { t } from "i18next";

View File

@@ -9,7 +9,7 @@ import {
alpha,
} from "@mui/material";
import { CloseRounded } from "@mui/icons-material";
import { deleteConnection } from "@/services/api";
import { deleteConnection } from "@/services/cmds";
import parseTraffic from "@/utils/parse-traffic";
const Tag = styled("span")(({ theme }) => ({

View File

@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { Box, Typography, Paper, Stack } from "@mui/material";
import { useLockFn } from "ahooks";
import { closeAllConnections } from "@/services/api";
import { closeAllConnections } from "@/services/cmds";
import { patchClashMode } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import {

View File

@@ -29,7 +29,7 @@ import {
} from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import { EnhancedCard } from "@/components/home/enhanced-card";
import { updateProxy, deleteConnection } from "@/services/api";
import { updateProxy, deleteConnection } from "@/services/cmds";
import delayManager from "@/services/delay";
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-provider";

View File

@@ -0,0 +1,570 @@
import {
forwardRef,
useImperativeHandle,
useState,
useEffect,
useCallback,
useMemo,
useRef,
memo,
} from "react";
import { Box, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import {
useTrafficGraphDataEnhanced,
type ITrafficDataPoint,
} from "@/hooks/use-traffic-monitor-enhanced";
// 流量数据项接口
export interface ITrafficItem {
up: number;
down: number;
timestamp?: number;
}
// 对外暴露的接口
export interface EnhancedCanvasTrafficGraphRef {
appendData: (data: ITrafficItem) => void;
toggleStyle: () => void;
}
type TimeRange = 1 | 5 | 10; // 分钟
// Canvas图表配置
const MAX_POINTS = 300;
const TARGET_FPS = 15; // 降低帧率减少闪烁
const LINE_WIDTH_UP = 2.5;
const LINE_WIDTH_DOWN = 2.5;
const LINE_WIDTH_GRID = 0.5;
const ALPHA_GRADIENT = 0.15; // 降低渐变透明度
const ALPHA_LINE = 0.9;
const PADDING_TOP = 16;
const PADDING_RIGHT = 16; // 增加右边距确保时间戳完整显示
const PADDING_BOTTOM = 32; // 进一步增加底部空间给时间轴和统计信息
const PADDING_LEFT = 16; // 增加左边距确保时间戳完整显示
const GRAPH_CONFIG = {
maxPoints: MAX_POINTS,
targetFPS: TARGET_FPS,
lineWidth: {
up: LINE_WIDTH_UP,
down: LINE_WIDTH_DOWN,
grid: LINE_WIDTH_GRID,
},
alpha: {
gradient: ALPHA_GRADIENT,
line: ALPHA_LINE,
},
padding: {
top: PADDING_TOP,
right: PADDING_RIGHT,
bottom: PADDING_BOTTOM,
left: PADDING_LEFT,
},
};
/**
* 稳定版Canvas流量图表组件
* 修复闪烁问题,添加时间轴显示
*/
export const EnhancedCanvasTrafficGraph = memo(
forwardRef<EnhancedCanvasTrafficGraphRef>((props, ref) => {
const theme = useTheme();
const { t } = useTranslation();
// 使用增强版全局流量数据管理
const { dataPoints, getDataForTimeRange, isDataFresh, samplerStats } =
useTrafficGraphDataEnhanced();
// 基础状态
const [timeRange, setTimeRange] = useState<TimeRange>(10);
const [chartStyle, setChartStyle] = useState<"bezier" | "line">("bezier");
// Canvas引用和渲染状态
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationFrameRef = useRef<number | undefined>(undefined);
const lastRenderTimeRef = useRef<number>(0);
const isInitializedRef = useRef<boolean>(false);
// 当前显示的数据缓存
const [displayData, setDisplayData] = useState<ITrafficDataPoint[]>([]);
// 主题颜色配置
const colors = useMemo(
() => ({
up: theme.palette.secondary.main,
down: theme.palette.primary.main,
grid: theme.palette.divider,
text: theme.palette.text.secondary,
background: theme.palette.background.paper,
}),
[theme],
);
// 根据时间范围获取数据点数量
const getPointsForTimeRange = useCallback(
(minutes: TimeRange): number =>
Math.min(minutes * 60, GRAPH_CONFIG.maxPoints),
[],
);
// 更新显示数据(防抖处理)
const updateDisplayDataDebounced = useMemo(() => {
let timeoutId: number;
return (newData: ITrafficDataPoint[]) => {
clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
setDisplayData(newData);
}, 50); // 50ms防抖
};
}, []);
// 监听数据变化
useEffect(() => {
const timeRangeData = getDataForTimeRange(timeRange);
updateDisplayDataDebounced(timeRangeData);
}, [
dataPoints,
timeRange,
getDataForTimeRange,
updateDisplayDataDebounced,
]);
// Y轴坐标计算对数刻度- 确保不与时间轴重叠
const calculateY = useCallback((value: number, height: number): number => {
const padding = GRAPH_CONFIG.padding;
const effectiveHeight = height - padding.top - padding.bottom;
const baseY = height - padding.bottom;
if (value === 0) return baseY - 2; // 稍微抬高零值线
const steps = effectiveHeight / 7;
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;
return padding.top + 1;
}, []);
// 绘制时间轴
const drawTimeAxis = useCallback(
(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
data: ITrafficDataPoint[],
) => {
if (data.length === 0) return;
const padding = GRAPH_CONFIG.padding;
const effectiveWidth = width - padding.left - padding.right;
const timeAxisY = height - padding.bottom + 14;
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)));
// 绘制第一个时间点(左对齐)
if (data.length > 0 && data[0].name) {
ctx.textAlign = "left";
const timeLabel = data[0].name.substring(0, 5);
ctx.fillText(timeLabel, padding.left, timeAxisY);
}
// 绘制中间的时间点(居中对齐)
ctx.textAlign = "center";
for (let i = step; i < data.length - step; i += step) {
const point = data[i];
if (!point.name) continue;
const x = padding.left + (i / (data.length - 1)) * effectiveWidth;
const timeLabel = point.name.substring(0, 5);
ctx.fillText(timeLabel, x, timeAxisY);
}
// 绘制最后一个时间点(右对齐)
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);
}
ctx.restore();
},
[colors.text],
);
// 绘制网格线
const drawGrid = useCallback(
(ctx: CanvasRenderingContext2D, width: number, height: number) => {
const padding = GRAPH_CONFIG.padding;
const effectiveWidth = width - padding.left - padding.right;
const effectiveHeight = height - padding.top - padding.bottom;
ctx.save();
ctx.strokeStyle = colors.grid;
ctx.lineWidth = GRAPH_CONFIG.lineWidth.grid;
ctx.globalAlpha = 0.2;
// 水平网格线
const horizontalLines = 4;
for (let i = 1; i <= horizontalLines; i++) {
const y = padding.top + (effectiveHeight / (horizontalLines + 1)) * i;
ctx.beginPath();
ctx.moveTo(padding.left, y);
ctx.lineTo(width - padding.right, y);
ctx.stroke();
}
// 垂直网格线
const verticalLines = 6;
for (let i = 1; i <= verticalLines; i++) {
const x = padding.left + (effectiveWidth / (verticalLines + 1)) * i;
ctx.beginPath();
ctx.moveTo(x, padding.top);
ctx.lineTo(x, height - padding.bottom);
ctx.stroke();
}
ctx.restore();
},
[colors.grid],
);
// 绘制流量线条
const drawTrafficLine = useCallback(
(
ctx: CanvasRenderingContext2D,
values: number[],
width: number,
height: number,
color: string,
withGradient = false,
) => {
if (values.length < 2) return;
const padding = GRAPH_CONFIG.padding;
const effectiveWidth = width - padding.left - padding.right;
const points = values.map((value, index) => [
padding.left + (index / (values.length - 1)) * effectiveWidth,
calculateY(value, height),
]);
ctx.save();
// 绘制渐变填充
if (withGradient && chartStyle === "bezier") {
const gradient = ctx.createLinearGradient(
0,
padding.top,
0,
height - padding.bottom,
);
gradient.addColorStop(
0,
`${color}${Math.round(GRAPH_CONFIG.alpha.gradient * 255)
.toString(16)
.padStart(2, "0")}`,
);
gradient.addColorStop(1, `${color}00`);
ctx.beginPath();
ctx.moveTo(points[0][0], points[0][1]);
if (chartStyle === "bezier") {
for (let i = 1; i < points.length; i++) {
const current = points[i];
const next = points[i + 1] || current;
const controlX = (current[0] + next[0]) / 2;
const controlY = (current[1] + next[1]) / 2;
ctx.quadraticCurveTo(current[0], current[1], controlX, controlY);
}
} else {
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i][0], points[i][1]);
}
}
ctx.lineTo(points[points.length - 1][0], height - padding.bottom);
ctx.lineTo(points[0][0], height - padding.bottom);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
}
// 绘制主线条
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = GRAPH_CONFIG.lineWidth.up;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.globalAlpha = GRAPH_CONFIG.alpha.line;
ctx.moveTo(points[0][0], points[0][1]);
if (chartStyle === "bezier") {
for (let i = 1; i < points.length; i++) {
const current = points[i];
const next = points[i + 1] || current;
const controlX = (current[0] + next[0]) / 2;
const controlY = (current[1] + next[1]) / 2;
ctx.quadraticCurveTo(current[0], current[1], controlX, controlY);
}
} else {
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i][0], points[i][1]);
}
}
ctx.stroke();
ctx.restore();
},
[calculateY, chartStyle],
);
// 主绘制函数
const drawGraph = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas || displayData.length === 0) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Canvas尺寸设置
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const width = rect.width;
const height = rect.height;
// 只在尺寸变化时重新设置Canvas
if (canvas.width !== width * dpr || canvas.height !== height * dpr) {
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
canvas.style.width = width + "px";
canvas.style.height = height + "px";
}
// 清空画布
ctx.clearRect(0, 0, width, height);
// 绘制网格
drawGrid(ctx, width, height);
// 绘制时间轴
drawTimeAxis(ctx, width, height, displayData);
// 提取流量数据
const upValues = displayData.map((d) => d.up);
const downValues = displayData.map((d) => d.down);
// 绘制下载线(背景层)
drawTrafficLine(ctx, downValues, width, height, colors.down, true);
// 绘制上传线(前景层)
drawTrafficLine(ctx, upValues, width, height, colors.up, true);
isInitializedRef.current = true;
}, [displayData, colors, drawGrid, drawTimeAxis, drawTrafficLine]);
// 受控的动画循环
useEffect(() => {
const animate = (currentTime: number) => {
// 控制帧率,减少不必要的重绘
if (
currentTime - lastRenderTimeRef.current >=
1000 / GRAPH_CONFIG.targetFPS
) {
drawGraph();
lastRenderTimeRef.current = currentTime;
}
animationFrameRef.current = requestAnimationFrame(animate);
};
// 只有在有数据时才开始动画
if (displayData.length > 0) {
animationFrameRef.current = requestAnimationFrame(animate);
}
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [drawGraph, displayData.length]);
// 切换时间范围
const handleTimeRangeClick = useCallback((event: React.MouseEvent) => {
event.stopPropagation();
setTimeRange((prev) => {
return prev === 1 ? 5 : prev === 5 ? 10 : 1;
});
}, []);
// 切换图表样式
const toggleStyle = useCallback(() => {
setChartStyle((prev) => (prev === "bezier" ? "line" : "bezier"));
}, []);
// 兼容性方法
const appendData = useCallback((data: ITrafficItem) => {
console.log(
"[EnhancedCanvasTrafficGraphV2] appendData called (using global data):",
data,
);
}, []);
// 暴露方法给父组件
useImperativeHandle(
ref,
() => ({
appendData,
toggleStyle,
}),
[appendData, toggleStyle],
);
// 获取时间范围文本
const getTimeRangeText = useCallback(() => {
return t("{{time}} Minutes", { time: timeRange });
}, [timeRange, t]);
return (
<Box
sx={{
width: "100%",
height: "100%",
position: "relative",
bgcolor: "action.hover",
borderRadius: 1,
cursor: "pointer",
overflow: "hidden",
}}
onClick={toggleStyle}
>
<canvas
ref={canvasRef}
style={{
width: "100%",
height: "100%",
display: "block",
}}
/>
{/* 控制层覆盖 */}
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: "none",
}}
>
{/* 时间范围按钮 */}
<Box
component="div"
onClick={handleTimeRangeClick}
sx={{
position: "absolute",
top: 6,
left: 8,
fontSize: "11px",
fontWeight: "bold",
color: "text.secondary",
cursor: "pointer",
pointerEvents: "all",
px: 1,
py: 0.5,
borderRadius: 0.5,
bgcolor: "rgba(0,0,0,0.05)",
"&:hover": {
bgcolor: "rgba(0,0,0,0.1)",
},
}}
>
{getTimeRangeText()}
</Box>
{/* 图例 */}
<Box
sx={{
position: "absolute",
top: 6,
right: 8,
display: "flex",
flexDirection: "column",
gap: 0.5,
}}
>
<Box
sx={{
fontSize: "11px",
fontWeight: "bold",
color: colors.up,
textAlign: "right",
}}
>
{t("Upload")}
</Box>
<Box
sx={{
fontSize: "11px",
fontWeight: "bold",
color: colors.down,
textAlign: "right",
}}
>
{t("Download")}
</Box>
</Box>
{/* 样式指示器 */}
<Box
sx={{
position: "absolute",
bottom: 6,
right: 8,
fontSize: "10px",
color: "text.disabled",
opacity: 0.7,
}}
>
{chartStyle === "bezier" ? "Smooth" : "Linear"}
</Box>
{/* 数据统计指示器(左下角) */}
<Box
sx={{
position: "absolute",
bottom: 6,
left: 8,
fontSize: "9px",
color: "text.disabled",
opacity: 0.6,
lineHeight: 1.2,
}}
>
Points: {displayData.length} | Fresh: {isDataFresh ? "✓" : "✗"} |
Compressed: {samplerStats.compressedBufferSize}
</Box>
</Box>
</Box>
);
}),
);
EnhancedCanvasTrafficGraph.displayName = "EnhancedCanvasTrafficGraph";

View File

@@ -1,494 +0,0 @@
import {
forwardRef,
useImperativeHandle,
useState,
useEffect,
useCallback,
useMemo,
useRef,
memo,
} from "react";
import { Box, useTheme } from "@mui/material";
import parseTraffic from "@/utils/parse-traffic";
import { useTranslation } from "react-i18next";
import { Line as ChartJsLine } from "react-chartjs-2";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Filler,
Scale,
Tick,
} from "chart.js";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Filler,
);
// 流量数据项接口
export interface ITrafficItem {
up: number;
down: number;
timestamp?: number;
}
// 对外暴露的接口
export interface EnhancedTrafficGraphRef {
appendData: (data: ITrafficItem) => void;
toggleStyle: () => void;
}
type TimeRange = 1 | 5 | 10; // 分钟
// 数据点类型
type DataPoint = ITrafficItem & { name: string; timestamp: number };
/**
* 增强型流量图表组件
*/
export const EnhancedTrafficGraph = memo(
forwardRef<EnhancedTrafficGraphRef>((props, ref) => {
const theme = useTheme();
const { t } = useTranslation();
// 基础状态
const [timeRange, setTimeRange] = useState<TimeRange>(10);
const [chartStyle, setChartStyle] = useState<"line" | "area">("area");
const [displayData, setDisplayData] = useState<DataPoint[]>([]);
// 数据缓冲区
const dataBufferRef = useRef<DataPoint[]>([]);
// 根据时间范围计算保留的数据点数量
const getMaxPointsByTimeRange = useCallback(
(minutes: TimeRange): number => minutes * 60,
[],
);
// 最大数据点数量
const MAX_BUFFER_SIZE = useMemo(
() => getMaxPointsByTimeRange(10),
[getMaxPointsByTimeRange],
);
// 颜色配置
const colors = useMemo(
() => ({
up: theme.palette.secondary.main,
down: theme.palette.primary.main,
grid: theme.palette.divider,
tooltipBg: theme.palette.background.paper,
text: theme.palette.text.primary,
tooltipBorder: theme.palette.divider,
}),
[theme],
);
// 切换时间范围
const handleTimeRangeClick = useCallback(
(event: React.MouseEvent<SVGTextElement>) => {
event.stopPropagation();
setTimeRange((prevRange) => {
return prevRange === 1 ? 5 : prevRange === 5 ? 10 : 1;
});
},
[],
);
// 点击图表主体或图例时切换样式
const handleToggleStyleClick = useCallback(
(event: React.MouseEvent<SVGTextElement | HTMLDivElement>) => {
event.stopPropagation();
setChartStyle((prev) => (prev === "line" ? "area" : "line"));
},
[],
);
// 初始化数据缓冲区
useEffect(() => {
const now = Date.now();
const tenMinutesAgo = now - 10 * 60 * 1000;
const initialBuffer = Array.from(
{ length: MAX_BUFFER_SIZE },
(_, index) => {
const pointTime =
tenMinutesAgo + index * ((10 * 60 * 1000) / MAX_BUFFER_SIZE);
const date = new Date(pointTime);
let nameValue: string;
try {
if (isNaN(date.getTime())) {
console.warn(
`Initial data generation: Invalid date for timestamp ${pointTime}`,
);
nameValue = "??:??:??";
} else {
nameValue = date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
} catch (e) {
console.error(
"Error in toLocaleTimeString during initial data gen:",
e,
"Date:",
date,
"Timestamp:",
pointTime,
);
nameValue = "Err:Time";
}
return {
up: 0,
down: 0,
timestamp: pointTime,
name: nameValue,
};
},
);
dataBufferRef.current = initialBuffer;
// 更新显示数据
const pointsToShow = getMaxPointsByTimeRange(timeRange);
setDisplayData(initialBuffer.slice(-pointsToShow));
}, [MAX_BUFFER_SIZE, getMaxPointsByTimeRange]);
// 添加数据点方法
const appendData = useCallback(
(data: ITrafficItem) => {
const safeData = {
up: typeof data.up === "number" && !isNaN(data.up) ? data.up : 0,
down:
typeof data.down === "number" && !isNaN(data.down) ? data.down : 0,
};
const timestamp = data.timestamp || Date.now();
const date = new Date(timestamp);
let nameValue: string;
try {
if (isNaN(date.getTime())) {
console.warn(`appendData: Invalid date for timestamp ${timestamp}`);
nameValue = "??:??:??";
} else {
nameValue = date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
} catch (e) {
console.error(
"Error in toLocaleTimeString in appendData:",
e,
"Date:",
date,
"Timestamp:",
timestamp,
);
nameValue = "Err:Time";
}
// 带时间标签的新数据点
const newPoint: DataPoint = {
...safeData,
name: nameValue,
timestamp: timestamp,
};
const newBuffer = [...dataBufferRef.current.slice(1), newPoint];
dataBufferRef.current = newBuffer;
const pointsToShow = getMaxPointsByTimeRange(timeRange);
setDisplayData(newBuffer.slice(-pointsToShow));
},
[timeRange, getMaxPointsByTimeRange],
);
// 监听时间范围变化
useEffect(() => {
const pointsToShow = getMaxPointsByTimeRange(timeRange);
if (dataBufferRef.current.length > 0) {
setDisplayData(dataBufferRef.current.slice(-pointsToShow));
}
}, [timeRange, getMaxPointsByTimeRange]);
// 切换图表样式
const toggleStyle = useCallback(() => {
setChartStyle((prev) => (prev === "line" ? "area" : "line"));
}, []);
// 暴露方法给父组件
useImperativeHandle(
ref,
() => ({
appendData,
toggleStyle,
}),
[appendData, toggleStyle],
);
const formatYAxis = useCallback((value: number | string): string => {
if (typeof value !== "number") return String(value);
const [num, unit] = parseTraffic(value);
return `${num}${unit}`;
}, []);
const formatXLabel = useCallback(
(tickValue: string | number, index: number, ticks: any[]) => {
const dataPoint = displayData[index as number];
if (dataPoint && dataPoint.name) {
const parts = dataPoint.name.split(":");
return `${parts[0]}:${parts[1]}`;
}
if (typeof tickValue === "string") {
const parts = tickValue.split(":");
if (parts.length >= 2) return `${parts[0]}:${parts[1]}`;
return tickValue;
}
return "";
},
[displayData],
);
// 获取当前时间范围文本
const getTimeRangeText = useCallback(() => {
return t("{{time}} Minutes", { time: timeRange });
}, [timeRange, t]);
const chartData = useMemo(() => {
const labels = displayData.map((d) => d.name);
return {
labels,
datasets: [
{
label: t("Upload"),
data: displayData.map((d) => d.up),
borderColor: colors.up,
backgroundColor: chartStyle === "area" ? colors.up : colors.up,
fill: chartStyle === "area",
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 2,
},
{
label: t("Download"),
data: displayData.map((d) => d.down),
borderColor: colors.down,
backgroundColor: chartStyle === "area" ? colors.down : colors.down,
fill: chartStyle === "area",
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 4,
borderWidth: 2,
},
],
};
}, [displayData, colors.up, colors.down, t, chartStyle]);
const chartOptions = useMemo(
() => ({
responsive: true,
maintainAspectRatio: false,
animation: false as false,
scales: {
x: {
display: true,
type: "category" as const,
labels: displayData.map((d) => d.name),
ticks: {
display: true,
color: colors.text,
font: { size: 10 },
callback: function (
this: Scale,
tickValue: string | number,
index: number,
ticks: Tick[],
): string | undefined {
let labelToFormat: string | undefined = undefined;
const currentDisplayTick = ticks[index];
if (
currentDisplayTick &&
typeof currentDisplayTick.label === "string"
) {
labelToFormat = currentDisplayTick.label;
} else {
const sourceLabels = displayData.map((d) => d.name);
if (
typeof tickValue === "number" &&
tickValue >= 0 &&
tickValue < sourceLabels.length
) {
labelToFormat = sourceLabels[tickValue];
} else if (typeof tickValue === "string") {
labelToFormat = tickValue;
}
}
if (typeof labelToFormat !== "string") {
return undefined;
}
const parts: string[] = labelToFormat.split(":");
return parts.length >= 2
? `${parts[0]}:${parts[1]}`
: labelToFormat;
},
autoSkip: true,
maxTicksLimit: Math.max(
5,
Math.floor(displayData.length / (timeRange * 2)),
),
minRotation: 0,
maxRotation: 0,
},
grid: {
display: true,
drawOnChartArea: false,
drawTicks: true,
tickLength: 2,
color: colors.text,
},
},
y: {
beginAtZero: true,
ticks: {
color: colors.text,
font: { size: 10 },
callback: formatYAxis,
},
grid: {
display: true,
drawTicks: true,
tickLength: 3,
color: colors.grid,
},
},
},
plugins: {
tooltip: {
enabled: true,
mode: "index" as const,
intersect: false,
backgroundColor: colors.tooltipBg,
titleColor: colors.text,
bodyColor: colors.text,
borderColor: colors.tooltipBorder,
borderWidth: 1,
cornerRadius: 4,
padding: 8,
callbacks: {
title: (tooltipItems: any[]) => {
return `${t("Time")}: ${tooltipItems[0].label}`;
},
label: (context: any): string => {
const label = context.dataset.label || "";
const value = context.parsed.y;
const [num, unit] = parseTraffic(value);
return `${label}: ${num} ${unit}/s`;
},
},
},
legend: {
display: false,
},
},
layout: {
padding: {
top: 16,
right: 7,
left: 3,
},
},
}),
[colors, t, formatYAxis, timeRange, displayData],
);
return (
<Box
sx={{
width: "100%",
height: "100%",
position: "relative",
bgcolor: "action.hover",
borderRadius: 1,
cursor: "pointer",
}}
onClick={handleToggleStyleClick}
>
<div style={{ width: "100%", height: "100%", position: "relative" }}>
{displayData.length > 0 && (
<ChartJsLine data={chartData} options={chartOptions} />
)}
<svg
width="100%"
height="100%"
style={{
position: "absolute",
top: 0,
left: 0,
pointerEvents: "none",
}}
>
<text
x="3.5%"
y="10%"
textAnchor="start"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight="bold"
onClick={handleTimeRangeClick}
style={{ cursor: "pointer", pointerEvents: "all" }}
>
{getTimeRangeText()}
</text>
<text
x="99%"
y="10%"
textAnchor="end"
fill={colors.up}
fontSize={12}
fontWeight="bold"
onClick={handleToggleStyleClick}
style={{ cursor: "pointer", pointerEvents: "all" }}
>
{t("Upload")}
</text>
<text
x="99%"
y="19%"
textAnchor="end"
fill={colors.down}
fontSize={12}
fontWeight="bold"
onClick={handleToggleStyleClick}
style={{ cursor: "pointer", pointerEvents: "all" }}
>
{t("Download")}
</text>
</svg>
</div>
</Box>
);
}),
);
EnhancedTrafficGraph.displayName = "EnhancedTrafficGraph";

View File

@@ -7,6 +7,7 @@ import {
useTheme,
PaletteColor,
Grid,
Box,
} from "@mui/material";
import {
ArrowUpwardRounded,
@@ -17,18 +18,19 @@ import {
CloudDownloadRounded,
} from "@mui/icons-material";
import {
EnhancedTrafficGraph,
EnhancedTrafficGraphRef,
ITrafficItem,
} from "./enhanced-traffic-graph";
EnhancedCanvasTrafficGraph,
type EnhancedCanvasTrafficGraphRef,
type ITrafficItem,
} from "./enhanced-canvas-traffic-graph";
import { useVisibility } from "@/hooks/use-visibility";
import { useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
import { createAuthSockette } from "@/utils/websocket";
import parseTraffic from "@/utils/parse-traffic";
import { isDebugEnabled, gc } from "@/services/api";
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 { TrafficErrorBoundary } from "@/components/common/traffic-error-boundary";
import useSWR from "swr";
interface MemoryUsage {
@@ -64,7 +66,6 @@ declare global {
// 控制更新频率
const CONNECTIONS_UPDATE_INTERVAL = 5000; // 5秒更新一次连接数据
const THROTTLE_TRAFFIC_UPDATE = 500; // 500ms节流流量数据更新
// 统计卡片组件 - 使用memo优化
const CompactStatCard = memo(
@@ -160,20 +161,15 @@ export const EnhancedTrafficStats = () => {
const theme = useTheme();
const { clashInfo } = useClashInfo();
const { verge } = useVerge();
const trafficRef = useRef<EnhancedTrafficGraphRef>(null);
const trafficRef = useRef<EnhancedCanvasTrafficGraphRef>(null);
const pageVisible = useVisibility();
// 使用AppDataProvider
const { connections, uptime } = useAppData();
// 使用单一状态对象减少状态更新次数
const [stats, setStats] = useState({
traffic: { up: 0, down: 0 },
memory: { inuse: 0, oslimit: undefined as number | undefined },
});
// 创建一个标记来追踪最后更新时间,用于节流
const lastUpdateRef = useRef({ traffic: 0 });
// 使用增强版的统一流量数据Hook
const { traffic, memory, isLoading, isDataFresh, hasValidData } =
useTrafficDataEnhanced();
// 是否显示流量图表
const trafficGraph = verge?.traffic_graph ?? true;
@@ -189,171 +185,7 @@ export const EnhancedTrafficStats = () => {
},
);
// 处理流量数据更新 - 使用节流控制更新频率
const handleTrafficUpdate = useCallback((event: MessageEvent) => {
try {
const data = JSON.parse(event.data) as ITrafficItem;
if (
data &&
typeof data.up === "number" &&
typeof data.down === "number"
) {
// 使用节流控制更新频率
const now = Date.now();
if (now - lastUpdateRef.current.traffic < THROTTLE_TRAFFIC_UPDATE) {
try {
trafficRef.current?.appendData({
up: data.up,
down: data.down,
timestamp: now,
});
} catch {}
return;
}
lastUpdateRef.current.traffic = now;
const safeUp = isNaN(data.up) ? 0 : data.up;
const safeDown = isNaN(data.down) ? 0 : data.down;
try {
setStats((prev) => ({
...prev,
traffic: { up: safeUp, down: safeDown },
}));
} catch {}
try {
trafficRef.current?.appendData({
up: safeUp,
down: safeDown,
timestamp: now,
});
} catch {}
}
} catch (err) {
console.error("[Traffic] 解析数据错误:", err, event.data);
}
}, []);
// 处理内存数据更新
const handleMemoryUpdate = useCallback((event: MessageEvent) => {
try {
const data = JSON.parse(event.data) as MemoryUsage;
if (data && typeof data.inuse === "number") {
setStats((prev) => ({
...prev,
memory: {
inuse: isNaN(data.inuse) ? 0 : data.inuse,
oslimit: data.oslimit,
},
}));
}
} catch (err) {
console.error("[Memory] 解析数据错误:", err, event.data);
}
}, []);
// 使用 WebSocket 连接获取数据 - 合并流量和内存连接逻辑
useEffect(() => {
if (!clashInfo || !pageVisible) return;
const { server, secret = "" } = clashInfo;
if (!server) return;
// WebSocket 引用
let sockets: {
traffic: ReturnType<typeof createAuthSockette> | null;
memory: ReturnType<typeof createAuthSockette> | null;
} = {
traffic: null,
memory: null,
};
// 清理现有连接的函数
const cleanupSockets = () => {
Object.values(sockets).forEach((socket) => {
if (socket) {
socket.close();
}
});
sockets = { traffic: null, memory: null };
};
// 关闭现有连接
cleanupSockets();
// 创建新连接
console.log(
`[Traffic][${EnhancedTrafficStats.name}] 正在连接: ${server}/traffic`,
);
sockets.traffic = createAuthSockette(`${server}/traffic`, secret, {
onmessage: handleTrafficUpdate,
onopen: (event) => {
console.log(
`[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接已建立`,
event,
);
},
onerror: (event) => {
console.error(
`[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接错误或达到最大重试次数`,
event,
);
setStats((prev) => ({ ...prev, traffic: { up: 0, down: 0 } }));
},
onclose: (event) => {
console.log(
`[Traffic][${EnhancedTrafficStats.name}] WebSocket 连接关闭`,
event.code,
event.reason,
);
if (event.code !== 1000 && event.code !== 1001) {
console.warn(
`[Traffic][${EnhancedTrafficStats.name}] 连接非正常关闭,重置状态`,
);
setStats((prev) => ({ ...prev, traffic: { up: 0, down: 0 } }));
}
},
});
console.log(
`[Memory][${EnhancedTrafficStats.name}] 正在连接: ${server}/memory`,
);
sockets.memory = createAuthSockette(`${server}/memory`, secret, {
onmessage: handleMemoryUpdate,
onopen: (event) => {
console.log(
`[Memory][${EnhancedTrafficStats.name}] WebSocket 连接已建立`,
event,
);
},
onerror: (event) => {
console.error(
`[Memory][${EnhancedTrafficStats.name}] WebSocket 连接错误或达到最大重试次数`,
event,
);
setStats((prev) => ({
...prev,
memory: { inuse: 0, oslimit: undefined },
}));
},
onclose: (event) => {
console.log(
`[Memory][${EnhancedTrafficStats.name}] WebSocket 连接关闭`,
event.code,
event.reason,
);
if (event.code !== 1000 && event.code !== 1001) {
console.warn(
`[Memory][${EnhancedTrafficStats.name}] 连接非正常关闭,重置状态`,
);
setStats((prev) => ({
...prev,
memory: { inuse: 0, oslimit: undefined },
}));
}
},
});
return cleanupSockets;
}, [clashInfo, pageVisible, handleTrafficUpdate, handleMemoryUpdate]);
// Canvas组件现在直接从全局Hook获取数据无需手动添加数据点
// 执行垃圾回收
const handleGarbageCollection = useCallback(async () => {
@@ -369,9 +201,9 @@ export const EnhancedTrafficStats = () => {
// 使用useMemo计算解析后的流量数据
const parsedData = useMemo(() => {
const [up, upUnit] = parseTraffic(stats.traffic.up);
const [down, downUnit] = parseTraffic(stats.traffic.down);
const [inuse, inuseUnit] = parseTraffic(stats.memory.inuse);
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 [uploadTotal, uploadTotalUnit] = parseTraffic(
connections.uploadTotal,
);
@@ -392,7 +224,7 @@ export const EnhancedTrafficStats = () => {
downloadTotalUnit,
connectionsCount: connections.count,
};
}, [stats, connections]);
}, [traffic, memory, connections]);
// 渲染流量图表 - 使用useMemo缓存渲染结果
const trafficGraphComponent = useMemo(() => {
@@ -411,7 +243,7 @@ export const EnhancedTrafficStats = () => {
onClick={() => trafficRef.current?.toggleStyle()}
>
<div style={{ height: "100%", position: "relative" }}>
<EnhancedTrafficGraph ref={trafficRef} />
<EnhancedCanvasTrafficGraph ref={trafficRef} />
{isDebug && (
<div
style={{
@@ -428,6 +260,10 @@ export const EnhancedTrafficStats = () => {
>
DEBUG: {!!trafficRef.current ? "图表已初始化" : "图表未初始化"}
<br />
: {isDataFresh ? "active" : "inactive"}
<br />
: {traffic?.is_fresh ? "Fresh" : "Stale"}
<br />
{new Date().toISOString().slice(11, 19)}
</div>
)}
@@ -487,19 +323,42 @@ export const EnhancedTrafficStats = () => {
);
return (
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
{trafficGraph && (
<Grid size={12}>
{/* 流量图表区域 */}
{trafficGraphComponent}
</Grid>
)}
{/* 统计卡片区域 */}
{statCards.map((card, index) => (
<Grid key={index} size={4}>
<CompactStatCard {...card} />
</Grid>
))}
</Grid>
<TrafficErrorBoundary
onError={(error, errorInfo) => {
console.error("[EnhancedTrafficStats] 组件错误:", error, errorInfo);
}}
>
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
{trafficGraph && (
<Grid size={12}>
{/* 流量图表区域 */}
{trafficGraphComponent}
</Grid>
)}
{/* 统计卡片区域 */}
{statCards.map((card, index) => (
<Grid key={index} size={4}>
<CompactStatCard {...card} />
</Grid>
))}
{/* 数据状态指示器(调试用)*/}
{isDebug && (
<Grid size={12}>
<Box
sx={{
p: 1,
bgcolor: "action.hover",
borderRadius: 1,
fontSize: "0.75rem",
}}
>
: {isDataFresh ? "新鲜" : "过期"} | :{" "}
{hasValidData ? "是" : "否"} | : {isLoading ? "是" : "否"}
</Box>
</Grid>
)}
</Grid>
</TrafficErrorBoundary>
);
};

View File

@@ -10,10 +10,10 @@ import { useVerge } from "@/hooks/use-verge";
import { TrafficGraph, type TrafficRef } from "./traffic-graph";
import { useVisibility } from "@/hooks/use-visibility";
import parseTraffic from "@/utils/parse-traffic";
import useSWRSubscription from "swr/subscription";
import { createAuthSockette } from "@/utils/websocket";
import { useTranslation } from "react-i18next";
import { isDebugEnabled, gc } from "@/services/api";
import { isDebugEnabled, gc, startTrafficService } from "@/services/cmds";
import { useTrafficDataEnhanced } from "@/hooks/use-traffic-monitor-enhanced";
import { LightweightTrafficErrorBoundary } from "@/components/common/traffic-error-boundary";
import useSWR from "swr";
interface MemoryUsage {
@@ -23,6 +23,18 @@ interface MemoryUsage {
// setup the traffic
export const LayoutTraffic = () => {
const { data: isDebug } = useSWR(
"clash-verge-rev-internal://isDebugEnabled",
() => isDebugEnabled(),
{
// default value before is fetched
fallbackData: false,
},
);
if (isDebug) {
console.debug("[Traffic][LayoutTraffic] 组件正在渲染");
}
const { t } = useTranslation();
const { clashInfo } = useClashInfo();
const { verge } = useVerge();
@@ -33,125 +45,54 @@ export const LayoutTraffic = () => {
const trafficRef = useRef<TrafficRef>(null);
const pageVisible = useVisibility();
const { data: isDebug } = useSWR(
"clash-verge-rev-internal://isDebugEnabled",
() => isDebugEnabled(),
{
// default value before is fetched
fallbackData: false,
},
);
// 使用增强版的统一流量数据Hook
const { traffic, memory, isLoading, isDataFresh, hasValidData } =
useTrafficDataEnhanced();
const { data: traffic = { up: 0, down: 0 } } = useSWRSubscription<
ITrafficItem,
any,
"getRealtimeTraffic" | null
>(
clashInfo && pageVisible ? "getRealtimeTraffic" : null,
(_key, { next }) => {
const { server = "", secret = "" } = clashInfo!;
// 启动流量服务
useEffect(() => {
console.log(
"[Traffic][LayoutTraffic] useEffect 触发clashInfo:",
clashInfo,
"pageVisible:",
pageVisible,
);
if (!server) {
console.warn("[Traffic] 服务器地址为空,无法建立连接");
next(null, { up: 0, down: 0 });
return () => {};
}
// 简化条件,只要组件挂载就尝试启动服务
console.log("[Traffic][LayoutTraffic] 开始启动流量服务");
startTrafficService().catch((error) => {
console.error("[Traffic][LayoutTraffic] 启动流量服务失败:", error);
});
}, []); // 移除依赖,只在组件挂载时启动一次
console.log(`[Traffic] 正在连接: ${server}/traffic`);
const s = createAuthSockette(`${server}/traffic`, secret, {
timeout: 8000, // 8秒超时
onmessage(event) {
const data = JSON.parse(event.data) as ITrafficItem;
trafficRef.current?.appendData(data);
next(null, data);
},
onerror(event) {
console.error("[Traffic] WebSocket 连接错误", event);
this.close();
next(null, { up: 0, down: 0 });
},
onclose(event) {
console.log("[Traffic] WebSocket 连接关闭", event);
},
onopen(event) {
console.log("[Traffic] WebSocket 连接已建立");
},
// 监听数据变化,为图表添加数据点
useEffect(() => {
if (traffic?.raw && trafficRef.current) {
trafficRef.current.appendData({
up: traffic.raw.up_rate || 0,
down: traffic.raw.down_rate || 0,
});
}
}, [traffic]);
return () => {
console.log("[Traffic] 清理WebSocket连接");
try {
s.close();
} catch (e) {
console.error("[Traffic] 关闭连接时出错", e);
}
};
},
{
fallbackData: { up: 0, down: 0 },
keepPreviousData: true,
},
);
/* --------- meta memory information --------- */
// 显示内存使用情况的设置
const displayMemory = verge?.enable_memory_usage ?? true;
const { data: memory = { inuse: 0 } } = useSWRSubscription<
MemoryUsage,
any,
"getRealtimeMemory" | null
>(
clashInfo && pageVisible && displayMemory ? "getRealtimeMemory" : null,
(_key, { next }) => {
const { server = "", secret = "" } = clashInfo!;
// 使用格式化的数据,避免重复解析
const upSpeed = traffic?.formatted?.up_rate || "0B";
const downSpeed = traffic?.formatted?.down_rate || "0B";
const memoryUsage = memory?.formatted?.inuse || "0B";
if (!server) {
console.warn("[Memory] 服务器地址为空,无法建立连接");
next(null, { inuse: 0 });
return () => {};
}
console.log(`[Memory] 正在连接: ${server}/memory`);
const s = createAuthSockette(`${server}/memory`, secret, {
timeout: 8000, // 8秒超时
onmessage(event) {
const data = JSON.parse(event.data) as MemoryUsage;
next(null, data);
},
onerror(event) {
console.error("[Memory] WebSocket 连接错误", event);
this.close();
next(null, { inuse: 0 });
},
onclose(event) {
console.log("[Memory] WebSocket 连接关闭", event);
},
onopen(event) {
console.log("[Memory] WebSocket 连接已建立");
},
});
return () => {
console.log("[Memory] 清理WebSocket连接");
try {
s.close();
} catch (e) {
console.error("[Memory] 关闭连接时出错", e);
}
};
},
{
fallbackData: { inuse: 0 },
keepPreviousData: true,
},
);
const [up, upUnit] = parseTraffic(traffic.up);
const [down, downUnit] = parseTraffic(traffic.down);
const [inuse, inuseUnit] = parseTraffic(memory.inuse);
// 提取数值和单位
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, ""];
const boxStyle: any = {
display: "flex",
@@ -175,55 +116,78 @@ export const LayoutTraffic = () => {
};
return (
<Box position="relative">
{trafficGraph && pageVisible && (
<div
style={{ width: "100%", height: 60, marginBottom: 6 }}
onClick={trafficRef.current?.toggleStyle}
>
<TrafficGraph ref={trafficRef} />
</div>
)}
<LightweightTrafficErrorBoundary>
<Box position="relative">
{trafficGraph && pageVisible && (
<div
style={{ width: "100%", height: 60, marginBottom: 6 }}
onClick={trafficRef.current?.toggleStyle}
>
<TrafficGraph ref={trafficRef} />
</div>
)}
<Box display="flex" flexDirection="column" gap={0.75}>
<Box title={t("Upload Speed")} {...boxStyle}>
<ArrowUpwardRounded
{...iconStyle}
color={+up > 0 ? "secondary" : "disabled"}
/>
<Typography {...valStyle} color="secondary">
{up}
</Typography>
<Typography {...unitStyle}>{upUnit}/s</Typography>
</Box>
<Box title={t("Download Speed")} {...boxStyle}>
<ArrowDownwardRounded
{...iconStyle}
color={+down > 0 ? "primary" : "disabled"}
/>
<Typography {...valStyle} color="primary">
{down}
</Typography>
<Typography {...unitStyle}>{downUnit}/s</Typography>
</Box>
{displayMemory && (
<Box display="flex" flexDirection="column" gap={0.75}>
<Box
title={t(isDebug ? "Memory Cleanup" : "Memory Usage")}
title={`${t("Upload Speed")} ${traffic?.is_fresh ? "" : "(Stale)"}`}
{...boxStyle}
sx={{ cursor: isDebug ? "pointer" : "auto" }}
color={isDebug ? "success.main" : "disabled"}
onClick={async () => {
isDebug && (await gc());
sx={{
...boxStyle.sx,
opacity: traffic?.is_fresh ? 1 : 0.6,
}}
>
<MemoryRounded {...iconStyle} />
<Typography {...valStyle}>{inuse}</Typography>
<Typography {...unitStyle}>{inuseUnit}</Typography>
<ArrowUpwardRounded
{...iconStyle}
color={
(traffic?.raw?.up_rate || 0) > 0 ? "secondary" : "disabled"
}
/>
<Typography {...valStyle} color="secondary">
{up}
</Typography>
<Typography {...unitStyle}>{upUnit}/s</Typography>
</Box>
)}
<Box
title={`${t("Download Speed")} ${traffic?.is_fresh ? "" : "(Stale)"}`}
{...boxStyle}
sx={{
...boxStyle.sx,
opacity: traffic?.is_fresh ? 1 : 0.6,
}}
>
<ArrowDownwardRounded
{...iconStyle}
color={
(traffic?.raw?.down_rate || 0) > 0 ? "primary" : "disabled"
}
/>
<Typography {...valStyle} color="primary">
{down}
</Typography>
<Typography {...unitStyle}>{downUnit}/s</Typography>
</Box>
{displayMemory && (
<Box
title={`${t(isDebug ? "Memory Cleanup" : "Memory Usage")} ${memory?.is_fresh ? "" : "(Stale)"} ${"usage_percent" in (memory?.formatted || {}) && memory.formatted.usage_percent ? `(${memory.formatted.usage_percent.toFixed(1)}%)` : ""}`}
{...boxStyle}
sx={{
cursor: isDebug ? "pointer" : "auto",
opacity: memory?.is_fresh ? 1 : 0.6,
}}
color={isDebug ? "success.main" : "disabled"}
onClick={async () => {
isDebug && (await gc());
}}
>
<MemoryRounded {...iconStyle} />
<Typography {...valStyle}>{inuse}</Typography>
<Typography {...unitStyle}>{inuseUnit}</Typography>
</Box>
)}
</Box>
</Box>
</Box>
</LightweightTrafficErrorBoundary>
);
};

View File

@@ -19,7 +19,7 @@ import {
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import { proxyProviderUpdate } from "@/services/api";
import { proxyProviderUpdate } from "@/services/cmds";
import { useAppData } from "@/providers/app-data-provider";
import { showNotice } from "@/services/noticeService";
import { StorageOutlined, RefreshRounded } from "@mui/icons-material";

View File

@@ -7,7 +7,7 @@ import {
updateProxy,
deleteConnection,
getGroupProxyDelays,
} from "@/services/api";
} from "@/services/cmds";
import { forceRefreshProxies } from "@/services/cmds";
import { useProfiles } from "@/hooks/use-profiles";
import { useVerge } from "@/hooks/use-verge";

View File

@@ -18,7 +18,7 @@ import {
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import { ruleProviderUpdate } from "@/services/api";
import { ruleProviderUpdate } from "@/services/cmds";
import { StorageOutlined, RefreshRounded } from "@mui/icons-material";
import { useAppData } from "@/providers/app-data-provider";
import dayjs from "dayjs";

View File

@@ -18,7 +18,7 @@ import {
ListItemText,
} from "@mui/material";
import { changeClashCore, restartCore } from "@/services/cmds";
import { closeAllConnections, upgradeCore } from "@/services/api";
import { closeAllConnections, upgradeCore } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
const VALID_CORE = [

View File

@@ -1,5 +1,6 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
import { showNotice } from "@/services/noticeService";
import { ContentCopy } from "@mui/icons-material";
import {
@@ -11,6 +12,7 @@ import {
ListItem,
ListItemText,
Snackbar,
Switch,
TextField,
Tooltip,
} from "@mui/material";
@@ -25,8 +27,12 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
const [isSaving, setIsSaving] = useState(false);
const { clashInfo, patchInfo } = useClashInfo();
const { verge, patchVerge } = useVerge();
const [controller, setController] = useState(clashInfo?.server || "");
const [secret, setSecret] = useState(clashInfo?.secret || "");
const [enableController, setEnableController] = useState(
verge?.enable_external_controller ?? false,
);
// 对话框打开时初始化配置
useImperativeHandle(ref, () => ({
@@ -34,25 +40,37 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
setOpen(true);
setController(clashInfo?.server || "");
setSecret(clashInfo?.secret || "");
setEnableController(verge?.enable_external_controller ?? false);
},
close: () => setOpen(false),
}));
// 保存配置
const onSave = useLockFn(async () => {
if (!controller.trim()) {
showNotice("error", t("Controller address cannot be empty"));
return;
}
if (!secret.trim()) {
showNotice("error", t("Secret cannot be empty"));
return;
}
try {
setIsSaving(true);
await patchInfo({ "external-controller": controller, secret });
// 先保存 enable_external_controller 设置
await patchVerge({ enable_external_controller: enableController });
// 如果启用了外部控制器,则保存控制器地址和密钥
if (enableController) {
if (!controller.trim()) {
showNotice("error", t("Controller address cannot be empty"));
return;
}
if (!secret.trim()) {
showNotice("error", t("Secret cannot be empty"));
return;
}
await patchInfo({ "external-controller": controller, secret });
} else {
// 如果禁用了外部控制器,则清空控制器地址
await patchInfo({ "external-controller": "" });
}
showNotice("success", t("Configuration saved successfully"));
setOpen(false);
} catch (err: any) {
@@ -100,6 +118,22 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
onOk={onSave}
>
<List>
<ListItem
sx={{
padding: "5px 2px",
display: "flex",
justifyContent: "space-between",
}}
>
<ListItemText primary={t("Enable External Controller")} />
<Switch
edge="end"
checked={enableController}
onChange={(e) => setEnableController(e.target.checked)}
disabled={isSaving}
/>
</ListItem>
<ListItem
sx={{
padding: "5px 2px",
@@ -113,20 +147,20 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
size="small"
sx={{
width: 175,
opacity: 1,
pointerEvents: "auto",
opacity: enableController ? 1 : 0.5,
pointerEvents: enableController ? "auto" : "none",
}}
value={controller}
placeholder="Required"
onChange={(e) => setController(e.target.value)}
disabled={isSaving}
disabled={isSaving || !enableController}
/>
<Tooltip title={t("Copy to clipboard")}>
<IconButton
size="small"
onClick={() => handleCopyToClipboard(controller, "controller")}
color="primary"
disabled={isSaving}
disabled={isSaving || !enableController}
>
<ContentCopy fontSize="small" />
</IconButton>
@@ -147,20 +181,20 @@ export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
size="small"
sx={{
width: 175,
opacity: 1,
pointerEvents: "auto",
opacity: enableController ? 1 : 0.5,
pointerEvents: enableController ? "auto" : "none",
}}
value={secret}
placeholder={t("Recommended")}
onChange={(e) => setSecret(e.target.value)}
disabled={isSaving}
disabled={isSaving || !enableController}
/>
<Tooltip title={t("Copy to clipboard")}>
<IconButton
size="small"
onClick={() => handleCopyToClipboard(secret, "secret")}
color="primary"
disabled={isSaving}
disabled={isSaving || !enableController}
>
<ContentCopy fontSize="small" />
</IconButton>

View File

@@ -4,7 +4,7 @@ import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { EditorViewer } from "@/components/profile/editor-viewer";
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-provider";
import { getClashConfig } from "@/services/api";
import { getClashConfig } from "@/services/cmds";
import {
getAutotemProxy,
getNetworkInterfacesInfo,

View File

@@ -3,7 +3,7 @@ import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { useClash } from "@/hooks/use-clash";
import { useListen } from "@/hooks/use-listen";
import { useVerge } from "@/hooks/use-verge";
import { updateGeoData } from "@/services/api";
import { updateGeoData } from "@/services/cmds";
import { invoke_uwp_tool } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";