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",
|
"smol",
|
||||||
"surf",
|
"surf",
|
||||||
"toml",
|
"toml",
|
||||||
|
"urlencoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2429,6 +2430,12 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urlencoding"
|
||||||
|
version = "2.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|||||||
@ -15,3 +15,4 @@ anyhow = "1"
|
|||||||
dirs = "6"
|
dirs = "6"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
crossterm = "0.29"
|
crossterm = "0.29"
|
||||||
|
urlencoding = "2"
|
||||||
|
|||||||
@ -5,6 +5,7 @@ A fullscreen terminal UI for controlling the [Clash](https://github.com/Dreamacr
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Proxy selector** -- browse proxy groups in a two-pane layout, switch active proxies
|
- **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
|
- **Connections viewer** -- view active connections in a sortable table, close individual or all connections
|
||||||
- **Configurable keybindings** -- customize all shortcuts via TOML config
|
- **Configurable keybindings** -- customize all shortcuts via TOML config
|
||||||
- **Periodic auto-refresh** -- configurable refresh interval (default 2s)
|
- **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_url = "http://127.0.0.1:9090"
|
||||||
clash_api_secret = "your-secret" # optional
|
clash_api_secret = "your-secret" # optional
|
||||||
refresh_interval = 2 # seconds
|
refresh_interval = 2 # seconds
|
||||||
|
test_delay_url = "http://www.gstatic.com/generate_204"
|
||||||
|
test_delay_timeout = 5000 # milliseconds
|
||||||
|
|
||||||
[keybindings]
|
[keybindings]
|
||||||
quit = "q"
|
quit = "q"
|
||||||
@ -31,6 +34,8 @@ select = "Enter"
|
|||||||
close_connection = "d"
|
close_connection = "d"
|
||||||
close_all_connections = "shift+d"
|
close_all_connections = "shift+d"
|
||||||
refresh = "r"
|
refresh = "r"
|
||||||
|
test_delay = "t"
|
||||||
|
test_delay_all = "shift+t"
|
||||||
```
|
```
|
||||||
|
|
||||||
All keybindings and settings are optional and use the defaults shown above.
|
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 |
|
| `Left` | Switch to groups pane |
|
||||||
| `Right` | Switch to members pane|
|
| `Right` | Switch to members pane|
|
||||||
| `Enter` | Select proxy |
|
| `Enter` | Select proxy |
|
||||||
|
| `t` | Test delay (selected) |
|
||||||
|
| `T` | Test delay (all) |
|
||||||
| `d` | Close connection |
|
| `d` | Close connection |
|
||||||
| `D` | Close all connections |
|
| `D` | Close all connections |
|
||||||
| `r` | Force refresh |
|
| `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))?;
|
.map_err(|e| anyhow!("failed to close all connections: {}", e))?;
|
||||||
Ok(())
|
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 {
|
pub fn format_bytes(bytes: u64) -> String {
|
||||||
|
|||||||
@ -8,6 +8,8 @@ pub struct AppConfig {
|
|||||||
pub clash_api_url: String,
|
pub clash_api_url: String,
|
||||||
pub clash_api_secret: Option<String>,
|
pub clash_api_secret: Option<String>,
|
||||||
pub refresh_interval: u64,
|
pub refresh_interval: u64,
|
||||||
|
pub test_delay_url: String,
|
||||||
|
pub test_delay_timeout: u64,
|
||||||
pub keybindings: KeybindingsConfig,
|
pub keybindings: KeybindingsConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,6 +19,8 @@ impl Default for AppConfig {
|
|||||||
clash_api_url: "http://127.0.0.1:9090".to_string(),
|
clash_api_url: "http://127.0.0.1:9090".to_string(),
|
||||||
clash_api_secret: None,
|
clash_api_secret: None,
|
||||||
refresh_interval: 2,
|
refresh_interval: 2,
|
||||||
|
test_delay_url: "http://www.gstatic.com/generate_204".to_string(),
|
||||||
|
test_delay_timeout: 5000,
|
||||||
keybindings: KeybindingsConfig::default(),
|
keybindings: KeybindingsConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -36,6 +40,8 @@ pub struct KeybindingsConfig {
|
|||||||
pub close_connection: String,
|
pub close_connection: String,
|
||||||
pub close_all_connections: String,
|
pub close_all_connections: String,
|
||||||
pub refresh: String,
|
pub refresh: String,
|
||||||
|
pub test_delay: String,
|
||||||
|
pub test_delay_all: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for KeybindingsConfig {
|
impl Default for KeybindingsConfig {
|
||||||
@ -52,6 +58,8 @@ impl Default for KeybindingsConfig {
|
|||||||
close_connection: "d".to_string(),
|
close_connection: "d".to_string(),
|
||||||
close_all_connections: "shift+d".to_string(),
|
close_all_connections: "shift+d".to_string(),
|
||||||
refresh: "r".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_connection: KeyBinding,
|
||||||
pub close_all_connections: KeyBinding,
|
pub close_all_connections: KeyBinding,
|
||||||
pub refresh: KeyBinding,
|
pub refresh: KeyBinding,
|
||||||
|
pub test_delay: KeyBinding,
|
||||||
|
pub test_delay_all: KeyBinding,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Keybindings {
|
impl Keybindings {
|
||||||
@ -103,6 +105,8 @@ impl Keybindings {
|
|||||||
close_connection: KeyBinding::parse(&cfg.close_connection)?,
|
close_connection: KeyBinding::parse(&cfg.close_connection)?,
|
||||||
close_all_connections: KeyBinding::parse(&cfg.close_all_connections)?,
|
close_all_connections: KeyBinding::parse(&cfg.close_all_connections)?,
|
||||||
refresh: KeyBinding::parse(&cfg.refresh)?,
|
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 client: clash::ClashClient,
|
||||||
pub keybindings: keybindings::Keybindings,
|
pub keybindings: keybindings::Keybindings,
|
||||||
pub refresh_interval: u64,
|
pub refresh_interval: u64,
|
||||||
|
pub test_delay_url: String,
|
||||||
|
pub test_delay_timeout: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
@ -44,6 +46,8 @@ fn main() -> anyhow::Result<()> {
|
|||||||
client,
|
client,
|
||||||
keybindings,
|
keybindings,
|
||||||
refresh_interval: config.refresh_interval,
|
refresh_interval: config.refresh_interval,
|
||||||
|
test_delay_url: config.test_delay_url,
|
||||||
|
test_delay_timeout: config.test_delay_timeout,
|
||||||
};
|
};
|
||||||
|
|
||||||
smol::block_on(
|
smol::block_on(
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
use iocraft::prelude::*;
|
use iocraft::prelude::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::clash::ProxyItem;
|
use crate::clash::ProxyItem;
|
||||||
@ -16,6 +17,8 @@ enum FocusPane {
|
|||||||
Members,
|
Members,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
||||||
let ctx = hooks.use_context::<crate::AppContext>();
|
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_group = hooks.use_state(|| 0usize);
|
||||||
let mut selected_member = hooks.use_state(|| 0usize);
|
let mut selected_member = hooks.use_state(|| 0usize);
|
||||||
let mut focus_pane = hooks.use_state(|| FocusPane::Groups);
|
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
|
// Clone everything from context before any closures that need Send
|
||||||
let client = ctx.client.clone();
|
let client = ctx.client.clone();
|
||||||
let kb = ctx.keybindings.clone();
|
let kb = ctx.keybindings.clone();
|
||||||
let refresh_interval = ctx.refresh_interval;
|
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 load = hooks.use_async_handler(move |_: ()| {
|
||||||
let client = client.clone();
|
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 client_for_select = ctx.client.clone();
|
||||||
let load_select = load.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| {
|
hooks.use_terminal_events(move |event| {
|
||||||
if let TerminalEvent::Key(key_event) = event {
|
if let TerminalEvent::Key(key_event) = event {
|
||||||
let state_read = state.read();
|
let state_read = state.read();
|
||||||
@ -82,6 +104,66 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
|||||||
.map(|m| m.len())
|
.map(|m| m.len())
|
||||||
.unwrap_or(0);
|
.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() {
|
match focus_pane.get() {
|
||||||
FocusPane::Groups => {
|
FocusPane::Groups => {
|
||||||
if kb.up.matches(&key_event) {
|
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 left_width = if width > 20 { width * 3 / 10 } else { 10 };
|
||||||
let right_width = width.saturating_sub(left_width);
|
let right_width = width.saturating_sub(left_width);
|
||||||
|
|
||||||
|
let testing_read = testing.read();
|
||||||
|
let frame = spinner_frame.get();
|
||||||
|
|
||||||
let state_read = state.read();
|
let state_read = state.read();
|
||||||
match &*state_read {
|
match &*state_read {
|
||||||
DataState::Init | DataState::Loading => {
|
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 marker = if is_focused { ">" } else { " " };
|
||||||
|
|
||||||
let delay_str = groups
|
let is_testing = testing_read.contains(member.as_str());
|
||||||
.iter()
|
|
||||||
.find(|g| g.name == *member)
|
let (delay_text, delay_color) = if is_testing {
|
||||||
.and_then(|g| g.latest_delay())
|
(format!("{} testing", SPINNER_FRAMES[frame]), Color::Yellow)
|
||||||
.map(|d| {
|
} else {
|
||||||
if d == 0 {
|
let delay = groups
|
||||||
"timeout".to_string()
|
.iter()
|
||||||
} else {
|
.find(|g| g.name == *member)
|
||||||
format!("{}ms", d)
|
.and_then(|g| g.latest_delay());
|
||||||
}
|
match delay {
|
||||||
})
|
Some(0) => ("timeout".to_string(), Color::Red),
|
||||||
.unwrap_or_default();
|
Some(d) => (format!("{}ms", d), delay_color_for(d)),
|
||||||
|
None => (String::new(), Color::DarkGrey),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
element! {
|
element! {
|
||||||
View(
|
View(
|
||||||
@ -277,7 +365,7 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
|||||||
padding_left: 1,
|
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!("{}{} ", 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 {
|
#(if is_current {
|
||||||
element! { Text(content: " <--", color: Color::Green) }.into_any()
|
element! { Text(content: " <--", color: Color::Green) }.into_any()
|
||||||
} else {
|
} else {
|
||||||
@ -296,7 +384,7 @@ pub fn ProxyView(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
|
|||||||
border_color: Color::DarkGrey,
|
border_color: Color::DarkGrey,
|
||||||
padding_left: 1,
|
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