diff --git a/README.md b/README.md index 6839e0a..f534f2e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Local AI API gateway for OpenAI / Gemini / Anthropic. Runs on your machine, keep ## Quick start (macOS) 1) Install: move `Token Proxy.app` to `/Applications`. If blocked: `xattr -cr /Applications/Token\ Proxy.app`. 2) Launch the app. The proxy starts automatically. -3) Open **Config File** tab, edit and save (writes `config.jsonc` in the Tauri config dir). Defaults are usable; just paste your upstream API keys. +3) Open **Config File** tab, edit and save (writes `config.jsonc` in the Tauri config dir). Defaults are usable; just paste your upstream API keys. Running proxies auto-apply the new config via reload or restart when needed. 4) Call via curl (example with local auth): ```bash curl -X POST \ @@ -100,6 +100,7 @@ Notes: | `app_proxy_url` | `null` | Proxy for app updater & as placeholder for upstreams (`"$app_proxy_url"`). Supports `http/https/socks5/socks5h`. | | `log_level` | `silent` | `silent|error|warn|info|debug|trace`; debug/trace log request headers (auth redacted) and small bodies (≤64KiB). Release builds force `silent`. | | `max_request_body_bytes` | `20971520` (20 MiB) | 0 = fallback to default. Protects inbound body size. | +| `retryable_failure_cooldown_secs` | `15` | Cooldown window after retryable failures that should temporarily sideline an upstream. `0` disables cooldown. Reloading or restarting the running proxy resets current cooldown state. | | `tray_token_rate.enabled` | `true` | macOS tray live rate; harmless elsewhere. | | `tray_token_rate.format` | `split` | `combined` (`total`), `split` (`↑in ↓out`), `both` (`total | ↑in ↓out`). | | `upstream_strategy` | `priority_fill_first` | `priority_fill_first` (default) keeps trying the highest-priority group in list order; `priority_round_robin` rotates within each priority group. | @@ -147,7 +148,8 @@ Notes: ## Load balancing & retries - Priorities: higher `priority` groups first; inside a group use list order (fill-first) or round-robin (if `priority_round_robin`). -- Retryable conditions: network timeout/connect errors, or status 400/403/429/307/5xx **except** 504/524. Retries stay within the same provider's priority groups. +- Retryable conditions: network timeout/connect errors, or status 400/401/403/404/408/422/429/307/5xx (including 504/524). Retries stay within the same provider's priority groups. +- Cooldown conditions: `401/403/408/429/5xx` will temporarily move the failed upstream behind ready peers for `retryable_failure_cooldown_secs` (default `15`); `400/404/422/307` stay retryable but do not trigger cross-request cooldown. - `/v1/messages` only: after the chosen native provider is exhausted (retryable errors), the proxy can fall back to the other native provider (`anthropic` ↔ `kiro`) if it is configured. ## Observability diff --git a/README.zh-CN.md b/README.zh-CN.md index 893afdb..22999c7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -26,7 +26,7 @@ ## 快速上手(macOS) 1) 安装:把 `Token Proxy.app` 放到 `/Applications`。若被拦截,执行 `xattr -cr /Applications/Token\ Proxy.app`。 2) 启动应用,代理会自动运行。 -3) 打开 **Config File** 标签,编辑并保存(写入 Tauri 配置目录下的 `config.jsonc`)。默认配置可用,只需填入上游 API Key。 +3) 打开 **Config File** 标签,编辑并保存(写入 Tauri 配置目录下的 `config.jsonc`)。默认配置可用,只需填入上游 API Key。若代理正在运行,保存后会按需自动 reload 或重启。 4) 发请求(本地鉴权示例): ```bash curl -X POST \ @@ -100,6 +100,7 @@ pnpm exec tsc --noEmit | `app_proxy_url` | `null` | 应用更新 & 上游可复用的代理;支持 `http/https/socks5/socks5h`;可在 upstream `proxy_url` 用 `"$app_proxy_url"` 占位 | | `log_level` | `silent` | `silent|error|warn|info|debug|trace`;debug/trace 会记录请求头(鉴权打码)与小体积请求体(≤64KiB);release 强制 `silent` | | `max_request_body_bytes` | `20971520` (20 MiB) | 0 表示回落到默认;保护入站体积 | +| `retryable_failure_cooldown_secs` | `15` | 对适合短时降级的可重试失败施加冷却窗口;`0` 表示关闭冷却。重载或重启运行中的代理会重置当前冷却状态 | | `tray_token_rate.enabled` | `true` | macOS 托盘实时速率;其他平台无害 | | `tray_token_rate.format` | `split` | `combined`(总数) / `split`(↑入 ↓出) / `both`(总数 | ↑入 ↓出) | | `upstream_strategy` | `priority_fill_first` | `priority_fill_first` 默认先填满高优先级;`priority_round_robin` 在同组内轮询 | @@ -147,7 +148,8 @@ pnpm exec tsc --noEmit ## 负载均衡与重试 - 优先级:高优先级组先尝试;组内按列表顺序(fill-first)或轮询(round-robin) -- 可重试条件:网络超时/连接错误,或状态码 400/403/429/307/5xx(排除 504/524);重试只在同一 provider 的优先级组内进行 +- 可重试条件:网络超时/连接错误,或状态码 400/401/403/404/408/422/429/307/5xx(包含 504/524);重试只在同一 provider 的优先级组内进行 +- 冷却条件:`401/403/408/429/5xx` 会让失败 upstream 在 `retryable_failure_cooldown_secs`(默认 `15`)内被暂时后置;`400/404/422/307` 仍可重试,但不会触发跨请求冷却 - 仅 `/v1/messages`:当命中的 native provider(`anthropic`/`kiro`)被耗尽(仍是可重试错误)时,若另一个 native provider 已配置,会自动 fallback(Anthropic ↔ Kiro) ## 可观测性 diff --git a/crates/token_proxy_core/src/proxy/config/mod.rs b/crates/token_proxy_core/src/proxy/config/mod.rs index 34d0d0f..753d600 100644 --- a/crates/token_proxy_core/src/proxy/config/mod.rs +++ b/crates/token_proxy_core/src/proxy/config/mod.rs @@ -5,6 +5,7 @@ mod normalize; mod types; use crate::paths::TokenProxyPaths; +use std::time::{Duration, Instant}; const DEFAULT_MAX_REQUEST_BODY_BYTES: u64 = 20 * 1024 * 1024; @@ -67,6 +68,9 @@ fn build_runtime_config(config: ProxyConfigFile) -> Result local_api_key: config.local_api_key, log_level, max_request_body_bytes, + retryable_failure_cooldown: resolve_retryable_failure_cooldown( + config.retryable_failure_cooldown_secs, + )?, upstream_strategy: config.upstream_strategy, upstreams, kiro_preferred_endpoint: config.kiro_preferred_endpoint, @@ -74,6 +78,14 @@ fn build_runtime_config(config: ProxyConfigFile) -> Result }) } +fn resolve_retryable_failure_cooldown(value: u64) -> Result { + let duration = Duration::from_secs(value); + if Instant::now().checked_add(duration).is_none() { + return Err("retryable_failure_cooldown_secs is too large.".to_string()); + } + Ok(duration) +} + fn resolve_max_request_body_bytes(value: Option) -> usize { let value = value.unwrap_or(DEFAULT_MAX_REQUEST_BODY_BYTES); let value = if value == 0 { @@ -96,3 +108,7 @@ fn normalize_app_proxy_url(value: Option<&str>) -> Result, String scheme => Err(format!("app_proxy_url scheme is not supported: {scheme}.")), } } + +#[cfg(test)] +#[path = "mod.test.rs"] +mod tests; diff --git a/crates/token_proxy_core/src/proxy/config/mod.test.rs b/crates/token_proxy_core/src/proxy/config/mod.test.rs new file mode 100644 index 0000000..2414dfc --- /dev/null +++ b/crates/token_proxy_core/src/proxy/config/mod.test.rs @@ -0,0 +1,11 @@ +use super::*; + +#[test] +fn build_runtime_config_rejects_retryable_failure_cooldown_that_overflows_instant() { + let mut config = ProxyConfigFile::default(); + config.retryable_failure_cooldown_secs = u64::MAX; + + let result = build_runtime_config(config); + + assert!(result.is_err()); +} diff --git a/crates/token_proxy_core/src/proxy/config/types.rs b/crates/token_proxy_core/src/proxy/config/types.rs index 42d57b4..f536182 100644 --- a/crates/token_proxy_core/src/proxy/config/types.rs +++ b/crates/token_proxy_core/src/proxy/config/types.rs @@ -30,6 +30,14 @@ fn default_log_level() -> LogLevel { LogLevel::Silent } +fn default_retryable_failure_cooldown_secs() -> u64 { + 15 +} + +fn is_default_retryable_failure_cooldown_secs(value: &u64) -> bool { + *value == default_retryable_failure_cooldown_secs() +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum InboundApiFormat { @@ -182,6 +190,11 @@ pub struct ProxyConfigFile { pub log_level: LogLevel, #[serde(skip_serializing_if = "Option::is_none")] pub max_request_body_bytes: Option, + #[serde( + default = "default_retryable_failure_cooldown_secs", + skip_serializing_if = "is_default_retryable_failure_cooldown_secs" + )] + pub retryable_failure_cooldown_secs: u64, #[serde(default)] pub tray_token_rate: TrayTokenRateConfig, #[serde(default)] @@ -204,6 +217,7 @@ impl Default for ProxyConfigFile { antigravity_user_agent: None, log_level: LogLevel::default(), max_request_body_bytes: None, + retryable_failure_cooldown_secs: default_retryable_failure_cooldown_secs(), tray_token_rate: TrayTokenRateConfig::default(), upstream_strategy: UpstreamStrategy::PriorityFillFirst, upstreams: Vec::new(), @@ -218,6 +232,7 @@ pub struct ProxyConfig { pub local_api_key: Option, pub log_level: LogLevel, pub max_request_body_bytes: usize, + pub retryable_failure_cooldown: std::time::Duration, pub upstream_strategy: UpstreamStrategy, pub upstreams: HashMap, pub kiro_preferred_endpoint: Option, diff --git a/crates/token_proxy_core/src/proxy/config/types.test.rs b/crates/token_proxy_core/src/proxy/config/types.test.rs index 3630fe2..9b951cd 100644 --- a/crates/token_proxy_core/src/proxy/config/types.test.rs +++ b/crates/token_proxy_core/src/proxy/config/types.test.rs @@ -158,3 +158,10 @@ fn test_upstream_url() { "https://api.example.com/openai/v1/messages" ); } + +#[test] +fn proxy_config_file_defaults_retryable_failure_cooldown_to_15_seconds() { + let config = ProxyConfigFile::default(); + + assert_eq!(config.retryable_failure_cooldown_secs, 15); +} diff --git a/crates/token_proxy_core/src/proxy/http.test.rs b/crates/token_proxy_core/src/proxy/http.test.rs index 2a873c4..dfdc4f1 100644 --- a/crates/token_proxy_core/src/proxy/http.test.rs +++ b/crates/token_proxy_core/src/proxy/http.test.rs @@ -10,6 +10,7 @@ fn config_with_local(key: &str) -> ProxyConfig { local_api_key: Some(key.to_string()), log_level: LogLevel::Silent, max_request_body_bytes: 1024, + retryable_failure_cooldown: std::time::Duration::from_secs(15), upstream_strategy: crate::proxy::config::UpstreamStrategy::PriorityFillFirst, upstreams: HashMap::new(), kiro_preferred_endpoint: None, @@ -24,6 +25,7 @@ fn config_without_local() -> ProxyConfig { local_api_key: None, log_level: LogLevel::Silent, max_request_body_bytes: 1024, + retryable_failure_cooldown: std::time::Duration::from_secs(15), upstream_strategy: crate::proxy::config::UpstreamStrategy::PriorityFillFirst, upstreams: HashMap::new(), kiro_preferred_endpoint: None, diff --git a/crates/token_proxy_core/src/proxy/server.test.rs b/crates/token_proxy_core/src/proxy/server.test.rs index 1a1748e..f24566e 100644 --- a/crates/token_proxy_core/src/proxy/server.test.rs +++ b/crates/token_proxy_core/src/proxy/server.test.rs @@ -123,6 +123,7 @@ fn config_with_runtime_upstreams( local_api_key: None, log_level: LogLevel::Silent, max_request_body_bytes: 20 * 1024 * 1024, + retryable_failure_cooldown: std::time::Duration::from_secs(15), upstream_strategy: UpstreamStrategy::PriorityRoundRobin, upstreams: provider_map, kiro_preferred_endpoint: None, @@ -263,12 +264,16 @@ async fn build_test_state_handle(config: ProxyConfig, data_dir: PathBuf) -> Prox } } } + let retryable_failure_cooldown = config.retryable_failure_cooldown; let state = Arc::new(ProxyState { config, http_clients: super::super::http_client::ProxyHttpClients::new().expect("http clients"), log: Arc::new(super::super::log::LogWriter::new(None)), cursors, - upstream_selector: super::super::upstream_selector::UpstreamSelectorRuntime::new(), + upstream_selector: + super::super::upstream_selector::UpstreamSelectorRuntime::new_with_cooldown( + retryable_failure_cooldown, + ), request_detail: Arc::new(super::super::request_detail::RequestDetailCapture::new( None, )), @@ -416,6 +421,48 @@ fn responses_request_falls_back_from_403_to_codex() { )); } +#[test] +fn responses_request_falls_back_from_401_to_codex() { + run_async(assert_responses_retry_fallback_status( + StatusCode::UNAUTHORIZED, + )); +} + +#[test] +fn responses_request_falls_back_from_404_to_codex() { + run_async(assert_responses_retry_fallback_status( + StatusCode::NOT_FOUND, + )); +} + +#[test] +fn responses_request_falls_back_from_408_to_codex() { + run_async(assert_responses_retry_fallback_status( + StatusCode::REQUEST_TIMEOUT, + )); +} + +#[test] +fn responses_request_falls_back_from_422_to_codex() { + run_async(assert_responses_retry_fallback_status( + StatusCode::UNPROCESSABLE_ENTITY, + )); +} + +#[test] +fn responses_request_falls_back_from_504_to_codex() { + run_async(assert_responses_retry_fallback_status( + StatusCode::GATEWAY_TIMEOUT, + )); +} + +#[test] +fn responses_request_falls_back_from_524_to_codex() { + run_async(assert_responses_retry_fallback_status( + StatusCode::from_u16(524).expect("524"), + )); +} + #[test] fn responses_request_skips_recently_failed_same_provider_upstream() { run_async(async { @@ -500,6 +547,90 @@ fn responses_request_skips_recently_failed_same_provider_upstream() { }); } +#[test] +fn responses_request_cooldowns_same_provider_upstream_after_401() { + run_async(async { + let primary = spawn_mock_upstream( + StatusCode::UNAUTHORIZED, + json!({ + "error": { "message": "primary unauthorized" } + }), + ) + .await; + let secondary = spawn_mock_upstream( + StatusCode::OK, + json!({ + "id": "resp_from_secondary", + "object": "response", + "created_at": 123, + "model": "gpt-5", + "status": "completed", + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [ + { "type": "output_text", "text": "from secondary" } + ] + } + ], + "usage": { "input_tokens": 1, "output_tokens": 2, "total_tokens": 3 } + }), + ) + .await; + + let mut config = config_with_runtime_upstreams(&[ + ( + PROVIDER_RESPONSES, + 10, + "responses-primary", + primary.base_url.as_str(), + FORMATS_RESPONSES, + ), + ( + PROVIDER_RESPONSES, + 10, + "responses-secondary", + secondary.base_url.as_str(), + FORMATS_RESPONSES, + ), + ]); + config.upstream_strategy = UpstreamStrategy::PriorityFillFirst; + + let data_dir = next_test_data_dir("responses_same_provider_cooldown_401"); + let state = build_test_state_handle(config, data_dir.clone()).await; + + let (first_status, first_json) = send_responses_request(state.clone()).await; + let (second_status, second_json) = send_responses_request(state).await; + + let primary_requests = primary.requests(); + let secondary_requests = secondary.requests(); + + primary.abort(); + secondary.abort(); + let _ = std::fs::remove_dir_all(&data_dir); + + assert_eq!(first_status, StatusCode::OK); + assert_eq!(second_status, StatusCode::OK); + assert_eq!( + first_json["output"][0]["content"][0]["text"].as_str(), + Some("from secondary") + ); + assert_eq!( + second_json["output"][0]["content"][0]["text"].as_str(), + Some("from secondary") + ); + assert_eq!( + primary_requests.len(), + 1, + "401 should cool down the upstream to avoid repeatedly hitting the same invalid account" + ); + assert_eq!(secondary_requests.len(), 2); + }); +} + #[test] fn responses_request_does_not_cooldown_same_provider_upstream_after_400() { run_async(async { @@ -584,6 +715,104 @@ fn responses_request_does_not_cooldown_same_provider_upstream_after_400() { }); } +#[test] +fn responses_request_reload_resets_existing_cooldown_and_applies_new_duration() { + run_async(async { + let primary = spawn_mock_upstream( + StatusCode::UNAUTHORIZED, + json!({ + "error": { "message": "primary unauthorized" } + }), + ) + .await; + let secondary = spawn_mock_upstream( + StatusCode::OK, + json!({ + "id": "resp_from_secondary", + "object": "response", + "created_at": 123, + "model": "gpt-5", + "status": "completed", + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [ + { "type": "output_text", "text": "from secondary" } + ] + } + ], + "usage": { "input_tokens": 1, "output_tokens": 2, "total_tokens": 3 } + }), + ) + .await; + + let mut config = config_with_runtime_upstreams(&[ + ( + PROVIDER_RESPONSES, + 10, + "responses-primary", + primary.base_url.as_str(), + FORMATS_RESPONSES, + ), + ( + PROVIDER_RESPONSES, + 10, + "responses-secondary", + secondary.base_url.as_str(), + FORMATS_RESPONSES, + ), + ]); + config.upstream_strategy = UpstreamStrategy::PriorityFillFirst; + config.retryable_failure_cooldown = std::time::Duration::from_secs(15); + + let data_dir = next_test_data_dir("responses_same_provider_reload_resets_cooldown"); + let state = build_test_state_handle(config.clone(), data_dir.clone()).await; + + let _ = send_responses_request(state.clone()).await; + let _ = send_responses_request(state.clone()).await; + + let primary_requests_before_reload = primary.requests(); + assert_eq!( + primary_requests_before_reload.len(), + 1, + "pre-reload second request should skip cooled-down upstream" + ); + + let mut reloaded_config = config; + reloaded_config.retryable_failure_cooldown = std::time::Duration::ZERO; + let reloaded_state_handle = + build_test_state_handle(reloaded_config, data_dir.clone()).await; + let reloaded_state = { + let guard = reloaded_state_handle.read().await; + guard.clone() + }; + { + let mut guard = state.write().await; + *guard = reloaded_state; + } + + let _ = send_responses_request(state.clone()).await; + let _ = send_responses_request(state).await; + + let primary_requests = primary.requests(); + let secondary_requests = secondary.requests(); + + primary.abort(); + secondary.abort(); + let _ = std::fs::remove_dir_all(&data_dir); + + assert_eq!( + primary_requests.len(), + 3, + "reload should clear old cooldowns, and zero cooldown should allow primary to be retried on every later request" + ); + assert_eq!(secondary_requests.len(), 4); + }); +} + #[test] fn chat_fallback_requires_format_conversion_enabled() { let config = config_with_providers(&[(PROVIDER_RESPONSES, FORMATS_RESPONSES)]); diff --git a/crates/token_proxy_core/src/proxy/service.rs b/crates/token_proxy_core/src/proxy/service.rs index cc7c1fc..abf8593 100644 --- a/crates/token_proxy_core/src/proxy/service.rs +++ b/crates/token_proxy_core/src/proxy/service.rs @@ -44,6 +44,13 @@ pub struct ProxyServiceHandle { inner: Arc, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProxyConfigApplyBehavior { + SavedOnly, + Reload, + Restart, +} + impl ProxyServiceHandle { pub fn new() -> Self { Self { @@ -70,6 +77,17 @@ impl ProxyServiceHandle { pub async fn reload(&self, ctx: &ProxyContext) -> Result { self.inner.reload(ctx).await } + + pub async fn reload_behavior( + &self, + ctx: &ProxyContext, + ) -> Result { + self.inner.reload_behavior(ctx).await + } + + pub async fn apply_saved_config(&self, ctx: &ProxyContext) -> ProxyConfigSaveResult { + self.inner.apply_saved_config(ctx).await + } } #[derive(Clone, Serialize, Debug)] @@ -86,6 +104,12 @@ pub struct ProxyServiceStatus { pub last_error: Option, } +#[derive(Clone, Serialize)] +pub struct ProxyConfigSaveResult { + pub status: ProxyServiceStatus, + pub apply_error: Option, +} + impl ProxyServiceStatus { fn stopped(last_error: Option) -> Self { Self { @@ -104,6 +128,22 @@ impl ProxyServiceStatus { } } +impl ProxyConfigSaveResult { + fn success(status: ProxyServiceStatus) -> Self { + Self { + status, + apply_error: None, + } + } + + fn apply_error(status: ProxyServiceStatus, error: String) -> Self { + Self { + status, + apply_error: Some(error), + } + } +} + struct ProxyService { inner: Mutex, } @@ -148,6 +188,21 @@ impl ProxyService { inner.reload(ctx).await?; Ok(inner.status()) } + + async fn reload_behavior( + &self, + ctx: &ProxyContext, + ) -> Result { + let mut inner = self.inner.lock().await; + inner.refresh_if_finished().await; + inner.reload_behavior(ctx).await + } + + async fn apply_saved_config(&self, ctx: &ProxyContext) -> ProxyConfigSaveResult { + let mut inner = self.inner.lock().await; + inner.refresh_if_finished().await; + inner.apply_saved_config(ctx).await + } } struct ProxyServiceInner { @@ -189,6 +244,21 @@ impl ProxyServiceInner { } async fn start(&mut self, ctx: &ProxyContext) -> Result<(), String> { + let was_running = self.running.is_some(); + let result = self.start_inner(ctx).await; + match &result { + Ok(()) if !was_running => { + self.last_error = None; + } + Err(error) => { + self.last_error = Some(error.clone()); + } + Ok(()) => {} + } + result + } + + async fn start_inner(&mut self, ctx: &ProxyContext) -> Result<(), String> { if self.running.is_some() { return Ok(()); } @@ -223,7 +293,6 @@ impl ProxyServiceInner { task: Some(task), shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT, }); - self.last_error = None; Ok(()) } @@ -241,6 +310,21 @@ impl ProxyServiceInner { } async fn reload(&mut self, ctx: &ProxyContext) -> Result<(), String> { + let was_running = self.running.is_some(); + let result = self.reload_inner(ctx).await; + match &result { + Ok(()) if was_running => { + self.last_error = None; + } + Err(error) => { + self.last_error = Some(error.clone()); + } + Ok(()) => {} + } + result + } + + async fn reload_inner(&mut self, ctx: &ProxyContext) -> Result<(), String> { tracing::debug!("proxy reload start"); let start = Instant::now(); if self.running.is_none() { @@ -248,35 +332,22 @@ impl ProxyServiceInner { return self.start(ctx).await; } let loaded_config = ProxyConfig::load(ctx.paths.as_ref()).await?; + let current_running_config = self.current_running_config().await; let addr = loaded_config.addr(); - let current_addr = self - .running + let current_addr = current_running_config .as_ref() - .map(|running| running.addr.as_str()) + .map(|(current_addr, _)| current_addr.as_str()) .unwrap_or_default() .to_string(); tracing::debug!(addr = %addr, current_addr = %current_addr, "proxy reload config loaded"); - if addr != current_addr { - // host/port 变更无法热更新监听地址;退化为安全重启。 + if classify_reload_behavior(current_running_config, &loaded_config) + == ProxyConfigApplyBehavior::Restart + { tracing::info!( addr = %addr, current_addr = %current_addr, - "proxy reload detected addr change, restarting" - ); - return self.restart(ctx).await; - } - let current_max_request_body_bytes = if let Some(running) = self.running.as_ref() { - let guard = running.state_handle.read().await; - guard.config.max_request_body_bytes - } else { - loaded_config.max_request_body_bytes - }; - if loaded_config.max_request_body_bytes != current_max_request_body_bytes { - tracing::info!( - new_max_request_body_bytes = loaded_config.max_request_body_bytes, - current_max_request_body_bytes = current_max_request_body_bytes, - "proxy reload detected body limit change, restarting" + "proxy reload detected restart-required config change" ); return self.restart(ctx).await; } @@ -298,6 +369,34 @@ impl ProxyServiceInner { Ok(()) } + async fn reload_behavior( + &mut self, + ctx: &ProxyContext, + ) -> Result { + let loaded_config = ProxyConfig::load(ctx.paths.as_ref()).await?; + let current_running_config = self.current_running_config().await; + Ok(classify_reload_behavior(current_running_config, &loaded_config)) + } + + async fn apply_saved_config(&mut self, ctx: &ProxyContext) -> ProxyConfigSaveResult { + // 保存后的自动应用必须把“是否仍在运行”的判断与真正的 reload/restart + // 放在同一把锁内完成,避免 save 与 stop/start 交错后把已停止的代理重新拉起。 + if self.running.is_none() { + return ProxyConfigSaveResult::success(self.status()); + } + + match self.reload(ctx).await { + Ok(()) => ProxyConfigSaveResult::success(self.status()), + Err(error) => ProxyConfigSaveResult::apply_error(self.status(), error), + } + } + + async fn current_running_config(&self) -> Option<(String, usize)> { + let running = self.running.as_ref()?; + let guard = running.state_handle.read().await; + Some((running.addr.clone(), guard.config.max_request_body_bytes)) + } + async fn finish_task(&mut self, mut running: RunningProxy) { if let Some(tx) = running.shutdown_tx.take() { let _ = tx.send(()); @@ -365,11 +464,13 @@ async fn build_proxy_state( let codex_accounts = ctx.codex_accounts.clone(); let antigravity_accounts = ctx.antigravity_accounts.clone(); Ok(Arc::new(ProxyState { + upstream_selector: super::upstream_selector::UpstreamSelectorRuntime::new_with_cooldown( + config.retryable_failure_cooldown, + ), config, http_clients, log, cursors, - upstream_selector: super::upstream_selector::UpstreamSelectorRuntime::new(), request_detail, token_rate, kiro_accounts, @@ -377,3 +478,22 @@ async fn build_proxy_state( antigravity_accounts, })) } + +fn classify_reload_behavior( + current_running_config: Option<(String, usize)>, + loaded_config: &ProxyConfig, +) -> ProxyConfigApplyBehavior { + let Some((current_addr, current_max_request_body_bytes)) = current_running_config else { + return ProxyConfigApplyBehavior::SavedOnly; + }; + if loaded_config.addr() != current_addr + || loaded_config.max_request_body_bytes != current_max_request_body_bytes + { + return ProxyConfigApplyBehavior::Restart; + } + ProxyConfigApplyBehavior::Reload +} + +#[cfg(test)] +#[path = "service.test.rs"] +mod tests; diff --git a/crates/token_proxy_core/src/proxy/service.test.rs b/crates/token_proxy_core/src/proxy/service.test.rs new file mode 100644 index 0000000..4176b0d --- /dev/null +++ b/crates/token_proxy_core/src/proxy/service.test.rs @@ -0,0 +1,156 @@ +use super::*; +use crate::app_proxy; +use crate::paths::TokenProxyPaths; +use rand::random; +use crate::logging::LogLevel; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +fn config_with_addr_and_body_limit(host: &str, port: u16, max_request_body_bytes: usize) -> ProxyConfig { + ProxyConfig { + host: host.to_string(), + port, + local_api_key: None, + log_level: LogLevel::Silent, + max_request_body_bytes, + retryable_failure_cooldown: Duration::from_secs(15), + upstream_strategy: crate::proxy::config::UpstreamStrategy::PriorityFillFirst, + upstreams: HashMap::new(), + kiro_preferred_endpoint: None, + antigravity_user_agent: None, + } +} + +#[test] +fn classify_reload_behavior_returns_reload_for_hot_reload_safe_changes() { + let current = config_with_addr_and_body_limit("127.0.0.1", 9208, 1024); + let next = config_with_addr_and_body_limit("127.0.0.1", 9208, 1024); + + let action = classify_reload_behavior(Some((current.addr(), current.max_request_body_bytes)), &next); + + assert_eq!(action, ProxyConfigApplyBehavior::Reload); +} + +#[test] +fn classify_reload_behavior_restarts_when_addr_changes() { + let current = config_with_addr_and_body_limit("127.0.0.1", 9208, 1024); + let next = config_with_addr_and_body_limit("127.0.0.1", 9300, 1024); + + let action = classify_reload_behavior(Some((current.addr(), current.max_request_body_bytes)), &next); + + assert_eq!(action, ProxyConfigApplyBehavior::Restart); +} + +#[test] +fn classify_reload_behavior_restarts_when_body_limit_changes() { + let current = config_with_addr_and_body_limit("127.0.0.1", 9208, 1024); + let next = config_with_addr_and_body_limit("127.0.0.1", 9208, 2048); + + let action = classify_reload_behavior(Some((current.addr(), current.max_request_body_bytes)), &next); + + assert_eq!(action, ProxyConfigApplyBehavior::Restart); +} + +#[test] +fn classify_reload_behavior_skips_apply_when_proxy_is_stopped() { + let next = config_with_addr_and_body_limit("127.0.0.1", 9208, 1024); + + let action = classify_reload_behavior(None, &next); + + assert_eq!(action, ProxyConfigApplyBehavior::SavedOnly); +} + +fn run_async(test: impl std::future::Future) { + tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(test); +} + +fn test_config_file(port: u16) -> crate::proxy::config::ProxyConfigFile { + crate::proxy::config::ProxyConfigFile { + port, + ..Default::default() + } +} + +fn create_test_context() -> (ProxyContext, std::path::PathBuf) { + let data_dir = std::env::temp_dir().join(format!("token-proxy-service-test-{}", random::())); + std::fs::create_dir_all(&data_dir).expect("create test data dir"); + let paths = Arc::new(TokenProxyPaths::from_app_data_dir(data_dir.clone()).expect("test paths")); + let app_proxy = app_proxy::new_state(); + let context = ProxyContext { + paths: paths.clone(), + logging: crate::logging::LoggingState::default(), + request_detail: Arc::new(crate::proxy::request_detail::RequestDetailCapture::default()), + token_rate: crate::proxy::token_rate::TokenRateTracker::new(), + kiro_accounts: Arc::new( + crate::kiro::KiroAccountStore::new(paths.as_ref(), app_proxy.clone()) + .expect("kiro store"), + ), + codex_accounts: Arc::new( + crate::codex::CodexAccountStore::new(paths.as_ref(), app_proxy.clone()) + .expect("codex store"), + ), + antigravity_accounts: Arc::new( + crate::antigravity::AntigravityAccountStore::new(paths.as_ref(), app_proxy) + .expect("antigravity store"), + ), + }; + (context, data_dir) +} + +#[test] +fn apply_saved_config_keeps_proxy_stopped_when_service_is_stopped() { + run_async(async { + let (context, data_dir) = create_test_context(); + crate::proxy::config::write_config(context.paths.as_ref(), test_config_file(0)) + .await + .expect("write config"); + + let service = ProxyServiceHandle::new(); + let result = service.apply_saved_config(&context).await; + + assert!(matches!(result.status.state, ProxyServiceState::Stopped)); + assert!(result.apply_error.is_none()); + + let _ = std::fs::remove_dir_all(data_dir); + }); +} + +#[test] +fn apply_saved_config_returns_status_and_error_when_restart_fails() { + run_async(async { + let (context, data_dir) = create_test_context(); + crate::proxy::config::write_config(context.paths.as_ref(), test_config_file(0)) + .await + .expect("write initial config"); + + let service = ProxyServiceHandle::new(); + let start_status = service.start(&context).await.expect("start proxy"); + assert!(matches!(start_status.state, ProxyServiceState::Running)); + + let blocker = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind blocker"); + let blocked_port = blocker + .local_addr() + .expect("blocker local addr") + .port(); + + crate::proxy::config::write_config(context.paths.as_ref(), test_config_file(blocked_port)) + .await + .expect("write restart config"); + + let result = service.apply_saved_config(&context).await; + + assert!(result.apply_error.is_some()); + assert!(matches!(result.status.state, ProxyServiceState::Stopped)); + assert_eq!(result.status.addr, None); + assert_eq!(result.status.last_error, result.apply_error); + + let _ = service.stop().await; + drop(blocker); + let _ = std::fs::remove_dir_all(data_dir); + }); +} diff --git a/crates/token_proxy_core/src/proxy/upstream.test.rs b/crates/token_proxy_core/src/proxy/upstream.test.rs index af7492d..ed68e1b 100644 --- a/crates/token_proxy_core/src/proxy/upstream.test.rs +++ b/crates/token_proxy_core/src/proxy/upstream.test.rs @@ -8,14 +8,49 @@ fn retryable_status_matches_proxy_policy() { assert!(is_retryable_status(StatusCode::TOO_MANY_REQUESTS)); assert!(is_retryable_status(StatusCode::TEMPORARY_REDIRECT)); assert!(is_retryable_status(StatusCode::INTERNAL_SERVER_ERROR)); + assert!(is_retryable_status(StatusCode::UNAUTHORIZED)); + assert!(is_retryable_status(StatusCode::NOT_FOUND)); + assert!(is_retryable_status(StatusCode::REQUEST_TIMEOUT)); + assert!(is_retryable_status(StatusCode::UNPROCESSABLE_ENTITY)); + assert!(is_retryable_status(StatusCode::GATEWAY_TIMEOUT)); + assert!(is_retryable_status( + StatusCode::from_u16(524).expect("524") + )); +} - // Exclude 504/524 timeouts from retries. - assert!(!is_retryable_status(StatusCode::GATEWAY_TIMEOUT)); - assert!(!is_retryable_status( +#[test] +fn cooldown_status_matches_proxy_policy() { + assert!(result::should_cooldown_retryable_status( + StatusCode::UNAUTHORIZED + )); + assert!(result::should_cooldown_retryable_status( + StatusCode::FORBIDDEN + )); + assert!(result::should_cooldown_retryable_status( + StatusCode::REQUEST_TIMEOUT + )); + assert!(result::should_cooldown_retryable_status( + StatusCode::TOO_MANY_REQUESTS + )); + assert!(result::should_cooldown_retryable_status( + StatusCode::GATEWAY_TIMEOUT + )); + assert!(result::should_cooldown_retryable_status( StatusCode::from_u16(524).expect("524") )); - assert!(!is_retryable_status(StatusCode::UNAUTHORIZED)); + assert!(!result::should_cooldown_retryable_status( + StatusCode::BAD_REQUEST + )); + assert!(!result::should_cooldown_retryable_status( + StatusCode::NOT_FOUND + )); + assert!(!result::should_cooldown_retryable_status( + StatusCode::UNPROCESSABLE_ENTITY + )); + assert!(!result::should_cooldown_retryable_status( + StatusCode::TEMPORARY_REDIRECT + )); } #[test] diff --git a/crates/token_proxy_core/src/proxy/upstream/result.rs b/crates/token_proxy_core/src/proxy/upstream/result.rs index 3da4bbb..73ed666 100644 --- a/crates/token_proxy_core/src/proxy/upstream/result.rs +++ b/crates/token_proxy_core/src/proxy/upstream/result.rs @@ -13,6 +13,18 @@ use crate::proxy::response::{build_proxy_response, build_proxy_response_buffered use crate::proxy::token_rate::TokenRateTracker; use crate::proxy::RequestMeta; +pub(super) fn should_cooldown_retryable_status(status: StatusCode) -> bool { + // cooldown 只用于“更像上游账号/节点短时异常”的错误,避免把请求内容问题扩散到后续请求。 + // 因此 400/404/422/307 虽然可在当前请求内换路重试,但不会跨请求冷却整个 upstream。 + matches!( + status, + StatusCode::UNAUTHORIZED + | StatusCode::FORBIDDEN + | StatusCode::REQUEST_TIMEOUT + | StatusCode::TOO_MANY_REQUESTS + ) || status.is_server_error() +} + pub(super) async fn handle_upstream_result( upstream_res: Result, meta: &RequestMeta, @@ -45,9 +57,7 @@ pub(super) async fn handle_upstream_result( message: format!("Upstream responded with {}", response.status()), response: Some(response), is_timeout: false, - should_cooldown: status == StatusCode::FORBIDDEN - || status == StatusCode::TOO_MANY_REQUESTS - || status.is_server_error(), + should_cooldown: should_cooldown_retryable_status(status), } } Ok(res) => { diff --git a/crates/token_proxy_core/src/proxy/upstream/utils.rs b/crates/token_proxy_core/src/proxy/upstream/utils.rs index d375fec..7369b46 100644 --- a/crates/token_proxy_core/src/proxy/upstream/utils.rs +++ b/crates/token_proxy_core/src/proxy/upstream/utils.rs @@ -63,20 +63,19 @@ pub(super) fn is_retryable_error(err: &reqwest::Error) -> bool { } pub(super) fn is_retryable_status(status: StatusCode) -> bool { - // 基于 new-api 的重试策略:400/429/307/5xx(排除 504/524);额外允许 403 触发 fallback。 - if status == StatusCode::BAD_REQUEST - || status == StatusCode::FORBIDDEN - || status == StatusCode::TOO_MANY_REQUESTS - || status == StatusCode::TEMPORARY_REDIRECT - { - return true; - } - if status == StatusCode::GATEWAY_TIMEOUT { - return false; - } - if status.as_u16() == 524 { - // Cloudflare timeout. - return false; - } - status.is_server_error() + // 为了尽量提供“无反馈”的自动切换体验,以下错误都允许继续尝试下一个渠道: + // - 显式可回退的鉴权/路由/请求超时/语义校验错误:401/404/408/422 + // - 配额/权限/重定向:400/403/429/307 + // - 所有 5xx,包括 504 与 Cloudflare 524。 + matches!( + status, + StatusCode::BAD_REQUEST + | StatusCode::UNAUTHORIZED + | StatusCode::FORBIDDEN + | StatusCode::NOT_FOUND + | StatusCode::REQUEST_TIMEOUT + | StatusCode::UNPROCESSABLE_ENTITY + | StatusCode::TOO_MANY_REQUESTS + | StatusCode::TEMPORARY_REDIRECT + ) || status.is_server_error() } diff --git a/crates/token_proxy_core/src/proxy/upstream_selector.rs b/crates/token_proxy_core/src/proxy/upstream_selector.rs index 4f75c7b..9fd228c 100644 --- a/crates/token_proxy_core/src/proxy/upstream_selector.rs +++ b/crates/token_proxy_core/src/proxy/upstream_selector.rs @@ -6,8 +6,6 @@ use std::{ use super::{config::UpstreamRuntime, config::UpstreamStrategy}; -const RETRYABLE_FAILURE_COOLDOWN: Duration = Duration::from_secs(15); - #[derive(Hash, PartialEq, Eq)] struct CooldownKey { provider: String, @@ -23,14 +21,17 @@ impl CooldownKey { } } -#[derive(Default)] pub(crate) struct UpstreamSelectorRuntime { + retryable_failure_cooldown: Duration, cooldowns: Mutex>, } impl UpstreamSelectorRuntime { - pub(crate) fn new() -> Self { - Self::default() + pub(crate) fn new_with_cooldown(retryable_failure_cooldown: Duration) -> Self { + Self { + retryable_failure_cooldown, + cooldowns: Mutex::new(HashMap::new()), + } } pub(crate) fn order_group( @@ -50,10 +51,13 @@ impl UpstreamSelectorRuntime { } pub(crate) fn mark_retryable_failure(&self, provider: &str, upstream_id: &str) { + let Some(until) = Instant::now().checked_add(self.retryable_failure_cooldown) else { + return; + }; self.mark_cooldown_until( provider, upstream_id, - Instant::now() + RETRYABLE_FAILURE_COOLDOWN, + until, ); } diff --git a/crates/token_proxy_core/src/proxy/upstream_selector.test.rs b/crates/token_proxy_core/src/proxy/upstream_selector.test.rs index 3ce57b6..49c6d8d 100644 --- a/crates/token_proxy_core/src/proxy/upstream_selector.test.rs +++ b/crates/token_proxy_core/src/proxy/upstream_selector.test.rs @@ -22,7 +22,7 @@ fn runtime(id: &str) -> UpstreamRuntime { #[test] fn cooled_upstream_moves_behind_ready_candidates() { - let selector = UpstreamSelectorRuntime::new(); + let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::from_secs(15)); let items = vec![runtime("a"), runtime("b"), runtime("c")]; selector.mark_cooldown_until("responses", "a", Instant::now() + Duration::from_secs(10)); @@ -34,7 +34,7 @@ fn cooled_upstream_moves_behind_ready_candidates() { #[test] fn all_cooled_upstreams_probe_earliest_expiry_first() { - let selector = UpstreamSelectorRuntime::new(); + let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::from_secs(15)); let items = vec![runtime("a"), runtime("b"), runtime("c")]; selector.mark_cooldown_until("responses", "a", Instant::now() + Duration::from_secs(30)); @@ -48,7 +48,7 @@ fn all_cooled_upstreams_probe_earliest_expiry_first() { #[test] fn clear_cooldown_restores_base_order() { - let selector = UpstreamSelectorRuntime::new(); + let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::from_secs(15)); let items = vec![runtime("a"), runtime("b")]; selector.mark_cooldown_until("responses", "a", Instant::now() + Duration::from_secs(10)); @@ -58,3 +58,28 @@ fn clear_cooldown_restores_base_order() { assert_eq!(order, vec![0, 1]); } + +#[test] +fn zero_retryable_failure_cooldown_disables_cross_request_cooling() { + let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::ZERO); + let items = vec![runtime("a"), runtime("b")]; + + selector.mark_retryable_failure("responses", "a"); + + let order = selector.order_group(UpstreamStrategy::PriorityFillFirst, "responses", &items, 0); + + assert_eq!(order, vec![0, 1]); +} + +#[test] +fn extreme_retryable_failure_cooldown_does_not_panic() { + let selector = UpstreamSelectorRuntime::new_with_cooldown(Duration::from_secs(u64::MAX)); + let items = vec![runtime("a"), runtime("b")]; + + let result = std::panic::catch_unwind(|| { + selector.mark_retryable_failure("responses", "a"); + selector.order_group(UpstreamStrategy::PriorityFillFirst, "responses", &items, 0) + }); + + assert!(result.is_ok()); +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..4dd8016 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,53 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; +import { defineConfig, globalIgnores } from "eslint/config"; + +export default defineConfig([ + globalIgnores([ + "dist", + "coverage", + ".reference/**", + "src/paraglide/**", + "src/routeTree.gen.ts", + ]), + { + files: ["src/**/*.{ts,tsx}", "vite.config.ts"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "react-hooks/exhaustive-deps": "error", + "react-refresh/only-export-components": "off", + }, + }, + { + files: ["vite.config.ts"], + languageOptions: { + globals: globals.node, + }, + }, + { + files: ["src/features/dashboard/RecentRequestsTable.tsx"], + rules: { + "react-hooks/incompatible-library": "off", + }, + }, +]); diff --git a/messages/en.json b/messages/en.json index 5674646..9b7eab4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -25,6 +25,7 @@ "config_status_saved": "Saved", "config_status_idle": "Idle", "config_request_failed_title": "Request failed", + "config_retry_save": "Retry save", "config_invalid_configuration": "Invalid configuration.", "sidebar_hide": "Hide sidebar", "sidebar_show": "Show sidebar", @@ -54,6 +55,8 @@ "proxy_core_app_proxy_url_help": "Used by the app and optional upstream proxy reuse. Supports http(s)://, socks5://, socks5h://. Upstreams can use {placeholder} to reference this proxy. Leave empty to skip proxy.", "proxy_core_kiro_preferred_endpoint_label": "Kiro preferred endpoint", "proxy_core_kiro_preferred_endpoint_help": "Sets the default Kiro endpoint (IDE = CodeWhisperer, CLI = Amazon Q).", + "proxy_core_retryable_failure_cooldown_secs_label": "Retryable failure cooldown (seconds)", + "proxy_core_retryable_failure_cooldown_secs_help": "Default is 15. Applies cross-request cooldown to 401/403/408/429/5xx after the running proxy loads this config. Reloading or restarting resets the current cooldown state. Use 0 to disable cooldown.", "proxy_core_format_conversion_title": "Format Conversion", "proxy_core_format_conversion_desc": "Auto-convert OpenAI/Anthropic/Gemini formats when the preferred provider is missing.", "proxy_core_format_conversion_aria": "Enable format conversion", @@ -270,7 +273,7 @@ "config_file_desc": "Disk location and maintenance actions.", "config_file_location_label": "Location", "config_file_help_1": "Use the toolbar to save changes back to the JSONC file.", - "config_file_help_2": "Saving triggers an automatic proxy reload (and safe restart if needed).", + "config_file_help_2": "Saving writes the JSONC file and automatically reloads or restarts a running proxy when needed. Stopped proxies stay stopped.", "config_file_last_saved_at": "Last saved at {time}", "config_file_unsaved_notice": "You have unsaved changes. Reload will ask for confirmation to avoid overwriting your edits.", "config_file_discard_title": "Discard unsaved changes?", @@ -330,8 +333,8 @@ "proxy_service_desc": "Safe stop/restart and manual config reload.", "proxy_service_state_label": "State", "proxy_service_addr_label": "Addr", - "proxy_service_help_1": "The proxy reads configuration from the JSONC file on disk. Save your changes first to apply them.", - "proxy_service_help_2": "Saving the config triggers an automatic reload (and safe restart if host/port changes).", + "proxy_service_help_1": "The proxy reads configuration from the JSONC file on disk. Save first; running proxies apply the latest disk config automatically.", + "proxy_service_help_2": "Use Reload or Restart here when you want to re-apply disk config manually, especially while the proxy is stopped.", "proxy_service_start": "Start", "proxy_service_stop": "Stop", "proxy_service_restart": "Restart", @@ -418,6 +421,7 @@ "logs_detail_response": "Error response", "error_host_required": "Host is required.", "error_port_range": "Port must be between 1 and 65535.", + "error_retryable_failure_cooldown_secs_integer": "Retryable failure cooldown must be a non-negative integer.", "error_upstream_at_least_one": "At least one upstream is required.", "error_upstream_at_least_one_enabled": "At least one enabled upstream is required.", "error_upstream_id_required": "Upstream id is required.", diff --git a/messages/zh.json b/messages/zh.json index 8c78423..152044b 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -25,6 +25,7 @@ "config_status_saved": "已保存", "config_status_idle": "空闲", "config_request_failed_title": "请求失败", + "config_retry_save": "重试保存", "config_invalid_configuration": "配置无效。", "sidebar_hide": "隐藏侧栏", "sidebar_show": "显示侧栏", @@ -54,6 +55,8 @@ "proxy_core_app_proxy_url_help": "用于应用使用与上游代理复用。支持 http(s)://、socks5://、socks5h://。上游可填写 {placeholder} 引用应用代理;留空不使用代理。", "proxy_core_kiro_preferred_endpoint_label": "Kiro 默认端点", "proxy_core_kiro_preferred_endpoint_help": "设置默认 Kiro 端点(IDE=CodeWhisperer,CLI=Amazon Q)。", + "proxy_core_retryable_failure_cooldown_secs_label": "可重试失败冷却(秒)", + "proxy_core_retryable_failure_cooldown_secs_help": "默认 15。运行中的代理加载该配置后,会对 401/403/408/429/5xx 触发跨请求冷却;重载或重启代理会重置当前冷却状态。填 0 可关闭冷却。", "proxy_core_format_conversion_title": "格式转换", "proxy_core_format_conversion_desc": "当首选提供商缺失时,自动转换 OpenAI/Anthropic/Gemini 格式。", "proxy_core_format_conversion_aria": "启用格式转换", @@ -271,7 +274,7 @@ "config_file_desc": "磁盘位置与维护操作。", "config_file_location_label": "位置", "config_file_help_1": "使用顶部工具栏将更改保存回 JSONC 文件。", - "config_file_help_2": "保存会触发代理自动 reload(必要时安全重启)。", + "config_file_help_2": "保存会写入 JSONC 文件;如果代理正在运行,会按需自动 reload 或重启。已停止的代理仍保持停止。", "config_file_last_saved_at": "上次保存时间:{time}", "config_file_unsaved_notice": "你有未保存的更改。为避免覆盖编辑内容,reload 会先弹出确认。", "config_file_discard_title": "丢弃未保存的更改?", @@ -331,8 +334,8 @@ "proxy_service_desc": "安全停止/重启与手动 reload 配置。", "proxy_service_state_label": "状态", "proxy_service_addr_label": "地址", - "proxy_service_help_1": "代理从磁盘上的 JSONC 文件读取配置。请先保存更改再应用。", - "proxy_service_help_2": "保存配置会触发自动 reload(如果 host/port 变化则安全重启)。", + "proxy_service_help_1": "代理从磁盘上的 JSONC 文件读取配置。请先保存;运行中的代理会自动应用最新磁盘配置。", + "proxy_service_help_2": "当你想手动重新应用磁盘配置时,可在这里使用 Reload 或 Restart,尤其是在代理已停止时。", "proxy_service_start": "启动", "proxy_service_stop": "停止", "proxy_service_restart": "重启", @@ -419,6 +422,7 @@ "logs_detail_response": "错误响应", "error_host_required": "Host 为必填。", "error_port_range": "端口必须在 1 到 65535 之间。", + "error_retryable_failure_cooldown_secs_integer": "可重试失败冷却必须是大于等于 0 的整数。", "error_upstream_at_least_one": "至少需要一个上游。", "error_upstream_at_least_one_enabled": "至少需要一个启用的上游。", "error_upstream_id_required": "上游 ID 为必填。", diff --git a/package.json b/package.json index e3e2322..c3a1106 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "vite", "i18n:compile": "paraglide-js compile --project ./project.inlang --outdir ./src/paraglide --strategy localStorage preferredLanguage baseLocale --emit-ts-declarations", "build": "pnpm run i18n:compile && tsc --noEmit && vite build", + "lint": "eslint .", "preview": "vite preview", "routes:gen": "tsr generate", "test": "vitest", @@ -64,6 +65,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@inlang/paraglide-js": "^2.13.2", "@tanstack/router-cli": "^1.166.2", "@tanstack/router-plugin": "^1.166.2", @@ -79,9 +81,14 @@ "@vitest/ui": "^4.0.18", "agentation": "^2.2.1", "babel-plugin-react-compiler": "^1.0.0", + "eslint": "^10.0.3", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", "jsdom": "^28.1.0", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", + "typescript-eslint": "^8.56.1", "vite": "^7.3.1", "vitest": "^4.0.18" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f10bfbc..e7093c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.0.3(jiti@2.6.1)) '@inlang/paraglide-js': specifier: ^2.13.2 version: 2.13.2 @@ -186,6 +189,18 @@ importers: babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 + eslint: + specifier: ^10.0.3 + version: 10.0.3(jiti@2.6.1) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@10.0.3(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: ^0.5.2 + version: 0.5.2(eslint@10.0.3(jiti@2.6.1)) + globals: + specifier: ^17.4.0 + version: 17.4.0 jsdom: specifier: ^28.1.0 version: 28.1.0 @@ -195,6 +210,9 @@ importers: typescript: specifier: ~5.9.3 version: 5.9.3 + typescript-eslint: + specifier: ^8.56.1 + version: 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) vite: specifier: ^7.3.1 version: 7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) @@ -555,6 +573,45 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.3': + resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.5.3': + resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.1.1': + resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.3': + resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.6.1': + resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -579,6 +636,22 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@inlang/paraglide-js@2.13.2': resolution: {integrity: sha512-ecxw95pmMbasVj7M/B6pu5wqYHomYQBcu3QzDl1svwAkbnRqRmsdrH4IizzFwqeVWd+uluibMIy1VOGywin94A==} hasBin: true @@ -1642,9 +1715,15 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@25.3.5': resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} @@ -1659,6 +1738,65 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@typescript-eslint/eslint-plugin@8.56.1': + resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.56.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.56.1': + resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.56.1': + resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-react@5.1.4': resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1708,6 +1846,11 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -1728,6 +1871,9 @@ packages: react-dom: optional: true + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1779,6 +1925,10 @@ packages: babel-plugin-react-compiler@1.0.0: resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} @@ -1791,6 +1941,10 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1847,6 +2001,10 @@ packages: cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -1932,6 +2090,9 @@ packages: babel-plugin-macros: optional: true + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1982,14 +2143,71 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.5.2: + resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.0.3: + resolution: {integrity: sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -1997,6 +2215,15 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2009,10 +2236,22 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -2040,6 +2279,14 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.4.0: + resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} + engines: {node: '>=18'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2047,6 +2294,12 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2066,12 +2319,24 @@ packages: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + immer@10.2.0: resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} immer@11.1.4: resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} @@ -2107,6 +2372,9 @@ packages: resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==} engines: {node: '>=18'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -2146,15 +2414,31 @@ packages: engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kysely@0.27.6: resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==} engines: {node: '>=14.0.0'} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.31.1: resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} engines: {node: '>= 12.0.0'} @@ -2229,6 +2513,10 @@ packages: resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lru-cache@11.2.6: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} @@ -2262,6 +2550,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -2274,6 +2566,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -2290,9 +2585,29 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2311,6 +2626,10 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + prettier@3.8.1: resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} @@ -2458,6 +2777,14 @@ packages: resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} engines: {node: '>=10'} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -2567,6 +2894,12 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2578,6 +2911,17 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.56.1: + resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2600,6 +2944,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + urlpattern-polyfill@10.1.0: resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==} @@ -2738,11 +3085,20 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2769,6 +3125,16 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -3072,6 +3438,40 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.3(jiti@2.6.1))': + dependencies: + eslint: 10.0.3(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.3': + dependencies: + '@eslint/object-schema': 3.0.3 + debug: 4.4.3 + minimatch: 10.2.4 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.5.3': + dependencies: + '@eslint/core': 1.1.1 + + '@eslint/core@1.1.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.0.3(jiti@2.6.1))': + optionalDependencies: + eslint: 10.0.3(jiti@2.6.1) + + '@eslint/object-schema@3.0.3': {} + + '@eslint/plugin-kit@0.6.1': + dependencies: + '@eslint/core': 1.1.1 + levn: 0.4.1 + '@exodus/bytes@1.15.0': {} '@floating-ui/core@1.7.3': @@ -3091,6 +3491,17 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@inlang/paraglide-js@2.13.2': dependencies: '@inlang/recommend-sherlock': 0.2.1 @@ -4087,8 +4498,12 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} + '@types/estree@1.0.8': {} + '@types/json-schema@7.0.15': {} + '@types/node@25.3.5': dependencies: undici-types: 7.18.2 @@ -4103,6 +4518,97 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + eslint: 10.0.3(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + eslint: 10.0.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.0.3(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.56.1': {} + + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + eslint: 10.0.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.3.5)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 @@ -4179,6 +4685,10 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn@8.16.0: {} agent-base@7.1.4: {} @@ -4188,6 +4698,13 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -4240,6 +4757,8 @@ snapshots: dependencies: '@babel/types': 7.28.5 + balanced-match@4.0.4: {} + baseline-browser-mapping@2.10.0: {} bidi-js@1.0.3: @@ -4248,6 +4767,10 @@ snapshots: binary-extensions@2.3.0: {} + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -4307,6 +4830,12 @@ snapshots: cookie-es@2.0.0: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -4378,6 +4907,8 @@ snapshots: dedent@1.5.1: {} + deep-is@0.1.4: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -4436,26 +4967,129 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.0.1(eslint@10.0.3(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + eslint: 10.0.3(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.2(eslint@10.0.3(jiti@2.6.1)): + dependencies: + eslint: 10.0.3(jiti@2.6.1) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.8 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.0.3(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.3 + '@eslint/config-helpers': 0.5.3 + '@eslint/core': 1.1.1 + '@eslint/plugin-kit': 0.6.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.4 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 5.0.1 + esprima@4.0.1: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + eventemitter3@5.0.4: {} expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 fflate@0.8.2: {} + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + flatted@3.3.3: {} fsevents@2.3.3: @@ -4475,10 +5109,22 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.4.0: {} + graceful-fs@4.2.11: {} has-flag@4.0.0: {} + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.0 @@ -4503,10 +5149,16 @@ snapshots: human-id@4.1.3: {} + ignore@5.3.2: {} + + ignore@7.0.5: {} + immer@10.2.0: {} immer@11.1.4: {} + imurmurhash@0.1.4: {} + indent-string@4.0.0: {} internmap@2.0.3: {} @@ -4529,6 +5181,8 @@ snapshots: isbot@5.1.35: {} + isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -4579,10 +5233,25 @@ snapshots: jsesc@3.1.0: {} + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + kysely@0.27.6: {} + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lightningcss-android-arm64@1.31.1: optional: true @@ -4632,6 +5301,10 @@ snapshots: lightningcss-win32-arm64-msvc: 1.31.1 lightningcss-win32-x64-msvc: 1.31.1 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lru-cache@11.2.6: {} lru-cache@5.1.1: @@ -4662,12 +5335,18 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + mrmime@2.0.1: {} ms@2.1.3: {} nanoid@3.3.11: {} + natural-compare@1.4.0: {} + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -4679,10 +5358,31 @@ snapshots: obug@2.1.1: {} + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + parse5@8.0.0: dependencies: entities: 6.0.1 + path-exists@4.0.0: {} + + path-key@3.1.1: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -4697,6 +5397,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + prettier@3.8.1: {} pretty-format@27.5.1: @@ -4854,6 +5556,12 @@ snapshots: seroval@1.5.0: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} sirv@3.0.2: @@ -4943,6 +5651,10 @@ snapshots: dependencies: punycode: 2.3.1 + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + tslib@2.8.1: {} tsx@4.21.0: @@ -4954,6 +5666,21 @@ snapshots: tw-animate-css@1.4.0: {} + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 10.0.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} undici-types@7.18.2: {} @@ -4973,6 +5700,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + urlpattern-polyfill@10.1.0: {} use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): @@ -5096,11 +5827,17 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + word-wrap@1.2.5: {} + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -5127,6 +5864,12 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@3.25.76: {} zod@4.3.6: {} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index db31f79..235676b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -156,22 +156,22 @@ async fn write_opencode_config( } #[tauri::command] -async fn write_proxy_config( +async fn save_proxy_config( app: tauri::AppHandle, proxy_service: tauri::State<'_, ProxyServiceHandle>, tray_state: tauri::State<'_, tray::TrayState>, logging_state: tauri::State<'_, logging::LoggingState>, app_proxy_state: tauri::State<'_, app_proxy::AppProxyState>, config: proxy::config::ProxyConfigFile, -) -> Result { - tracing::debug!("write_proxy_config start"); +) -> Result { + tracing::debug!("save_proxy_config start"); let start = Instant::now(); - tracing::debug!("write_proxy_config apply_config start"); + tracing::debug!("save_proxy_config apply_config start"); let apply_start = Instant::now(); tray_state.apply_config(&config.tray_token_rate).await; tracing::debug!( elapsed_ms = apply_start.elapsed().as_millis(), - "write_proxy_config apply_config done" + "save_proxy_config apply_config done" ); let log_level = config.log_level; let app_proxy_url = proxy::config::app_proxy_url_from_config(&config) @@ -179,38 +179,23 @@ async fn write_proxy_config( .flatten(); let paths = app.state::>(); if let Err(err) = proxy::config::write_config(paths.inner().as_ref(), config).await { - tracing::error!(error = %err, "write_proxy_config save failed"); + tracing::error!(error = %err, "save_proxy_config save failed"); tray_state.apply_error("保存失败", &err); return Err(err); } tracing::debug!( elapsed_ms = start.elapsed().as_millis(), - "write_proxy_config saved" + "save_proxy_config saved" ); - let reload_start = Instant::now(); logging_state.apply_level(log_level); app_proxy::set(&app_proxy_state, app_proxy_url).await; let proxy_context = app.state::(); - match proxy_service.reload(proxy_context.inner()).await { - Ok(status) => { - tracing::debug!( - elapsed_ms = reload_start.elapsed().as_millis(), - state = ?status.state, - "write_proxy_config reloaded" - ); - tray_state.apply_status(&status); - Ok(status) - } - Err(err) => { - tracing::error!( - elapsed_ms = reload_start.elapsed().as_millis(), - error = %err, - "write_proxy_config reload failed" - ); - tray_state.apply_error("重载失败", &err); - Err(err) - } + let result = proxy_service.apply_saved_config(proxy_context.inner()).await; + tray_state.apply_status(&result.status); + if let Some(error) = result.apply_error.as_deref() { + tray_state.apply_error("应用失败", error); } + Ok(result) } #[tauri::command] @@ -763,7 +748,7 @@ pub fn run() { write_claude_code_settings, write_codex_config, write_opencode_config, - write_proxy_config, + save_proxy_config, read_dashboard_snapshot, read_request_log_detail, read_request_detail_capture, diff --git a/src-tauri/tauri.conf.dev.json b/src-tauri/tauri.conf.dev.json index 9a5e0fc..89a7678 100644 --- a/src-tauri/tauri.conf.dev.json +++ b/src-tauri/tauri.conf.dev.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Token Proxy (dev)", - "version": "0.1.36", + "version": "0.1.48", "identifier": "com.mxyhi.token-proxy.dev", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 30638ac..7429501 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -607,9 +607,9 @@ function SidebarMenuSkeleton({ showIcon?: boolean }) { // Random width between 50 to 90%. - const width = React.useMemo(() => { + const [width] = React.useState(() => { return `${Math.floor(Math.random() * 40) + 50}%` - }, []) + }) return (
({ + AppSidebar: () =>
, +})); + +vi.mock("@/components/site-header", () => ({ + SiteHeader: ({ title }: { title: string }) =>
{title}
, +})); + +vi.mock("@/features/config/cards", () => ({ + ClientSetupCard: () =>
, + ConfigFileCard: () =>
, + AutoStartCard: () =>
, + ProjectLinksCard: () =>
, + ProxyCoreCard: () =>
, + TrayTokenRateCard: () =>
, + UpdateCard: () =>
, + UpstreamsCard: () =>
, + ValidationCard: () =>
, +})); + +vi.mock("@/features/dashboard/DashboardPanel", () => ({ + DashboardPanel: () =>
, +})); + +vi.mock("@/features/logs/LogsPanel", () => ({ + LogsPanel: () =>
, +})); + +vi.mock("@/features/providers/ProvidersPanel", () => ({ + ProvidersPanel: () =>
, +})); + +const IDLE_PROXY_STATUS: ProxyServiceStatus = { + state: "stopped", + addr: null, + last_error: null, +}; + +describe("config/AppView", () => { + it("does not show a persistent save button when there are pending edits", () => { + render( + undefined} + onToggleUpstreamKeys={() => undefined} + onFormChange={() => undefined} + onStrategyChange={() => undefined} + onAutoStartChange={() => undefined} + onAddUpstream={() => undefined} + onRemoveUpstream={() => undefined} + onChangeUpstream={() => undefined} + onReload={() => undefined} + onSave={() => undefined} + onProxyServiceRefresh={() => undefined} + onProxyServiceStart={() => undefined} + onProxyServiceStop={() => undefined} + onProxyServiceRestart={() => undefined} + onProxyServiceReload={() => undefined} + /> + ); + + expect(screen.getByRole("button", { name: m.common_refresh() })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: m.common_save() })).not.toBeInTheDocument(); + }); + + it("shows retry action only inside error alert for dirty draft", () => { + const onSave = vi.fn(); + + render( + undefined} + onToggleUpstreamKeys={() => undefined} + onFormChange={() => undefined} + onStrategyChange={() => undefined} + onAutoStartChange={() => undefined} + onAddUpstream={() => undefined} + onRemoveUpstream={() => undefined} + onChangeUpstream={() => undefined} + onReload={() => undefined} + onSave={onSave} + onProxyServiceRefresh={() => undefined} + onProxyServiceStart={() => undefined} + onProxyServiceStop={() => undefined} + onProxyServiceRestart={() => undefined} + onProxyServiceReload={() => undefined} + /> + ); + + fireEvent.click(screen.getByRole("button", { name: m.config_retry_save() })); + + expect(onSave).toHaveBeenCalledTimes(1); + }); + + it("does not render informational save alerts", () => { + render( + undefined} + onToggleUpstreamKeys={() => undefined} + onFormChange={() => undefined} + onStrategyChange={() => undefined} + onAutoStartChange={() => undefined} + onAddUpstream={() => undefined} + onRemoveUpstream={() => undefined} + onChangeUpstream={() => undefined} + onReload={() => undefined} + onSave={() => undefined} + onProxyServiceRefresh={() => undefined} + onProxyServiceStart={() => undefined} + onProxyServiceStop={() => undefined} + onProxyServiceRestart={() => undefined} + onProxyServiceReload={() => undefined} + /> + ); + + expect(screen.queryByText("should not be shown")).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/config/AppView.tsx b/src/features/config/AppView.tsx index 0adcc93..7dc2132 100644 --- a/src/features/config/AppView.tsx +++ b/src/features/config/AppView.tsx @@ -1,4 +1,4 @@ -import { AlertCircle, Loader2, RefreshCw } from "lucide-react"; +import { AlertCircle, RefreshCw } from "lucide-react"; import { useMemo, type CSSProperties } from "react"; import { AppSidebar } from "@/components/app-sidebar"; @@ -89,23 +89,19 @@ type AppViewProps = { type ConfigToolbarProps = { section: ConfigSection; status: AppViewProps["status"]; - canSave: boolean; isDirty: boolean; onReload: () => void; - onSave: () => void; }; function ConfigToolbar({ section, status, - canSave, isDirty, onReload, - onSave, }: ConfigToolbarProps) { const isLoading = status === "loading"; const isSaving = status === "saving"; - const canReload = !isLoading && !isSaving; + const canReload = !isSaving && !isLoading; return (
{m.common_refresh()} )} -
); } type StatusAlertProps = { + status: AppViewProps["status"]; statusMessage: string; + canSave: boolean; + isDirty: boolean; + onSave: () => void; }; -function StatusAlert({ statusMessage }: StatusAlertProps) { - if (!statusMessage) { +function StatusAlert({ + status, + statusMessage, + canSave, + isDirty, + onSave, +}: StatusAlertProps) { + if (status !== "error" || !statusMessage) { return null; } + + const canRetrySave = isDirty && canSave; + return (