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
This commit is contained in:
lzt404 committed 2026-09-04 01:50:45 +08:00
1 parent 2c40691240
commit e3551f0e3c
13 files changed
+897 -113

No files matched your search

+11 -11
View File
@@ -556,17 +556,6 @@ fn parse_modalities(value: &Value) -> Option<Vec<String>> {
(!modalities.is_empty()).then_some(modalities)
}
fn reasoning_efforts(model: &Map<String, Value>) -> Vec<String> {
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<String> {
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<String, Value>) -> Vec<String> {
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#"{
+30
View File
@@ -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<CoreConfigView, String> {
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>,
+82
View File
@@ -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<GuiConfigFile, String> {
|| 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<GuiConfigFile, String> {
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(())
}
+103
View File
@@ -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,
+68 -1
View File
@@ -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<GuiApiKeyEntry>,
api_access_remarks: Vec<GuiApiAccessRemark>,
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<String>,
start_core_on_launch: Option<bool>,
silent_start: Option<bool>,
debug: Option<bool>,
commercial_mode: Option<bool>,
logging_to_file: Option<bool>,
logs_max_total_size_mb: Option<u32>,
error_logs_max_files: Option<u32>,
usage_statistics_enabled: Option<bool>,
redis_usage_queue_retention_seconds: Option<u32>,
request_log: Option<bool>,
plugins_enabled: Option<bool>,
routing_strategy: Option<String>,
@@ -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<String>,
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,
+47 -2
View File
@@ -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<PathBuf> {
["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]
+83 -6
View File
@@ -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::<serde_norway::Value>("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::<serde_norway::Value>(
"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::<serde_norway::Value>("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"],
+7
View File
@@ -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(),
+24 -10
View File
@@ -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': '既存のキーは表示されません。忘れた場合は、そのままキーを再設定してください。',
+24 -10
View File
@@ -430,17 +430,31 @@ export const en: Record<MessageKey, string> = {
'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.',
+24 -10
View File
@@ -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': '现有密钥不会显示;如果忘记请直接重新设置密钥',
+294 -43
View File
@@ -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<number | null>(null);
const [notice, setNotice] = useState<{ message: string; tone: NoticeTone } | null>(null);
const [activeSubpage, setActiveSubpage] = useState<ConfigSubpage>('general');
@@ -174,6 +192,7 @@ export function ConfigPanelPage() {
const [hostError, setHostError] = useState('');
const [retryError, setRetryError] = useState('');
const networkDraftDirtyRef = useRef<NetworkDraftDirty>(cleanNetworkDraft());
const loggingDraftDirtyRef = useRef(false);
const noticeTimerRef = useRef<number | null>(null);
const copyTimerRef = useRef<number | null>(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<CoreConfigSettings>('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<CoreStatus>('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<void>('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 (
<section className="page config-page">
@@ -1008,15 +1124,6 @@ export function ConfigPanelPage() {
<h2>{t('config.webuiKey.title')}</h2>
</div>
<div className="config-heading-actions">
{!loading && settings ? (
<span
className={`state-pill ${settings.managementSecretConfigured ? 'success' : ''}`}
>
{settings.managementSecretConfigured
? t('config.webuiKey.configured')
: t('config.webuiKey.unconfigured')}
</span>
) : null}
<button
type="button"
className="secondary-button compact-button"
@@ -1127,35 +1234,179 @@ export function ConfigPanelPage() {
<FileText size={18} aria-hidden="true" />
<h2>{t('config.diagnostics.title')}</h2>
</div>
{!loading && settings ? (
<span className={`state-pill ${settings.requestLog ? 'warning' : ''}`}>
{settings.requestLog
? t('config.diagnostics.enabled')
: t('config.diagnostics.disabled')}
</span>
) : null}
</div>
<div className="config-diagnostics-content">
<div className="config-diagnostics-copy">
<strong>{t('config.diagnostics.heading')}</strong>
<p>{t('config.diagnostics.description')}</p>
<small className="config-diagnostics-warning">
<AlertCircle size={15} aria-hidden="true" />
<span>{t('config.diagnostics.warning')}</span>
</small>
<p className="config-diagnostics-intro">{t('config.diagnostics.description')}</p>
<div className="config-diagnostics-toggle-grid">
<div className="config-diagnostics-setting">
<span className="config-diagnostics-setting-icon" aria-hidden="true"><Bug size={18} /></span>
<div className="config-diagnostics-setting-copy">
<strong>{t('config.diagnostics.debug.title')}</strong>
<small>{t('config.diagnostics.debug.description')}</small>
</div>
<label className="switch-control" title={t('config.diagnostics.debug.title')}>
<input
type="checkbox"
role="switch"
checked={debugDraft}
disabled={controlsDisabled}
onChange={(event) => {
setDebugDraft(event.currentTarget.checked);
markLoggingDraftDirty();
}}
/>
<span className="switch-track" />
</label>
</div>
<label className="switch-control" title={t('config.diagnostics.title')}>
<div className="config-diagnostics-setting">
<span className="config-diagnostics-setting-icon" aria-hidden="true"><Gauge size={18} /></span>
<div className="config-diagnostics-setting-copy">
<strong>{t('config.diagnostics.commercial.title')}</strong>
<small>{t('config.diagnostics.commercial.description')}</small>
</div>
<label className="switch-control" title={t('config.diagnostics.commercial.title')}>
<input
type="checkbox"
role="switch"
checked={commercialModeDraft}
disabled={controlsDisabled}
onChange={(event) => {
setCommercialModeDraft(event.currentTarget.checked);
markLoggingDraftDirty();
}}
/>
<span className="switch-track" />
</label>
</div>
<div className="config-diagnostics-setting">
<span className="config-diagnostics-setting-icon" aria-hidden="true"><HardDrive size={18} /></span>
<div className="config-diagnostics-setting-copy">
<strong>{t('config.diagnostics.fileLogging.title')}</strong>
<small>{t('config.diagnostics.fileLogging.description')}</small>
</div>
<label className="switch-control" title={t('config.diagnostics.fileLogging.title')}>
<input
type="checkbox"
role="switch"
checked={loggingToFileDraft}
disabled={controlsDisabled}
onChange={(event) => {
setLoggingToFileDraft(event.currentTarget.checked);
markLoggingDraftDirty();
}}
/>
<span className="switch-track" />
</label>
</div>
<div className="config-diagnostics-setting">
<span className="config-diagnostics-setting-icon" aria-hidden="true"><Database size={18} /></span>
<div className="config-diagnostics-setting-copy">
<strong>{t('config.diagnostics.usage.title')}</strong>
<small>{t('config.diagnostics.usage.description')}</small>
</div>
<label className="switch-control" title={t('config.diagnostics.usage.title')}>
<input
type="checkbox"
role="switch"
checked={usageStatisticsDraft}
disabled={controlsDisabled}
onChange={(event) => {
setUsageStatisticsDraft(event.currentTarget.checked);
markLoggingDraftDirty();
}}
/>
<span className="switch-track" />
</label>
</div>
</div>
<div className="config-diagnostics-fields">
<label className="config-diagnostics-field">
<span>{t('config.diagnostics.maxSize.title')}</span>
<input
type="checkbox"
role="switch"
aria-label={t('config.diagnostics.title')}
checked={settings?.requestLog ?? false}
className="config-dialog-text-input"
type="number"
min="0"
step="1"
value={logsMaxTotalSizeDraft}
disabled={controlsDisabled}
onChange={(event) => void setRequestLogEnabled(event.currentTarget.checked)}
onChange={(event) => {
setLogsMaxTotalSizeDraft(event.currentTarget.value);
markLoggingDraftDirty();
}}
/>
<span className="switch-track" />
<small>{t('config.diagnostics.maxSize.hint')}</small>
</label>
<label className="config-diagnostics-field">
<span>{t('config.diagnostics.errorFiles.title')}</span>
<input
className="config-dialog-text-input"
type="number"
min="0"
step="1"
value={errorLogsMaxFilesDraft}
disabled={controlsDisabled}
onChange={(event) => {
setErrorLogsMaxFilesDraft(event.currentTarget.value);
markLoggingDraftDirty();
}}
/>
<small>{t('config.diagnostics.errorFiles.hint')}</small>
</label>
<label className="config-diagnostics-field">
<span>{t('config.diagnostics.redisRetention.title')}</span>
<input
className="config-dialog-text-input"
type="number"
min="1"
max="3600"
step="1"
value={redisUsageRetentionDraft}
disabled={controlsDisabled}
onChange={(event) => {
setRedisUsageRetentionDraft(event.currentTarget.value);
markLoggingDraftDirty();
}}
/>
<small>{t('config.diagnostics.redisRetention.hint')}</small>
</label>
</div>
{commercialModeDraft ? (
<div className="config-diagnostics-commercial-note">
<AlertCircle size={16} aria-hidden="true" />
<span>{t('config.diagnostics.commercial.warning')}</span>
</div>
) : null}
<div className="config-diagnostics-footer">
<span className={`config-form-message ${loggingError ? 'error' : ''}`} role="alert">
{loggingError || ' '}
</span>
<div className="config-diagnostics-actions">
<button
type="button"
className="secondary-button compact-button"
disabled={controlsDisabled}
onClick={() => void openCoreLogsDirectory()}
>
<FolderOpen size={16} aria-hidden="true" />
{t('config.diagnostics.openLogs')}
</button>
<button
type="button"
className="primary-button compact-button"
disabled={controlsDisabled || !loggingSettingsDirty}
onClick={() => void saveCoreLoggingSettings()}
>
<Check size={16} aria-hidden="true" />
{loggingSettingsBusy ? t('common.saving') : t('config.diagnostics.save')}
</button>
</div>
</div>
</section>
</div>
+100 -20
View File
@@ -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 {