Compare commits

...

10 Commits

Author SHA1 Message Date
YoVinchen
d11a12a746 Merge branch 'main' into feature/error-request-logging 2025-12-16 18:09:19 +08:00
YoVinchen
e76ee66d13 chore(clippy): fix uninlined format args 2025-12-16 18:02:08 +08:00
YoVinchen
a540bf92ca fix(speedtest): skip client build for invalid inputs 2025-12-16 17:50:41 +08:00
YoVinchen
24391fc431 Merge remote-tracking branch 'origin/main' into feature/error-request-logging
# Conflicts:
#	src-tauri/src/proxy/handlers.rs
#	src-tauri/src/proxy/mod.rs
#	src-tauri/src/proxy/provider_router.rs
#	src-tauri/src/services/proxy.rs
#	src/components/providers/ProviderActions.tsx
2025-12-16 17:40:49 +08:00
YoVinchen
2e6ba77187 feat(proxy): add settings button to proxy panel
Add configuration buttons in both running and stopped states to
provide easy access to proxy settings dialog.
2025-12-15 00:16:12 +08:00
YoVinchen
53ccd5f70d style: apply code formatting
- Remove trailing whitespace in misc.rs
- Add trailing comma in App.tsx
- Format multi-line className in ProviderCard.tsx
2025-12-14 16:16:56 +08:00
YoVinchen
2af8dd2dac style: fix clippy warnings and typescript errors
- Add allow(dead_code) for CircuitBreaker::get_state (reserved for future)
- Fix all uninlined format string warnings (27 instances)
- Use inline format syntax for better readability
- Fix unused import and parameter warnings in ProviderActions.tsx
- Achieve zero warnings in both Rust and TypeScript
2025-12-14 16:05:38 +08:00
YoVinchen
4cf4654863 feat(proxy): implement error capture and logging in all handlers
- Capture and log all failed requests in handle_messages (Claude)
- Capture and log all failed requests in handle_gemini (Gemini)
- Capture and log all failed requests in handle_responses (Codex)
- Capture and log all failed requests in handle_chat_completions (Codex)
- Record error status codes, messages, and latency for all failures
- Generate unique session_id for each request
- Support both streaming and non-streaming error scenarios
2025-12-14 16:03:28 +08:00
YoVinchen
8b202ea988 feat(proxy): enhance error logging with context support
- Add log_error_with_context() method for detailed error recording
- Support streaming flag, session_id, and provider_type fields
- Remove dead_code warning from log_error() method
- Enable comprehensive error request tracking in database
2025-12-14 16:03:02 +08:00
YoVinchen
14bc8a00e5 feat(proxy): add error mapper for HTTP status code mapping
- Add error_mapper.rs module to map ProxyError to HTTP status codes
- Implement map_proxy_error_to_status() for error classification
- Implement get_error_message() for user-friendly error messages
- Support all error types: upstream, timeout, connection, provider failures
- Include comprehensive unit tests for all mappings
2025-12-14 16:02:04 +08:00
13 changed files with 327 additions and 106 deletions

View File

