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:
yly 2026-05-10 15:30:55 +08:00
parent 4d79ba5884
commit 75c644555b
8 changed files with 167 additions and 14 deletions

7
Cargo.lock generated
View File

@ -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"

View File

@ -15,3 +15,4 @@ anyhow = "1"
dirs = "6"
chrono = { version = "0.4", features = ["serde"] }
crossterm = "0.29"
urlencoding = "2"

View File

@ -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 |

View File

@ -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 {

View File

@ -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(),
}
}
}

View File

@ -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)?,
})
}
}

View File

@ -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(

View File

@ -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
}
}