From e3551f0e3cc467a612564c1bd85d56991940ee29 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 4 Sep 2026 00:39:00 +0800 Subject: [PATCH] feat(config): complete logging and diagnostics management - persist and migrate CPA logging settings with validation and rollback - add logging controls, commercial-mode restart, and log-folder access - streamline diagnostics UI and update localized copy - move test-only catalog helper to eliminate dead-code warnings --- src-tauri/src/codex_catalog.rs | 22 +- src-tauri/src/core_config/commands.rs | 30 +++ src-tauri/src/core_config/settings.rs | 82 +++++++ src-tauri/src/core_config/yaml.rs | 103 ++++++++ src-tauri/src/main.rs | 69 +++++- src-tauri/src/management_api.rs | 49 +++- src-tauri/src/tests/core_config.rs | 89 ++++++- src-tauri/src/usage.rs | 7 + src/i18n/ja.ts | 34 ++- src/i18n/locales/en.ts | 34 ++- src/i18n/locales/zh-CN.ts | 34 ++- src/pages/ConfigPanel.tsx | 337 ++++++++++++++++++++++---- src/styles.css | 120 +++++++-- 13 files changed, 897 insertions(+), 113 deletions(-) diff --git a/src-tauri/src/codex_catalog.rs b/src-tauri/src/codex_catalog.rs index ca7c763..83f8944 100644 --- a/src-tauri/src/codex_catalog.rs +++ b/src-tauri/src/codex_catalog.rs @@ -556,17 +556,6 @@ fn parse_modalities(value: &Value) -> Option> { (!modalities.is_empty()).then_some(modalities) } -fn reasoning_efforts(model: &Map) -> Vec { - model - .get("supported_reasoning_levels") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|level| level.get("effort").and_then(Value::as_str)) - .map(str::to_ascii_lowercase) - .collect() -} - fn optional_string(value: &Value, keys: &[&str]) -> Option { keys.iter() .filter_map(|key| value.get(*key).and_then(Value::as_str).map(str::trim)) @@ -642,6 +631,17 @@ fn reasoning_description(level: &str) -> &'static str { mod tests { use super::*; + fn reasoning_efforts(model: &Map) -> Vec { + model + .get("supported_reasoning_levels") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|level| level.get("effort").and_then(Value::as_str)) + .map(str::to_ascii_lowercase) + .collect() + } + fn test_sources() -> CatalogSources { parse_sources( r#"{ diff --git a/src-tauri/src/core_config/commands.rs b/src-tauri/src/core_config/commands.rs index ee884bd..f4613a0 100644 --- a/src-tauri/src/core_config/commands.rs +++ b/src-tauri/src/core_config/commands.rs @@ -241,6 +241,36 @@ pub(crate) fn get_core_config_settings( Ok(CoreConfigView::from(&config)) } +#[tauri::command] +pub(crate) fn save_core_logging_settings( + gui_config_state: tauri::State<'_, GuiConfigState>, + settings: CoreLoggingSettingsInput, +) -> Result { + if !(1..=3600).contains(&settings.redis_usage_queue_retention_seconds) { + return Err("Redis 用量队列保留时间必须在 1 到 3600 秒之间".to_string()); + } + + let previous = current_core_config_settings(gui_config_state.inner())?; + let mut next = previous.clone(); + next.debug = settings.debug; + next.commercial_mode = settings.commercial_mode; + next.logging_to_file = settings.logging_to_file; + next.logs_max_total_size_mb = settings.logs_max_total_size_mb; + next.error_logs_max_files = settings.error_logs_max_files; + next.usage_statistics_enabled = settings.usage_statistics_enabled; + next.redis_usage_queue_retention_seconds = settings.redis_usage_queue_retention_seconds; + + patch_core_logging_settings(&next)?; + let config = match gui_config_state.sync_core_settings(&next) { + Ok(config) => config, + Err(error) => { + let rollback_error = patch_core_logging_settings(&previous).err(); + return Err(config_update_error_with_rollback(error, rollback_error)); + } + }; + Ok(CoreConfigView::from(&config)) +} + #[tauri::command] pub(crate) fn add_core_api_key( gui_config_state: tauri::State<'_, GuiConfigState>, diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index f3c2635..3901a99 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -585,11 +585,44 @@ pub(crate) fn apply_gui_managed_settings( "auth-dir", serde_norway::Value::String(config.auth_dir.clone()), )?; + changed |= set_core_yaml_top_level_value( + document, + "debug", + serde_norway::Value::Bool(config.debug), + )?; + changed |= set_core_yaml_top_level_value( + document, + "commercial-mode", + serde_norway::Value::Bool(config.commercial_mode), + )?; + changed |= set_core_yaml_top_level_value( + document, + "logging-to-file", + serde_norway::Value::Bool(config.logging_to_file), + )?; + changed |= set_core_yaml_top_level_value( + document, + "logs-max-total-size-mb", + serde_norway::to_value(config.logs_max_total_size_mb) + .map_err(|err| format!("序列化日志容量限制失败: {err}"))?, + )?; + changed |= set_core_yaml_top_level_value( + document, + "error-logs-max-files", + serde_norway::to_value(config.error_logs_max_files) + .map_err(|err| format!("序列化错误日志保留数失败: {err}"))?, + )?; changed |= set_core_yaml_top_level_value( document, "usage-statistics-enabled", serde_norway::Value::Bool(config.usage_statistics_enabled), )?; + changed |= set_core_yaml_top_level_value( + document, + "redis-usage-queue-retention-seconds", + serde_norway::to_value(config.redis_usage_queue_retention_seconds) + .map_err(|err| format!("序列化 Redis 用量队列保留时间失败: {err}"))?, + )?; changed |= set_core_yaml_top_level_value( document, "request-log", @@ -1160,7 +1193,13 @@ pub(crate) fn load_or_create_gui_config() -> Result { || presence.auth_dir.is_none() || presence.api_keys.is_none() || presence.management_secret_key.is_none() + || presence.debug.is_none() + || presence.commercial_mode.is_none() + || presence.logging_to_file.is_none() + || presence.logs_max_total_size_mb.is_none() + || presence.error_logs_max_files.is_none() || presence.usage_statistics_enabled.is_none() + || presence.redis_usage_queue_retention_seconds.is_none() || presence.request_log.is_none() || presence.plugins_enabled.is_none() || presence.routing_strategy.is_none() @@ -1199,6 +1238,25 @@ pub(crate) fn load_or_create_gui_config() -> Result { if presence.usage_statistics_enabled.is_none() { config.usage_statistics_enabled = core_settings.usage_statistics_enabled; } + if presence.debug.is_none() { + config.debug = core_settings.debug; + } + if presence.commercial_mode.is_none() { + config.commercial_mode = core_settings.commercial_mode; + } + if presence.logging_to_file.is_none() { + config.logging_to_file = core_settings.logging_to_file; + } + if presence.logs_max_total_size_mb.is_none() { + config.logs_max_total_size_mb = core_settings.logs_max_total_size_mb; + } + if presence.error_logs_max_files.is_none() { + config.error_logs_max_files = core_settings.error_logs_max_files; + } + if presence.redis_usage_queue_retention_seconds.is_none() { + config.redis_usage_queue_retention_seconds = + core_settings.redis_usage_queue_retention_seconds; + } if presence.request_log.is_none() { config.request_log = core_settings.request_log; } @@ -1323,7 +1381,13 @@ pub(crate) fn apply_core_settings_to_gui_config( { config.management_secret_key = secret_key.to_string(); } + config.debug = core_settings.debug; + config.commercial_mode = core_settings.commercial_mode; + config.logging_to_file = core_settings.logging_to_file; + config.logs_max_total_size_mb = core_settings.logs_max_total_size_mb; + config.error_logs_max_files = core_settings.error_logs_max_files; config.usage_statistics_enabled = core_settings.usage_statistics_enabled; + config.redis_usage_queue_retention_seconds = core_settings.redis_usage_queue_retention_seconds; config.request_log = core_settings.request_log; config.plugins_enabled = core_settings.plugins_enabled; config.routing_strategy = core_settings.routing_strategy.clone(); @@ -1686,10 +1750,25 @@ pub(crate) fn write_gui_config_to_path( "management-secret-key", value(config.management_secret_key.as_str()), ), + ("debug", value(config.debug)), + ("commercial-mode", value(config.commercial_mode)), + ("logging-to-file", value(config.logging_to_file)), + ( + "logs-max-total-size-mb", + value(i64::from(config.logs_max_total_size_mb)), + ), + ( + "error-logs-max-files", + value(i64::from(config.error_logs_max_files)), + ), ( "usage-statistics-enabled", value(config.usage_statistics_enabled), ), + ( + "redis-usage-queue-retention-seconds", + value(i64::from(config.redis_usage_queue_retention_seconds)), + ), ("request-log", value(config.request_log)), ("plugins-enabled", value(config.plugins_enabled)), ("routing-strategy", value(config.routing_strategy.as_str())), @@ -1846,6 +1925,9 @@ pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> { return Err("会话粘性 TTL 不能包含控制字符".to_string()); } + if !(1..=3600).contains(&config.redis_usage_queue_retention_seconds) { + return Err("Redis 用量队列保留时间必须在 1 到 3600 秒之间".to_string()); + } Ok(()) } diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index 26f88f9..427dc29 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -275,6 +275,46 @@ pub(crate) fn patch_core_request_log(enabled: bool) -> Result<(), String> { }) } +pub(crate) fn patch_core_logging_settings(settings: &CoreConfigSettings) -> Result<(), String> { + let settings = settings.clone(); + patch_existing_core_config(move |document| { + let mut changed = false; + for (key, value) in [ + ("debug", serde_norway::Value::Bool(settings.debug)), + ( + "commercial-mode", + serde_norway::Value::Bool(settings.commercial_mode), + ), + ( + "logging-to-file", + serde_norway::Value::Bool(settings.logging_to_file), + ), + ( + "logs-max-total-size-mb", + serde_norway::to_value(settings.logs_max_total_size_mb) + .map_err(|err| format!("序列化日志容量限制失败: {err}"))?, + ), + ( + "error-logs-max-files", + serde_norway::to_value(settings.error_logs_max_files) + .map_err(|err| format!("序列化错误日志保留数失败: {err}"))?, + ), + ( + "usage-statistics-enabled", + serde_norway::Value::Bool(settings.usage_statistics_enabled), + ), + ( + "redis-usage-queue-retention-seconds", + serde_norway::to_value(settings.redis_usage_queue_retention_seconds) + .map_err(|err| format!("序列化 Redis 用量队列保留时间失败: {err}"))?, + ), + ] { + changed |= set_core_yaml_top_level_value(document, key, value)?; + } + Ok(changed) + }) +} + pub(crate) fn patch_core_routing_strategy(strategy: &str) -> Result<(), String> { let strategy = strategy.to_string(); patch_existing_core_config(move |document| { @@ -1144,6 +1184,48 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or_else(|| OAUTH_DIR_NAME.to_string()); + let debug = yaml_mapping_value(root, "debug") + .map(|value| { + value + .as_bool() + .ok_or_else(|| "debug 必须是布尔值".to_string()) + }) + .transpose()? + .unwrap_or(false); + let commercial_mode = yaml_mapping_value(root, "commercial-mode") + .map(|value| { + value + .as_bool() + .ok_or_else(|| "commercial-mode 必须是布尔值".to_string()) + }) + .transpose()? + .unwrap_or(false); + let logging_to_file = yaml_mapping_value(root, "logging-to-file") + .map(|value| { + value + .as_bool() + .ok_or_else(|| "logging-to-file 必须是布尔值".to_string()) + }) + .transpose()? + .unwrap_or(false); + let logs_max_total_size_mb = yaml_mapping_value(root, "logs-max-total-size-mb") + .map(|value| { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| "logs-max-total-size-mb 必须是非负整数".to_string()) + }) + .transpose()? + .unwrap_or(DEFAULT_LOGS_MAX_TOTAL_SIZE_MB); + let error_logs_max_files = yaml_mapping_value(root, "error-logs-max-files") + .map(|value| { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| "error-logs-max-files 必须是非负整数".to_string()) + }) + .transpose()? + .unwrap_or(DEFAULT_ERROR_LOGS_MAX_FILES); let usage_statistics_enabled = yaml_mapping_value(root, "usage-statistics-enabled") .map(|value| { value @@ -1152,6 +1234,21 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or(true); + let redis_usage_queue_retention_seconds = + yaml_mapping_value(root, "redis-usage-queue-retention-seconds") + .map(|value| { + value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| "redis-usage-queue-retention-seconds 必须是非负整数".to_string()) + }) + .transpose()? + .unwrap_or(DEFAULT_REDIS_USAGE_QUEUE_RETENTION_SECONDS); + let redis_usage_queue_retention_seconds = if redis_usage_queue_retention_seconds == 0 { + DEFAULT_REDIS_USAGE_QUEUE_RETENTION_SECONDS + } else { + redis_usage_queue_retention_seconds.min(3600) + }; let request_log = yaml_mapping_value(root, "request-log") .map(|value| { value @@ -1264,7 +1361,13 @@ pub(crate) fn core_config_settings_from_value( management_secret_configured: management_secret_key .as_deref() .is_some_and(|value| !value.is_empty()), + debug, + commercial_mode, + logging_to_file, + logs_max_total_size_mb, + error_logs_max_files, usage_statistics_enabled, + redis_usage_queue_retention_seconds, request_log, plugins_enabled, routing_strategy, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7752563..fb8cefa 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -138,6 +138,9 @@ const DEFAULT_MAX_RETRY_CREDENTIALS: u32 = 0; const DEFAULT_MAX_RETRY_INTERVAL: u32 = 30; const DEFAULT_STREAMING_BOOTSTRAP_RETRIES: u32 = 0; const DEFAULT_DISABLE_COOLING: bool = false; +const DEFAULT_LOGS_MAX_TOTAL_SIZE_MB: u32 = 0; +const DEFAULT_ERROR_LOGS_MAX_FILES: u32 = 10; +const DEFAULT_REDIS_USAGE_QUEUE_RETENTION_SECONDS: u32 = 60; const LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY: &str = "123456"; const MANAGED_AGENT_PROVIDER_ID: &str = "cpa-gui"; const ZCODE_CONFIG_FILE: &str = "config.json"; @@ -594,7 +597,13 @@ struct GuiConfigFile { api_keys: Vec, api_access_remarks: Vec, management_secret_key: String, + debug: bool, + commercial_mode: bool, + logging_to_file: bool, + logs_max_total_size_mb: u32, + error_logs_max_files: u32, usage_statistics_enabled: bool, + redis_usage_queue_retention_seconds: u32, request_log: bool, plugins_enabled: bool, routing_strategy: String, @@ -882,7 +891,13 @@ impl Default for GuiConfigFile { // Populated with an OS-generated secret while loading the GUI // configuration. Core hashes the value written into config.yaml. management_secret_key: String::new(), + debug: false, + commercial_mode: false, + logging_to_file: false, + logs_max_total_size_mb: DEFAULT_LOGS_MAX_TOTAL_SIZE_MB, + error_logs_max_files: DEFAULT_ERROR_LOGS_MAX_FILES, usage_statistics_enabled: true, + redis_usage_queue_retention_seconds: DEFAULT_REDIS_USAGE_QUEUE_RETENTION_SECONDS, request_log: false, plugins_enabled: false, routing_strategy: "round-robin".to_string(), @@ -917,7 +932,13 @@ struct GuiConfigPresence { default_terminal: Option, start_core_on_launch: Option, silent_start: Option, + debug: Option, + commercial_mode: Option, + logging_to_file: Option, + logs_max_total_size_mb: Option, + error_logs_max_files: Option, usage_statistics_enabled: Option, + redis_usage_queue_retention_seconds: Option, request_log: Option, plugins_enabled: Option, routing_strategy: Option, @@ -1379,6 +1400,18 @@ struct CoreTlsSettings { key: String, } +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CoreLoggingSettingsInput { + debug: bool, + commercial_mode: bool, + logging_to_file: bool, + logs_max_total_size_mb: u32, + error_logs_max_files: u32, + usage_statistics_enabled: bool, + redis_usage_queue_retention_seconds: u32, +} + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct CoreConfigSettings { @@ -1390,8 +1423,13 @@ struct CoreConfigSettings { auth_dir: String, api_keys: Vec, management_secret_configured: bool, - #[serde(skip_serializing)] + debug: bool, + commercial_mode: bool, + logging_to_file: bool, + logs_max_total_size_mb: u32, + error_logs_max_files: u32, usage_statistics_enabled: bool, + redis_usage_queue_retention_seconds: u32, request_log: bool, plugins_enabled: bool, routing_strategy: String, @@ -1424,6 +1462,13 @@ struct CoreConfigView { management_secret_configured: bool, port: u16, allow_lan: bool, + debug: bool, + commercial_mode: bool, + logging_to_file: bool, + logs_max_total_size_mb: u32, + error_logs_max_files: u32, + usage_statistics_enabled: bool, + redis_usage_queue_retention_seconds: u32, request_log: bool, plugins_enabled: bool, routing_strategy: String, @@ -2038,7 +2083,14 @@ impl GuiConfigState { config.port = settings.port; config.allow_lan = !is_loopback_host(&settings.host); config.auth_dir = settings.auth_dir.clone(); + config.debug = settings.debug; + config.commercial_mode = settings.commercial_mode; + config.logging_to_file = settings.logging_to_file; + config.logs_max_total_size_mb = settings.logs_max_total_size_mb; + config.error_logs_max_files = settings.error_logs_max_files; config.usage_statistics_enabled = settings.usage_statistics_enabled; + config.redis_usage_queue_retention_seconds = + settings.redis_usage_queue_retention_seconds; config.request_log = settings.request_log; if let Some(secret_key) = settings .management_secret_key @@ -2099,7 +2151,13 @@ impl From<&GuiConfigFile> for CoreConfigSettings { auth_dir: config.auth_dir.clone(), api_keys: gui_api_key_values(&config.api_keys), management_secret_configured: !config.management_secret_key.is_empty(), + debug: config.debug, + commercial_mode: config.commercial_mode, + logging_to_file: config.logging_to_file, + logs_max_total_size_mb: config.logs_max_total_size_mb, + error_logs_max_files: config.error_logs_max_files, usage_statistics_enabled: config.usage_statistics_enabled, + redis_usage_queue_retention_seconds: config.redis_usage_queue_retention_seconds, request_log: config.request_log, plugins_enabled: config.plugins_enabled, routing_strategy: config.routing_strategy.clone(), @@ -2131,6 +2189,13 @@ impl From<&GuiConfigFile> for CoreConfigView { management_secret_configured: !config.management_secret_key.is_empty(), port: config.port, allow_lan: config.allow_lan, + debug: config.debug, + commercial_mode: config.commercial_mode, + logging_to_file: config.logging_to_file, + logs_max_total_size_mb: config.logs_max_total_size_mb, + error_logs_max_files: config.error_logs_max_files, + usage_statistics_enabled: config.usage_statistics_enabled, + redis_usage_queue_retention_seconds: config.redis_usage_queue_retention_seconds, request_log: config.request_log, plugins_enabled: config.plugins_enabled, routing_strategy: config.routing_strategy.clone(), @@ -2444,6 +2509,7 @@ fn main() { get_core_tls_settings, save_core_tls_settings, get_core_config_settings, + save_core_logging_settings, set_core_request_log, add_core_api_key, update_core_api_key, @@ -2454,6 +2520,7 @@ fn main() { provider_health::provider_health_probe, management_api::upload_auth_file, management_api::open_auth_files_directory, + management_api::open_core_logs_directory, set_core_plugins_enabled, set_core_routing_strategy, set_core_proxy_url, diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index 00e1d6c..a8a857f 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -8,9 +8,10 @@ use super::{ use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, + env, error::Error, fs, - path::Path, + path::{Path, PathBuf}, process::{Command, Stdio}, time::Duration, }; @@ -138,6 +139,50 @@ pub(crate) fn open_auth_files_directory( open_directory_in_file_manager(&auth_dir) } +#[tauri::command] +pub(crate) fn open_core_logs_directory( + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result<(), String> { + let config = gui_config_state.snapshot()?; + let install_dir = core_install_dir()?; + let logs_dir = if let Some(writable_path) = writable_path_from_environment(&install_dir) { + let logs_dir = writable_path.join("logs"); + fs::create_dir_all(&logs_dir) + .map_err(|error| format!("创建日志目录失败 {}: {error}", path_to_string(&logs_dir)))?; + logs_dir + } else { + let logs_dir = install_dir.join("logs"); + match fs::create_dir_all(&logs_dir) { + Ok(()) => logs_dir, + Err(_) => { + let fallback = auth_dir_path_for_core(&config.auth_dir, &install_dir).join("logs"); + fs::create_dir_all(&fallback).map_err(|error| { + format!("创建日志目录失败 {}: {error}", path_to_string(&fallback)) + })?; + fallback + } + } + }; + + open_directory_in_file_manager(&logs_dir) +} + +fn writable_path_from_environment(install_dir: &Path) -> Option { + ["WRITABLE_PATH", "writable_path"] + .into_iter() + .find_map(|key| env::var(key).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .map(|path| { + if path.is_absolute() { + path + } else { + install_dir.join(path) + } + }) +} + fn open_directory_in_file_manager(path: &Path) -> Result<(), String> { #[cfg(target_os = "windows")] let mut command = Command::new(windows_explorer_executable()); @@ -155,7 +200,7 @@ fn open_directory_in_file_manager(path: &Path) -> Result<(), String> { command .spawn() .map(|_| ()) - .map_err(|error| format!("打开凭证目录失败 {}: {error}", path_to_string(path))) + .map_err(|error| format!("打开目录失败 {}: {error}", path_to_string(path))) } #[tauri::command] diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index 6b1b364..d524365 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -86,6 +86,13 @@ fn core_config_view_exposes_api_key_metadata_for_the_webview() { assert_eq!(view["apiKeys"][0]["remark"], DEFAULT_API_KEY_INITIAL_REMARK); assert!(view["apiKeys"][0].get("builtIn").is_none()); assert_eq!(view["managementSecretConfigured"], true); + assert_eq!(view["debug"], false); + assert_eq!(view["commercialMode"], false); + assert_eq!(view["loggingToFile"], false); + assert_eq!(view["logsMaxTotalSizeMb"], 0); + assert_eq!(view["errorLogsMaxFiles"], 10); + assert_eq!(view["usageStatisticsEnabled"], true); + assert_eq!(view["redisUsageQueueRetentionSeconds"], 60); assert_eq!(view["requestLog"], false); assert!(view.get("managementSecretKey").is_none()); } @@ -336,7 +343,13 @@ fn legacy_gui_config_can_seed_managed_core_settings() { auth_dir: "/tmp/external-auth".to_string(), api_keys: vec!["existing-key".to_string()], management_secret_configured: true, + debug: true, + commercial_mode: true, + logging_to_file: true, + logs_max_total_size_mb: 256, + error_logs_max_files: 24, usage_statistics_enabled: false, + redis_usage_queue_retention_seconds: 120, request_log: true, plugins_enabled: true, routing_strategy: "fill-first".to_string(), @@ -353,6 +366,12 @@ fn legacy_gui_config_can_seed_managed_core_settings() { assert!(presence.api_keys.is_none()); assert!(presence.management_secret_key.is_none()); + assert!(presence.debug.is_none()); + assert!(presence.commercial_mode.is_none()); + assert!(presence.logging_to_file.is_none()); + assert!(presence.logs_max_total_size_mb.is_none()); + assert!(presence.error_logs_max_files.is_none()); + assert!(presence.redis_usage_queue_retention_seconds.is_none()); assert!(presence.request_log.is_none()); assert!(presence.plugins_enabled.is_none()); assert!(presence.routing_strategy.is_none()); @@ -364,7 +383,13 @@ fn legacy_gui_config_can_seed_managed_core_settings() { assert_eq!(config.port, 9000); assert!(config.disable_cooling); assert_eq!(config.auth_dir, "/tmp/external-auth"); + assert!(config.debug); + assert!(config.commercial_mode); + assert!(config.logging_to_file); + assert_eq!(config.logs_max_total_size_mb, 256); + assert_eq!(config.error_logs_max_files, 24); assert!(!config.usage_statistics_enabled); + assert_eq!(config.redis_usage_queue_retention_seconds, 120); assert!(config.request_log); assert!(config.plugins_enabled); assert_eq!(config.routing_strategy, "fill-first"); @@ -810,13 +835,47 @@ fn core_config_reads_legacy_api_key_entries() { } #[test] -fn core_config_reads_request_log_and_defaults_to_disabled() { - let enabled = serde_norway::from_str::("request-log: true\n").unwrap(); - let settings = core_config_settings_from_value(&enabled).unwrap(); +fn core_config_reads_logging_settings_and_applies_defaults() { + let configured = serde_norway::from_str::( + "debug: true\ncommercial-mode: true\nlogging-to-file: true\nlogs-max-total-size-mb: 512\nerror-logs-max-files: 25\nusage-statistics-enabled: false\nredis-usage-queue-retention-seconds: 7200\nrequest-log: true\n", + ) + .unwrap(); + let settings = core_config_settings_from_value(&configured).unwrap(); + + assert!(settings.debug); + assert!(settings.commercial_mode); + assert!(settings.logging_to_file); + assert_eq!(settings.logs_max_total_size_mb, 512); + assert_eq!(settings.error_logs_max_files, 25); + assert!(!settings.usage_statistics_enabled); + assert_eq!(settings.redis_usage_queue_retention_seconds, 3600); assert!(settings.request_log); let defaults = core_config_settings_from_value(&serde_norway::from_str("{}").unwrap()).unwrap(); + assert!(!defaults.debug); + assert!(!defaults.commercial_mode); + assert!(!defaults.logging_to_file); + assert_eq!( + defaults.logs_max_total_size_mb, + DEFAULT_LOGS_MAX_TOTAL_SIZE_MB + ); + assert_eq!(defaults.error_logs_max_files, DEFAULT_ERROR_LOGS_MAX_FILES); + assert!(defaults.usage_statistics_enabled); + assert_eq!( + defaults.redis_usage_queue_retention_seconds, + DEFAULT_REDIS_USAGE_QUEUE_RETENTION_SECONDS + ); assert!(!defaults.request_log); + + let zero_retention = + serde_norway::from_str::("redis-usage-queue-retention-seconds: 0\n") + .unwrap(); + assert_eq!( + core_config_settings_from_value(&zero_retention) + .unwrap() + .redis_usage_queue_retention_seconds, + DEFAULT_REDIS_USAGE_QUEUE_RETENTION_SECONDS + ); } #[test] @@ -874,7 +933,7 @@ fn core_config_reads_retry_fields_and_uses_core_defaults() { #[test] fn managed_session_settings_use_canonical_yaml_and_preserve_unrelated_content() { - let input = "# global proxy\nproxy-url: old\n# routing options\nrouting:\n strategy: round-robin\n sessionAffinity: false\n sessionAffinityTTL: 10m\n# unrelated option\ndebug: true\n"; + let input = "# global proxy\nproxy-url: old\n# routing options\nrouting:\n strategy: round-robin\n sessionAffinity: false\n sessionAffinityTTL: 10m\n# unrelated option\nunrelated-option: true\ndebug: true\n"; let config = GuiConfigFile { proxy_url: "http://127.0.0.1:8080".to_string(), routing_session_affinity: true, @@ -899,7 +958,8 @@ fn managed_session_settings_use_canonical_yaml_and_preserve_unrelated_content() assert_eq!(document["streaming"]["bootstrap-retries"], 1); assert!(rendered.contains("# global proxy")); assert!(rendered.contains("# unrelated option")); - assert_eq!(document["debug"], true); + assert_eq!(document["unrelated-option"], true); + assert_eq!(document["debug"], false); } #[test] @@ -1061,7 +1121,13 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { ], api_access_remarks: Vec::new(), management_secret_key: String::new(), + debug: true, + commercial_mode: true, + logging_to_file: true, + logs_max_total_size_mb: 512, + error_logs_max_files: 25, usage_statistics_enabled: false, + redis_usage_queue_retention_seconds: 180, request_log: true, plugins_enabled: true, routing_strategy: "fill-first".to_string(), @@ -1104,7 +1170,13 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { assert_eq!(document["max-retry-credentials"], 2); assert_eq!(document["max-retry-interval"], 5); assert_eq!(document["streaming"]["bootstrap-retries"], 1); + assert_eq!(document["debug"], true); + assert_eq!(document["commercial-mode"], true); + assert_eq!(document["logging-to-file"], true); + assert_eq!(document["logs-max-total-size-mb"], 512); + assert_eq!(document["error-logs-max-files"], 25); assert_eq!(document["usage-statistics-enabled"], false); + assert_eq!(document["redis-usage-queue-retention-seconds"], 180); assert_eq!(document["request-log"], true); assert!(document.get("new-option").is_none()); assert_eq!(document["nested"], current_document["nested"]); @@ -1155,7 +1227,7 @@ fn startup_merge_preserves_plugin_store_config_written_by_core() { document["plugins"]["configs"]["model-fallback-router"]["store"]["install"]["type"], "github-release" ); - assert!(document.get("commercial-mode").is_none()); + assert_eq!(document["commercial-mode"], false); assert!(document["plugins"]["configs"].get("example").is_none()); } @@ -1177,7 +1249,12 @@ fn startup_merge_without_current_config_uses_gui_defaults() { assert_eq!(document["api-keys"][0], DEFAULT_API_KEY, "{merged}"); assert_eq!(document["plugins"]["enabled"], false); assert_eq!(document["routing"]["strategy"], "round-robin"); + assert_eq!(document["commercial-mode"], false); + assert_eq!(document["logging-to-file"], false); + assert_eq!(document["logs-max-total-size-mb"], 0); + assert_eq!(document["error-logs-max-files"], 10); assert_eq!(document["usage-statistics-enabled"], true); + assert_eq!(document["redis-usage-queue-retention-seconds"], 60); assert_eq!(document["request-log"], false); assert_eq!( document["remote-management"]["secret-key"], diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index c3ccbbd..97a0e91 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -3855,7 +3855,14 @@ mod tests { api_keys: Vec::new(), api_access_remarks: Vec::new(), management_secret_key: "123456".to_string(), + debug: false, + commercial_mode: false, + logging_to_file: false, + logs_max_total_size_mb: crate::DEFAULT_LOGS_MAX_TOTAL_SIZE_MB, + error_logs_max_files: crate::DEFAULT_ERROR_LOGS_MAX_FILES, usage_statistics_enabled: true, + redis_usage_queue_retention_seconds: crate::DEFAULT_REDIS_USAGE_QUEUE_RETENTION_SECONDS, + request_log: false, plugins_enabled: false, routing_strategy: "round-robin".to_string(), proxy_url: String::new(), diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 998a364..2ef698d 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -349,17 +349,31 @@ export const jaOverrides = { 'config.notice.sessionAffinityEnabled': 'セッションアフィニティを有効にしました', 'config.notice.sessionAffinityDisabled': 'セッションアフィニティを無効にしました', 'config.notice.sessionTtlUpdated': 'セッションアフィニティ TTL を保存しました', - 'config.diagnostics.title': 'ログ診断', - 'config.diagnostics.heading': '詳細なリクエストとレスポンスのログを記録', - 'config.diagnostics.description': '有効にすると、モデル呼び出しや上流通信の問題を調査するために、CPA が詳細なリクエストとレスポンスを保存します。', - 'config.diagnostics.warning': 'ログには機密性の高いリクエスト内容が含まれ、ディスク使用量が増える可能性があります。問題調査時のみ有効にしてください。', - 'config.diagnostics.enabled': '有効', - 'config.diagnostics.disabled': '無効', - 'config.diagnostics.notice.enabled': 'ログ診断を有効にしました', - 'config.diagnostics.notice.disabled': 'ログ診断を無効にしました', + 'config.diagnostics.title': 'ログと診断', + 'config.diagnostics.description': 'デバッグ出力、ファイルログ、使用量統計を CPA WebUI と同じ項目で設定します。', + 'config.diagnostics.debug.title': 'デバッグモード', + 'config.diagnostics.debug.description': '問題調査用の詳細なデバッグログを出力します。', + 'config.diagnostics.commercial.title': '商用モード', + 'config.diagnostics.commercial.description': '高負荷なリクエストログと一部 HTTP ミドルウェアを無効にします。', + 'config.diagnostics.commercial.warning': '商用モードの変更時は、保存後に実行中の CPA コアを自動再起動します。', + 'config.diagnostics.fileLogging.title': 'ログファイルへ書き込む', + 'config.diagnostics.fileLogging.description': 'アプリログをローテーションファイルへ保存します。無効時はコンソールへ出力します。', + 'config.diagnostics.usage.title': '使用量統計', + 'config.diagnostics.usage.description': 'リクエストとトークン使用量をメモリ内で集計します。', + 'config.diagnostics.maxSize.title': 'ログ合計サイズ上限(MB)', + 'config.diagnostics.maxSize.hint': '上限超過時に古いログを削除します。0 は無制限です。', + 'config.diagnostics.errorFiles.title': 'エラーリクエストログ保持数', + 'config.diagnostics.errorFiles.hint': 'リクエストログ無効時の保持数です。0 は自動削除なしです。', + 'config.diagnostics.redisRetention.title': '使用量キュー保持時間(秒)', + 'config.diagnostics.redisRetention.hint': '管理 API 用キューの保持時間を 1~3600 秒で設定します。', + 'config.diagnostics.save': 'ログ設定を保存', + 'config.diagnostics.openLogs': 'ログフォルダーを開く', + 'config.diagnostics.notice.saved': 'ログと診断の設定を保存しました', + 'config.diagnostics.notice.savedAndRestarted': 'ログと診断の設定を保存し、CPA を再起動しました', + 'config.diagnostics.error.nonNegativeInteger': 'ログサイズと保持数には 0 以上の整数を入力してください', + 'config.diagnostics.error.redisRetention': '使用量キュー保持時間には 1~3600 の整数を入力してください', + 'config.diagnostics.error.openLogs': 'ログフォルダーを開けませんでした:{{error}}', 'config.webuiKey.title': 'WebUI キー', - 'config.webuiKey.configured': '設定済み', - 'config.webuiKey.unconfigured': '未設定', 'config.webuiKey.heading': '管理 API のアクセス認証情報', 'config.webuiKey.description': 'CPA WebUI へのログインと管理 API の呼び出しに使用します。上の API 認証キーとは用途が異なります。', 'config.webuiKey.securityHint': '既存のキーは表示されません。忘れた場合は、そのままキーを再設定してください。', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index a8aed33..23c9afd 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -430,17 +430,31 @@ export const en: Record = { 'config.software.behavior.ask': 'Ask every time', 'config.software.behavior.minimize': 'Minimize to Tray', 'config.software.behavior.exit': 'Exit App', - 'config.diagnostics.title': 'Log Diagnostics', - 'config.diagnostics.heading': 'Record detailed request and response logs', - 'config.diagnostics.description': 'When enabled, CPA saves detailed request and response data for troubleshooting model calls and upstream communication.', - 'config.diagnostics.warning': 'Logs may contain sensitive request content and use additional disk space. Enable only while troubleshooting.', - 'config.diagnostics.enabled': 'Enabled', - 'config.diagnostics.disabled': 'Disabled', - 'config.diagnostics.notice.enabled': 'Log diagnostics enabled', - 'config.diagnostics.notice.disabled': 'Log diagnostics disabled', + 'config.diagnostics.title': 'Logging & Diagnostics', + 'config.diagnostics.description': 'Configure debug output, file logging, and usage statistics with the same controls available in the CPA WebUI.', + 'config.diagnostics.debug.title': 'Debug mode', + 'config.diagnostics.debug.description': 'Emit detailed debug-level logs to help diagnose runtime issues.', + 'config.diagnostics.commercial.title': 'Commercial mode', + 'config.diagnostics.commercial.description': 'Disable high-overhead request logging and selected HTTP middleware for high concurrency.', + 'config.diagnostics.commercial.warning': 'Commercial mode rebuilds HTTP middleware. Saving automatically restarts CPA when it is running.', + 'config.diagnostics.fileLogging.title': 'Write logs to files', + 'config.diagnostics.fileLogging.description': 'Write application logs to rotating files; when disabled, logs go to the console.', + 'config.diagnostics.usage.title': 'Usage statistics', + 'config.diagnostics.usage.description': 'Aggregate request and token usage in memory for the usage records page.', + 'config.diagnostics.maxSize.title': 'Maximum total log size (MB)', + 'config.diagnostics.maxSize.hint': 'Oldest files are deleted after the limit is exceeded. Use 0 for unlimited.', + 'config.diagnostics.errorFiles.title': 'Error request log retention', + 'config.diagnostics.errorFiles.hint': 'Files retained while request logging is disabled. Use 0 to disable cleanup.', + 'config.diagnostics.redisRetention.title': 'Usage queue retention (seconds)', + 'config.diagnostics.redisRetention.hint': 'In-memory retention for Management API usage entries, from 1 to 3600 seconds.', + 'config.diagnostics.save': 'Save logging settings', + 'config.diagnostics.openLogs': 'Open Log Folder', + 'config.diagnostics.notice.saved': 'Logging and diagnostics settings saved', + 'config.diagnostics.notice.savedAndRestarted': 'Logging and diagnostics settings saved; CPA restarted', + 'config.diagnostics.error.nonNegativeInteger': 'Log size and error log retention must be non-negative integers', + 'config.diagnostics.error.redisRetention': 'Usage queue retention must be an integer between 1 and 3600', + 'config.diagnostics.error.openLogs': 'Failed to open the log folder: {{error}}', 'config.webuiKey.title': 'WebUI Key', - 'config.webuiKey.configured': 'Configured', - 'config.webuiKey.unconfigured': 'Not configured', 'config.webuiKey.heading': 'Management access credential', 'config.webuiKey.description': 'Used to sign in to the CPA WebUI and call the Management API. It is different from the API authentication keys above.', 'config.webuiKey.securityHint': 'The current key is not displayed. If you forget it, simply reset the key.', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 145a112..7d71436 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -428,17 +428,31 @@ export const zhCN = { 'config.software.behavior.ask': '每次询问', 'config.software.behavior.minimize': '最小化到托盘', 'config.software.behavior.exit': '退出程序', - 'config.diagnostics.title': '日志诊断', - 'config.diagnostics.heading': '记录详细请求与响应日志', - 'config.diagnostics.description': '开启后,CPA 会保存详细请求和响应信息,用于定位模型调用与上游通信问题。', - 'config.diagnostics.warning': '日志可能包含敏感请求内容并增加磁盘占用,仅在排查问题时开启。', - 'config.diagnostics.enabled': '已开启', - 'config.diagnostics.disabled': '已关闭', - 'config.diagnostics.notice.enabled': '日志诊断已开启', - 'config.diagnostics.notice.disabled': '日志诊断已关闭', + 'config.diagnostics.title': '日志与诊断', + 'config.diagnostics.description': '配置调试输出、文件日志及用量统计。所有字段与 CPA WebUI 的日志设置保持一致。', + 'config.diagnostics.debug.title': '调试模式', + 'config.diagnostics.debug.description': '输出更详细的调试级日志,便于定位运行异常。', + 'config.diagnostics.commercial.title': '商业模式', + 'config.diagnostics.commercial.description': '关闭高开销请求日志和部分 HTTP 中间件,以降低高并发开销。', + 'config.diagnostics.commercial.warning': '商业模式需要重建 HTTP 中间件;保存后会自动重启正在运行的 CPA 内核。', + 'config.diagnostics.fileLogging.title': '写入日志文件', + 'config.diagnostics.fileLogging.description': '将应用日志写入轮转文件;关闭后日志仅输出到控制台。', + 'config.diagnostics.usage.title': '用量统计', + 'config.diagnostics.usage.description': '在内存中聚合请求和 Token 用量,供使用记录页面读取。', + 'config.diagnostics.maxSize.title': '日志文件总容量上限(MB)', + 'config.diagnostics.maxSize.hint': '超过限制后自动删除最旧日志;填写 0 表示不限制。', + 'config.diagnostics.errorFiles.title': '错误请求日志保留数', + 'config.diagnostics.errorFiles.hint': '请求日志关闭时保留的错误报文数量;填写 0 表示不自动清理。', + 'config.diagnostics.redisRetention.title': '用量队列保留时间(秒)', + 'config.diagnostics.redisRetention.hint': '管理接口用量队列的内存保留时间,可设置 1–3600 秒。', + 'config.diagnostics.save': '保存日志设置', + 'config.diagnostics.openLogs': '打开日志文件', + 'config.diagnostics.notice.saved': '日志与诊断设置已保存', + 'config.diagnostics.notice.savedAndRestarted': '日志与诊断设置已保存,CPA 内核已重启', + 'config.diagnostics.error.nonNegativeInteger': '日志容量和错误日志保留数必须是非负整数', + 'config.diagnostics.error.redisRetention': '用量队列保留时间必须是 1 到 3600 之间的整数', + 'config.diagnostics.error.openLogs': '打开日志文件失败:{{error}}', 'config.webuiKey.title': 'WebUI 密钥', - 'config.webuiKey.configured': '已配置', - 'config.webuiKey.unconfigured': '未配置', 'config.webuiKey.heading': '管理接口访问凭据', 'config.webuiKey.description': '用于登录 CPA WebUI 和调用管理接口,与上方 API 鉴权密钥用途不同。', 'config.webuiKey.securityHint': '现有密钥不会显示;如果忘记请直接重新设置密钥', diff --git a/src/pages/ConfigPanel.tsx b/src/pages/ConfigPanel.tsx index 527a843..0e28151 100644 --- a/src/pages/ConfigPanel.tsx +++ b/src/pages/ConfigPanel.tsx @@ -4,13 +4,17 @@ import { listen } from '@tauri-apps/api/event'; import { open } from '@tauri-apps/plugin-dialog'; import { AlertCircle, + Bug, Check, Copy, Clock3, + Database, Eye, EyeOff, FileText, FolderOpen, + Gauge, + HardDrive, KeyRound, Link2, LockKeyhole, @@ -34,8 +38,13 @@ import { ThinkingAliasesPage } from './ThinkingAliasesPage'; type CoreConfigSettings = { apiKeys: CoreApiKey[]; - managementSecretConfigured: boolean; - requestLog: boolean; + debug: boolean; + commercialMode: boolean; + loggingToFile: boolean; + logsMaxTotalSizeMb: number; + errorLogsMaxFiles: number; + usageStatisticsEnabled: boolean; + redisUsageQueueRetentionSeconds: number; host: string; port: number; allowLan: boolean; @@ -60,7 +69,8 @@ type ConfigAction = | 'update-key' | 'delete-key' | 'management-secret' - | 'request-log' + | 'logging' + | 'open-logs' | 'routing' | 'network' | 'retry' @@ -157,6 +167,14 @@ export function ConfigPanelPage() { const [managementSecretConfirm, setManagementSecretConfirm] = useState(''); const [showManagementSecret, setShowManagementSecret] = useState(false); const [managementSecretError, setManagementSecretError] = useState(''); + const [debugDraft, setDebugDraft] = useState(false); + const [commercialModeDraft, setCommercialModeDraft] = useState(false); + const [loggingToFileDraft, setLoggingToFileDraft] = useState(false); + const [logsMaxTotalSizeDraft, setLogsMaxTotalSizeDraft] = useState('0'); + const [errorLogsMaxFilesDraft, setErrorLogsMaxFilesDraft] = useState('10'); + const [usageStatisticsDraft, setUsageStatisticsDraft] = useState(true); + const [redisUsageRetentionDraft, setRedisUsageRetentionDraft] = useState('60'); + const [loggingError, setLoggingError] = useState(''); const [copiedIndex, setCopiedIndex] = useState(null); const [notice, setNotice] = useState<{ message: string; tone: NoticeTone } | null>(null); const [activeSubpage, setActiveSubpage] = useState('general'); @@ -174,6 +192,7 @@ export function ConfigPanelPage() { const [hostError, setHostError] = useState(''); const [retryError, setRetryError] = useState(''); const networkDraftDirtyRef = useRef(cleanNetworkDraft()); + const loggingDraftDirtyRef = useRef(false); const noticeTimerRef = useRef(null); const copyTimerRef = useRef(null); @@ -232,9 +251,19 @@ export function ConfigPanelPage() { if (!dirty.streamingBootstrapRetries) { setStreamingBootstrapRetriesDraft(String(result.streamingBootstrapRetries)); } + if (!loggingDraftDirtyRef.current) { + setDebugDraft(result.debug); + setCommercialModeDraft(result.commercialMode); + setLoggingToFileDraft(result.loggingToFile); + setLogsMaxTotalSizeDraft(String(result.logsMaxTotalSizeMb)); + setErrorLogsMaxFilesDraft(String(result.errorLogsMaxFiles)); + setUsageStatisticsDraft(result.usageStatisticsEnabled); + setRedisUsageRetentionDraft(String(result.redisUsageQueueRetentionSeconds)); + } return; } networkDraftDirtyRef.current = cleanNetworkDraft(); + loggingDraftDirtyRef.current = false; setPortDraft(String(result.port)); setHostDraft(result.host); setProxyUrlDraft(result.proxyUrl); @@ -245,9 +274,22 @@ export function ConfigPanelPage() { setMaxRetryCredentialsDraft(String(result.maxRetryCredentials)); setMaxRetryIntervalDraft(String(result.maxRetryInterval)); setStreamingBootstrapRetriesDraft(String(result.streamingBootstrapRetries)); + setDebugDraft(result.debug); + setCommercialModeDraft(result.commercialMode); + setLoggingToFileDraft(result.loggingToFile); + setLogsMaxTotalSizeDraft(String(result.logsMaxTotalSizeMb)); + setErrorLogsMaxFilesDraft(String(result.errorLogsMaxFiles)); + setUsageStatisticsDraft(result.usageStatisticsEnabled); + setRedisUsageRetentionDraft(String(result.redisUsageQueueRetentionSeconds)); setPortError(''); setHostError(''); setRetryError(''); + setLoggingError(''); + }; + + const markLoggingDraftDirty = () => { + loggingDraftDirtyRef.current = true; + setLoggingError(''); }; const markDraftDirty = (field: NetworkDraftField) => { @@ -452,16 +494,80 @@ export function ConfigPanelPage() { } }; - const setRequestLogEnabled = async (enabled: boolean) => { + const saveCoreLoggingSettings = async () => { if (!settings || busyAction !== null) return; - await runMutation( - 'request-log', - 'set_core_request_log', - { enabled }, - enabled - ? t('config.diagnostics.notice.enabled') - : t('config.diagnostics.notice.disabled'), - ); + const logsMaxTotalSizeMb = Number(logsMaxTotalSizeDraft); + const errorLogsMaxFiles = Number(errorLogsMaxFilesDraft); + const redisUsageQueueRetentionSeconds = Number(redisUsageRetentionDraft); + if ( + !Number.isInteger(logsMaxTotalSizeMb) + || logsMaxTotalSizeMb < 0 + || logsMaxTotalSizeMb > 4294967295 + || !Number.isInteger(errorLogsMaxFiles) + || errorLogsMaxFiles < 0 + || errorLogsMaxFiles > 4294967295 + ) { + const message = t('config.diagnostics.error.nonNegativeInteger'); + setLoggingError(message); + showNotice(message, 'error'); + return; + } + if ( + !Number.isInteger(redisUsageQueueRetentionSeconds) + || redisUsageQueueRetentionSeconds < 1 + || redisUsageQueueRetentionSeconds > 3600 + ) { + const message = t('config.diagnostics.error.redisRetention'); + setLoggingError(message); + showNotice(message, 'error'); + return; + } + + const commercialModeChanged = commercialModeDraft !== settings.commercialMode; + setBusyAction('logging'); + setLoggingError(''); + try { + const result = await invoke('save_core_logging_settings', { + settings: { + debug: debugDraft, + commercialMode: commercialModeDraft, + loggingToFile: loggingToFileDraft, + logsMaxTotalSizeMb, + errorLogsMaxFiles, + usageStatisticsEnabled: usageStatisticsDraft, + redisUsageQueueRetentionSeconds, + }, + }); + loggingDraftDirtyRef.current = false; + applySettings(result, 'preserve'); + if (commercialModeChanged && coreStatus?.running) { + const status = await invoke('restart_core_process'); + publishStatus(status); + showNotice(t('config.diagnostics.notice.savedAndRestarted'), 'success'); + } else { + showNotice(t('config.diagnostics.notice.saved'), 'success'); + } + } catch (error) { + const message = t('config.error.saveFailed', { error: String(error) }); + setLoggingError(message); + showNotice(message, 'error'); + void refreshStatus(); + void loadSettings('preserve'); + } finally { + setBusyAction(null); + } + }; + + const openCoreLogsDirectory = async () => { + if (busyAction !== null) return; + setBusyAction('open-logs'); + try { + await invoke('open_core_logs_directory'); + } catch (error) { + showNotice(t('config.diagnostics.error.openLogs', { error: String(error) }), 'error'); + } finally { + setBusyAction(null); + } }; const confirmDelete = async () => { @@ -776,6 +882,15 @@ export function ConfigPanelPage() { || maxRetryIntervalDraft !== String(settings?.maxRetryInterval) || streamingBootstrapRetriesDraft !== String(settings?.streamingBootstrapRetries) ); + const loggingSettingsDirty = Boolean(settings) && ( + debugDraft !== settings?.debug + || commercialModeDraft !== settings?.commercialMode + || loggingToFileDraft !== settings?.loggingToFile + || logsMaxTotalSizeDraft !== String(settings?.logsMaxTotalSizeMb) + || errorLogsMaxFilesDraft !== String(settings?.errorLogsMaxFiles) + || usageStatisticsDraft !== settings?.usageStatisticsEnabled + || redisUsageRetentionDraft !== String(settings?.redisUsageQueueRetentionSeconds) + ); const softwareCloseBehaviorDirty = softwareSettings !== null && softwareCloseBehaviorDraft !== softwareSettings.closeBehavior; const softwareAutostartDirty = softwareSettings !== null @@ -827,6 +942,7 @@ export function ConfigPanelPage() { const deletingLastKey = deleteIndex !== null && settings?.apiKeys.length === 1; const keyMutationBusy = busyAction === 'add-key' || busyAction === 'update-key'; const managementSecretBusy = busyAction === 'management-secret'; + const loggingSettingsBusy = busyAction === 'logging'; return (
@@ -1008,15 +1124,6 @@ export function ConfigPanelPage() {

{t('config.webuiKey.title')}

- {!loading && settings ? ( - - {settings.managementSecretConfigured - ? t('config.webuiKey.configured') - : t('config.webuiKey.unconfigured')} - - ) : null}
- {!loading && settings ? ( - - {settings.requestLog - ? t('config.diagnostics.enabled') - : t('config.diagnostics.disabled')} - - ) : null} -
-
- {t('config.diagnostics.heading')} -

{t('config.diagnostics.description')}

- - +

{t('config.diagnostics.description')}

+ +
+
+ +
+ {t('config.diagnostics.debug.title')} + {t('config.diagnostics.debug.description')} +
+
-
+ +
+ + + +
+ + {commercialModeDraft ? ( +
+
+ ) : null} + +
+ + {loggingError || ' '} + +
+ + +
diff --git a/src/styles.css b/src/styles.css index 6e01287..4bf1ddb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1946,49 +1946,114 @@ textarea:focus-visible { gap: 16px; } -.config-diagnostics-content { - display: flex; +.config-diagnostics-intro { + margin: -4px 0 0; + color: var(--theme-6b655d); + font-size: var(--font-size-label); + line-height: var(--line-height-relaxed); +} + +.config-diagnostics-toggle-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.config-diagnostics-setting { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) 44px; align-items: center; - justify-content: space-between; - gap: 24px; - padding: 16px; + gap: 12px; + min-width: 0; + padding: 15px; border: 1px solid var(--theme-e3e1db); border-radius: 8px; background: var(--theme-faf8f2); } -.config-diagnostics-copy { +.config-diagnostics-setting-icon { display: grid; - min-width: 0; - gap: 7px; + width: 38px; + height: 38px; + place-items: center; + border: 1px solid var(--theme-e3e1db); + border-radius: 8px; + background: var(--theme-fffdf8); + color: var(--theme-5f5a53); } -.config-diagnostics-copy strong { +.config-diagnostics-setting-copy { + display: grid; + min-width: 0; + gap: 4px; +} + +.config-diagnostics-setting-copy strong { color: var(--theme-2d2a26); font-size: var(--font-size-body); } -.config-diagnostics-copy p { +.config-diagnostics-setting-copy small, +.config-diagnostics-field small { margin: 0; color: var(--theme-5f5a53); font-size: var(--font-size-label); line-height: var(--line-height-relaxed); } -.config-diagnostics-warning { - display: inline-flex; - align-items: flex-start; +.config-diagnostics-fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.config-diagnostics-field { + display: grid; + min-width: 0; gap: 7px; - color: var(--theme-9f6429); - font-size: var(--font-size-caption); + padding: 14px; + border: 1px solid var(--theme-e3e1db); + border-radius: 8px; + background: var(--theme-fffdf8); +} + +.config-diagnostics-field > span { + color: var(--theme-39352f); + font-size: var(--font-size-label); + font-weight: 700; +} + +.config-diagnostics-commercial-note { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 11px 13px; + border: 1px solid var(--theme-dfbf91); + border-radius: 7px; + background: var(--theme-fff7e9); + color: var(--theme-8b5b21); + font-size: var(--font-size-label); line-height: var(--line-height-body); } -.config-diagnostics-warning svg { +.config-diagnostics-commercial-note svg { flex: none; margin-top: 1px; } +.config-diagnostics-footer { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; +} + +.config-diagnostics-actions { + display: flex; + align-items: center; + gap: 10px; +} + .config-management-content { display: grid; grid-template-columns: minmax(220px, 0.72fr) minmax(420px, 1.28fr); @@ -2957,10 +3022,25 @@ textarea:focus-visible { } @media (max-width: 480px) { - .config-diagnostics-content { - align-items: flex-start; - gap: 16px; - padding: 14px; + .config-diagnostics-toggle-grid, + .config-diagnostics-fields { + grid-template-columns: 1fr; + } + + .config-diagnostics-footer { + align-items: stretch; + grid-template-columns: 1fr; + flex-direction: column; + } + + .config-diagnostics-actions, + .config-diagnostics-actions button { + width: 100%; + } + + .config-diagnostics-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); } .config-software-setting-row {