@@ -69,11 +69,7 @@ impl Database {
} }
/// 添加供应商到故障转移队列末尾 /// 添加供应商到故障转移队列末尾
pub fn add_to_failover_queue( pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
&self,
app_type: &str,
provider_id: &str,
) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
// 获取当前最大 queue_order // 获取当前最大 queue_order
@@ -199,11 +195,8 @@ impl Database {
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> { pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.execute( conn.execute("DELETE FROM failover_queue WHERE app_type = ?1", [app_type])
"DELETE FROM failover_queue WHERE app_type = ?1", .map_err(|e| AppError::Database(e.to_string()))?;
[app_type],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(()) Ok(())
} }

View File

@@ -333,9 +333,7 @@ pub fn run() {
let app_state = AppState::new(db); let app_state = AppState::new(db);
// 设置 AppHandle 用于代理故障转移时的 UI 更新 // 设置 AppHandle 用于代理故障转移时的 UI 更新
app_state app_state.proxy_service.set_app_handle(app.handle().clone());
.proxy_service
.set_app_handle(app.handle().clone());
// ============================================================ // ============================================================
// 按表独立判断的导入逻辑(各类数据独立检查,互不影响) // 按表独立判断的导入逻辑(各类数据独立检查,互不影响)

View File

@@ -169,8 +169,7 @@ impl CircuitBreaker {
// 超过限额,回退计数,拒绝请求 // 超过限额,回退计数,拒绝请求
self.half_open_requests.fetch_sub(1, Ordering::SeqCst); self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
log::debug!( log::debug!(
"Circuit breaker HalfOpen: rejecting request (limit reached: {})", "Circuit breaker HalfOpen: rejecting request (limit reached: {max_half_open_requests})"
max_half_open_requests
); );
false false
} }
@@ -239,9 +238,7 @@ impl CircuitBreaker {
self.half_open_requests.fetch_sub(1, Ordering::SeqCst); self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
// HalfOpen 状态下失败,立即转为 Open // HalfOpen 状态下失败,立即转为 Open
log::warn!( log::warn!("Circuit breaker HalfOpen probe failed, transitioning to Open");
"Circuit breaker HalfOpen probe failed, transitioning to Open"
);
drop(config); drop(config);
self.transition_to_open().await; self.transition_to_open().await;
} }
@@ -286,6 +283,7 @@ impl CircuitBreaker {
} }
/// 获取当前状态 /// 获取当前状态
#[allow(dead_code)]
pub async fn get_state(&self) -> CircuitState { pub async fn get_state(&self) -> CircuitState {
*self.state.read().await *self.state.read().await
} }

View File

