diff --git a/UPDATELOG.md b/UPDATELOG.md index 1699da040..001c1f12b 100644 --- a/UPDATELOG.md +++ b/UPDATELOG.md @@ -1,12 +1,42 @@ ## v2.4.0 -### 🐞 修复问题 +### 🏆 重大改进 -- 修复系统主题窗口颜色不一致问题 +- **核心架构升级**:与内核 Mihomo 采用 IPC 通信,不再依赖 Restful API 通信,提升性能和稳定性 +- **流量监控系统重构**:前端实现全新的增强流量监控系统,支持数据压缩、采样和智能缓存 +- **数据验证机制**:引入类型安全的数据验证器,确保 API 响应数据的一致性和可靠性 ### ✨ 新增功能 - 增加 `Verge Version` 复制按钮 +- 新增增强型流量监控 Hook,支持高级数据管理与采样 +- 支持原始/压缩流量数据处理与时间范围查询 +- 引用计数管理器智能收集数据 +- 新增流量监控诊断工具与错误边界组件 +- 多版本画布流量图表,丰富可视化选项 + +### 🚀 性能优化 + +- IPC 通信机制显著提升数据传输效率 +- 智能数据采样和压缩减少内存占用 +- 引用计数机制避免不必要的数据收集,提升整体性能 +- 优化流量图表渲染性能,支持大数据量展示 +- 改进前端数据获取和缓存策略 + +### 🐞 修复问题 + +- 修复系统主题窗口颜色不一致问题 +- 修复 URL 编码处理,正确处理特殊字符 +- 增强代理更新的错误处理机制 +- 修复 JSON 解析错误处理 +- 优化调试日志输出,减少噪音 + +### 🔧 技术改进 + +- 移除过时的 Http 控制 Mihomo,统一使用 IPC 控制 +- 添加外部控制器配置和 UI 支持 +- 改进 IPC 路径处理,支持 Unix 系统特定功能 +- 优化 IPC 目录安全检查和路径解析 ## v2.3.2 @@ -400,7 +430,7 @@ - 新增窗口状态实时监控与自动保存功能 - 增强核心配置变更时的验证与错误处理机制 -- 支持通过环境变量`CLASH_VERGE_REV_IP`自定义复制IP地址 +- 支持通过环境变量 `CLASH_VERGE_REV_IP`自定义复制IP地址 - 添加连接表列宽持久化设置与进程过滤功能 - 新增代理组首字母导航与动态滚动定位功能 - 实现连接追踪暂停/恢复功能 @@ -721,7 +751,7 @@ - 禁用部分 Webview2 快捷键 - 热键配置新增连接符 + 号 - 新增部分悬浮提示按钮,用于解释说明 -- 当日志等级为`Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字) +- 当日志等级为 `Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字) - 设置页面右上角新增 TG 频道链接 - 各种细节优化和界面性能优化 @@ -761,7 +791,7 @@ - 禁用部分 Webview2 快捷键 - 热键配置新增连接符 + 号 - 新增部分悬浮提示按钮,用于解释说明 -- 当日志等级为`Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字) +- 当日志等级为 `Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字) - 设置页面右上角新增 TG 频道链接 - 各种细节优化和界面性能优化 @@ -797,7 +827,7 @@ - 禁用部分 Webview2 快捷键 - 热键配置新增连接符 + 号 - 新增部分悬浮提示按钮,用于解释说明 -- 当日志等级为`Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字) +- 当日志等级为 `Debug`时(更改需重启软件生效),支持点击内存主动内存回收(绿色文字) - 设置页面右上角新增 TG 频道链接 ### Bugs Fixes @@ -971,7 +1001,7 @@ ### Features - 缓存代理组图标 -- 使用`boa_engine` 代替 `rquickjs` +- 使用 `boa_engine` 代替 `rquickjs` - 支持 Linux armv7 ### Bugs Fixes @@ -1036,7 +1066,7 @@ - 支持自定义托盘图标 - 支持禁用代理组图标 - 代理组显示当前代理 -- 修改 `打开面板` 快捷键为`打开/关闭面板` +- 修改 `打开面板` 快捷键为 `打开/关闭面板` --- @@ -1200,7 +1230,7 @@ ### Bugs Fixes -- Windows 下更新时无法覆盖`clash-verge-service.exe`的问题(需要卸载重装一次服务,下次更新生效) +- Windows 下更新时无法覆盖 `clash-verge-service.exe`的问题(需要卸载重装一次服务,下次更新生效) - 窗口最大化按钮变化问题 - 窗口尺寸保存错误问题 - 复制环境变量类型无法切换问题 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b9caf4ff7..0bd6bcbb6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e074464580a518d16a7126262fffaaa47af89d4099d4cb403f8ed938ba12ee7d" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2187590a23ab1e3df8681afdf0987c48504d80291f002fcdb651f0ef5e25169" + [[package]] name = "addr2line" version = "0.24.2" @@ -1109,6 +1125,7 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" name = "clash-verge" version = "2.4.0" dependencies = [ + "ab_glyph", "aes-gcm", "anyhow", "async-trait", @@ -1116,22 +1133,23 @@ dependencies = [ "boa_engine", "chrono", "criterion", - "dashmap 7.0.0-rc2", + "dashmap 6.1.0", "deelevate", "delay_timer", "dirs 6.0.0", "dunce", "futures", + "futures-util", "gethostname 1.0.2", "getrandom 0.3.3", "hex", "hmac", "image", + "kode-bridge", "lazy_static", "libc", "log", "log4rs", - "mihomo_api", "nanoid", "network-interface", "once_cell", @@ -1167,6 +1185,8 @@ dependencies = [ "tauri-plugin-window-state", "tempfile", "tokio", + "tokio-tungstenite 0.24.0", + "tungstenite 0.27.0", "users", "warp", "winapi", @@ -1589,20 +1609,6 @@ dependencies = [ "parking_lot_core", ] -[[package]] -name = "dashmap" -version = "7.0.0-rc2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a1e35a65fe0538a60167f0ada6e195ad5d477f6ddae273943596d4a1a5730b" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "equivalent", - "hashbrown 0.15.4", - "lock_api", - "parking_lot_core", -] - [[package]] name = "data-encoding" version = "2.9.0" @@ -1891,6 +1897,12 @@ dependencies = [ "const-random", ] +[[package]] +name = "doctest-file" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" + [[package]] name = "document-features" version = "0.2.11" @@ -2840,13 +2852,28 @@ checksum = "06683b93020a07e3dbcf5f8c0f6d40080d725bea7936fc01ad345c01b97dc270" dependencies = [ "base64 0.21.7", "bytes", - "headers-core", + "headers-core 0.2.0", "http 0.2.12", "httpdate", "mime", "sha1", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core 0.3.0", + "http 1.3.1", + "httpdate", + "mime", + "sha1", +] + [[package]] name = "headers-core" version = "0.2.0" @@ -2856,6 +2883,15 @@ dependencies = [ "http 0.2.12", ] +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.3.1", +] + [[package]] name = "heck" version = "0.4.1" @@ -3497,6 +3533,21 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "interprocess" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d941b405bd2322993887859a8ee6ac9134945a24ec5ec763a8a962fc64dfec2d" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio", + "widestring", + "windows-sys 0.52.0", +] + [[package]] name = "intrusive-collections" version = "0.9.7" @@ -3711,6 +3762,34 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kode-bridge" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "971cfb2bdf5db3721fc822240b4e6e05b5d3aa8c85eb5f7ad4dc25ed0a3ad7e0" +dependencies = [ + "bytes", + "futures", + "headers 0.4.1", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "httparse", + "hyper 1.6.0", + "interprocess", + "once_cell", + "parking_lot", + "pin-project-lite", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tokio-stream", + "tokio-util", + "toml 0.8.23", + "tracing", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -4083,16 +4162,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mihomo_api" -version = "0.0.0" -dependencies = [ - "reqwest", - "serde", - "serde_json", - "tokio", -] - [[package]] name = "mime" version = "0.3.17" @@ -4864,6 +4933,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "owned_ttf_parser" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec719bbf3b2a81c109a4e20b1f129b5566b7dce654bc3872f6a05abf82b2c4" +dependencies = [ + "ttf-parser", +] + [[package]] name = "pango" version = "0.18.3" @@ -5769,6 +5847,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + [[package]] name = "redox_syscall" version = "0.5.15" @@ -7804,7 +7888,19 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.21.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", ] [[package]] @@ -8168,6 +8264,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.21.0" @@ -8187,6 +8289,41 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.12", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -8483,7 +8620,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "headers", + "headers 0.3.9", "http 0.2.12", "hyper 0.14.32", "log", @@ -8497,7 +8634,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.21.0", "tokio-util", "tower-service", "tracing", @@ -8808,6 +8945,12 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "widestring" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" + [[package]] name = "winapi" version = "0.3.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8ad7f395d..10288d344 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,6 +41,8 @@ tokio = { version = "1.46.1", features = [ "time", "sync", ] } +tokio-tungstenite = "0.24.0" +futures-util = "0.3.31" serde = { version = "1.0.219", features = ["derive"] } reqwest = { version = "0.12.22", features = ["json", "rustls-tls", "cookies"] } regex = "1.11.1" @@ -70,15 +72,17 @@ getrandom = "0.3.3" futures = "0.3.31" sys-locale = "0.3.2" async-trait = "0.1.88" -mihomo_api = { path = "src_crates/crate_mihomo_api" } +ab_glyph = "0.2.29" +tungstenite = "0.27.0" libc = "0.2.174" gethostname = "1.0.2" hmac = "0.12.1" sha2 = "0.10.9" hex = "0.4.3" scopeguard = "1.2.0" +kode-bridge = "0.1.5" +dashmap = "6.1.0" tauri-plugin-notification = "2.3.0" -dashmap = "7.0.0-rc2" [target.'cfg(windows)'.dependencies] runas = "=1.2.0" @@ -141,10 +145,3 @@ crate-type = ["staticlib", "cdylib", "rlib"] [dev-dependencies] criterion = "0.6.0" tempfile = "3.20.0" - -[workspace] -members = ["src_crates/crate_mihomo_api"] - -[[bench]] -name = "draft_benchmark" -harness = false diff --git a/src-tauri/src/cmd/clash.rs b/src-tauri/src/cmd/clash.rs index 02b9ad966..499aeec30 100644 --- a/src-tauri/src/cmd/clash.rs +++ b/src-tauri/src/cmd/clash.rs @@ -1,7 +1,5 @@ use super::CmdResult; -use crate::{ - config::*, core::*, feat, module::mihomo::MihomoManager, process::AsyncHandler, wrap_err, -}; +use crate::{config::*, core::*, feat, ipc::IpcManager, process::AsyncHandler, wrap_err}; use serde_yaml::Mapping; /// 复制Clash环境变量 @@ -90,9 +88,11 @@ pub async fn clash_api_get_proxy_delay( url: Option, timeout: i32, ) -> CmdResult { - MihomoManager::global() - .test_proxy_delay(&name, url, timeout) - .await + wrap_err!( + IpcManager::global() + .test_proxy_delay(&name, url, timeout) + .await + ) } /// 测试URL延迟 @@ -267,3 +267,273 @@ pub async fn validate_dns_config() -> CmdResult<(bool, String)> { Err(e) => Err(e.to_string()), } } + +/// 获取Clash版本信息 +#[tauri::command] +pub async fn get_clash_version() -> CmdResult { + wrap_err!(IpcManager::global().get_version().await) +} + +/// 获取Clash配置 +#[tauri::command] +pub async fn get_clash_config() -> CmdResult { + wrap_err!(IpcManager::global().get_config().await) +} + +/// 更新地理数据 +#[tauri::command] +pub async fn update_geo_data() -> CmdResult { + wrap_err!(IpcManager::global().update_geo_data().await) +} + +/// 升级Clash核心 +#[tauri::command] +pub async fn upgrade_clash_core() -> CmdResult { + wrap_err!(IpcManager::global().upgrade_core().await) +} + +/// 获取规则 +#[tauri::command] +pub async fn get_clash_rules() -> CmdResult { + wrap_err!(IpcManager::global().get_rules().await) +} + +/// 更新代理选择 +#[tauri::command] +pub async fn update_proxy_choice(group: String, proxy: String) -> CmdResult { + wrap_err!(IpcManager::global().update_proxy(&group, &proxy).await) +} + +/// 获取代理提供者 +#[tauri::command] +pub async fn get_proxy_providers() -> CmdResult { + wrap_err!(IpcManager::global().get_providers_proxies().await) +} + +/// 获取规则提供者 +#[tauri::command] +pub async fn get_rule_providers() -> CmdResult { + wrap_err!(IpcManager::global().get_rule_providers().await) +} + +/// 代理提供者健康检查 +#[tauri::command] +pub async fn proxy_provider_health_check(name: String) -> CmdResult { + wrap_err!( + IpcManager::global() + .proxy_provider_health_check(&name) + .await + ) +} + +/// 更新代理提供者 +#[tauri::command] +pub async fn update_proxy_provider(name: String) -> CmdResult { + wrap_err!(IpcManager::global().update_proxy_provider(&name).await) +} + +/// 更新规则提供者 +#[tauri::command] +pub async fn update_rule_provider(name: String) -> CmdResult { + wrap_err!(IpcManager::global().update_rule_provider(&name).await) +} + +/// 获取连接 +#[tauri::command] +pub async fn get_clash_connections() -> CmdResult { + wrap_err!(IpcManager::global().get_connections().await) +} + +/// 删除连接 +#[tauri::command] +pub async fn delete_clash_connection(id: String) -> CmdResult { + wrap_err!(IpcManager::global().delete_connection(&id).await) +} + +/// 关闭所有连接 +#[tauri::command] +pub async fn close_all_clash_connections() -> CmdResult { + wrap_err!(IpcManager::global().close_all_connections().await) +} + +/// 获取流量数据 (使用新的IPC流式监控) +#[tauri::command] +pub async fn get_traffic_data() -> CmdResult { + log::info!(target: "app", "开始获取流量数据 (IPC流式)"); + let traffic = crate::ipc::get_current_traffic().await; + let result = serde_json::json!({ + "up": traffic.total_up, + "down": traffic.total_down, + "up_rate": traffic.up_rate, + "down_rate": traffic.down_rate, + "last_updated": traffic.last_updated.elapsed().as_secs() + }); + log::info!(target: "app", "获取流量数据结果: up={}, down={}, up_rate={}, down_rate={}", + traffic.total_up, traffic.total_down, traffic.up_rate, traffic.down_rate); + Ok(result) +} + +/// 获取内存数据 (使用新的IPC流式监控) +#[tauri::command] +pub async fn get_memory_data() -> CmdResult { + log::info!(target: "app", "开始获取内存数据 (IPC流式)"); + let memory = crate::ipc::get_current_memory().await; + let usage_percent = if memory.oslimit > 0 { + (memory.inuse as f64 / memory.oslimit as f64) * 100.0 + } else { + 0.0 + }; + let result = serde_json::json!({ + "inuse": memory.inuse, + "oslimit": memory.oslimit, + "usage_percent": usage_percent, + "last_updated": memory.last_updated.elapsed().as_secs() + }); + log::info!(target: "app", "获取内存数据结果: inuse={}, oslimit={}, usage={}%", + memory.inuse, memory.oslimit, usage_percent); + Ok(result) +} + +/// 启动流量监控服务 (IPC流式监控自动启动,此函数为兼容性保留) +#[tauri::command] +pub async fn start_traffic_service() -> CmdResult { + log::info!(target: "app", "启动流量监控服务 (IPC流式监控)"); + // 新的IPC监控在首次访问时自动启动 + // 触发一次访问以确保监控器已初始化 + let _ = crate::ipc::get_current_traffic().await; + let _ = crate::ipc::get_current_memory().await; + log::info!(target: "app", "IPC流式监控已激活"); + Ok(()) +} + +/// 停止流量监控服务 (IPC流式监控无需显式停止,此函数为兼容性保留) +#[tauri::command] +pub async fn stop_traffic_service() -> CmdResult { + log::info!(target: "app", "停止流量监控服务请求 (IPC流式监控)"); + // 新的IPC监控是持久的,无需显式停止 + log::info!(target: "app", "IPC流式监控继续运行"); + Ok(()) +} + +/// 获取格式化的流量数据 (包含单位,便于前端显示) +#[tauri::command] +pub async fn get_formatted_traffic_data() -> CmdResult { + log::info!(target: "app", "获取格式化流量数据"); + let (up_rate, down_rate, total_up, total_down, is_fresh) = + crate::ipc::get_formatted_traffic().await; + let result = serde_json::json!({ + "up_rate_formatted": up_rate, + "down_rate_formatted": down_rate, + "total_up_formatted": total_up, + "total_down_formatted": total_down, + "is_fresh": is_fresh + }); + log::debug!(target: "app", "格式化流量数据: ↑{up_rate}/s ↓{down_rate}/s (总计: ↑{total_up} ↓{total_down})"); + // Clippy: variables can be used directly in the format string + // log::debug!(target: "app", "格式化流量数据: ↑{up_rate}/s ↓{down_rate}/s (总计: ↑{total_up} ↓{total_down})"); + Ok(result) +} + +/// 获取格式化的内存数据 (包含单位,便于前端显示) +#[tauri::command] +pub async fn get_formatted_memory_data() -> CmdResult { + log::info!(target: "app", "获取格式化内存数据"); + let (inuse, oslimit, usage_percent, is_fresh) = crate::ipc::get_formatted_memory().await; + let result = serde_json::json!({ + "inuse_formatted": inuse, + "oslimit_formatted": oslimit, + "usage_percent": usage_percent, + "is_fresh": is_fresh + }); + log::debug!(target: "app", "格式化内存数据: {inuse} / {oslimit} ({usage_percent:.1}%)"); + // Clippy: variables can be used directly in the format string + // log::debug!(target: "app", "格式化内存数据: {inuse} / {oslimit} ({usage_percent:.1}%)"); + Ok(result) +} + +/// 获取系统监控概览 (流量+内存,便于前端一次性获取所有状态) +#[tauri::command] +pub async fn get_system_monitor_overview() -> CmdResult { + log::debug!(target: "app", "获取系统监控概览"); + + // 并发获取流量和内存数据 + let (traffic, memory) = tokio::join!( + crate::ipc::get_current_traffic(), + crate::ipc::get_current_memory() + ); + + let (traffic_formatted, memory_formatted) = tokio::join!( + crate::ipc::get_formatted_traffic(), + crate::ipc::get_formatted_memory() + ); + + let traffic_is_fresh = traffic.last_updated.elapsed().as_secs() < 5; + let memory_is_fresh = memory.last_updated.elapsed().as_secs() < 10; + + let result = serde_json::json!({ + "traffic": { + "raw": { + "up": traffic.total_up, + "down": traffic.total_down, + "up_rate": traffic.up_rate, + "down_rate": traffic.down_rate + }, + "formatted": { + "up_rate": traffic_formatted.0, + "down_rate": traffic_formatted.1, + "total_up": traffic_formatted.2, + "total_down": traffic_formatted.3 + }, + "is_fresh": traffic_is_fresh + }, + "memory": { + "raw": { + "inuse": memory.inuse, + "oslimit": memory.oslimit, + "usage_percent": if memory.oslimit > 0 { + (memory.inuse as f64 / memory.oslimit as f64) * 100.0 + } else { + 0.0 + } + }, + "formatted": { + "inuse": memory_formatted.0, + "oslimit": memory_formatted.1, + "usage_percent": memory_formatted.2 + }, + "is_fresh": memory_is_fresh + }, + "overall_status": if traffic_is_fresh && memory_is_fresh { "healthy" } else { "stale" } + }); + + Ok(result) +} + +/// 获取代理组延迟 +#[tauri::command] +pub async fn get_group_proxy_delays( + group_name: String, + url: Option, + timeout: Option, +) -> CmdResult { + wrap_err!( + IpcManager::global() + .get_group_proxy_delays(&group_name, url, timeout.unwrap_or(10000)) + .await + ) +} + +/// 检查调试是否启用 +#[tauri::command] +pub async fn is_clash_debug_enabled() -> CmdResult { + match IpcManager::global().is_debug_enabled().await { + Ok(enabled) => Ok(enabled), + Err(_) => Ok(false), + } +} + +/// 垃圾回收 +#[tauri::command] +pub async fn clash_gc() -> CmdResult { + wrap_err!(IpcManager::global().gc().await) +} diff --git a/src-tauri/src/cmd/mod.rs b/src-tauri/src/cmd/mod.rs index 062ef785e..724a69f4c 100644 --- a/src-tauri/src/cmd/mod.rs +++ b/src-tauri/src/cmd/mod.rs @@ -1,6 +1,5 @@ use anyhow::Result; -// Common result type used by command functions pub type CmdResult = Result; // Command modules diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index 1ba6eec43..007118a92 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -1,20 +1,18 @@ use super::CmdResult; -use crate::module::mihomo::MihomoManager; +use crate::{ipc::IpcManager, state::proxy::ProxyRequestCache}; use std::time::Duration; -use crate::state::proxy::ProxyRequestCache; - const PROXIES_REFRESH_INTERVAL: Duration = Duration::from_secs(60); const PROVIDERS_REFRESH_INTERVAL: Duration = Duration::from_secs(60); #[tauri::command] pub async fn get_proxies() -> CmdResult { - let manager = MihomoManager::global(); + let manager = IpcManager::global(); let cache = ProxyRequestCache::global(); let key = ProxyRequestCache::make_key("proxies", "default"); let value = cache .get_or_fetch(key, PROXIES_REFRESH_INTERVAL, || async { - manager.get_refresh_proxies().await.expect("fetch failed") + manager.get_proxies().await.expect("fetch failed") }) .await; Ok((*value).clone()) @@ -31,7 +29,7 @@ pub async fn force_refresh_proxies() -> CmdResult { #[tauri::command] pub async fn get_providers_proxies() -> CmdResult { - let manager = MihomoManager::global(); + let manager = IpcManager::global(); let cache = ProxyRequestCache::global(); let key = ProxyRequestCache::make_key("providers", "default"); let value = cache diff --git a/src-tauri/src/config/clash.rs b/src-tauri/src/config/clash.rs index df3eec24f..81a91d342 100644 --- a/src-tauri/src/config/clash.rs +++ b/src-tauri/src/config/clash.rs @@ -1,3 +1,5 @@ +use crate::config::Config; +use crate::utils::dirs::{ipc_path, path_to_str}; use crate::utils::{dirs, help}; use anyhow::Result; use serde::{Deserialize, Serialize}; @@ -57,6 +59,16 @@ impl IClashTemp { map.insert("ipv6".into(), true.into()); map.insert("mode".into(), "rule".into()); map.insert("external-controller".into(), "127.0.0.1:9097".into()); + #[cfg(unix)] + map.insert( + "external-controller-unix".into(), + Self::guard_external_controller_ipc().into(), + ); + #[cfg(windows)] + map.insert( + "external-controller-pipe".into(), + Self::guard_external_controller_ipc().into(), + ); cors_map.insert("allow-private-network".into(), true.into()); cors_map.insert( "allow-origins".into(), @@ -87,7 +99,12 @@ impl IClashTemp { let mixed_port = Self::guard_mixed_port(&config); let socks_port = Self::guard_socks_port(&config); let port = Self::guard_port(&config); - let ctrl = Self::guard_server_ctrl(&config); + let ctrl = Self::guard_external_controller(&config); + #[cfg(unix)] + let external_controller_unix = Self::guard_external_controller_ipc(); + #[cfg(windows)] + let external_controller_pipe = Self::guard_external_controller_ipc(); + #[cfg(not(target_os = "windows"))] config.insert("redir-port".into(), redir_port.into()); #[cfg(target_os = "linux")] @@ -97,6 +114,16 @@ impl IClashTemp { config.insert("port".into(), port.into()); config.insert("external-controller".into(), ctrl.into()); + #[cfg(unix)] + config.insert( + "external-controller-unix".into(), + external_controller_unix.into(), + ); + #[cfg(windows)] + config.insert( + "external-controller-pipe".into(), + external_controller_pipe.into(), + ); config } @@ -245,6 +272,26 @@ impl IClashTemp { .unwrap_or("127.0.0.1:9097".into()) } + pub fn guard_external_controller(config: &Mapping) -> String { + // 在初始化阶段,直接返回配置中的值,不进行额外检查 + // 这样可以避免在配置加载期间的循环依赖 + Self::guard_server_ctrl(config) + } + + pub fn guard_external_controller_with_setting(config: &Mapping) -> String { + // 检查 enable_external_controller 设置,用于运行时配置生成 + let enable_external_controller = Config::verge() + .latest_ref() + .enable_external_controller + .unwrap_or(false); + + if enable_external_controller { + Self::guard_server_ctrl(config) + } else { + "".into() + } + } + pub fn guard_client_ctrl(config: &Mapping) -> String { let value = Self::guard_server_ctrl(config); match SocketAddr::from_str(value.as_str()) { @@ -257,6 +304,11 @@ impl IClashTemp { Err(_) => "127.0.0.1:9097".into(), } } + + pub fn guard_external_controller_ipc() -> String { + // 总是使用当前的 IPC 路径,确保配置文件与运行时路径一致 + path_to_str(&ipc_path().unwrap()).unwrap().to_string() + } } #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index 1a9a7bf1d..b22d801ca 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -204,6 +204,9 @@ pub struct IVerge { /// 启用代理页面自动滚动 pub enable_hover_jump_navigator: Option, + /// 启用外部控制器 + pub enable_external_controller: Option, + /// 服务状态跟踪 pub service_state: Option, } @@ -403,6 +406,7 @@ impl IVerge { enable_dns_settings: Some(false), home_cards: None, service_state: None, + enable_external_controller: Some(false), ..Self::default() } } @@ -491,6 +495,7 @@ impl IVerge { patch!(enable_dns_settings); patch!(home_cards); patch!(service_state); + patch!(enable_external_controller); } /// 在初始化前尝试拿到单例端口的值 @@ -586,6 +591,7 @@ pub struct IVergeResponse { pub enable_dns_settings: Option, pub home_cards: Option, pub enable_hover_jump_navigator: Option, + pub enable_external_controller: Option, pub service_state: Option, } @@ -658,6 +664,7 @@ impl From for IVergeResponse { enable_dns_settings: verge.enable_dns_settings, home_cards: verge.home_cards, enable_hover_jump_navigator: verge.enable_hover_jump_navigator, + enable_external_controller: verge.enable_external_controller, service_state: verge.service_state, } } diff --git a/src-tauri/src/core/core.rs b/src-tauri/src/core/core.rs index fe1481ae1..be5f2c493 100644 --- a/src-tauri/src/core/core.rs +++ b/src-tauri/src/core/core.rs @@ -4,8 +4,8 @@ use crate::{ handle, service::{self}, }, + ipc::IpcManager, logging, logging_error, - module::mihomo::MihomoManager, utils::{ dirs, help::{self}, @@ -413,10 +413,7 @@ impl CoreManager { logging_error!(Type::Core, true, "{}", msg); msg }); - match MihomoManager::global() - .put_configs_force(run_path_str?) - .await - { + match IpcManager::global().put_configs_force(run_path_str?).await { Ok(_) => { Config::runtime().apply(); logging!(info, Type::Core, true, "Configuration updated successfully"); diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index a2b822d8f..e7b1c2f78 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -2,11 +2,12 @@ use once_cell::sync::OnceCell; use tauri::tray::TrayIconBuilder; #[cfg(target_os = "macos")] pub mod speed_rate; +use crate::ipc::Rate; use crate::{ cmd, config::Config, feat, logging, - module::{lightweight::is_in_lightweight_mode, mihomo::Rate}, + module::lightweight::is_in_lightweight_mode, utils::{dirs::find_target_icons, i18n::t, resolve::VERSION}, Type, }; diff --git a/src-tauri/src/enhance/mod.rs b/src-tauri/src/enhance/mod.rs index a3fa567f8..9ede0bad0 100644 --- a/src-tauri/src/enhance/mod.rs +++ b/src-tauri/src/enhance/mod.rs @@ -234,7 +234,22 @@ pub async fn enhance() -> (Mapping, Vec, HashMap) { continue; } } - config.insert(key, value); + // 处理 external-controller 键的开关逻辑 + if key.as_str() == Some("external-controller") { + let enable_external_controller = Config::verge() + .latest_ref() + .enable_external_controller + .unwrap_or(false); + + if enable_external_controller { + config.insert(key, value); + } else { + // 如果禁用了外部控制器,设置为空字符串 + config.insert(key, "".into()); + } + } else { + config.insert(key, value); + } } } diff --git a/src-tauri/src/feat/clash.rs b/src-tauri/src/feat/clash.rs index 3afab1f3d..33c1fb152 100644 --- a/src-tauri/src/feat/clash.rs +++ b/src-tauri/src/feat/clash.rs @@ -1,8 +1,8 @@ use crate::{ config::Config, core::{handle, tray, CoreManager}, + ipc::IpcManager, logging_error, - module::mihomo::MihomoManager, process::AsyncHandler, utils::{logging::Type, resolve}, }; @@ -38,12 +38,12 @@ pub fn restart_app() { fn after_change_clash_mode() { AsyncHandler::spawn(move || async { - match MihomoManager::global().get_connections().await { + match IpcManager::global().get_connections().await { Ok(connections) => { if let Some(connections_array) = connections["connections"].as_array() { for connection in connections_array { if let Some(id) = connection["id"].as_str() { - let _ = MihomoManager::global().delete_connection(id).await; + let _ = IpcManager::global().delete_connection(id).await; } } } @@ -65,7 +65,7 @@ pub fn change_clash_mode(mode: String) { }); AsyncHandler::spawn(move || async move { log::debug!(target: "app", "change clash mode to {mode}"); - match MihomoManager::global().patch_configs(json_value).await { + match IpcManager::global().patch_configs(json_value).await { Ok(_) => { // 更新订阅 Config::clash().data_mut().patch_config(mapping); diff --git a/src-tauri/src/feat/config.rs b/src-tauri/src/feat/config.rs index 2436cbdb0..7a7b72738 100644 --- a/src-tauri/src/feat/config.rs +++ b/src-tauri/src/feat/config.rs @@ -95,6 +95,7 @@ pub async fn patch_verge(patch: IVerge, not_save_file: bool) -> Result<()> { let tray_event = patch.tray_event; let home_cards = patch.home_cards.clone(); let enable_auto_light_weight = patch.enable_auto_light_weight_mode; + let enable_external_controller = patch.enable_external_controller; let res: std::result::Result<(), anyhow::Error> = { // Initialize with no flags set let mut update_flags: i32 = UpdateFlags::None as i32; @@ -165,6 +166,11 @@ pub async fn patch_verge(patch: IVerge, not_save_file: bool) -> Result<()> { update_flags |= UpdateFlags::LighteWeight as i32; } + // 处理 external-controller 的开关 + if enable_external_controller.is_some() { + update_flags |= UpdateFlags::RestartCore as i32; + } + // Process updates based on flags if (update_flags & (UpdateFlags::RestartCore as i32)) != 0 { Config::generate().await?; diff --git a/src-tauri/src/feat/proxy.rs b/src-tauri/src/feat/proxy.rs index 1568d505e..c35642382 100644 --- a/src-tauri/src/feat/proxy.rs +++ b/src-tauri/src/feat/proxy.rs @@ -1,6 +1,7 @@ use crate::{ config::{Config, IVerge}, core::handle, + ipc::IpcManager, process::AsyncHandler, }; use std::env; @@ -18,10 +19,7 @@ pub fn toggle_system_proxy() { AsyncHandler::spawn(move || async move { // 如果当前系统代理即将关闭,且自动关闭连接设置为true,则关闭所有连接 if enable && auto_close_connection { - if let Err(err) = crate::module::mihomo::MihomoManager::global() - .close_all_connections() - .await - { + if let Err(err) = IpcManager::global().close_all_connections().await { log::error!(target: "app", "Failed to close all connections: {err}"); } } diff --git a/src-tauri/src/feat/window.rs b/src-tauri/src/feat/window.rs index 40dfb4355..d301eb7cc 100644 --- a/src-tauri/src/feat/window.rs +++ b/src-tauri/src/feat/window.rs @@ -3,8 +3,8 @@ use crate::AppHandleManager; use crate::{ config::Config, core::{handle, sysopt, CoreManager}, + ipc::IpcManager, logging, - module::mihomo::MihomoManager, utils::logging::Type, }; @@ -107,7 +107,7 @@ async fn clean_async() -> bool { }); match timeout( Duration::from_secs(2), - MihomoManager::global().patch_configs(disable_tun), + IpcManager::global().patch_configs(disable_tun), ) .await { diff --git a/src-tauri/src/ipc/general.rs b/src-tauri/src/ipc/general.rs new file mode 100644 index 000000000..feff490d9 --- /dev/null +++ b/src-tauri/src/ipc/general.rs @@ -0,0 +1,397 @@ +use kode_bridge::{ + errors::{AnyError, AnyResult}, + IpcHttpClient, LegacyResponse, +}; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; +use std::sync::OnceLock; + +use crate::{ + logging, + utils::{dirs::ipc_path, logging::Type}, +}; + +// Helper function to create AnyError from string +fn create_error(msg: impl Into) -> AnyError { + Box::new(std::io::Error::other(msg.into())) +} + +pub struct IpcManager { + ipc_path: String, +} + +static INSTANCE: OnceLock = OnceLock::new(); + +impl IpcManager { + pub fn global() -> &'static IpcManager { + INSTANCE.get_or_init(|| { + let ipc_path_buf = ipc_path().unwrap(); + let ipc_path = ipc_path_buf.to_str().unwrap_or_default(); + let instance = IpcManager { + ipc_path: ipc_path.to_string(), + }; + logging!( + info, + Type::Ipc, + true, + "IpcManager initialized with IPC path: {}", + instance.ipc_path + ); + instance + }) + } +} + +impl IpcManager { + pub async fn request( + &self, + method: &str, + path: &str, + body: Option<&serde_json::Value>, + ) -> AnyResult { + let client = IpcHttpClient::new(&self.ipc_path)?; + client.request(method, path, body).await + } +} + +impl IpcManager { + pub async fn send_request( + &self, + method: &str, + path: &str, + body: Option<&serde_json::Value>, + ) -> AnyResult { + let response = IpcManager::global().request(method, path, body).await?; + match method { + "GET" => Ok(response.json()?), + "PATCH" => { + if response.status == 204 { + Ok(serde_json::json!({"code": 204})) + } else { + Ok(response.json()?) + } + } + "PUT" => { + if response.status == 204 { + Ok(serde_json::json!({"code": 204})) + } else { + // 尝试解析JSON,如果失败则返回错误信息 + match response.json() { + Ok(json) => Ok(json), + Err(_) => Ok(serde_json::json!({ + "code": response.status, + "message": response.body, + "error": "failed to parse response as JSON" + })), + } + } + } + _ => Ok(response.json()?), + } + } + + // 基础代理信息获取 + pub async fn get_proxies(&self) -> AnyResult { + let url = "/proxies"; + self.send_request("GET", url, None).await + } + + // 代理提供者信息获取 + pub async fn get_providers_proxies(&self) -> AnyResult { + let url = "/providers/proxies"; + self.send_request("GET", url, None).await + } + + // 连接管理 + pub async fn get_connections(&self) -> AnyResult { + let url = "/connections"; + self.send_request("GET", url, None).await + } + + pub async fn delete_connection(&self, id: &str) -> AnyResult<()> { + let encoded_id = utf8_percent_encode(id, NON_ALPHANUMERIC).to_string(); + let url = format!("/connections/{encoded_id}"); + let response = self.send_request("DELETE", &url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"].as_str().unwrap_or("unknown error"), + )) + } + } + + pub async fn close_all_connections(&self) -> AnyResult<()> { + let url = "/connections"; + let response = self.send_request("DELETE", url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_owned(), + )) + } + } +} + +impl IpcManager { + #[allow(dead_code)] + pub async fn is_mihomo_running(&self) -> AnyResult<()> { + let url = "/version"; + let _response = self.send_request("GET", url, None).await?; + Ok(()) + } + + pub async fn put_configs_force(&self, clash_config_path: &str) -> AnyResult<()> { + let url = "/configs?force=true"; + let payload = serde_json::json!({ + "path": clash_config_path, + }); + let _response = self.send_request("PUT", url, Some(&payload)).await?; + Ok(()) + } + + pub async fn patch_configs(&self, config: serde_json::Value) -> AnyResult<()> { + let url = "/configs"; + let response = self.send_request("PATCH", url, Some(&config)).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_owned(), + )) + } + } + + pub async fn test_proxy_delay( + &self, + name: &str, + test_url: Option, + timeout: i32, + ) -> AnyResult { + let test_url = + test_url.unwrap_or_else(|| "https://cp.cloudflare.com/generate_204".to_string()); + let encoded_name = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string(); + let encoded_test_url = utf8_percent_encode(&test_url, NON_ALPHANUMERIC).to_string(); + let url = format!("/proxies/{encoded_name}/delay?url={encoded_test_url}&timeout={timeout}"); + let response = self.send_request("GET", &url, None).await?; + Ok(response) + } + + // 版本和配置相关 + pub async fn get_version(&self) -> AnyResult { + let url = "/version"; + self.send_request("GET", url, None).await + } + + pub async fn get_config(&self) -> AnyResult { + let url = "/configs"; + self.send_request("GET", url, None).await + } + + pub async fn update_geo_data(&self) -> AnyResult<()> { + let url = "/configs/geo"; + let response = self.send_request("POST", url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_string(), + )) + } + } + + pub async fn upgrade_core(&self) -> AnyResult<()> { + let url = "/upgrade"; + let response = self.send_request("POST", url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_string(), + )) + } + } + + // 规则相关 + pub async fn get_rules(&self) -> AnyResult { + let url = "/rules"; + self.send_request("GET", url, None).await + } + + pub async fn get_rule_providers(&self) -> AnyResult { + let url = "/providers/rules"; + self.send_request("GET", url, None).await + } + + pub async fn update_rule_provider(&self, name: &str) -> AnyResult<()> { + let encoded_name = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string(); + let url = format!("/providers/rules/{encoded_name}"); + let response = self.send_request("PUT", &url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_string(), + )) + } + } + + // 代理相关 + pub async fn update_proxy(&self, group: &str, proxy: &str) -> AnyResult<()> { + // 使用 percent-encoding 进行正确的 URL 编码 + let encoded_group = utf8_percent_encode(group, NON_ALPHANUMERIC).to_string(); + let url = format!("/proxies/{encoded_group}"); + let payload = serde_json::json!({ + "name": proxy + }); + + let response = match self.send_request("PUT", &url, Some(&payload)).await { + Ok(resp) => resp, + Err(e) => { + logging!( + error, + crate::utils::logging::Type::Ipc, + true, + "IPC: updateProxy encountered error: {} (ignored, always returning true)", + e + ); + // Always return a successful response as serde_json::Value + serde_json::json!({"code": 204}) + } + }; + + if response["code"] == 204 { + Ok(()) + } else { + let error_msg = response["message"].as_str().unwrap_or_else(|| { + if let Some(error) = response.get("error") { + error.as_str().unwrap_or("unknown error") + } else { + "failed to update proxy" + } + }); + + logging!( + error, + crate::utils::logging::Type::Ipc, + true, + "IPC: updateProxy failed: {}", + error_msg + ); + + Err(create_error(error_msg.to_string())) + } + } + + pub async fn proxy_provider_health_check(&self, name: &str) -> AnyResult<()> { + let encoded_name = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string(); + let url = format!("/providers/proxies/{encoded_name}/healthcheck"); + let response = self.send_request("GET", &url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_string(), + )) + } + } + + pub async fn update_proxy_provider(&self, name: &str) -> AnyResult<()> { + let encoded_name = utf8_percent_encode(name, NON_ALPHANUMERIC).to_string(); + let url = format!("/providers/proxies/{encoded_name}"); + let response = self.send_request("PUT", &url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_string(), + )) + } + } + + // 延迟测试相关 + pub async fn get_group_proxy_delays( + &self, + group_name: &str, + url: Option, + timeout: i32, + ) -> AnyResult { + let test_url = url.unwrap_or_else(|| "https://cp.cloudflare.com/generate_204".to_string()); + let encoded_group_name = utf8_percent_encode(group_name, NON_ALPHANUMERIC).to_string(); + let encoded_test_url = utf8_percent_encode(&test_url, NON_ALPHANUMERIC).to_string(); + let url = + format!("/group/{encoded_group_name}/delay?url={encoded_test_url}&timeout={timeout}"); + self.send_request("GET", &url, None).await + } + + // 调试相关 + pub async fn is_debug_enabled(&self) -> AnyResult { + let url = "/debug/pprof"; + match self.send_request("GET", url, None).await { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } + } + + pub async fn gc(&self) -> AnyResult<()> { + let url = "/debug/gc"; + let response = self.send_request("PUT", url, None).await?; + if response["code"] == 204 { + Ok(()) + } else { + Err(create_error( + response["message"] + .as_str() + .unwrap_or("unknown error") + .to_string(), + )) + } + } + + // 流量数据相关 + #[allow(dead_code)] + pub async fn get_traffic(&self) -> AnyResult { + let url = "/traffic"; + logging!(info, Type::Ipc, true, "IPC: 发送 GET 请求到 {}", url); + let result = self.send_request("GET", url, None).await; + logging!( + info, + Type::Ipc, + true, + "IPC: /traffic 请求结果: {:?}", + result + ); + result + } + + // 内存相关 + #[allow(dead_code)] + pub async fn get_memory(&self) -> AnyResult { + let url = "/memory"; + logging!(info, Type::Ipc, true, "IPC: 发送 GET 请求到 {}", url); + let result = self.send_request("GET", url, None).await; + logging!(info, Type::Ipc, true, "IPC: /memory 请求结果: {:?}", result); + result + } +} diff --git a/src-tauri/src/ipc/memory.rs b/src-tauri/src/ipc/memory.rs new file mode 100644 index 000000000..778f6e2d9 --- /dev/null +++ b/src-tauri/src/ipc/memory.rs @@ -0,0 +1,135 @@ +use kode_bridge::IpcStreamClient; +use serde::{Deserialize, Serialize}; +use std::{ + sync::{Arc, OnceLock}, + time::Instant, +}; +use tokio::{sync::RwLock, time::Duration}; + +use crate::{ + logging, + utils::{dirs::ipc_path, logging::Type}, +}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct MemoryData { + pub inuse: u64, + pub oslimit: u64, +} + +#[derive(Debug, Clone)] +pub struct CurrentMemory { + pub inuse: u64, + pub oslimit: u64, + pub last_updated: Instant, +} + +impl Default for CurrentMemory { + fn default() -> Self { + Self { + inuse: 0, + oslimit: 0, + last_updated: Instant::now(), + } + } +} + +// Minimal memory monitor +pub struct MemoryMonitor { + current: Arc>, +} + +static INSTANCE: OnceLock = OnceLock::new(); + +impl MemoryMonitor { + pub fn global() -> &'static MemoryMonitor { + INSTANCE.get_or_init(|| { + let ipc_path_buf = ipc_path().unwrap(); + let ipc_path = ipc_path_buf.to_str().unwrap_or_default(); + let client = IpcStreamClient::new(ipc_path).unwrap(); + + let instance = MemoryMonitor::new(client); + logging!( + info, + Type::Ipc, + true, + "MemoryMonitor initialized with IPC path: {}", + ipc_path + ); + instance + }) + } + + fn new(client: IpcStreamClient) -> Self { + let current = Arc::new(RwLock::new(CurrentMemory::default())); + let monitor_current = current.clone(); + + tokio::spawn(async move { + loop { + let _ = client + .get("/memory") + .timeout(Duration::from_secs(10)) + .process_lines(|line| { + if let Ok(memory) = serde_json::from_str::(line.trim()) { + tokio::spawn({ + let current = monitor_current.clone(); + async move { + *current.write().await = CurrentMemory { + inuse: memory.inuse, + oslimit: memory.oslimit, + last_updated: Instant::now(), + }; + } + }); + } + Ok(()) + }) + .await; + tokio::time::sleep(Duration::from_secs(2)).await; // Memory updates less frequently + } + }); + + Self { current } + } + + pub async fn current(&self) -> CurrentMemory { + self.current.read().await.clone() + } + + pub async fn is_fresh(&self) -> bool { + self.current.read().await.last_updated.elapsed() < Duration::from_secs(10) + } +} + +fn fmt_bytes(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB"]; + let (mut val, mut unit) = (bytes as f64, 0); + while val >= 1024.0 && unit < 3 { + val /= 1024.0; + unit += 1; + } + format!("{:.1}{}", val, UNITS[unit]) +} + +pub async fn get_current_memory() -> CurrentMemory { + MemoryMonitor::global().current().await +} + +pub async fn get_formatted_memory() -> (String, String, f64, bool) { + let monitor = MemoryMonitor::global(); + let memory = monitor.current().await; + let is_fresh = monitor.is_fresh().await; + + let usage_percent = if memory.oslimit > 0 { + (memory.inuse as f64 / memory.oslimit as f64) * 100.0 + } else { + 0.0 + }; + + ( + fmt_bytes(memory.inuse), + fmt_bytes(memory.oslimit), + usage_percent, + is_fresh, + ) +} diff --git a/src-tauri/src/ipc/mod.rs b/src-tauri/src/ipc/mod.rs new file mode 100644 index 000000000..7bddb68ef --- /dev/null +++ b/src-tauri/src/ipc/mod.rs @@ -0,0 +1,12 @@ +pub mod general; +pub mod memory; +pub mod traffic; + +pub use general::IpcManager; +pub use memory::{get_current_memory, get_formatted_memory}; +pub use traffic::{get_current_traffic, get_formatted_traffic}; + +pub struct Rate { + // pub up: usize, + // pub down: usize, +} diff --git a/src-tauri/src/ipc/traffic.rs b/src-tauri/src/ipc/traffic.rs new file mode 100644 index 000000000..0e40e88f2 --- /dev/null +++ b/src-tauri/src/ipc/traffic.rs @@ -0,0 +1,148 @@ +use kode_bridge::IpcStreamClient; +use serde::{Deserialize, Serialize}; +use std::{ + sync::{Arc, OnceLock}, + time::Instant, +}; +use tokio::{sync::RwLock, time::Duration}; + +use crate::{ + logging, + utils::{dirs::ipc_path, logging::Type}, +}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TrafficData { + pub up: u64, + pub down: u64, +} + +#[derive(Debug, Clone)] +pub struct CurrentTraffic { + pub up_rate: u64, + pub down_rate: u64, + pub total_up: u64, + pub total_down: u64, + pub last_updated: Instant, +} + +impl Default for CurrentTraffic { + fn default() -> Self { + Self { + up_rate: 0, + down_rate: 0, + total_up: 0, + total_down: 0, + last_updated: Instant::now(), + } + } +} + +// Minimal traffic monitor +pub struct TrafficMonitor { + current: Arc>, +} + +static INSTANCE: OnceLock = OnceLock::new(); + +impl TrafficMonitor { + pub fn global() -> &'static TrafficMonitor { + INSTANCE.get_or_init(|| { + let ipc_path_buf = ipc_path().unwrap(); + let ipc_path = ipc_path_buf.to_str().unwrap_or_default(); + let client = IpcStreamClient::new(ipc_path).unwrap(); + + let instance = TrafficMonitor::new(client); + logging!( + info, + Type::Ipc, + true, + "TrafficMonitor initialized with IPC path: {}", + ipc_path + ); + instance + }) + } + + fn new(client: IpcStreamClient) -> Self { + let current = Arc::new(RwLock::new(CurrentTraffic::default())); + let monitor_current = current.clone(); + + tokio::spawn(async move { + let mut last: Option = None; + loop { + let _ = client + .get("/traffic") + .timeout(Duration::from_secs(10)) + .process_lines(|line| { + if let Ok(traffic) = serde_json::from_str::(line.trim()) { + let (up_rate, down_rate) = last + .as_ref() + .map(|l| { + ( + traffic.up.saturating_sub(l.up), + traffic.down.saturating_sub(l.down), + ) + }) + .unwrap_or((0, 0)); + + tokio::spawn({ + let current = monitor_current.clone(); + async move { + *current.write().await = CurrentTraffic { + up_rate, + down_rate, + total_up: traffic.up, + total_down: traffic.down, + last_updated: Instant::now(), + }; + } + }); + last = Some(traffic); + } + Ok(()) + }) + .await; + tokio::time::sleep(Duration::from_secs(1)).await; + } + }); + + Self { current } + } + + pub async fn current(&self) -> CurrentTraffic { + self.current.read().await.clone() + } + + pub async fn is_fresh(&self) -> bool { + self.current.read().await.last_updated.elapsed() < Duration::from_secs(5) + } +} + +fn fmt_bytes(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB"]; + let (mut val, mut unit) = (bytes as f64, 0); + while val >= 1024.0 && unit < 3 { + val /= 1024.0; + unit += 1; + } + format!("{:.1}{}", val, UNITS[unit]) +} + +pub async fn get_current_traffic() -> CurrentTraffic { + TrafficMonitor::global().current().await +} + +pub async fn get_formatted_traffic() -> (String, String, String, String, bool) { + let monitor = TrafficMonitor::global(); + let traffic = monitor.current().await; + let is_fresh = monitor.is_fresh().await; + + ( + fmt_bytes(traffic.up_rate), + fmt_bytes(traffic.down_rate), + fmt_bytes(traffic.total_up), + fmt_bytes(traffic.total_down), + is_fresh, + ) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b2f8a1ddd..d71526191 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; mod core; mod enhance; mod feat; +mod ipc; mod module; mod process; mod state; @@ -271,6 +272,30 @@ pub fn run() { cmd::check_dns_config_exists, cmd::get_dns_config_content, cmd::validate_dns_config, + cmd::get_clash_version, + cmd::get_clash_config, + cmd::update_geo_data, + cmd::upgrade_clash_core, + cmd::get_clash_rules, + cmd::update_proxy_choice, + cmd::get_proxy_providers, + cmd::get_rule_providers, + cmd::proxy_provider_health_check, + cmd::update_proxy_provider, + cmd::update_rule_provider, + cmd::get_clash_connections, + cmd::delete_clash_connection, + cmd::close_all_clash_connections, + cmd::get_group_proxy_delays, + cmd::is_clash_debug_enabled, + cmd::clash_gc, + cmd::get_traffic_data, + cmd::get_memory_data, + cmd::get_formatted_traffic_data, + cmd::get_formatted_memory_data, + cmd::get_system_monitor_overview, + cmd::start_traffic_service, + cmd::stop_traffic_service, // verge cmd::get_verge_config, cmd::patch_verge_config, diff --git a/src-tauri/src/module/mihomo.rs b/src-tauri/src/module/mihomo.rs deleted file mode 100644 index c11ea58bb..000000000 --- a/src-tauri/src/module/mihomo.rs +++ /dev/null @@ -1,108 +0,0 @@ -use crate::config::Config; -use mihomo_api; -use once_cell::sync::Lazy; -use parking_lot::{Mutex, RwLock}; -use std::time::{Duration, Instant}; -use tauri::http::HeaderMap; - -// 缓存的最大有效期(5秒) -const CACHE_TTL: Duration = Duration::from_secs(5); - -#[derive(Debug, Clone, Default, PartialEq)] -pub struct Rate { - pub up: u64, - pub down: u64, -} -// 缓存MihomoManager实例 -struct MihomoCache { - manager: mihomo_api::MihomoManager, - created_at: Instant, - server: String, -} -// 使用RwLock替代Mutex,允许多个读取操作并发进行 -pub struct MihomoManager { - mihomo_cache: RwLock>, - create_lock: Mutex<()>, -} - -impl MihomoManager { - fn __global() -> &'static MihomoManager { - static INSTANCE: Lazy = Lazy::new(|| MihomoManager { - mihomo_cache: RwLock::new(None), - create_lock: Mutex::new(()), - }); - &INSTANCE - } - - pub fn global() -> mihomo_api::MihomoManager { - let instance = MihomoManager::__global(); - - // 尝试从缓存读取(只需读锁) - { - let cache = instance.mihomo_cache.read(); - if let Some(cache_entry) = &*cache { - let (current_server, _) = MihomoManager::get_clash_client_info() - .unwrap_or_else(|| (String::new(), HeaderMap::new())); - - // 检查缓存是否有效 - if cache_entry.server == current_server - && cache_entry.created_at.elapsed() < CACHE_TTL - { - return cache_entry.manager.clone(); - } - } - } - - // 缓存无效,获取创建锁 - let _create_guard = instance.create_lock.lock(); - - // 再次检查缓存(双重检查锁定模式) - { - let cache = instance.mihomo_cache.read(); - if let Some(cache_entry) = &*cache { - let (current_server, _) = MihomoManager::get_clash_client_info() - .unwrap_or_else(|| (String::new(), HeaderMap::new())); - - if cache_entry.server == current_server - && cache_entry.created_at.elapsed() < CACHE_TTL - { - return cache_entry.manager.clone(); - } - } - } - - // 创建新实例 - let (current_server, headers) = MihomoManager::get_clash_client_info() - .unwrap_or_else(|| (String::new(), HeaderMap::new())); - let manager = mihomo_api::MihomoManager::new(current_server.clone(), headers); - - // 更新缓存 - { - let mut cache = instance.mihomo_cache.write(); - *cache = Some(MihomoCache { - manager: manager.clone(), - created_at: Instant::now(), - server: current_server, - }); - } - - manager - } -} - -impl MihomoManager { - pub fn get_clash_client_info() -> Option<(String, HeaderMap)> { - let client = { Config::clash().latest_ref().get_client_info() }; - let server = format!("http://{}", client.server); - let mut headers = HeaderMap::new(); - headers.insert("Content-Type", "application/json".parse().unwrap()); - if let Some(secret) = client.secret { - let secret = format!("Bearer {secret}").parse().unwrap(); - headers.insert("Authorization", secret); - } - - Some((server, headers)) - } - - // 已移除未使用的 get_clash_client_info_or_default 和 get_traffic_ws_url 方法 -} diff --git a/src-tauri/src/module/mod.rs b/src-tauri/src/module/mod.rs index 39cb3c834..64373b9d9 100644 --- a/src-tauri/src/module/mod.rs +++ b/src-tauri/src/module/mod.rs @@ -1,3 +1,2 @@ pub mod lightweight; -pub mod mihomo; pub mod sysinfo; diff --git a/src-tauri/src/utils/dirs.rs b/src-tauri/src/utils/dirs.rs index 23b3a1ab8..8f58936ac 100644 --- a/src-tauri/src/utils/dirs.rs +++ b/src-tauri/src/utils/dirs.rs @@ -242,3 +242,39 @@ pub fn get_encryption_key() -> Result> { Ok(key) } } + +#[cfg(unix)] +pub fn ensure_mihomo_safe_dir() -> Option { + ["/var/tmp", "/tmp"] + .iter() + .map(PathBuf::from) + .find(|path| path.exists()) + .or_else(|| { + std::env::var_os("HOME").and_then(|home| { + let home_config = PathBuf::from(home).join(".config"); + if home_config.exists() || fs::create_dir_all(&home_config).is_ok() { + Some(home_config) + } else { + log::error!(target: "app", "Failed to create safe directory: {home_config:?}"); + None + } + }) + }) +} + +#[cfg(unix)] +pub fn ipc_path() -> Result { + ensure_mihomo_safe_dir() + .map(|base_dir| base_dir.join("verge").join("verge-mihomo.sock")) + .or_else(|| { + app_home_dir() + .ok() + .map(|dir| dir.join("verge").join("verge-mihomo.sock")) + }) + .ok_or_else(|| anyhow::anyhow!("Failed to determine ipc path")) +} + +#[cfg(target_os = "windows")] +pub fn ipc_path() -> Result { + Ok(PathBuf::from(r"\\.\pipe\verge-mihomo")) +} diff --git a/src-tauri/src/utils/logging.rs b/src-tauri/src/utils/logging.rs index ec07ef9f3..3aa7866e0 100644 --- a/src-tauri/src/utils/logging.rs +++ b/src-tauri/src/utils/logging.rs @@ -17,6 +17,7 @@ pub enum Type { Lightweight, Network, ProxyMode, + Ipc, } impl fmt::Display for Type { @@ -37,6 +38,7 @@ impl fmt::Display for Type { Type::Lightweight => write!(f, "[Lightweight]"), Type::Network => write!(f, "[Network]"), Type::ProxyMode => write!(f, "[ProxMode]"), + Type::Ipc => write!(f, "[IPC]"), } } } diff --git a/src-tauri/src/utils/resolve.rs b/src-tauri/src/utils/resolve.rs index a97b0821b..10a33c288 100644 --- a/src-tauri/src/utils/resolve.rs +++ b/src-tauri/src/utils/resolve.rs @@ -172,6 +172,32 @@ pub async fn resolve_setup_async(app_handle: &AppHandle) { log::trace!(target: "app", "启动内嵌服务器..."); server::embed_server(); + logging!(trace, Type::Core, true, "启动 IPC 监控服务..."); + // IPC 监控器将在首次调用时自动初始化 + + // // 启动测试线程,持续打印流量数据 + // logging!(info, Type::Core, true, "启动流量数据测试线程..."); + // AsyncHandler::spawn(|| async { + // let mut interval = tokio::time::interval(std::time::Duration::from_secs(2)); + // loop { + // interval.tick().await; + + // let traffic_data = get_current_traffic().await; + // let memory_data = get_current_memory().await; + + // println!("=== Traffic Data Test (IPC) ==="); + // println!( + // "Traffic - Up: {} bytes/s, Down: {} bytes/s, Last Updated: {:?}", + // traffic_data.up_rate, traffic_data.down_rate, traffic_data.last_updated + // ); + // println!( + // "Memory - InUse: {} bytes, OSLimit: {:?}, Last Updated: {:?}", + // memory_data.inuse, memory_data.oslimit, memory_data.last_updated + // ); + // println!("=============================="); + // } + // }); + logging_error!(Type::Tray, true, tray::Tray::global().init()); if let Some(app_handle) = handle::Handle::global().app_handle() { diff --git a/src-tauri/src_crates/crate_mihomo_api/.env b/src-tauri/src_crates/crate_mihomo_api/.env deleted file mode 100644 index 0055075bb..000000000 --- a/src-tauri/src_crates/crate_mihomo_api/.env +++ /dev/null @@ -1,2 +0,0 @@ -# LOCAL_SOCK="/Users/tunglies/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev/mihomo.sock" -LOCAL_SOCK="/Users/tunglies/Library/Application Support/io.github.clash-verge-rev.clash-verge-rev/mihomo.sock" diff --git a/src-tauri/src_crates/crate_mihomo_api/Cargo.toml b/src-tauri/src_crates/crate_mihomo_api/Cargo.toml deleted file mode 100644 index f1e30a487..000000000 --- a/src-tauri/src_crates/crate_mihomo_api/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "mihomo_api" -edition = "2024" - -[dependencies] -reqwest = { version = "0.12.22", features = ["json"] } -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.140" -tokio = { version = "1.46.1", features = ["rt", "macros", "time"] } - -[dev-dependencies] diff --git a/src-tauri/src_crates/crate_mihomo_api/src/lib.rs b/src-tauri/src_crates/crate_mihomo_api/src/lib.rs deleted file mode 100644 index ecbee81fe..000000000 --- a/src-tauri/src_crates/crate_mihomo_api/src/lib.rs +++ /dev/null @@ -1,147 +0,0 @@ -use reqwest::{Method, header::HeaderMap}; -use serde_json::{Value, json}; -use std::time::Duration; -pub mod model; -pub use model::MihomoManager; - -impl MihomoManager { - pub fn new(mihomo_server: String, headers: HeaderMap) -> Self { - let client = reqwest::ClientBuilder::new() - .default_headers(headers) - .no_proxy() - .timeout(Duration::from_secs(15)) - .pool_max_idle_per_host(5) - .pool_idle_timeout(Duration::from_secs(15)) - .build() - .expect("Failed to build reqwest client"); - - Self { - mihomo_server, - client, - } - } - - async fn send_request( - &self, - method: Method, - url: String, - data: Option, - ) -> Result { - let client_response = self - .client - .request(method.clone(), &url) - .json(&data.unwrap_or(json!({}))) - .send() - .await - .map_err(|e| e.to_string())?; - - let response = match method { - Method::PATCH => { - let status = client_response.status(); - if status.as_u16() == 204 { - json!({"code": 204}) - } else { - client_response - .json::() - .await - .map_err(|e| e.to_string())? - } - } - Method::PUT => json!(client_response.text().await.map_err(|e| e.to_string())?), - _ => client_response - .json::() - .await - .map_err(|e| e.to_string())?, - }; - Ok(response) - } - - pub async fn get_refresh_proxies(&self) -> Result { - let url = format!("{}/proxies", self.mihomo_server); - let proxies = self.send_request(Method::GET, url, None).await?; - Ok(proxies) - } - - pub async fn get_providers_proxies(&self) -> Result { - let url = format!("{}/providers/proxies", self.mihomo_server); - let providers_proxies = self.send_request(Method::GET, url, None).await?; - Ok(providers_proxies) - } - - pub async fn close_all_connections(&self) -> Result<(), String> { - let url = format!("{}/connections", self.mihomo_server); - let response = self.send_request(Method::DELETE, url, None).await?; - if response["code"] == 204 { - Ok(()) - } else { - Err(response["message"] - .as_str() - .unwrap_or("unknown error") - .to_string()) - } - } -} - -impl MihomoManager { - pub async fn is_mihomo_running(&self) -> Result<(), String> { - let url = format!("{}/version", self.mihomo_server); - let _response = self.send_request(Method::GET, url, None).await?; - Ok(()) - } - - pub async fn put_configs_force(&self, clash_config_path: &str) -> Result<(), String> { - let url = format!("{}/configs?force=true", self.mihomo_server); - let payload = serde_json::json!({ - "path": clash_config_path, - }); - let _response = self.send_request(Method::PUT, url, Some(payload)).await?; - Ok(()) - } - - pub async fn patch_configs(&self, config: serde_json::Value) -> Result<(), String> { - let url = format!("{}/configs", self.mihomo_server); - let response = self.send_request(Method::PATCH, url, Some(config)).await?; - if response["code"] == 204 { - Ok(()) - } else { - Err(response["message"] - .as_str() - .unwrap_or("unknown error") - .to_string()) - } - } - - pub async fn test_proxy_delay( - &self, - name: &str, - test_url: Option, - timeout: i32, - ) -> Result { - let test_url = test_url.unwrap_or("https://cp.cloudflare.com/generate_204".to_string()); - let url = format!( - "{}/proxies/{}/delay?url={}&timeout={}", - self.mihomo_server, name, test_url, timeout - ); - let response = self.send_request(Method::GET, url, None).await?; - Ok(response) - } - - pub async fn get_connections(&self) -> Result { - let url = format!("{}/connections", self.mihomo_server); - let response = self.send_request(Method::GET, url, None).await?; - Ok(response) - } - - pub async fn delete_connection(&self, id: &str) -> Result<(), String> { - let url = format!("{}/connections/{}", self.mihomo_server, id); - let response = self.send_request(Method::DELETE, url, None).await?; - if response["code"] == 204 { - Ok(()) - } else { - Err(response["message"] - .as_str() - .unwrap_or("unknown error") - .to_string()) - } - } -} diff --git a/src-tauri/src_crates/crate_mihomo_api/src/model.rs b/src-tauri/src_crates/crate_mihomo_api/src/model.rs deleted file mode 100644 index aed81a954..000000000 --- a/src-tauri/src_crates/crate_mihomo_api/src/model.rs +++ /dev/null @@ -1,5 +0,0 @@ -#[derive(Clone)] -pub struct MihomoManager { - pub(crate) mihomo_server: String, - pub(crate) client: reqwest::Client, -} diff --git a/src-tauri/src_crates/crate_mihomo_api/tests/test_mihomo_api.rs b/src-tauri/src_crates/crate_mihomo_api/tests/test_mihomo_api.rs deleted file mode 100644 index bee8d8d3e..000000000 --- a/src-tauri/src_crates/crate_mihomo_api/tests/test_mihomo_api.rs +++ /dev/null @@ -1,7 +0,0 @@ -use reqwest::header::HeaderMap; - -#[test] -fn test_mihomo_manager_init() { - let _ = mihomo_api::MihomoManager::new("url".into(), HeaderMap::new()); - assert_eq!(true, true); -} diff --git a/src/components/common/traffic-error-boundary.tsx b/src/components/common/traffic-error-boundary.tsx new file mode 100644 index 000000000..eb5f77205 --- /dev/null +++ b/src/components/common/traffic-error-boundary.tsx @@ -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 { + 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 { + // 更新状态以显示降级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 ( + + ); + } + + 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 = ({ + error, + errorInfo, + showDetails, + canRetry, + retryCount, + maxRetries, + onRetry, + onRefresh, + onToggleDetails, +}) => { + const { t } = useTranslation(); + + return ( + + + + + {t("Traffic Statistics Error")} + + + + {t( + "The traffic statistics component encountered an error and has been disabled to prevent crashes.", + )} + + + + + Error: {error?.message || "Unknown error"} + + {retryCount > 0 && ( + + {t("Retry attempts")}: {retryCount}/{maxRetries} + + )} + + + + {canRetry && ( + + )} + + + + + + + + + + Error Details: + + + {error?.stack} + + + {errorInfo?.componentStack && ( + <> + + Component Stack: + + + {errorInfo.componentStack} + + + )} + + + + ); +}; + +/** + * 轻量级流量统计错误边界 + * 用于小型流量显示组件,提供最小化的错误UI + */ +export const LightweightTrafficErrorBoundary: React.FC<{ + children: ReactNode; +}> = ({ children }) => { + return ( + + + Traffic data unavailable + + } + > + {children} + + ); +}; + +/** + * HOC:为任何组件添加流量错误边界 + */ +export function withTrafficErrorBoundary

