feat(settings): add request log diagnostics toggle
- add log diagnostics card under basic settings - persist and synchronize CPA request-log configuration - support configuration migration and rollback on save failure - add localized UI copy and responsive styles - add tests for request-log parsing and YAML merging
This commit is contained in:
1 parent
c278ff9df8
commit
2c40691240
10 files changed
+208
No files matched your search
@@ -394,6 +394,25 @@ pub(crate) fn set_core_plugins_enabled(
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_core_request_log(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
enabled: bool,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
let mut settings = current_core_config_settings(gui_config_state.inner())?;
|
||||
let previous_enabled = settings.request_log;
|
||||
settings.request_log = enabled;
|
||||
patch_core_request_log(settings.request_log)?;
|
||||
let config = match gui_config_state.sync_core_settings(&settings) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error = patch_core_request_log(previous_enabled).err();
|
||||
return Err(config_update_error_with_rollback(error, rollback_error));
|
||||
}
|
||||
};
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_core_routing_strategy(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
|
||||
@@ -590,6 +590,11 @@ pub(crate) fn apply_gui_managed_settings(
|
||||
"usage-statistics-enabled",
|
||||
serde_norway::Value::Bool(config.usage_statistics_enabled),
|
||||
)?;
|
||||
changed |= set_core_yaml_top_level_value(
|
||||
document,
|
||||
"request-log",
|
||||
serde_norway::Value::Bool(config.request_log),
|
||||
)?;
|
||||
changed |= set_core_yaml_nested_value(
|
||||
document,
|
||||
"remote-management",
|
||||
@@ -1156,6 +1161,7 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
|
||||
|| presence.api_keys.is_none()
|
||||
|| presence.management_secret_key.is_none()
|
||||
|| presence.usage_statistics_enabled.is_none()
|
||||
|| presence.request_log.is_none()
|
||||
|| presence.plugins_enabled.is_none()
|
||||
|| presence.routing_strategy.is_none()
|
||||
|| presence.proxy_url.is_none()
|
||||
@@ -1193,6 +1199,9 @@ 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.request_log.is_none() {
|
||||
config.request_log = core_settings.request_log;
|
||||
}
|
||||
if presence.management_secret_key.is_none() {
|
||||
config.management_secret_key = core_settings
|
||||
.management_secret_key
|
||||
@@ -1315,6 +1324,7 @@ pub(crate) fn apply_core_settings_to_gui_config(
|
||||
config.management_secret_key = secret_key.to_string();
|
||||
}
|
||||
config.usage_statistics_enabled = core_settings.usage_statistics_enabled;
|
||||
config.request_log = core_settings.request_log;
|
||||
config.plugins_enabled = core_settings.plugins_enabled;
|
||||
config.routing_strategy = core_settings.routing_strategy.clone();
|
||||
config.proxy_url = core_settings.proxy_url.clone();
|
||||
@@ -1680,6 +1690,7 @@ pub(crate) fn write_gui_config_to_path(
|
||||
"usage-statistics-enabled",
|
||||
value(config.usage_statistics_enabled),
|
||||
),
|
||||
("request-log", value(config.request_log)),
|
||||
("plugins-enabled", value(config.plugins_enabled)),
|
||||
("routing-strategy", value(config.routing_strategy.as_str())),
|
||||
("proxy-url", value(config.proxy_url.as_str())),
|
||||
|
||||
@@ -269,6 +269,12 @@ pub(crate) fn patch_core_plugins_enabled(enabled: bool) -> Result<(), String> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn patch_core_request_log(enabled: bool) -> Result<(), String> {
|
||||
patch_existing_core_config(|document| {
|
||||
set_core_yaml_top_level_value(document, "request-log", serde_norway::Value::Bool(enabled))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn patch_core_routing_strategy(strategy: &str) -> Result<(), String> {
|
||||
let strategy = strategy.to_string();
|
||||
patch_existing_core_config(move |document| {
|
||||
@@ -1146,6 +1152,14 @@ pub(crate) fn core_config_settings_from_value(
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(true);
|
||||
let request_log = yaml_mapping_value(root, "request-log")
|
||||
.map(|value| {
|
||||
value
|
||||
.as_bool()
|
||||
.ok_or_else(|| "request-log 必须是布尔值".to_string())
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(false);
|
||||
let api_keys = extract_core_api_keys(root)?
|
||||
.into_iter()
|
||||
.filter(|api_key| !is_example_core_api_key(api_key))
|
||||
@@ -1251,6 +1265,7 @@ pub(crate) fn core_config_settings_from_value(
|
||||
.as_deref()
|
||||
.is_some_and(|value| !value.is_empty()),
|
||||
usage_statistics_enabled,
|
||||
request_log,
|
||||
plugins_enabled,
|
||||
routing_strategy,
|
||||
proxy_url,
|
||||
|
||||
@@ -595,6 +595,7 @@ struct GuiConfigFile {
|
||||
api_access_remarks: Vec<GuiApiAccessRemark>,
|
||||
management_secret_key: String,
|
||||
usage_statistics_enabled: bool,
|
||||
request_log: bool,
|
||||
plugins_enabled: bool,
|
||||
routing_strategy: String,
|
||||
proxy_url: String,
|
||||
@@ -882,6 +883,7 @@ impl Default for GuiConfigFile {
|
||||
// configuration. Core hashes the value written into config.yaml.
|
||||
management_secret_key: String::new(),
|
||||
usage_statistics_enabled: true,
|
||||
request_log: false,
|
||||
plugins_enabled: false,
|
||||
routing_strategy: "round-robin".to_string(),
|
||||
proxy_url: String::new(),
|
||||
@@ -916,6 +918,7 @@ struct GuiConfigPresence {
|
||||
start_core_on_launch: Option<bool>,
|
||||
silent_start: Option<bool>,
|
||||
usage_statistics_enabled: Option<bool>,
|
||||
request_log: Option<bool>,
|
||||
plugins_enabled: Option<bool>,
|
||||
routing_strategy: Option<String>,
|
||||
proxy_url: Option<String>,
|
||||
@@ -1389,6 +1392,7 @@ struct CoreConfigSettings {
|
||||
management_secret_configured: bool,
|
||||
#[serde(skip_serializing)]
|
||||
usage_statistics_enabled: bool,
|
||||
request_log: bool,
|
||||
plugins_enabled: bool,
|
||||
routing_strategy: String,
|
||||
proxy_url: String,
|
||||
@@ -1420,6 +1424,7 @@ struct CoreConfigView {
|
||||
management_secret_configured: bool,
|
||||
port: u16,
|
||||
allow_lan: bool,
|
||||
request_log: bool,
|
||||
plugins_enabled: bool,
|
||||
routing_strategy: String,
|
||||
proxy_url: String,
|
||||
@@ -2034,6 +2039,7 @@ impl GuiConfigState {
|
||||
config.allow_lan = !is_loopback_host(&settings.host);
|
||||
config.auth_dir = settings.auth_dir.clone();
|
||||
config.usage_statistics_enabled = settings.usage_statistics_enabled;
|
||||
config.request_log = settings.request_log;
|
||||
if let Some(secret_key) = settings
|
||||
.management_secret_key
|
||||
.as_deref()
|
||||
@@ -2094,6 +2100,7 @@ impl From<&GuiConfigFile> for CoreConfigSettings {
|
||||
api_keys: gui_api_key_values(&config.api_keys),
|
||||
management_secret_configured: !config.management_secret_key.is_empty(),
|
||||
usage_statistics_enabled: config.usage_statistics_enabled,
|
||||
request_log: config.request_log,
|
||||
plugins_enabled: config.plugins_enabled,
|
||||
routing_strategy: config.routing_strategy.clone(),
|
||||
proxy_url: config.proxy_url.clone(),
|
||||
@@ -2124,6 +2131,7 @@ impl From<&GuiConfigFile> for CoreConfigView {
|
||||
management_secret_configured: !config.management_secret_key.is_empty(),
|
||||
port: config.port,
|
||||
allow_lan: config.allow_lan,
|
||||
request_log: config.request_log,
|
||||
plugins_enabled: config.plugins_enabled,
|
||||
routing_strategy: config.routing_strategy.clone(),
|
||||
proxy_url: config.proxy_url.clone(),
|
||||
@@ -2436,6 +2444,7 @@ fn main() {
|
||||
get_core_tls_settings,
|
||||
save_core_tls_settings,
|
||||
get_core_config_settings,
|
||||
set_core_request_log,
|
||||
add_core_api_key,
|
||||
update_core_api_key,
|
||||
delete_core_api_key,
|
||||
|
||||
@@ -86,6 +86,7 @@ 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["requestLog"], false);
|
||||
assert!(view.get("managementSecretKey").is_none());
|
||||
}
|
||||
|
||||
@@ -336,6 +337,7 @@ fn legacy_gui_config_can_seed_managed_core_settings() {
|
||||
api_keys: vec!["existing-key".to_string()],
|
||||
management_secret_configured: true,
|
||||
usage_statistics_enabled: false,
|
||||
request_log: true,
|
||||
plugins_enabled: true,
|
||||
routing_strategy: "fill-first".to_string(),
|
||||
proxy_url: "http://127.0.0.1:8080".to_string(),
|
||||
@@ -351,6 +353,7 @@ fn legacy_gui_config_can_seed_managed_core_settings() {
|
||||
|
||||
assert!(presence.api_keys.is_none());
|
||||
assert!(presence.management_secret_key.is_none());
|
||||
assert!(presence.request_log.is_none());
|
||||
assert!(presence.plugins_enabled.is_none());
|
||||
assert!(presence.routing_strategy.is_none());
|
||||
apply_core_settings_to_gui_config(&mut config, &core_settings);
|
||||
@@ -362,6 +365,7 @@ fn legacy_gui_config_can_seed_managed_core_settings() {
|
||||
assert!(config.disable_cooling);
|
||||
assert_eq!(config.auth_dir, "/tmp/external-auth");
|
||||
assert!(!config.usage_statistics_enabled);
|
||||
assert!(config.request_log);
|
||||
assert!(config.plugins_enabled);
|
||||
assert_eq!(config.routing_strategy, "fill-first");
|
||||
assert_eq!(config.proxy_url, "http://127.0.0.1:8080");
|
||||
@@ -805,6 +809,16 @@ fn core_config_reads_legacy_api_key_entries() {
|
||||
assert_eq!(settings.routing_strategy, "round-robin");
|
||||
}
|
||||
|
||||
#[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();
|
||||
assert!(settings.request_log);
|
||||
|
||||
let defaults = core_config_settings_from_value(&serde_norway::from_str("{}").unwrap()).unwrap();
|
||||
assert!(!defaults.request_log);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_config_reads_proxy_and_session_affinity_fields() {
|
||||
let canonical = serde_norway::from_str::<serde_norway::Value>(
|
||||
@@ -1048,6 +1062,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() {
|
||||
api_access_remarks: Vec::new(),
|
||||
management_secret_key: String::new(),
|
||||
usage_statistics_enabled: false,
|
||||
request_log: true,
|
||||
plugins_enabled: true,
|
||||
routing_strategy: "fill-first".to_string(),
|
||||
proxy_url: "socks5://127.0.0.1:7890".to_string(),
|
||||
@@ -1090,6 +1105,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() {
|
||||
assert_eq!(document["max-retry-interval"], 5);
|
||||
assert_eq!(document["streaming"]["bootstrap-retries"], 1);
|
||||
assert_eq!(document["usage-statistics-enabled"], false);
|
||||
assert_eq!(document["request-log"], true);
|
||||
assert!(document.get("new-option").is_none());
|
||||
assert_eq!(document["nested"], current_document["nested"]);
|
||||
assert!(document["nested"].get("added").is_none());
|
||||
@@ -1162,6 +1178,7 @@ fn startup_merge_without_current_config_uses_gui_defaults() {
|
||||
assert_eq!(document["plugins"]["enabled"], false);
|
||||
assert_eq!(document["routing"]["strategy"], "round-robin");
|
||||
assert_eq!(document["usage-statistics-enabled"], true);
|
||||
assert_eq!(document["request-log"], false);
|
||||
assert_eq!(
|
||||
document["remote-management"]["secret-key"],
|
||||
config.management_secret_key
|
||||
|
||||
@@ -349,6 +349,14 @@ 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.webuiKey.title': 'WebUI キー',
|
||||
'config.webuiKey.configured': '設定済み',
|
||||
'config.webuiKey.unconfigured': '未設定',
|
||||
|
||||
@@ -430,6 +430,14 @@ 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.webuiKey.title': 'WebUI Key',
|
||||
'config.webuiKey.configured': 'Configured',
|
||||
'config.webuiKey.unconfigured': 'Not configured',
|
||||
|
||||
@@ -428,6 +428,14 @@ 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.webuiKey.title': 'WebUI 密钥',
|
||||
'config.webuiKey.configured': '已配置',
|
||||
'config.webuiKey.unconfigured': '未配置',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Clock3,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
Link2,
|
||||
@@ -34,6 +35,7 @@ import { ThinkingAliasesPage } from './ThinkingAliasesPage';
|
||||
type CoreConfigSettings = {
|
||||
apiKeys: CoreApiKey[];
|
||||
managementSecretConfigured: boolean;
|
||||
requestLog: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
allowLan: boolean;
|
||||
@@ -58,6 +60,7 @@ type ConfigAction =
|
||||
| 'update-key'
|
||||
| 'delete-key'
|
||||
| 'management-secret'
|
||||
| 'request-log'
|
||||
| 'routing'
|
||||
| 'network'
|
||||
| 'retry'
|
||||
@@ -449,6 +452,18 @@ export function ConfigPanelPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const setRequestLogEnabled = async (enabled: boolean) => {
|
||||
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 confirmDelete = async () => {
|
||||
if (deleteIndex === null) {
|
||||
return;
|
||||
@@ -1105,6 +1120,44 @@ export function ConfigPanelPage() {
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel config-diagnostics-panel">
|
||||
<div className="config-panel-heading">
|
||||
<div className="config-heading-title">
|
||||
<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>
|
||||
</div>
|
||||
<label className="switch-control" title={t('config.diagnostics.title')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label={t('config.diagnostics.title')}
|
||||
checked={settings?.requestLog ?? false}
|
||||
disabled={controlsDisabled}
|
||||
onChange={(event) => void setRequestLogEnabled(event.currentTarget.checked)}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : activeSubpage === 'network' || activeSubpage === 'routing' ? (
|
||||
<div
|
||||
|
||||
@@ -837,6 +837,12 @@ textarea:focus-visible {
|
||||
color: var(--theme-9f2d20);
|
||||
}
|
||||
|
||||
.state-pill.warning {
|
||||
border-color: var(--theme-dfbf91);
|
||||
background: var(--theme-fff7e9);
|
||||
color: var(--theme-8b5b21);
|
||||
}
|
||||
|
||||
.state-pill.update {
|
||||
border-color: var(--theme-dfbf91);
|
||||
background: var(--theme-fff7e9);
|
||||
@@ -1935,6 +1941,54 @@ textarea:focus-visible {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.config-diagnostics-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.config-diagnostics-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
border-radius: 8px;
|
||||
background: var(--theme-faf8f2);
|
||||
}
|
||||
|
||||
.config-diagnostics-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.config-diagnostics-copy strong {
|
||||
color: var(--theme-2d2a26);
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.config-diagnostics-copy p {
|
||||
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;
|
||||
gap: 7px;
|
||||
color: var(--theme-9f6429);
|
||||
font-size: var(--font-size-caption);
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
|
||||
.config-diagnostics-warning svg {
|
||||
flex: none;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.config-management-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.72fr) minmax(420px, 1.28fr);
|
||||
@@ -2903,6 +2957,12 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.config-diagnostics-content {
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.config-software-setting-row {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user