@@ -0,0 +1,110 @@
//! 错误类型到 HTTP 状态码的映射
//!
//! 将 ProxyError 映射到合适的 HTTP 状态码,用于日志记录
use super::ProxyError;
/// 将 ProxyError 映射到 HTTP 状态码
///
/// 映射规则:
/// - 上游错误:直接使用上游返回的状态码
/// - 超时504 Gateway Timeout
/// - 连接失败502 Bad Gateway
/// - 无可用 Provider503 Service Unavailable
/// - 重试耗尽503 Service Unavailable
/// - 其他错误500 Internal Server Error
pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
match error {
// 上游错误:使用实际状态码
ProxyError::UpstreamError { status, .. } => *status,
// 超时错误504 Gateway Timeout
ProxyError::Timeout(_) => 504,
// 转发失败/连接失败502 Bad Gateway
ProxyError::ForwardFailed(_) => 502,
// 无可用 Provider503 Service Unavailable
ProxyError::NoAvailableProvider => 503,
// 重试耗尽503 Service Unavailable
ProxyError::MaxRetriesExceeded => 503,
// Provider 不健康503 Service Unavailable
ProxyError::ProviderUnhealthy(_) => 503,
// 数据库错误500 Internal Server Error
ProxyError::DatabaseError(_) => 500,
// 转换错误500 Internal Server Error
ProxyError::TransformError(_) => 500,
// 其他未知错误500 Internal Server Error
_ => 500,
}
}
/// 将 ProxyError 转换为用户友好的错误消息
pub fn get_error_message(error: &ProxyError) -> String {
match error {
ProxyError::UpstreamError { status, body } => {
if let Some(body) = body {
format!("上游错误 ({status}): {body}")
} else {
format!("上游错误 ({status})")
}
}
ProxyError::Timeout(msg) => format!("请求超时: {msg}"),
ProxyError::ForwardFailed(msg) => format!("转发失败: {msg}"),
ProxyError::NoAvailableProvider => "无可用 Provider".to_string(),
ProxyError::MaxRetriesExceeded => "所有 Provider 都失败,重试耗尽".to_string(),
ProxyError::ProviderUnhealthy(msg) => format!("Provider 不健康: {msg}"),
ProxyError::DatabaseError(msg) => format!("数据库错误: {msg}"),
ProxyError::TransformError(msg) => format!("请求/响应转换错误: {msg}"),
_ => error.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_map_upstream_error() {
let error = ProxyError::UpstreamError {
status: 401,
body: Some("Unauthorized".to_string()),
};
assert_eq!(map_proxy_error_to_status(&error), 401);
}
#[test]
fn test_map_timeout_error() {
let error = ProxyError::Timeout("Request timeout".to_string());
assert_eq!(map_proxy_error_to_status(&error), 504);
}
#[test]
fn test_map_connection_error() {
let error = ProxyError::ForwardFailed("Connection refused".to_string());
assert_eq!(map_proxy_error_to_status(&error), 502);
}
#[test]
fn test_map_no_provider_error() {
let error = ProxyError::NoAvailableProvider;
assert_eq!(map_proxy_error_to_status(&error), 503);
}
#[test]
fn test_get_error_message() {
let error = ProxyError::UpstreamError {
status: 500,
body: Some("Internal Server Error".to_string()),
};
let msg = get_error_message(&error);
assert!(msg.contains("上游错误"));
assert!(msg.contains("500"));
assert!(msg.contains("Internal Server Error"));
}
}

View File

@@ -48,17 +48,13 @@ impl FailoverSwitchManager {
provider_id: &str, provider_id: &str,
provider_name: &str, provider_name: &str,
) -> Result<bool, AppError> { ) -> Result<bool, AppError> {
let switch_key = format!("{}:{}", app_type, provider_id); let switch_key = format!("{app_type}:{provider_id}");
// 去重检查:如果相同切换已在进行中,跳过 // 去重检查:如果相同切换已在进行中,跳过
{ {
let mut pending = self.pending_switches.write().await; let mut pending = self.pending_switches.write().await;
if pending.contains(&switch_key) { if pending.contains(&switch_key) {
log::debug!( log::debug!("[Failover] 切换已在进行中,跳过: {app_type} -> {provider_id}");
"[Failover] 切换已在进行中,跳过: {} -> {}",
app_type,
provider_id
);
return Ok(false); return Ok(false);
} }
pending.insert(switch_key.clone()); pending.insert(switch_key.clone());
@@ -85,19 +81,14 @@ impl FailoverSwitchManager {
provider_id: &str, provider_id: &str,
provider_name: &str, provider_name: &str,
) -> Result<bool, AppError> { ) -> Result<bool, AppError> {
log::info!( log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
"[Failover] 开始切换供应商: {} -> {} ({})",
app_type,
provider_name,
provider_id
);
// 1. 更新数据库 is_current // 1. 更新数据库 is_current
self.db.set_current_provider(app_type, provider_id)?; self.db.set_current_provider(app_type, provider_id)?;
// 2. 更新本地 settings设备级 // 2. 更新本地 settings设备级
let app_type_enum = crate::app_config::AppType::from_str(app_type) let app_type_enum = crate::app_config::AppType::from_str(app_type)
.map_err(|_| AppError::Message(format!("无效的应用类型: {}", app_type)))?; .map_err(|_| AppError::Message(format!("无效的应用类型: {app_type}")))?;
crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?; crate::settings::set_current_provider(&app_type_enum, Some(provider_id))?;
// 3. 更新托盘菜单和发射事件 // 3. 更新托盘菜单和发射事件
@@ -136,12 +127,7 @@ impl FailoverSwitchManager {
} }
} }
log::info!( log::info!("[Failover] 供应商切换完成: {app_type} -> {provider_name} ({provider_id})");
"[Failover] 供应商切换完成: {} -> {} ({})",
app_type,
provider_name,
provider_id
);
Ok(true) Ok(true)
} }

View File