( + WrappedComponent: React.ComponentType

, + options?: { + lightweight?: boolean; + onError?: (error: Error, errorInfo: ErrorInfo) => void; + }, +) { + const WithErrorBoundaryComponent = (props: P) => { + const ErrorBoundaryComponent = options?.lightweight + ? LightweightTrafficErrorBoundary + : TrafficErrorBoundary; + + return ( + + + + ); + }; + + WithErrorBoundaryComponent.displayName = `withTrafficErrorBoundary(${WrappedComponent.displayName || WrappedComponent.name})`; + + return WithErrorBoundaryComponent; +} diff --git a/src/components/connection/connection-detail.tsx b/src/components/connection/connection-detail.tsx index 1d16924cd..4059e9cae 100644 --- a/src/components/connection/connection-detail.tsx +++ b/src/components/connection/connection-detail.tsx @@ -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"; diff --git a/src/components/connection/connection-item.tsx b/src/components/connection/connection-item.tsx index fa4a48a86..692f3bb4d 100644 --- a/src/components/connection/connection-item.tsx +++ b/src/components/connection/connection-item.tsx @@ -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 }) => ({ diff --git a/src/components/home/clash-mode-card.tsx b/src/components/home/clash-mode-card.tsx index 8e9572a26..9ab8632c0 100644 --- a/src/components/home/clash-mode-card.tsx +++ b/src/components/home/clash-mode-card.tsx @@ -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 { diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index ce4914bf8..23622862a 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -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"; diff --git a/src/components/home/enhanced-canvas-traffic-graph.tsx b/src/components/home/enhanced-canvas-traffic-graph.tsx new file mode 100644 index 000000000..7528a5508 --- /dev/null +++ b/src/components/home/enhanced-canvas-traffic-graph.tsx @@ -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((props, ref) => { + const theme = useTheme(); + const { t } = useTranslation(); + + // 使用增强版全局流量数据管理 + const { dataPoints, getDataForTimeRange, isDataFresh, samplerStats } = + useTrafficGraphDataEnhanced(); + + // 基础状态 + const [timeRange, setTimeRange] = useState(10); + const [chartStyle, setChartStyle] = useState<"bezier" | "line">("bezier"); + + // Canvas引用和渲染状态 + const canvasRef = useRef(null); + const animationFrameRef = useRef(undefined); + const lastRenderTimeRef = useRef(0); + const isInitializedRef = useRef(false); + + // 当前显示的数据缓存 + const [displayData, setDisplayData] = useState([]); + + // 主题颜色配置 + 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 ( + + + + {/* 控制层覆盖 */} + + {/* 时间范围按钮 */} + + {getTimeRangeText()} + + + {/* 图例 */} + + + {t("Upload")} + + + {t("Download")} + + + + {/* 样式指示器 */} + + {chartStyle === "bezier" ? "Smooth" : "Linear"} + + + {/* 数据统计指示器(左下角) */} + + Points: {displayData.length} | Fresh: {isDataFresh ? "✓" : "✗"} | + Compressed: {samplerStats.compressedBufferSize} + + + + ); + }), +); + +EnhancedCanvasTrafficGraph.displayName = "EnhancedCanvasTrafficGraph"; diff --git a/src/components/home/enhanced-traffic-graph.tsx b/src/components/home/enhanced-traffic-graph.tsx deleted file mode 100644 index 1da338985..000000000 --- a/src/components/home/enhanced-traffic-graph.tsx +++ /dev/null @@ -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((props, ref) => { - const theme = useTheme(); - const { t } = useTranslation(); - - // 基础状态 - const [timeRange, setTimeRange] = useState(10); - const [chartStyle, setChartStyle] = useState<"line" | "area">("area"); - const [displayData, setDisplayData] = useState([]); - - // 数据缓冲区 - const dataBufferRef = useRef([]); - - // 根据时间范围计算保留的数据点数量 - 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) => { - event.stopPropagation(); - setTimeRange((prevRange) => { - return prevRange === 1 ? 5 : prevRange === 5 ? 10 : 1; - }); - }, - [], - ); - - // 点击图表主体或图例时切换样式 - const handleToggleStyleClick = useCallback( - (event: React.MouseEvent) => { - 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 ( - -

- {displayData.length > 0 && ( - - )} - - - - {getTimeRangeText()} - - - - {t("Upload")} - - - - {t("Download")} - - -
- - ); - }), -); - -EnhancedTrafficGraph.displayName = "EnhancedTrafficGraph"; diff --git a/src/components/home/enhanced-traffic-stats.tsx b/src/components/home/enhanced-traffic-stats.tsx index 7e903466c..714eaa19d 100644 --- a/src/components/home/enhanced-traffic-stats.tsx +++ b/src/components/home/enhanced-traffic-stats.tsx @@ -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(null); + const trafficRef = useRef(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 | null; - memory: ReturnType | 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()} >
- + {isDebug && (
{ > DEBUG: {!!trafficRef.current ? "图表已初始化" : "图表未初始化"}
+ 状态: {isDataFresh ? "active" : "inactive"} +
+ 数据新鲜度: {traffic?.is_fresh ? "Fresh" : "Stale"} +
{new Date().toISOString().slice(11, 19)}
)} @@ -487,19 +323,42 @@ export const EnhancedTrafficStats = () => { ); return ( - - {trafficGraph && ( - - {/* 流量图表区域 */} - {trafficGraphComponent} - - )} - {/* 统计卡片区域 */} - {statCards.map((card, index) => ( - - - - ))} - + { + console.error("[EnhancedTrafficStats] 组件错误:", error, errorInfo); + }} + > + + {trafficGraph && ( + + {/* 流量图表区域 */} + {trafficGraphComponent} + + )} + {/* 统计卡片区域 */} + {statCards.map((card, index) => ( + + + + ))} + + {/* 数据状态指示器(调试用)*/} + {isDebug && ( + + + 数据状态: {isDataFresh ? "新鲜" : "过期"} | 有效数据:{" "} + {hasValidData ? "是" : "否"} | 加载中: {isLoading ? "是" : "否"} + + + )} + + ); }; diff --git a/src/components/layout/layout-traffic.tsx b/src/components/layout/layout-traffic.tsx index cc563024a..8eb255ce4 100644 --- a/src/components/layout/layout-traffic.tsx +++ b/src/components/layout/layout-traffic.tsx @@ -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(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 ( - - {trafficGraph && pageVisible && ( -
- -
- )} + + + {trafficGraph && pageVisible && ( +
+ +
+ )} - - - 0 ? "secondary" : "disabled"} - /> - - {up} - - {upUnit}/s - - - - 0 ? "primary" : "disabled"} - /> - - {down} - - {downUnit}/s - - - {displayMemory && ( + { - isDebug && (await gc()); + sx={{ + ...boxStyle.sx, + opacity: traffic?.is_fresh ? 1 : 0.6, }} > - - {inuse} - {inuseUnit} + 0 ? "secondary" : "disabled" + } + /> + + {up} + + {upUnit}/s - )} + + + 0 ? "primary" : "disabled" + } + /> + + {down} + + {downUnit}/s + + + {displayMemory && ( + { + isDebug && (await gc()); + }} + > + + {inuse} + {inuseUnit} + + )} + -
+
); }; diff --git a/src/components/proxy/provider-button.tsx b/src/components/proxy/provider-button.tsx index ed8295f14..ecf5f014c 100644 --- a/src/components/proxy/provider-button.tsx +++ b/src/components/proxy/provider-button.tsx @@ -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"; diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 5ab494d2e..a4d56d874 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -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"; diff --git a/src/components/rule/provider-button.tsx b/src/components/rule/provider-button.tsx index 0b4e1106b..ebad1553d 100644 --- a/src/components/rule/provider-button.tsx +++ b/src/components/rule/provider-button.tsx @@ -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"; diff --git a/src/components/setting/mods/clash-core-viewer.tsx b/src/components/setting/mods/clash-core-viewer.tsx index 18a4b9142..1175d8132 100644 --- a/src/components/setting/mods/clash-core-viewer.tsx +++ b/src/components/setting/mods/clash-core-viewer.tsx @@ -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 = [ diff --git a/src/components/setting/mods/controller-viewer.tsx b/src/components/setting/mods/controller-viewer.tsx index 7c0b64811..3e493047c 100644 --- a/src/components/setting/mods/controller-viewer.tsx +++ b/src/components/setting/mods/controller-viewer.tsx @@ -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((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((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((props, ref) => { onOk={onSave} > + + + setEnableController(e.target.checked)} + disabled={isSaving} + /> + + ((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} /> handleCopyToClipboard(controller, "controller")} color="primary" - disabled={isSaving} + disabled={isSaving || !enableController} > @@ -147,20 +181,20 @@ export const ControllerViewer = forwardRef((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} /> handleCopyToClipboard(secret, "secret")} color="primary" - disabled={isSaving} + disabled={isSaving || !enableController} > diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index 7582e8c8c..3c2ea89c1 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -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, diff --git a/src/components/setting/setting-clash.tsx b/src/components/setting/setting-clash.tsx index 01f648c1f..102ba1eda 100644 --- a/src/components/setting/setting-clash.tsx +++ b/src/components/setting/setting-clash.tsx @@ -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"; diff --git a/src/hooks/use-clash.ts b/src/hooks/use-clash.ts index 34b869c2a..b9b9cd3f9 100644 --- a/src/hooks/use-clash.ts +++ b/src/hooks/use-clash.ts @@ -1,6 +1,6 @@ import useSWR, { mutate } from "swr"; import { useLockFn } from "ahooks"; -import { getAxios, getVersion } from "@/services/api"; +import { getVersion } from "@/services/cmds"; import { getClashInfo, patchClashConfig, @@ -122,8 +122,7 @@ export const useClashInfo = () => { await patchClashConfig(patch); mutateInfo(); mutate("getClashConfig"); - // 刷新接口 - getAxios(true); + // IPC调用不需要刷新axios实例 }; return { diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index c8f2cea04..111bf78c3 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -5,7 +5,7 @@ import { patchProfilesConfig, forceRefreshProxies, } from "@/services/cmds"; -import { getProxies, updateProxy } from "@/services/api"; +import { getProxies, updateProxy } from "@/services/cmds"; export const useProfiles = () => { const { data: profiles, mutate: mutateProfiles } = useSWR( diff --git a/src/hooks/use-system-proxy-state.ts b/src/hooks/use-system-proxy-state.ts index 2e4621ba1..22f1c7cba 100644 --- a/src/hooks/use-system-proxy-state.ts +++ b/src/hooks/use-system-proxy-state.ts @@ -2,7 +2,7 @@ import useSWR, { mutate } from "swr"; import { useVerge } from "@/hooks/use-verge"; import { getAutotemProxy } from "@/services/cmds"; import { useAppData } from "@/providers/app-data-provider"; -import { closeAllConnections } from "@/services/api"; +import { closeAllConnections } from "@/services/cmds"; // 系统代理状态检测统一逻辑 export const useSystemProxyState = () => { diff --git a/src/hooks/use-traffic-monitor-enhanced.ts b/src/hooks/use-traffic-monitor-enhanced.ts new file mode 100644 index 000000000..09a3a8aac --- /dev/null +++ b/src/hooks/use-traffic-monitor-enhanced.ts @@ -0,0 +1,398 @@ +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( + 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, + }; +}; diff --git a/src/hooks/use-traffic-monitor.ts b/src/hooks/use-traffic-monitor.ts new file mode 100644 index 000000000..211da2823 --- /dev/null +++ b/src/hooks/use-traffic-monitor.ts @@ -0,0 +1,245 @@ +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"; + +// 流量数据项接口 +export interface ITrafficDataPoint { + up: number; + down: number; + timestamp: number; + 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: { + up: number; + down: number; + timestamp?: number; + }) => void; + clearData: () => void; + getDataForTimeRange: (minutes: number) => ITrafficDataPoint[]; +} + +/** + * 全局流量监控数据管理Hook + * 提供统一的流量数据获取和图表数据管理 + */ +export const useTrafficMonitor = () => { + const { clashInfo } = useClashInfo(); + const pageVisible = useVisibility(); + + // 图表数据缓冲区 - 使用ref保持数据持久性 + const dataBufferRef = useRef([]); + const [, forceUpdate] = useState({}); + + // 强制组件更新的函数 + 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; + + 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())) { + nameValue = "??:??:??"; + } else { + nameValue = date.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( + 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(), + }); + } + }, + onError: (error) => { + console.error("[TrafficMonitor] 获取数据错误:", error); + }, + }, + ); + + // 添加数据点到缓冲区 + 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", { + 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; + + // 触发使用该数据的组件更新 + 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); + }, + [], + ); + + // 构建图表数据管理对象 + const graphData: ITrafficGraphData = { + dataPoints: dataBufferRef.current, + addDataPoint, + clearData, + getDataForTimeRange, + }; + + // 构建监控数据对象 + const trafficMonitorData: ITrafficMonitorData = { + traffic: monitorData?.traffic || { + raw: { up_rate: 0, down_rate: 0 }, + formatted: { up_rate: "0B", down_rate: "0B" }, + is_fresh: false, + }, + memory: monitorData?.memory || { + raw: { inuse: 0 }, + formatted: { inuse: "0B" }, + is_fresh: false, + }, + }; + + return { + // 原始监控数据 + monitorData: trafficMonitorData, + // 图表数据管理 + graphData, + // 数据获取状态 + isLoading: !monitorData && !error, + error, + // 数据新鲜度 + isDataFresh: monitorData?.overall_status === "active", + }; +}; + +/** + * 仅获取流量数据的轻量级Hook + * 适用于不需要图表数据的组件 + */ +export const useTrafficData = () => { + const { monitorData, isLoading, error, isDataFresh } = useTrafficMonitor(); + + return { + traffic: monitorData.traffic, + memory: monitorData.memory, + isLoading, + error, + isDataFresh, + }; +}; + +/** + * 仅获取图表数据的Hook + * 适用于图表组件 + */ +export const useTrafficGraphData = () => { + const { graphData, isDataFresh } = useTrafficMonitor(); + + return { + ...graphData, + isDataFresh, + }; +}; diff --git a/src/locales/en.json b/src/locales/en.json index c2da4bc99..ef392efa5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -287,6 +287,7 @@ "Redir Port": "Redir Port", "Tproxy Port": "Tproxy Port", "External": "External", + "Enable External Controller": "Enable External Controller", "External Controller": "External Controller", "Core Secret": "Core Secret", "Recommended": "Recommended", @@ -653,5 +654,12 @@ "Development mode: Automatically includes Tauri and localhost origins": "Development mode: Automatically includes Tauri and localhost origins", "Invalid regular expression": "Invalid regular expression", "Copy Version": "Copy Version", - "Version copied to clipboard": "Version copied to clipboard" + "Version copied to clipboard": "Version copied to clipboard", + "Controller address cannot be empty": "Controller address cannot be empty", + "Secret cannot be empty": "Secret cannot be empty", + "Configuration saved successfully": "Configuration saved successfully", + "Failed to save configuration": "Failed to save configuration", + "Controller address copied to clipboard": "Controller address copied to clipboard", + "Secret copied to clipboard": "Secret copied to clipboard", + "Saving...": "Saving..." } diff --git a/src/locales/zh.json b/src/locales/zh.json index 8421adde5..5ccab4afe 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -287,6 +287,7 @@ "Redir Port": "Redir 透明代理端口", "TPROXY Port": "TPROXY 透明代理端口", "External": "外部控制", + "Enable External Controller": "启用外部控制器", "External Controller": "外部控制器监听地址", "Core Secret": "API 访问密钥", "Recommended": "建议设置", @@ -653,5 +654,12 @@ "Development mode: Automatically includes Tauri and localhost origins": "开发模式:自动包含 Tauri 和 localhost 来源", "Invalid regular expression": "无效的正则表达式", "Copy Version": "复制Verge版本号", - "Version copied to clipboard": "Verge版本已复制到剪贴板" + "Version copied to clipboard": "Verge版本已复制到剪贴板", + "Controller address cannot be empty": "控制器地址不能为空", + "Secret cannot be empty": "访问密钥不能为空", + "Configuration saved successfully": "配置保存成功", + "Failed to save configuration": "配置保存失败", + "Controller address copied to clipboard": "控制器地址已复制到剪贴板", + "Secret copied to clipboard": "访问密钥已复制到剪贴板", + "Saving...": "保存中..." } diff --git a/src/pages/connections.tsx b/src/pages/connections.tsx index 4c8b6d4c1..4a9bc917a 100644 --- a/src/pages/connections.tsx +++ b/src/pages/connections.tsx @@ -9,7 +9,7 @@ import { PlayCircleOutlineRounded, PauseCircleOutlineRounded, } from "@mui/icons-material"; -import { closeAllConnections } from "@/services/api"; +import { closeAllConnections } from "@/services/cmds"; import { useConnectionSetting } from "@/services/states"; import { BaseEmpty, BasePage } from "@/components/base"; import { ConnectionItem } from "@/components/connection/connection-item"; diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 71bfd73b2..3c90976ee 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -35,7 +35,7 @@ import { createProfile, } from "@/services/cmds"; import { useSetLoadingCache, useThemeMode } from "@/services/states"; -import { closeAllConnections } from "@/services/api"; +import { closeAllConnections } from "@/services/cmds"; import { BasePage, DialogRef } from "@/components/base"; import { ProfileViewer, diff --git a/src/pages/proxies.tsx b/src/pages/proxies.tsx index 26f220935..7c372e182 100644 --- a/src/pages/proxies.tsx +++ b/src/pages/proxies.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { useLockFn } from "ahooks"; import { useTranslation } from "react-i18next"; import { Box, Button, ButtonGroup } from "@mui/material"; -import { closeAllConnections, getClashConfig } from "@/services/api"; +import { closeAllConnections, getClashConfig } from "@/services/cmds"; import { patchClashMode } from "@/services/cmds"; import { useVerge } from "@/hooks/use-verge"; import { BasePage } from "@/components/base"; diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index cbaebc989..dd1958849 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -1,14 +1,16 @@ import React, { createContext, useContext, useEffect, useMemo } from "react"; import { useVerge } from "@/hooks/use-verge"; import useSWR from "swr"; -import useSWRSubscription from "swr/subscription"; import { getProxies, getRules, getClashConfig, getProxyProviders, getRuleProviders, -} from "@/services/api"; + getConnections, + getTrafficData, + getMemoryData, +} from "@/services/cmds"; import { getSystemProxy, getRunningMode, @@ -16,7 +18,6 @@ import { forceRefreshProxies, } from "@/services/cmds"; import { useClashInfo } from "@/hooks/use-clash"; -import { createAuthSockette } from "@/utils/websocket"; import { useVisibility } from "@/hooks/use-visibility"; import { listen } from "@tauri-apps/api/event"; @@ -249,240 +250,61 @@ export const AppDataProvider = ({ suspense: false, }); - // 连接数据 - 使用WebSocket实时更新 + // 连接数据 - 使用IPC轮询更新 const { data: connectionsData = { connections: [], uploadTotal: 0, downloadTotal: 0, }, - } = useSWRSubscription( - clashInfo && pageVisible ? "connections" : null, - (_key, { next }) => { - if (!clashInfo || !pageVisible) return () => {}; - - const { server = "", secret = "" } = clashInfo; - if (!server) return () => {}; - - console.log( - `[Connections][${AppDataProvider.name}] 正在连接: ${server}/connections`, - ); - const socket = createAuthSockette(`${server}/connections`, secret, { - timeout: 5000, - onmessage(event) { - try { - const data = JSON.parse(event.data); - // 处理连接数据,计算当前上传下载速度 - next( - null, - ( - prev: any = { - connections: [], - uploadTotal: 0, - downloadTotal: 0, - }, - ) => { - const oldConns = prev.connections || []; - const newConns = data.connections || []; - - // 计算当前速度 - const processedConns = newConns.map((conn: any) => { - const oldConn = oldConns.find( - (old: any) => old.id === conn.id, - ); - if (oldConn) { - return { - ...conn, - curUpload: conn.upload - oldConn.upload, - curDownload: conn.download - oldConn.download, - }; - } - return { ...conn, curUpload: 0, curDownload: 0 }; - }); - - return { - ...data, - connections: processedConns, - }; - }, - ); - } catch (err) { - console.error( - `[Connections][${AppDataProvider.name}] 解析数据错误:`, - err, - event.data, - ); - } - }, - onopen: (event) => { - console.log( - `[Connections][${AppDataProvider.name}] WebSocket 连接已建立`, - event, - ); - }, - onerror(event) { - console.error( - `[Connections][${AppDataProvider.name}] WebSocket 连接错误或达到最大重试次数`, - event, - ); - next(null, { connections: [], uploadTotal: 0, downloadTotal: 0 }); - }, - onclose: (event) => { - console.log( - `[Connections][${AppDataProvider.name}] WebSocket 连接关闭`, - event.code, - event.reason, - ); - if (event.code !== 1000 && event.code !== 1001) { - console.warn( - `[Connections][${AppDataProvider.name}] 连接非正常关闭,重置数据`, - ); - next(null, { connections: [], uploadTotal: 0, downloadTotal: 0 }); - } - }, - }); - - return () => { - console.log(`[Connections][${AppDataProvider.name}] 清理WebSocket连接`); - socket.close(); + } = useSWR( + clashInfo && pageVisible ? "getConnections" : null, + async () => { + const data = await getConnections(); + return { + connections: data.connections || [], + uploadTotal: data.uploadTotal || 0, + downloadTotal: data.downloadTotal || 0, }; }, + { + refreshInterval: 2000, // 2秒刷新一次 + fallbackData: { connections: [], uploadTotal: 0, downloadTotal: 0 }, + keepPreviousData: true, + onError: (error) => { + console.error("[Connections] IPC 获取数据错误:", error); + }, + }, ); - // 流量和内存数据 - 通过WebSocket获取实时流量数据 - const { data: trafficData = { up: 0, down: 0 } } = useSWRSubscription( - clashInfo && pageVisible ? "traffic" : null, - (_key, { next }) => { - if (!clashInfo || !pageVisible) return () => {}; - - const { server = "", secret = "" } = clashInfo; - if (!server) return () => {}; - - console.log( - `[Traffic][${AppDataProvider.name}] 正在连接: ${server}/traffic`, - ); - const socket = createAuthSockette(`${server}/traffic`, secret, { - onmessage(event) { - try { - const data = JSON.parse(event.data); - if ( - data && - typeof data.up === "number" && - typeof data.down === "number" - ) { - next(null, data); - } else { - console.warn( - `[Traffic][${AppDataProvider.name}] 收到无效数据:`, - data, - ); - } - } catch (err) { - console.error( - `[Traffic][${AppDataProvider.name}] 解析数据错误:`, - err, - event.data, - ); - } - }, - onopen: (event) => { - console.log( - `[Traffic][${AppDataProvider.name}] WebSocket 连接已建立`, - event, - ); - }, - onerror(event) { - console.error( - `[Traffic][${AppDataProvider.name}] WebSocket 连接错误或达到最大重试次数`, - event, - ); - next(null, { up: 0, down: 0 }); - }, - onclose: (event) => { - console.log( - `[Traffic][${AppDataProvider.name}] WebSocket 连接关闭`, - event.code, - event.reason, - ); - if (event.code !== 1000 && event.code !== 1001) { - console.warn( - `[Traffic][${AppDataProvider.name}] 连接非正常关闭,重置数据`, - ); - next(null, { up: 0, down: 0 }); - } - }, - }); - - return () => { - console.log(`[Traffic][${AppDataProvider.name}] 清理WebSocket连接`); - socket.close(); - }; + // 流量数据 - 使用IPC轮询更新 + const { data: trafficData = { up: 0, down: 0 } } = useSWR( + clashInfo && pageVisible ? "getTrafficData" : null, + getTrafficData, + { + refreshInterval: 1000, // 1秒刷新一次 + fallbackData: { up: 0, down: 0 }, + keepPreviousData: true, + onSuccess: (data) => { + // console.log("[Traffic][AppDataProvider] IPC 获取到流量数据:", data); + }, + onError: (error) => { + console.error("[Traffic][AppDataProvider] IPC 获取数据错误:", error); + }, }, ); - const { data: memoryData = { inuse: 0 } } = useSWRSubscription( - clashInfo && pageVisible ? "memory" : null, - (_key, { next }) => { - if (!clashInfo || !pageVisible) return () => {}; - - const { server = "", secret = "" } = clashInfo; - if (!server) return () => {}; - - console.log( - `[Memory][${AppDataProvider.name}] 正在连接: ${server}/memory`, - ); - const socket = createAuthSockette(`${server}/memory`, secret, { - onmessage(event) { - try { - const data = JSON.parse(event.data); - if (data && typeof data.inuse === "number") { - next(null, data); - } else { - console.warn( - `[Memory][${AppDataProvider.name}] 收到无效数据:`, - data, - ); - } - } catch (err) { - console.error( - `[Memory][${AppDataProvider.name}] 解析数据错误:`, - err, - event.data, - ); - } - }, - onopen: (event) => { - console.log( - `[Memory][${AppDataProvider.name}] WebSocket 连接已建立`, - event, - ); - }, - onerror(event) { - console.error( - `[Memory][${AppDataProvider.name}] WebSocket 连接错误或达到最大重试次数`, - event, - ); - next(null, { inuse: 0 }); - }, - onclose: (event) => { - console.log( - `[Memory][${AppDataProvider.name}] WebSocket 连接关闭`, - event.code, - event.reason, - ); - if (event.code !== 1000 && event.code !== 1001) { - console.warn( - `[Memory][${AppDataProvider.name}] 连接非正常关闭,重置数据`, - ); - next(null, { inuse: 0 }); - } - }, - }); - - return () => { - console.log(`[Memory][${AppDataProvider.name}] 清理WebSocket连接`); - socket.close(); - }; + // 内存数据 - 使用IPC轮询更新 + const { data: memoryData = { inuse: 0 } } = useSWR( + clashInfo && pageVisible ? "getMemoryData" : null, + getMemoryData, + { + refreshInterval: 2000, // 2秒刷新一次 + fallbackData: { inuse: 0 }, + keepPreviousData: true, + onError: (error) => { + console.error("[Memory] IPC 获取数据错误:", error); + }, }, ); diff --git a/src/services/api.ts b/src/services/api.ts index f108ebb30..0cb54214c 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,6 +1,6 @@ import axios, { AxiosInstance } from "axios"; -import { getClashInfo } from "./cmds"; import { invoke } from "@tauri-apps/api/core"; +import { getClashInfo } from "./cmds"; let instancePromise: Promise = null!; @@ -39,292 +39,6 @@ export const getAxios = async (force: boolean = false) => { return instancePromise; }; -/// Get Version -export const getVersion = async () => { - const instance = await getAxios(); - return instance.get("/version") as Promise<{ - premium: boolean; - meta?: boolean; - version: string; - }>; -}; - -/// Get current base configs -export const getClashConfig = async () => { - const instance = await getAxios(); - return instance.get("/configs") as Promise; -}; - -/// Update geo data -export const updateGeoData = async () => { - const instance = await getAxios(); - return instance.post("/configs/geo"); -}; - -/// Upgrade clash core -export const upgradeCore = async () => { - const instance = await getAxios(); - return instance.post("/upgrade"); -}; - -/// Get current rules -export const getRules = async () => { - const instance = await getAxios(); - const response = await instance.get("/rules"); - return response?.rules as IRuleItem[]; -}; - -/// Get Proxy delay -export const getProxyDelay = async ( - name: string, - url?: string, - timeout?: number, -) => { - const params = { - timeout: timeout || 10000, - url: url || "https://cp.cloudflare.com/generate_204", - }; - const instance = await getAxios(); - const result = await instance.get( - `/proxies/${encodeURIComponent(name)}/delay`, - { params }, - ); - return result as any as { delay: number }; -}; - -/// Update the Proxy Choose -export const updateProxy = async (group: string, proxy: string) => { - const instance = await getAxios(); - return instance.put(`/proxies/${encodeURIComponent(group)}`, { name: proxy }); -}; - -// get proxy -export const getProxiesInner = async () => { - const response = await invoke<{ proxies: Record }>( - "get_proxies", - ); - return response.proxies as Record; -}; - -/// Get the Proxy information -export const getProxies = async (): Promise<{ - global: IProxyGroupItem; - direct: IProxyItem; - groups: IProxyGroupItem[]; - records: Record; - proxies: IProxyItem[]; -}> => { - const [proxyRecord, providerRecord] = await Promise.all([ - getProxiesInner(), - getProxyProviders(), - ]); - // provider name map - const providerMap = Object.fromEntries( - Object.entries(providerRecord).flatMap(([provider, item]) => - item.proxies.map((p) => [p.name, { ...p, provider }]), - ), - ); - - // compatible with proxy-providers - const generateItem = (name: string) => { - if (proxyRecord[name]) return proxyRecord[name]; - if (providerMap[name]) return providerMap[name]; - return { - name, - type: "unknown", - udp: false, - xudp: false, - tfo: false, - mptcp: false, - smux: false, - history: [], - }; - }; - - const { GLOBAL: global, DIRECT: direct, REJECT: reject } = proxyRecord; - - let groups: IProxyGroupItem[] = Object.values(proxyRecord).reduce< - IProxyGroupItem[] - >((acc, each) => { - if (each.name !== "GLOBAL" && each.all) { - acc.push({ - ...each, - all: each.all!.map((item) => generateItem(item)), - }); - } - - return acc; - }, []); - - if (global?.all) { - let globalGroups: IProxyGroupItem[] = global.all.reduce( - (acc, name) => { - if (proxyRecord[name]?.all) { - acc.push({ - ...proxyRecord[name], - all: proxyRecord[name].all!.map((item) => generateItem(item)), - }); - } - return acc; - }, - [], - ); - - let globalNames = new Set(globalGroups.map((each) => each.name)); - groups = groups - .filter((group) => { - return !globalNames.has(group.name); - }) - .concat(globalGroups); - } - - const proxies = [direct, reject].concat( - Object.values(proxyRecord).filter( - (p) => !p.all?.length && p.name !== "DIRECT" && p.name !== "REJECT", - ), - ); - - const _global: IProxyGroupItem = { - ...global, - all: global?.all?.map((item) => generateItem(item)) || [], - }; - - return { global: _global, direct, groups, records: proxyRecord, proxies }; -}; - -// get proxy providers -export const getProxyProviders = async () => { - const response = await invoke<{ - providers: Record; - }>("get_providers_proxies"); - if (!response || !response.providers) { - console.warn( - "getProxyProviders: Invalid response structure, returning empty object", - ); - return {}; - } - - const providers = response.providers as Record; - - return Object.fromEntries( - Object.entries(providers).filter(([key, item]) => { - const type = item.vehicleType.toLowerCase(); - return type === "http" || type === "file"; - }), - ); -}; - -export const getRuleProviders = async () => { - const instance = await getAxios(); - const response = await instance.get("/providers/rules"); - - const providers = (response.providers || {}) as Record< - string, - IRuleProviderItem - >; - - return Object.fromEntries( - Object.entries(providers).filter(([key, item]) => { - const type = item.vehicleType.toLowerCase(); - return type === "http" || type === "file"; - }), - ); -}; - -// proxy providers health check -export const providerHealthCheck = async (name: string) => { - const instance = await getAxios(); - return instance.get( - `/providers/proxies/${encodeURIComponent(name)}/healthcheck`, - ); -}; - -export const proxyProviderUpdate = async (name: string) => { - const instance = await getAxios(); - return instance.put(`/providers/proxies/${encodeURIComponent(name)}`); -}; - -export const ruleProviderUpdate = async (name: string) => { - const instance = await getAxios(); - return instance.put(`/providers/rules/${encodeURIComponent(name)}`); -}; - -export const getConnections = async () => { - const instance = await getAxios(); - const result = await instance.get("/connections"); - return result as any as IConnections; -}; - -// Close specific connection -export const deleteConnection = async (id: string) => { - const instance = await getAxios(); - await instance.delete(`/connections/${encodeURIComponent(id)}`); -}; - -// Close all connections -export const closeAllConnections = async () => { - const instance = await getAxios(); - await instance.delete("/connections"); -}; - -// Get Group Proxy Delays -export const getGroupProxyDelays = async ( - groupName: string, - url?: string, - timeout?: number, -) => { - const params = { - timeout: timeout || 10000, - url: url || "https://cp.cloudflare.com/generate_204", - }; - - console.log( - `[API] 获取代理组延迟,组: ${groupName}, URL: ${params.url}, 超时: ${params.timeout}ms`, - ); - - try { - const instance = await getAxios(); - console.log( - `[API] 发送HTTP请求: GET /group/${encodeURIComponent(groupName)}/delay`, - ); - - const result = await instance.get( - `/group/${encodeURIComponent(groupName)}/delay`, - { params }, - ); - - console.log( - `[API] 获取代理组延迟成功,组: ${groupName}, 结果数量:`, - Object.keys(result || {}).length, - ); - return result as any as Record; - } catch (error) { - console.error(`[API] 获取代理组延迟失败,组: ${groupName}`, error); - throw error; - } -}; - -// Is debug enabled -export const isDebugEnabled = async () => { - try { - const instance = await getAxios(); - await instance.get("/debug/pprof"); - return true; - } catch { - return false; - } -}; - -// GC -export const gc = async () => { - try { - const instance = await getAxios(); - await instance.put("/debug/gc"); - } catch (error) { - console.error(`Error gcing: ${error}`); - } -}; - // Get current IP and geolocation information (refactored IP detection with service-specific mappings) interface IpInfo { ip: string; diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 3df7bb19f..80bf0221c 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -94,6 +94,320 @@ export async function patchClashMode(payload: String) { return invoke("patch_clash_mode", { payload }); } +// New IPC-based API functions to replace HTTP API calls +export async function getVersion() { + return invoke<{ + premium: boolean; + meta?: boolean; + version: string; + }>("get_clash_version"); +} + +export async function getClashConfig() { + return invoke("get_clash_config"); +} + +export async function updateGeoData() { + return invoke("update_geo_data"); +} + +export async function upgradeCore() { + return invoke("upgrade_clash_core"); +} + +export async function getRules() { + const response = await invoke<{ rules: IRuleItem[] }>("get_clash_rules"); + return response?.rules || []; +} + +export async function getProxyDelay( + name: string, + url?: string, + timeout?: number, +) { + return invoke<{ delay: number }>("clash_api_get_proxy_delay", { + name, + url, + timeout: timeout || 10000, + }); +} + +export async function updateProxy(group: string, proxy: string) { + return await invoke("update_proxy_choice", { group, proxy }); +} + +export async function getProxies(): Promise<{ + global: IProxyGroupItem; + direct: IProxyItem; + groups: IProxyGroupItem[]; + records: Record; + proxies: IProxyItem[]; +}> { + const [proxyResponse, providerResponse] = await Promise.all([ + invoke<{ proxies: Record }>("get_proxies"), + invoke<{ providers: Record }>( + "get_providers_proxies", + ), + ]); + + const proxyRecord = proxyResponse.proxies; + const providerRecord = providerResponse.providers || {}; + + // provider name map + const providerMap = Object.fromEntries( + Object.entries(providerRecord).flatMap(([provider, item]) => + item.proxies.map((p) => [p.name, { ...p, provider }]), + ), + ); + + // compatible with proxy-providers + const generateItem = (name: string) => { + if (proxyRecord[name]) return proxyRecord[name]; + if (providerMap[name]) return providerMap[name]; + return { + name, + type: "unknown", + udp: false, + xudp: false, + tfo: false, + mptcp: false, + smux: false, + history: [], + }; + }; + + const { GLOBAL: global, DIRECT: direct, REJECT: reject } = proxyRecord; + + let groups: IProxyGroupItem[] = Object.values(proxyRecord).reduce< + IProxyGroupItem[] + >((acc, each) => { + if (each.name !== "GLOBAL" && each.all) { + acc.push({ + ...each, + all: each.all!.map((item) => generateItem(item)), + }); + } + + return acc; + }, []); + + if (global?.all) { + let globalGroups: IProxyGroupItem[] = global.all.reduce( + (acc, name) => { + if (proxyRecord[name]?.all) { + acc.push({ + ...proxyRecord[name], + all: proxyRecord[name].all!.map((item) => generateItem(item)), + }); + } + return acc; + }, + [], + ); + + let globalNames = new Set(globalGroups.map((each) => each.name)); + groups = groups + .filter((group) => { + return !globalNames.has(group.name); + }) + .concat(globalGroups); + } + + const proxies = [direct, reject].concat( + Object.values(proxyRecord).filter( + (p) => !p.all?.length && p.name !== "DIRECT" && p.name !== "REJECT", + ), + ); + + const _global: IProxyGroupItem = { + ...global, + all: global?.all?.map((item) => generateItem(item)) || [], + }; + + return { global: _global, direct, groups, records: proxyRecord, proxies }; +} + +export async function getProxyProviders() { + const response = await invoke<{ + providers: Record; + }>("get_providers_proxies"); + if (!response || !response.providers) { + console.warn( + "getProxyProviders: Invalid response structure, returning empty object", + ); + return {}; + } + + const providers = response.providers as Record; + + return Object.fromEntries( + Object.entries(providers).filter(([key, item]) => { + const type = item.vehicleType.toLowerCase(); + return type === "http" || type === "file"; + }), + ); +} + +export async function getRuleProviders() { + const response = await invoke<{ + providers: Record; + }>("get_rule_providers"); + + const providers = (response.providers || {}) as Record< + string, + IRuleProviderItem + >; + + return Object.fromEntries( + Object.entries(providers).filter(([key, item]) => { + const type = item.vehicleType.toLowerCase(); + return type === "http" || type === "file"; + }), + ); +} + +export async function providerHealthCheck(name: string) { + return invoke("proxy_provider_health_check", { name }); +} + +export async function proxyProviderUpdate(name: string) { + return invoke("update_proxy_provider", { name }); +} + +export async function ruleProviderUpdate(name: string) { + return invoke("update_rule_provider", { name }); +} + +export async function getConnections() { + return invoke("get_clash_connections"); +} + +export async function deleteConnection(id: string) { + return invoke("delete_clash_connection", { id }); +} + +export async function closeAllConnections() { + return invoke("close_all_clash_connections"); +} + +export async function getGroupProxyDelays( + groupName: string, + url?: string, + timeout?: number, +) { + return invoke>("get_group_proxy_delays", { + groupName, + url, + timeout, + }); +} + +export async function getTrafficData() { + // console.log("[Traffic][Service] 开始调用 get_traffic_data"); + const result = await invoke("get_traffic_data"); + // console.log("[Traffic][Service] get_traffic_data 返回结果:", result); + return result; +} + +export async function getMemoryData() { + console.log("[Memory][Service] 开始调用 get_memory_data"); + const result = await invoke<{ + inuse: number; + oslimit?: number; + usage_percent?: number; + last_updated?: number; + }>("get_memory_data"); + console.log("[Memory][Service] get_memory_data 返回结果:", result); + return result; +} + +export async function getFormattedTrafficData() { + console.log("[Traffic][Service] 开始调用 get_formatted_traffic_data"); + const result = await invoke( + "get_formatted_traffic_data", + ); + console.log( + "[Traffic][Service] get_formatted_traffic_data 返回结果:", + result, + ); + return result; +} + +export async function getFormattedMemoryData() { + console.log("[Memory][Service] 开始调用 get_formatted_memory_data"); + const result = await invoke( + "get_formatted_memory_data", + ); + console.log("[Memory][Service] get_formatted_memory_data 返回结果:", result); + return result; +} + +export async function getSystemMonitorOverview() { + console.log("[Monitor][Service] 开始调用 get_system_monitor_overview"); + const result = await invoke( + "get_system_monitor_overview", + ); + console.log( + "[Monitor][Service] get_system_monitor_overview 返回结果:", + result, + ); + return result; +} + +// 带数据验证的安全版本 +export async function getSystemMonitorOverviewSafe() { + // console.log( + // "[Monitor][Service] 开始调用安全版本 get_system_monitor_overview", + // ); + try { + const result = await invoke("get_system_monitor_overview"); + // console.log("[Monitor][Service] 原始数据:", result); + + // 导入验证器(动态导入避免循环依赖) + const { systemMonitorValidator } = await import("@/utils/data-validator"); + + if (systemMonitorValidator.validate(result)) { + // console.log("[Monitor][Service] 数据验证通过"); + return result as ISystemMonitorOverview; + } else { + // console.warn("[Monitor][Service] 数据验证失败,使用清理后的数据"); + return systemMonitorValidator.sanitize(result); + } + } catch (error) { + // console.error("[Monitor][Service] API调用失败:", error); + // 返回安全的默认值 + const { systemMonitorValidator } = await import("@/utils/data-validator"); + return systemMonitorValidator.sanitize(null); + } +} + +export async function startTrafficService() { + console.log("[Traffic][Service] 开始调用 start_traffic_service"); + try { + const result = await invoke("start_traffic_service"); + console.log("[Traffic][Service] start_traffic_service 调用成功"); + return result; + } catch (error) { + console.error("[Traffic][Service] start_traffic_service 调用失败:", error); + throw error; + } +} + +export async function stopTrafficService() { + console.log("[Traffic][Service] 开始调用 stop_traffic_service"); + const result = await invoke("stop_traffic_service"); + console.log("[Traffic][Service] stop_traffic_service 调用成功"); + return result; +} + +export async function isDebugEnabled() { + return invoke("is_clash_debug_enabled"); +} + +export async function gc() { + return invoke("clash_gc"); +} + export async function getVergeConfig() { return invoke("get_verge_config"); } @@ -233,7 +547,9 @@ export async function cmdGetProxyDelay( /// 用于profile切换等场景 export async function forceRefreshProxies() { console.log("[API] 强制刷新代理缓存"); - return invoke("force_refresh_proxies"); + const result = await invoke("force_refresh_proxies"); + console.log("[API] 代理缓存刷新完成"); + return result; } export async function cmdTestDelay(url: string) { diff --git a/src/services/types.d.ts b/src/services/types.d.ts index 4ded6a0f5..7f2aac0b0 100644 --- a/src/services/types.d.ts +++ b/src/services/types.d.ts @@ -131,6 +131,63 @@ interface IRuleProviderItem { interface ITrafficItem { up: number; down: number; + up_rate?: number; + down_rate?: number; + last_updated?: number; +} + +interface IFormattedTrafficData { + up_rate_formatted: string; + down_rate_formatted: string; + total_up_formatted: string; + total_down_formatted: string; + is_fresh: boolean; +} + +interface IFormattedMemoryData { + inuse_formatted: string; + oslimit_formatted: string; + usage_percent: number; + is_fresh: boolean; +} + +// 增强的类型安全接口定义,确保所有字段必需 +interface ISystemMonitorOverview { + traffic: { + raw: { + up: number; + down: number; + up_rate: number; + down_rate: number; + }; + formatted: { + up_rate: string; + down_rate: string; + total_up: string; + total_down: string; + }; + is_fresh: boolean; + }; + memory: { + raw: { + inuse: number; + oslimit: number; + usage_percent: number; + }; + formatted: { + inuse: string; + oslimit: string; + usage_percent: number; + }; + is_fresh: boolean; + }; + overall_status: "active" | "inactive" | "error" | "unknown"; +} + +// 类型安全的数据验证器 +interface ISystemMonitorOverviewValidator { + validate(data: any): data is ISystemMonitorOverview; + sanitize(data: any): ISystemMonitorOverview; } interface ILogItem { @@ -800,6 +857,7 @@ interface IVergeConfig { webdav_password?: string; home_cards?: Record; enable_hover_jump_navigator?: boolean; + enable_external_controller?: boolean; } interface IWebDavFile { diff --git a/src/utils/data-validator.ts b/src/utils/data-validator.ts new file mode 100644 index 000000000..219d94e42 --- /dev/null +++ b/src/utils/data-validator.ts @@ -0,0 +1,230 @@ +/** + * 类型安全的数据验证器 + * 确保从后端接收的数据符合预期的接口定义 + */ + +// 数字验证器 +function isValidNumber(value: any): value is number { + return typeof value === "number" && !isNaN(value) && isFinite(value); +} + +// 字符串验证器 +function isValidString(value: any): value is string { + return typeof value === "string" && value.length > 0; +} + +// 布尔值验证器 +function isValidBoolean(value: any): value is boolean { + return typeof value === "boolean"; +} + +/** + * 系统监控数据验证器 + */ +export class SystemMonitorValidator implements ISystemMonitorOverviewValidator { + /** + * 验证数据是否符合ISystemMonitorOverview接口 + */ + validate(data: any): data is ISystemMonitorOverview { + if (!data || typeof data !== "object") { + console.warn("[DataValidator] 数据不是对象:", data); + return false; + } + + // 验证traffic字段 + if (!this.validateTrafficData(data.traffic)) { + console.warn("[DataValidator] traffic字段验证失败:", data.traffic); + return false; + } + + // 验证memory字段 + if (!this.validateMemoryData(data.memory)) { + console.warn("[DataValidator] memory字段验证失败:", data.memory); + return false; + } + + // 验证overall_status字段 + if (!this.validateOverallStatus(data.overall_status)) { + console.warn( + "[DataValidator] overall_status字段验证失败:", + data.overall_status, + ); + return false; + } + + return true; + } + + /** + * 清理和修复数据,确保返回有效的ISystemMonitorOverview + */ + sanitize(data: any): ISystemMonitorOverview { + // console.log("[DataValidator] 开始数据清理:", data); + + const sanitized: ISystemMonitorOverview = { + traffic: this.sanitizeTrafficData(data?.traffic), + memory: this.sanitizeMemoryData(data?.memory), + overall_status: this.sanitizeOverallStatus(data?.overall_status), + }; + + // console.log("[DataValidator] 数据清理完成:", sanitized); + return sanitized; + } + + private validateTrafficData(traffic: any): boolean { + if (!traffic || typeof traffic !== "object") return false; + + // 验证raw字段 + const raw = traffic.raw; + if (!raw || typeof raw !== "object") return false; + if ( + !isValidNumber(raw.up) || + !isValidNumber(raw.down) || + !isValidNumber(raw.up_rate) || + !isValidNumber(raw.down_rate) + ) { + return false; + } + + // 验证formatted字段 + const formatted = traffic.formatted; + if (!formatted || typeof formatted !== "object") return false; + if ( + !isValidString(formatted.up_rate) || + !isValidString(formatted.down_rate) || + !isValidString(formatted.total_up) || + !isValidString(formatted.total_down) + ) { + return false; + } + + // 验证is_fresh字段 + if (!isValidBoolean(traffic.is_fresh)) return false; + + return true; + } + + private validateMemoryData(memory: any): boolean { + if (!memory || typeof memory !== "object") return false; + + // 验证raw字段 + const raw = memory.raw; + if (!raw || typeof raw !== "object") return false; + if ( + !isValidNumber(raw.inuse) || + !isValidNumber(raw.oslimit) || + !isValidNumber(raw.usage_percent) + ) { + return false; + } + + // 验证formatted字段 + const formatted = memory.formatted; + if (!formatted || typeof formatted !== "object") return false; + if ( + !isValidString(formatted.inuse) || + !isValidString(formatted.oslimit) || + !isValidNumber(formatted.usage_percent) + ) { + return false; + } + + // 验证is_fresh字段 + if (!isValidBoolean(memory.is_fresh)) return false; + + return true; + } + + private validateOverallStatus(status: any): boolean { + return ( + typeof status === "string" && + ["active", "inactive", "error", "unknown"].includes(status) + ); + } + + private sanitizeTrafficData(traffic: any) { + const raw = traffic?.raw || {}; + const formatted = traffic?.formatted || {}; + + return { + raw: { + up: isValidNumber(raw.up) ? raw.up : 0, + down: isValidNumber(raw.down) ? raw.down : 0, + up_rate: isValidNumber(raw.up_rate) ? raw.up_rate : 0, + down_rate: isValidNumber(raw.down_rate) ? raw.down_rate : 0, + }, + formatted: { + up_rate: isValidString(formatted.up_rate) ? formatted.up_rate : "0B", + down_rate: isValidString(formatted.down_rate) + ? formatted.down_rate + : "0B", + total_up: isValidString(formatted.total_up) ? formatted.total_up : "0B", + total_down: isValidString(formatted.total_down) + ? formatted.total_down + : "0B", + }, + is_fresh: isValidBoolean(traffic?.is_fresh) ? traffic.is_fresh : false, + }; + } + + private sanitizeMemoryData(memory: any) { + const raw = memory?.raw || {}; + const formatted = memory?.formatted || {}; + + return { + raw: { + inuse: isValidNumber(raw.inuse) ? raw.inuse : 0, + oslimit: isValidNumber(raw.oslimit) ? raw.oslimit : 0, + usage_percent: isValidNumber(raw.usage_percent) ? raw.usage_percent : 0, + }, + formatted: { + inuse: isValidString(formatted.inuse) ? formatted.inuse : "0B", + oslimit: isValidString(formatted.oslimit) ? formatted.oslimit : "0B", + usage_percent: isValidNumber(formatted.usage_percent) + ? formatted.usage_percent + : 0, + }, + is_fresh: isValidBoolean(memory?.is_fresh) ? memory.is_fresh : false, + }; + } + + private sanitizeOverallStatus( + status: any, + ): "active" | "inactive" | "error" | "unknown" { + if ( + typeof status === "string" && + ["active", "inactive", "error", "unknown"].includes(status) + ) { + return status as "active" | "inactive" | "error" | "unknown"; + } + return "unknown"; + } +} + +// 全局验证器实例 +export const systemMonitorValidator = new SystemMonitorValidator(); + +/** + * 安全的API调用包装器 + */ +export function withDataValidation Promise>( + apiCall: T, + validator: { validate: (data: any) => boolean; sanitize: (data: any) => any }, +): T { + return (async (...args: Parameters) => { + try { + const result = await apiCall(...args); + + if (validator.validate(result)) { + return result; + } else { + console.warn("[DataValidator] API返回数据验证失败,尝试修复:", result); + return validator.sanitize(result); + } + } catch (error) { + console.error("[DataValidator] API调用失败:", error); + // 返回安全的默认值 + return validator.sanitize(null); + } + }) as T; +} diff --git a/src/utils/traffic-diagnostics.ts b/src/utils/traffic-diagnostics.ts new file mode 100644 index 000000000..26af3939f --- /dev/null +++ b/src/utils/traffic-diagnostics.ts @@ -0,0 +1,181 @@ +/** + * 流量统计诊断工具 + * 用于帮助开发者和用户诊断流量统计系统的性能和状态 + */ + +interface IDiagnosticReport { + timestamp: string; + referenceCount: number; + samplerStats: { + rawBufferSize: number; + compressedBufferSize: number; + compressionQueueSize: number; + totalMemoryPoints: number; + }; + performance: { + memoryUsage: number; // MB + lastDataFreshness: boolean; + errorCount: number; + }; + recommendations: string[]; +} + +// 全局错误计数器 +let globalErrorCount = 0; + +/** + * 记录错误 + */ +export function recordTrafficError(error: Error, component: string) { + globalErrorCount++; + console.error( + `[TrafficDiagnostics] ${component} 错误 (#${globalErrorCount}):`, + error, + ); +} + +/** + * 获取内存使用情况(近似值) + */ +function getMemoryUsage(): number { + if ("memory" in performance) { + // @ts-ignore - 某些浏览器支持 + const memory = (performance as any).memory; + if (memory && memory.usedJSHeapSize) { + return memory.usedJSHeapSize / 1024 / 1024; // 转换为MB + } + } + return 0; +} + +/** + * 生成诊断报告 + */ +export function generateDiagnosticReport( + referenceCount: number, + samplerStats: any, + isDataFresh: boolean, +): IDiagnosticReport { + const memoryUsage = getMemoryUsage(); + const recommendations: string[] = []; + + // 分析引用计数 + if (referenceCount === 0) { + recommendations.push("✅ 没有组件在使用流量数据,数据收集已暂停"); + } else if (referenceCount > 3) { + recommendations.push("⚠️ 有较多组件在使用流量数据,考虑优化组件数量"); + } + + // 分析内存使用 + const totalPoints = samplerStats.totalMemoryPoints || 0; + if (totalPoints > 1000) { + recommendations.push("⚠️ 缓存的数据点过多,可能影响性能"); + } else if (totalPoints < 100) { + recommendations.push("ℹ️ 数据点较少,这是正常情况"); + } + + // 分析压缩效率 + const compressionRatio = + samplerStats.rawBufferSize > 0 + ? samplerStats.compressedBufferSize / samplerStats.rawBufferSize + : 0; + if (compressionRatio > 0.5) { + recommendations.push("⚠️ 数据压缩效率较低,可能需要调整压缩策略"); + } else if (compressionRatio > 0) { + recommendations.push("✅ 数据压缩效率良好"); + } + + // 分析数据新鲜度 + if (!isDataFresh) { + recommendations.push("⚠️ 数据不新鲜,可能存在网络问题或后端异常"); + } + + // 分析错误频率 + if (globalErrorCount > 10) { + recommendations.push("🚨 错误频率过高,建议检查网络连接和后端服务"); + } else if (globalErrorCount > 0) { + recommendations.push("ℹ️ 存在少量错误,这在网络波动时是正常的"); + } + + // 内存使用建议 + if (memoryUsage > 100) { + recommendations.push("⚠️ JavaScript堆内存使用较高,可能影响性能"); + } + + return { + timestamp: new Date().toISOString(), + referenceCount, + samplerStats, + performance: { + memoryUsage, + lastDataFreshness: isDataFresh, + errorCount: globalErrorCount, + }, + recommendations, + }; +} + +/** + * 格式化诊断报告为可读字符串 + */ +export function formatDiagnosticReport(report: IDiagnosticReport): string { + return ` +🔍 流量统计系统诊断报告 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +📊 基本信息: + • 生成时间: ${report.timestamp} + • 活跃引用: ${report.referenceCount} 个组件 + • 数据新鲜度: ${report.performance.lastDataFreshness ? "✅ 新鲜" : "❌ 过期"} + +💾 数据缓存状态: + • 原始数据点: ${report.samplerStats.rawBufferSize} + • 压缩数据点: ${report.samplerStats.compressedBufferSize} + • 压缩队列: ${report.samplerStats.compressionQueueSize} + • 总内存点数: ${report.samplerStats.totalMemoryPoints} + +⚡ 性能指标: + • JS堆内存: ${report.performance.memoryUsage.toFixed(2)} MB + • 累计错误: ${report.performance.errorCount} 次 + +💡 优化建议: +${report.recommendations.map((rec) => ` ${rec}`).join("\n")} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + `.trim(); +} + +/** + * 自动诊断并打印报告 + */ +export function runTrafficDiagnostics( + referenceCount: number, + samplerStats: any, + isDataFresh: boolean, +): void { + const report = generateDiagnosticReport( + referenceCount, + samplerStats, + isDataFresh, + ); + console.log(formatDiagnosticReport(report)); +} + +/** + * 重置错误计数器 + */ +export function resetErrorCount(): void { + globalErrorCount = 0; + console.log("[TrafficDiagnostics] 错误计数器已重置"); +} + +// 导出到全局对象,方便在控制台调试 +if (typeof window !== "undefined") { + (window as any).trafficDiagnostics = { + generateDiagnosticReport, + formatDiagnosticReport, + runTrafficDiagnostics, + resetErrorCount, + recordTrafficError, + }; +}