Add delay test feature for proxy nodes
Test latency for a single proxy (t) or all proxies in a group (T) concurrently. Shows animated spinner during testing and color-coded delay results (green < 200ms, yellow < 500ms, red >= 500ms/timeout). Test URL and timeout are configurable via TOML config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4d79ba5884
commit
75c644555b
7
Cargo.lock
generated
7
Cargo.lock
generated
@ -473,6 +473,7 @@ dependencies = [
|
||||
"smol",
|
||||
"surf",
|
||||
"toml",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2429,6 +2430,12 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
||||
@ -15,3 +15,4 @@ anyhow = "1"
|
||||
dirs = "6"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
crossterm = "0.29"
|
||||
urlencoding = "2"
|
||||
|
||||
@ -5,6 +5,7 @@ A fullscreen terminal UI for controlling the [Clash](https://github.com/Dreamacr
|
||||
## Features
|
||||
|
||||
- **Proxy selector** -- browse proxy groups in a two-pane layout, switch active proxies
|
||||
- **Delay testing** -- test latency for a single proxy or all proxies in a group (concurrent)
|
||||
- **Connections viewer** -- view active connections in a sortable table, close individual or all connections
|
||||
- **Configurable keybindings** -- customize all shortcuts via TOML config
|
||||
- **Periodic auto-refresh** -- configurable refresh interval (default 2s)
|
||||
@ -18,6 +19,8 @@ Config file: `~/.config/clash_tui/config.toml`
|
||||
clash_api_url = "http://127.0.0.1:9090"
|
||||
clash_api_secret = "your-secret" # optional
|
||||
refresh_interval = 2 # seconds
|
||||
test_delay_url = "http://www.gstatic.com/generate_204"
|
||||
test_delay_timeout = 5000 # milliseconds
|
||||
|
||||
[keybindings]
|
||||
quit = "q"
|
||||
@ -31,6 +34,8 @@ select = "Enter"
|
||||
close_connection = "d"
|
||||
close_all_connections = "shift+d"
|
||||
refresh = "r"
|
||||
test_delay = "t"
|
||||
test_delay_all = "shift+t"
|
||||
```
|
||||
|
||||
All keybindings and settings are optional and use the defaults shown above.
|
||||
@ -59,6 +64,8 @@ CLI arguments override config file values.
|
||||
| `Left` | Switch to groups pane |
|
||||
| `Right` | Switch to members pane|
|
||||
| `Enter` | Select proxy |
|
||||
| `t` | Test delay (selected) |
|
||||
| `T` | Test delay (all) |
|
||||
| `d` | Close connection |
|
||||
| `D` | Close all connections |
|
||||
| `r` | Force refresh |
|
||||
|
||||
22
src/clash.rs
22
src/clash.rs
@ -168,6 +168,28 @@ impl ClashClient {
|
||||
.map_err(|e| anyhow!("failed to close all connections: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn test_delay(&self, name: &str, timeout: u64, url: &str) -> Result<u64> {
|
||||
let request_url = format!(
|
||||
"{}/proxies/{}/delay?timeout={}&url={}",
|
||||
self.base_url,
|
||||
urlencoding::encode(name),
|
||||
timeout,
|
||||
urlencoding::encode(url),
|
||||
);
|
||||
let mut req = surf::get(&request_url);
|
||||
if let Some(ref auth) = self.auth_header() {
|
||||
req = req.header("Authorization", auth.as_str());
|
||||
}
|
||||
let resp: serde_json::Value = req
|
||||
.recv_json()
|
||||
.await
|
||||
.map_err(|e| anyhow!("delay test failed for {}: {}", name, e))?;
|
||||
let delay = resp["delay"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow!("unexpected delay response for {}", name))?;
|
||||
Ok(delay)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_bytes(bytes: u64) -> String {
|
||||
|
||||
@ -8,6 +8,8 @@ pub struct AppConfig {
|
||||
pub clash_api_url: String,
|
||||
pub clash_api_secret: Option<String>,
|
||||
pub refresh_interval: u64,
|
||||
pub test_delay_url: String,
|
||||
pub test_delay_timeout: u64,
|
||||
pub keybindings: KeybindingsConfig,
|
||||
}
|
||||
|
||||
@ -17,6 +19,8 @@ impl Default for AppConfig {
|
||||
clash_api_url: "http://127.0.0.1:9090".to_string(),
|
||||
clash_api_secret: None,
|
||||
refresh_interval: 2,
|
||||
test_delay_url: "http://www.gstatic.com/generate_204".to_string(),
|
||||
test_delay_timeout: 5000,
|
||||
keybindings: KeybindingsConfig::default(),
|
||||
}
|
||||
}
|
||||
@ -36,6 +40,8 @@ pub struct KeybindingsConfig {
|
||||
pub close_connection: String,
|
||||
pub close_all_connections: String,
|
||||
pub refresh: String,
|
||||
pub test_delay: String,
|
||||
pub test_delay_all: String,
|
||||
}
|
||||
|
||||
impl Default for KeybindingsConfig {
|
||||
@ -52,6 +58,8 @@ impl Default for KeybindingsConfig {
|
||||
close_connection: "d".to_string(),
|
||||
close_all_connections: "shift+d".to_string(),
|
||||
refresh: "r".to_string(),
|
||||
test_delay: "t".to_string(),
|
||||
test_delay_all: "shift+t".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,6 +87,8 @@ pub struct Keybindings {
|
||||
pub close_connection: KeyBinding,
|
||||
pub close_all_connections: KeyBinding,
|
||||
pub refresh: KeyBinding,
|
||||
pub test_delay: KeyBinding,
|
||||
pub test_delay_all: KeyBinding,
|
||||
}
|
||||
|
||||
impl Keybindings {
|
||||
@ -103,6 +105,8 @@ impl Keybindings {
|
||||
close_connection: KeyBinding::parse(&cfg.close_connection)?,
|
||||
close_all_connections: KeyBinding::parse(&cfg.close_all_connections)?,
|
||||
refresh: KeyBinding::parse(&cfg.refresh)?,
|
||||
test_delay: KeyBinding::parse(&cfg.test_delay)?,
|
||||
test_delay_all: KeyBinding::parse(&cfg.test_delay_all)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,6 +27,8 @@ pub struct AppContext {
|
||||
pub client: clash::ClashClient,
|
||||
pub keybindings: keybindings::Keybindings,
|
||||
pub refresh_interval: u64,
|
||||
pub test_delay_url: String,
|
||||
pub test_delay_timeout: u64,
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
@ -44,6 +46,8 @@ fn main() -> anyhow::Result<()> {
|
||||
client,
|
||||
keybindings,
|
||||
refresh_interval: config.refresh_interval,
|
||||
test_delay_url: config.test_delay_url,
|
||||
test_delay_timeout: config.test_delay_timeout,
|
||||
};
|
||||
|
||||
smol::block_on(
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use iocraft::prelude::*;
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::clash::ProxyItem;
|
||||
@ -16,6 +17,8 @@ enum FocusPane {
|
||||
Members,
|
||||
}
|
||||
|
||||
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
#[component]
|
||||
pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
||||
let ctx = hooks.use_context::<crate::AppContext>();
|
||||
@ -25,11 +28,15 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
||||
let mut selected_group = hooks.use_state(|| 0usize);
|
||||
let mut selected_member = hooks.use_state(|| 0usize);
|
||||
let mut focus_pane = hooks.use_state(|| FocusPane::Groups);
|
||||
let testing = hooks.use_state(HashSet::<String>::new);
|
||||
let mut spinner_frame = hooks.use_state(|| 0usize);
|
||||
|
||||
// Clone everything from context before any closures that need Send
|
||||
let client = ctx.client.clone();
|
||||
let kb = ctx.keybindings.clone();
|
||||
let refresh_interval = ctx.refresh_interval;
|
||||
let test_delay_url = ctx.test_delay_url.clone();
|
||||
let test_delay_timeout = ctx.test_delay_timeout;
|
||||
|
||||
let load = hooks.use_async_handler(move |_: ()| {
|
||||
let client = client.clone();
|
||||
@ -64,8 +71,23 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
||||
}
|
||||
});
|
||||
|
||||
// Spinner animation when testing
|
||||
let testing_snapshot = testing;
|
||||
hooks.use_future(async move {
|
||||
loop {
|
||||
smol::Timer::after(Duration::from_millis(80)).await;
|
||||
let current = testing_snapshot.read();
|
||||
if !current.is_empty() {
|
||||
spinner_frame.set((spinner_frame.get() + 1) % SPINNER_FRAMES.len());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let client_for_select = ctx.client.clone();
|
||||
let load_select = load.clone();
|
||||
let client_for_delay = ctx.client.clone();
|
||||
let load_after_delay = load.clone();
|
||||
let testing_state = testing;
|
||||
hooks.use_terminal_events(move |event| {
|
||||
if let TerminalEvent::Key(key_event) = event {
|
||||
let state_read = state.read();
|
||||
@ -82,6 +104,66 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Test delay: single member
|
||||
if focus_pane.get() == FocusPane::Members
|
||||
&& kb.test_delay.matches(&key_event)
|
||||
&& current_members > 0
|
||||
{
|
||||
let members = groups[current_group].all.as_deref().unwrap_or(&[]);
|
||||
let member_name = members[selected_member.get().min(current_members - 1)].clone();
|
||||
let client = client_for_delay.clone();
|
||||
let load = load_after_delay.clone();
|
||||
let mut ts = testing_state;
|
||||
let url = test_delay_url.clone();
|
||||
let timeout = test_delay_timeout;
|
||||
ts.write().insert(member_name.clone());
|
||||
smol::spawn(async move {
|
||||
let _ = client.test_delay(&member_name, timeout, &url).await;
|
||||
smol::Timer::after(Duration::from_millis(300)).await;
|
||||
ts.write().remove(&member_name);
|
||||
load(());
|
||||
})
|
||||
.detach();
|
||||
return;
|
||||
}
|
||||
|
||||
// Test delay: all members in current group
|
||||
if focus_pane.get() == FocusPane::Members
|
||||
&& kb.test_delay_all.matches(&key_event)
|
||||
&& current_members > 0
|
||||
{
|
||||
let members = groups[current_group].all.as_deref().unwrap_or(&[]);
|
||||
let client = client_for_delay.clone();
|
||||
let load = load_after_delay.clone();
|
||||
let mut ts = testing_state;
|
||||
let url = test_delay_url.clone();
|
||||
let timeout = test_delay_timeout;
|
||||
for name in members.iter() {
|
||||
ts.write().insert(name.clone());
|
||||
}
|
||||
let names: Vec<String> = members.to_vec();
|
||||
smol::spawn(async move {
|
||||
// Test all concurrently
|
||||
let mut tasks = Vec::new();
|
||||
for name in &names {
|
||||
let c = client.clone();
|
||||
let n = name.clone();
|
||||
let u = url.clone();
|
||||
let mut ts2 = ts;
|
||||
tasks.push(smol::spawn(async move {
|
||||
let _ = c.test_delay(&n, timeout, &u).await;
|
||||
ts2.write().remove(&n);
|
||||
}));
|
||||
}
|
||||
for t in tasks {
|
||||
let _ = t.await;
|
||||
}
|
||||
load(());
|
||||
})
|
||||
.detach();
|
||||
return;
|
||||
}
|
||||
|
||||
match focus_pane.get() {
|
||||
FocusPane::Groups => {
|
||||
if kb.up.matches(&key_event) {
|
||||
@ -138,6 +220,9 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
||||
let left_width = if width > 20 { width * 3 / 10 } else { 10 };
|
||||
let right_width = width.saturating_sub(left_width);
|
||||
|
||||
let testing_read = testing.read();
|
||||
let frame = spinner_frame.get();
|
||||
|
||||
let state_read = state.read();
|
||||
match &*state_read {
|
||||
DataState::Init | DataState::Loading => {
|
||||
@ -257,18 +342,21 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
||||
};
|
||||
let marker = if is_focused { ">" } else { " " };
|
||||
|
||||
let delay_str = groups
|
||||
let is_testing = testing_read.contains(member.as_str());
|
||||
|
||||
let (delay_text, delay_color) = if is_testing {
|
||||
(format!("{} testing", SPINNER_FRAMES[frame]), Color::Yellow)
|
||||
} else {
|
||||
let delay = groups
|
||||
.iter()
|
||||
.find(|g| g.name == *member)
|
||||
.and_then(|g| g.latest_delay())
|
||||
.map(|d| {
|
||||
if d == 0 {
|
||||
"timeout".to_string()
|
||||
} else {
|
||||
format!("{}ms", d)
|
||||
.and_then(|g| g.latest_delay());
|
||||
match delay {
|
||||
Some(0) => ("timeout".to_string(), Color::Red),
|
||||
Some(d) => (format!("{}ms", d), delay_color_for(d)),
|
||||
None => (String::new(), Color::DarkGrey),
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
};
|
||||
|
||||
element! {
|
||||
View(
|
||||
@ -277,7 +365,7 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
||||
padding_left: 1,
|
||||
) {
|
||||
Text(content: format!("{}{} ", marker, member), color: if is_current { Color::Green } else if is_selected { Color::White } else { Color::Grey })
|
||||
Text(content: format!("({})", delay_str), color: Color::DarkGrey)
|
||||
Text(content: format!("({})", delay_text), color: delay_color)
|
||||
#(if is_current {
|
||||
element! { Text(content: " <--", color: Color::Green) }.into_any()
|
||||
} else {
|
||||
@ -296,7 +384,7 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
||||
border_color: Color::DarkGrey,
|
||||
padding_left: 1,
|
||||
) {
|
||||
Text(content: "[Enter] Select [Up/Down] Navigate [Left/Right] Switch Pane", color: Color::DarkGrey)
|
||||
Text(content: "[Enter] Select [t] Test [T] Test All [Up/Down] Navigate", color: Color::DarkGrey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -304,3 +392,15 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn delay_color_for(delay: u64) -> Color {
|
||||
if delay == 0 {
|
||||
Color::Red
|
||||
} else if delay < 200 {
|
||||
Color::Green
|
||||
} else if delay < 500 {
|
||||
Color::Yellow
|
||||
} else {
|
||||
Color::Red
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user