@@ -330,9 +330,8 @@ impl RequestForwarder {
status.failed_requests += 1; status.failed_requests += 1;
status.last_error = Some("所有供应商暂时不可用(熔断器限制)".to_string()); status.last_error = Some("所有供应商暂时不可用(熔断器限制)".to_string());
if status.total_requests > 0 { if status.total_requests > 0 {
status.success_rate = (status.success_requests as f32 status.success_rate =
/ status.total_requests as f32) (status.success_requests as f32 / status.total_requests as f32) * 100.0;
* 100.0;
} }
} }
return Err(ProxyError::NoAvailableProvider); return Err(ProxyError::NoAvailableProvider);

View File

@@ -8,14 +8,13 @@
//! - Claude 的格式转换逻辑保留在此文件(独有功能) //! - Claude 的格式转换逻辑保留在此文件(独有功能)
use super::{ use super::{
error_mapper::{get_error_message, map_proxy_error_to_status},
handler_config::{ handler_config::{
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG, CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
}, },
handler_context::RequestContext, handler_context::RequestContext,
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform}, providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
response_processor::{ response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
create_logged_passthrough_stream, process_response, SseUsageCollector,
},
server::ProxyState, server::ProxyState,
types::*, types::*,
usage::parser::TokenUsage, usage::parser::TokenUsage,
@@ -62,8 +61,7 @@ pub async fn handle_messages(
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let ctx = let ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
// 检查是否需要格式转换OpenRouter 等中转服务) // 检查是否需要格式转换OpenRouter 等中转服务)
let adapter = get_adapter(&AppType::Claude); let adapter = get_adapter(&AppType::Claude);
@@ -83,7 +81,7 @@ pub async fn handle_messages(
// 转发请求 // 转发请求
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let response = forwarder let response = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Claude, &AppType::Claude,
"/v1/messages", "/v1/messages",
@@ -91,7 +89,14 @@ pub async fn handle_messages(
headers, headers,
ctx.get_providers(), ctx.get_providers(),
) )
.await?; .await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
let status = response.status(); let status = response.status();
log::info!("[Claude] 上游响应状态: {status}"); log::info!("[Claude] 上游响应状态: {status}");
@@ -304,7 +309,7 @@ pub async fn handle_chat_completions(
); );
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let response = forwarder let response = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/v1/chat/completions", "/v1/chat/completions",
@@ -312,7 +317,14 @@ pub async fn handle_chat_completions(
headers, headers,
ctx.get_providers(), ctx.get_providers(),
) )
.await?; .await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
log::info!("[Codex] 上游响应状态: {}", response.status()); log::info!("[Codex] 上游响应状态: {}", response.status());
@@ -327,8 +339,13 @@ pub async fn handle_responses(
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?; let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
let is_stream = body
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let response = forwarder let response = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Codex, &AppType::Codex,
"/v1/responses", "/v1/responses",
@@ -336,7 +353,14 @@ pub async fn handle_responses(
headers, headers,
ctx.get_providers(), ctx.get_providers(),
) )
.await?; .await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
log::info!("[Codex] 上游响应状态: {}", response.status()); log::info!("[Codex] 上游响应状态: {}", response.status());
@@ -365,10 +389,15 @@ pub async fn handle_gemini(
.map(|pq| pq.as_str()) .map(|pq| pq.as_str())
.unwrap_or(uri.path()); .unwrap_or(uri.path());
log::info!("[Gemini] 请求端点: {}", endpoint); log::info!("[Gemini] 请求端点: {endpoint}");
let is_stream = body
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let forwarder = ctx.create_forwarder(&state); let forwarder = ctx.create_forwarder(&state);
let response = forwarder let response = match forwarder
.forward_with_retry( .forward_with_retry(
&AppType::Gemini, &AppType::Gemini,
endpoint, endpoint,
@@ -376,7 +405,14 @@ pub async fn handle_gemini(
headers, headers,
ctx.get_providers(), ctx.get_providers(),
) )
.await?; .await
{
Ok(resp) => resp,
Err(e) => {
log_forward_error(&state, &ctx, is_stream, &e);
return Err(e);
}
};
log::info!("[Gemini] 上游响应状态: {}", response.status()); log::info!("[Gemini] 上游响应状态: {}", response.status());
@@ -387,6 +423,35 @@ pub async fn handle_gemini(
// 使用量记录(保留用于 Claude 转换逻辑) // 使用量记录(保留用于 Claude 转换逻辑)
// ============================================================================ // ============================================================================
fn log_forward_error(
state: &ProxyState,
ctx: &RequestContext,
is_streaming: bool,
error: &ProxyError,
) {
use super::usage::logger::UsageLogger;
let logger = UsageLogger::new(&state.db);
let status_code = map_proxy_error_to_status(error);
let error_message = get_error_message(error);
let request_id = uuid::Uuid::new_v4().to_string();
if let Err(e) = logger.log_error_with_context(
request_id.clone(),
ctx.provider.id.clone(),
ctx.app_type_str.to_string(),
ctx.request_model.clone(),
status_code,
error_message,
ctx.latency_ms(),
is_streaming,
Some(request_id),
None,
) {
log::warn!("记录失败请求日志失败: {e}");
}
}
/// 记录请求使用量 /// 记录请求使用量
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn log_usage( async fn log_usage(

View File

@@ -4,6 +4,7 @@
pub mod circuit_breaker; pub mod circuit_breaker;
pub mod error; pub mod error;
pub mod error_mapper;
pub(crate) mod failover_switch; pub(crate) mod failover_switch;
mod forwarder; mod forwarder;
pub mod handler_config; pub mod handler_config;

View File

@@ -102,8 +102,7 @@ impl ProviderRouter {
if result.is_empty() { if result.is_empty() {
return Err(AppError::Config(format!( return Err(AppError::Config(format!(
"No available provider for {} (all circuit breakers open or no providers configured)", "No available provider for {app_type} (all circuit breakers open or no providers configured)"
app_type
))); )));
} }
@@ -199,7 +198,7 @@ impl ProviderRouter {
breaker.update_config(config.clone()).await; breaker.update_config(config.clone()).await;
} }
log::info!("已更新 {} 个熔断器的配置", count); log::info!("已更新 {count} 个熔断器的配置");
} }
/// 获取熔断器状态 /// 获取熔断器状态

View File

@@ -107,6 +107,8 @@ impl<'a> UsageLogger<'a> {
} }
/// 记录失败的请求 /// 记录失败的请求
///
/// 用于记录无法从上游获取 usage 信息的失败请求
#[allow(dead_code, clippy::too_many_arguments)] #[allow(dead_code, clippy::too_many_arguments)]
pub fn log_error( pub fn log_error(
&self, &self,
@@ -138,6 +140,43 @@ impl<'a> UsageLogger<'a> {
self.log_request(&log) self.log_request(&log)
} }
/// 记录失败的请求(带更多上下文信息)
///
/// 相比 log_error这个方法接受更多参数以提供完整的请求上下文
#[allow(clippy::too_many_arguments)]
pub fn log_error_with_context(
&self,
request_id: String,
provider_id: String,
app_type: String,
model: String,
status_code: u16,
error_message: String,
latency_ms: u64,
is_streaming: bool,
session_id: Option<String>,
provider_type: Option<String>,
) -> Result<(), AppError> {
let log = RequestLog {
request_id,
provider_id,
app_type,
model,
usage: TokenUsage::default(),
cost: None,
latency_ms,
first_token_ms: None,
status_code,
error_message: Some(error_message),
session_id,
provider_type,
is_streaming,
cost_multiplier: "1.0".to_string(),
};
self.log_request(&log)
}
/// 获取模型定价 /// 获取模型定价
pub fn get_model_pricing(&self, model_id: &str) -> Result<Option<ModelPricing>, AppError> { pub fn get_model_pricing(&self, model_id: &str) -> Result<Option<ModelPricing>, AppError> {
let conn = crate::database::lock_conn!(self.db.conn); let conn = crate::database::lock_conn!(self.db.conn);

View File

@@ -142,8 +142,7 @@ impl ProxyService {
log::warn!("同步 Claude Token 到数据库失败: {e}"); log::warn!("同步 Claude Token 到数据库失败: {e}");
} else { } else {
log::info!( log::info!(
"已同步 Claude Token 到数据库 (provider: {})", "已同步 Claude Token 到数据库 (provider: {provider_id})"
provider_id
); );
} }
} }
@@ -182,8 +181,7 @@ impl ProxyService {
log::warn!("同步 Codex Token 到数据库失败: {e}"); log::warn!("同步 Codex Token 到数据库失败: {e}");
} else { } else {
log::info!( log::info!(
"已同步 Codex Token 到数据库 (provider: {})", "已同步 Codex Token 到数据库 (provider: {provider_id})"
provider_id
); );
} }
} }
@@ -222,8 +220,7 @@ impl ProxyService {
log::warn!("同步 Gemini Token 到数据库失败: {e}"); log::warn!("同步 Gemini Token 到数据库失败: {e}");
} else { } else {
log::info!( log::info!(
"已同步 Gemini Token 到数据库 (provider: {})", "已同步 Gemini Token 到数据库 (provider: {provider_id})"
provider_id
); );
} }
} }
@@ -354,7 +351,7 @@ impl ProxyService {
}); });
} }
self.write_claude_live(&live_config)?; self.write_claude_live(&live_config)?;
log::info!("Claude Live 配置已接管,代理地址: {}", proxy_url); log::info!("Claude Live 配置已接管,代理地址: {proxy_url}");
} }
// Codex: 修改 config.toml 的 base_urlauth.json 的 OPENAI_API_KEY代理会注入真实 Token // Codex: 修改 config.toml 的 base_urlauth.json 的 OPENAI_API_KEY代理会注入真实 Token
@@ -373,7 +370,7 @@ impl ProxyService {
live_config["config"] = json!(updated_config); live_config["config"] = json!(updated_config);
self.write_codex_live(&live_config)?; self.write_codex_live(&live_config)?;
log::info!("Codex Live 配置已接管,代理地址: {}", proxy_url); log::info!("Codex Live 配置已接管,代理地址: {proxy_url}");
} }
// Gemini: 修改 GOOGLE_GEMINI_BASE_URL使用占位符替代真实 Token代理会注入真实 Token // Gemini: 修改 GOOGLE_GEMINI_BASE_URL使用占位符替代真实 Token代理会注入真实 Token
@@ -389,7 +386,7 @@ impl ProxyService {
}); });
} }
self.write_gemini_live(&live_config)?; self.write_gemini_live(&live_config)?;
log::info!("Gemini Live 配置已接管,代理地址: {}", proxy_url); log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
} }
Ok(()) Ok(())
@@ -513,11 +510,7 @@ impl ProxyService {
.set_current_provider(app_type_enum.as_str(), provider_id) .set_current_provider(app_type_enum.as_str(), provider_id)
.map_err(|e| format!("更新当前供应商失败: {e}"))?; .map_err(|e| format!("更新当前供应商失败: {e}"))?;
log::info!( log::info!("代理模式:已切换 {app_type} 的目标供应商为 {provider_id}");
"代理模式:已切换 {} 的目标供应商为 {}",
app_type,
provider_id
);
Ok(()) Ok(())
} }
@@ -721,11 +714,7 @@ impl ProxyService {
server server
.reset_provider_circuit_breaker(provider_id, app_type) .reset_provider_circuit_breaker(provider_id, app_type)
.await; .await;
log::info!( log::info!("已重置 Provider {provider_id} (app: {app_type}) 的熔断器");
"已重置 Provider {} (app: {}) 的熔断器",
provider_id,
app_type
);
} }
Ok(()) Ok(())
} }

View File

@@ -31,40 +31,51 @@ impl SpeedtestService {
return Ok(vec![]); return Ok(vec![]);
} }
let mut results: Vec<Option<EndpointLatency>> = vec![None; urls.len()];
let mut valid_targets = Vec::new();
for (idx, raw_url) in urls.into_iter().enumerate() {
let trimmed = raw_url.trim().to_string();
if trimmed.is_empty() {
results[idx] = Some(EndpointLatency {
url: raw_url,
latency: None,
status: None,
error: Some("URL 不能为空".to_string()),
});
continue;
}
match Url::parse(&trimmed) {
Ok(parsed_url) => valid_targets.push((idx, trimmed, parsed_url)),
Err(err) => {
results[idx] = Some(EndpointLatency {
url: trimmed,
latency: None,
status: None,
error: Some(format!("URL 无效: {err}")),
});
}
}
}
if valid_targets.is_empty() {
return Ok(results.into_iter().flatten().collect::<Vec<_>>());
}
let timeout = Self::sanitize_timeout(timeout_secs); let timeout = Self::sanitize_timeout(timeout_secs);
let client = Self::build_client(timeout)?; let client = Self::build_client(timeout)?;
let tasks = urls.into_iter().map(|raw_url| { let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| {
let client = client.clone(); let client = client.clone();
async move { async move {
let trimmed = raw_url.trim().to_string();
if trimmed.is_empty() {
return EndpointLatency {
url: raw_url,
latency: None,
status: None,
error: Some("URL 不能为空".to_string()),
};
}
let parsed_url = match Url::parse(&trimmed) {
Ok(url) => url,
Err(err) => {
return EndpointLatency {
url: trimmed,
latency: None,
status: None,
error: Some(format!("URL 无效: {err}")),
};
}
};
// 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。 // 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。
let _ = client.get(parsed_url.clone()).send().await; let _ = client.get(parsed_url.clone()).send().await;
// 第二次请求开始计时,并将其作为结果返回。 // 第二次请求开始计时,并将其作为结果返回。
let start = Instant::now(); let start = Instant::now();
match client.get(parsed_url).send().await { let latency = match client.get(parsed_url).send().await {
Ok(resp) => EndpointLatency { Ok(resp) => EndpointLatency {
url: trimmed, url: trimmed,
latency: Some(start.elapsed().as_millis()), latency: Some(start.elapsed().as_millis()),
@@ -88,11 +99,17 @@ impl SpeedtestService {
error: Some(error_message), error: Some(error_message),
} }
} }
} };
(idx, latency)
} }
}); });
Ok(join_all(tasks).await) for (idx, latency) in join_all(tasks).await {
results[idx] = Some(latency);
}
Ok(results.into_iter().flatten().collect::<Vec<_>>())
} }
fn build_client(timeout_secs: u64) -> Result<Client, AppError> { fn build_client(timeout_secs: u64) -> Result<Client, AppError> {

View File

@@ -1,5 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { Activity, Clock, TrendingUp, Server, ListOrdered } from "lucide-react"; import {
Activity,
Clock,
TrendingUp,
Server,
ListOrdered,
Settings,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useProxyStatus } from "@/hooks/useProxyStatus";
import { ProxySettingsDialog } from "./ProxySettingsDialog"; import { ProxySettingsDialog } from "./ProxySettingsDialog";
@@ -40,8 +47,19 @@ export function ProxyPanel() {
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4"> <div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
<div> <div>
<p className="text-xs text-muted-foreground"></p> <div className="flex items-center justify-between mb-2">
<div className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center"> <p className="text-xs text-muted-foreground"></p>
<Button
size="sm"
variant="ghost"
onClick={() => setShowSettings(true)}
className="h-7 gap-1.5 text-xs"
>
<Settings className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60"> <code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
http://{status.address}:{status.port} http://{status.address}:{status.port}
</code> </code>
@@ -190,9 +208,18 @@ export function ProxyPanel() {
<p className="text-base font-medium text-foreground mb-1"> <p className="text-base font-medium text-foreground mb-1">
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground mb-4">
使 使
</p> </p>
<Button
size="sm"
variant="outline"
onClick={() => setShowSettings(true)}
className="gap-1.5"
>
<Settings className="h-4 w-4" />
</Button>
</div> </div>
)} )}
</section> </section>