diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f39e634639..f5853682ac 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1613,10 +1613,10 @@ type OpsCleanupConfig struct { Enabled bool `mapstructure:"enabled"` Schedule string `mapstructure:"schedule"` - // Retention days (0 disables that cleanup target). - // - // vNext requirement: default 30 days across ops datasets. + // Retention days. Error and metrics targets accept 0 as an explicit truncate; + // system logs require a positive value because their runtime setting is bounded. ErrorLogRetentionDays int `mapstructure:"error_log_retention_days"` + SystemLogRetentionDays int `mapstructure:"system_log_retention_days"` MinuteMetricsRetentionDays int `mapstructure:"minute_metrics_retention_days"` HourlyMetricsRetentionDays int `mapstructure:"hourly_metrics_retention_days"` } @@ -2251,6 +2251,7 @@ func setDefaults() { viper.SetDefault("ops.cleanup.schedule", "0 2 * * *") // Retention days: vNext defaults to 30 days across ops datasets. viper.SetDefault("ops.cleanup.error_log_retention_days", 30) + viper.SetDefault("ops.cleanup.system_log_retention_days", 30) viper.SetDefault("ops.cleanup.minute_metrics_retention_days", 30) viper.SetDefault("ops.cleanup.hourly_metrics_retention_days", 30) viper.SetDefault("ops.aggregation.enabled", true) @@ -3674,6 +3675,9 @@ func (c *Config) Validate() error { if c.Ops.Cleanup.ErrorLogRetentionDays < 0 { return fmt.Errorf("ops.cleanup.error_log_retention_days must be non-negative") } + if c.Ops.Cleanup.Enabled && c.Ops.Cleanup.SystemLogRetentionDays <= 0 { + return fmt.Errorf("ops.cleanup.system_log_retention_days must be positive when ops cleanup is enabled") + } if c.Ops.Cleanup.MinuteMetricsRetentionDays < 0 { return fmt.Errorf("ops.cleanup.minute_metrics_retention_days must be non-negative") } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index a8fbba0977..717960a91a 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1191,6 +1191,21 @@ func TestLoadDefaultUsageCleanupConfig(t *testing.T) { } } +func TestLoadDefaultOpsCleanupConfig(t *testing.T) { + resetViperWithJWTSecret(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if !cfg.Ops.Cleanup.Enabled { + t.Fatal("Ops.Cleanup.Enabled = false, want true") + } + if cfg.Ops.Cleanup.SystemLogRetentionDays != 30 { + t.Fatalf("Ops.Cleanup.SystemLogRetentionDays = %d, want 30", cfg.Ops.Cleanup.SystemLogRetentionDays) + } +} + func TestValidateUsageCleanupConfigEnabled(t *testing.T) { resetViperWithJWTSecret(t) @@ -2137,6 +2152,11 @@ func TestValidateConfigErrors(t *testing.T) { mutate: func(c *Config) { c.Ops.Cleanup.ErrorLogRetentionDays = -1 }, wantErr: "ops.cleanup.error_log_retention_days", }, + { + name: "ops cleanup system log retention", + mutate: func(c *Config) { c.Ops.Cleanup.SystemLogRetentionDays = 0 }, + wantErr: "ops.cleanup.system_log_retention_days", + }, { name: "ops cleanup minute retention", mutate: func(c *Config) { c.Ops.Cleanup.MinuteMetricsRetentionDays = -1 }, diff --git a/backend/internal/service/ops_cleanup_overlay_test.go b/backend/internal/service/ops_cleanup_overlay_test.go index f751a42627..f1e1cbf9dc 100644 --- a/backend/internal/service/ops_cleanup_overlay_test.go +++ b/backend/internal/service/ops_cleanup_overlay_test.go @@ -45,7 +45,8 @@ func TestComputeEffective_FallbackToCfgWhenSettingsAbsent(t *testing.T) { svc.computeEffectiveLocked(context.Background()) - if svc.effective != base { + want := opsCleanupEffectiveConfig{OpsCleanupConfig: base, SystemLogRetentionDays: 30} + if svc.effective != want { t.Fatalf("expected effective == cfg base, got %#v", svc.effective) } } @@ -70,12 +71,15 @@ func TestComputeEffective_SettingsOverridesAll(t *testing.T) { svc.computeEffectiveLocked(context.Background()) - want := config.OpsCleanupConfig{ - Enabled: true, - Schedule: "0 * * * *", - ErrorLogRetentionDays: 0, - MinuteMetricsRetentionDays: 7, - HourlyMetricsRetentionDays: 14, + want := opsCleanupEffectiveConfig{ + OpsCleanupConfig: config.OpsCleanupConfig{ + Enabled: true, + Schedule: "0 * * * *", + ErrorLogRetentionDays: 0, + MinuteMetricsRetentionDays: 7, + HourlyMetricsRetentionDays: 14, + }, + SystemLogRetentionDays: 30, } if svc.effective != want { t.Fatalf("effective mismatch:\nwant %#v\n got %#v", want, svc.effective) @@ -156,11 +160,72 @@ func TestComputeEffective_BadJSONFallsBackToCfg(t *testing.T) { svc.computeEffectiveLocked(context.Background()) - if svc.effective != base { + want := opsCleanupEffectiveConfig{OpsCleanupConfig: base, SystemLogRetentionDays: 30} + if svc.effective != want { t.Fatalf("expected fallback to cfg on bad JSON, got %#v", svc.effective) } } +func TestComputeEffective_RuntimeConfigOwnsSystemLogRetention(t *testing.T) { + repo := newRuntimeSettingRepoStub() + writeAdvancedSettings(t, repo, OpsDataRetentionSettings{ + CleanupEnabled: true, + CleanupSchedule: "0 2 * * *", + ErrorLogRetentionDays: 90, + }) + repo.values[SettingKeyOpsRuntimeLogConfig] = `{"retention_days":7}` + svc := makeOverlayService(repo, config.OpsCleanupConfig{ErrorLogRetentionDays: 30}) + + svc.computeEffectiveLocked(context.Background()) + + if svc.effective.ErrorLogRetentionDays != 90 { + t.Fatalf("error log retention = %d, want 90", svc.effective.ErrorLogRetentionDays) + } + if svc.effective.SystemLogRetentionDays != 7 { + t.Fatalf("system log retention = %d, want 7", svc.effective.SystemLogRetentionDays) + } +} + +func TestComputeEffective_InvalidRuntimeConfigDoesNotDiscardAdvancedSettings(t *testing.T) { + repo := newRuntimeSettingRepoStub() + writeAdvancedSettings(t, repo, OpsDataRetentionSettings{ + CleanupEnabled: true, + CleanupSchedule: "0 * * * *", + ErrorLogRetentionDays: 14, + }) + repo.values[SettingKeyOpsRuntimeLogConfig] = `{invalid-json}` + svc := makeOverlayService(repo, config.OpsCleanupConfig{ErrorLogRetentionDays: 30}) + + svc.computeEffectiveLocked(context.Background()) + + if !svc.effective.Enabled || svc.effective.ErrorLogRetentionDays != 14 { + t.Fatalf("advanced settings were discarded: %#v", svc.effective) + } + if svc.effective.SystemLogRetentionDays != 30 { + t.Fatalf("system log retention = %d, want default 30", svc.effective.SystemLogRetentionDays) + } +} + +func TestComputeEffective_PartialAdvancedSettingsUseSafeDefaults(t *testing.T) { + repo := newRuntimeSettingRepoStub() + repo.values[SettingKeyOpsAdvancedSettings] = `{"data_retention":{"cleanup_enabled":true}}` + svc := makeOverlayService(repo, config.OpsCleanupConfig{ + Schedule: "0 3 * * *", + ErrorLogRetentionDays: 90, + MinuteMetricsRetentionDays: 90, + HourlyMetricsRetentionDays: 90, + }) + + svc.computeEffectiveLocked(context.Background()) + + if svc.effective.Schedule != opsCleanupDefaultSchedule || + svc.effective.ErrorLogRetentionDays != 30 || + svc.effective.MinuteMetricsRetentionDays != 30 || + svc.effective.HourlyMetricsRetentionDays != 30 { + t.Fatalf("partial settings did not use safe defaults: %#v", svc.effective) + } +} + // 验证 OpsService.UpdateOpsAdvancedSettings 写入后会调用 cleanupReloader.Reload。 type fakeCleanupReloader struct { calls int diff --git a/backend/internal/service/ops_cleanup_service.go b/backend/internal/service/ops_cleanup_service.go index 38e94fb17a..52024e9abc 100644 --- a/backend/internal/service/ops_cleanup_service.go +++ b/backend/internal/service/ops_cleanup_service.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "encoding/json" - "errors" "fmt" "strings" "sync" @@ -59,11 +58,16 @@ type OpsCleanupService struct { cron *cron.Cron started bool stopped bool - effective config.OpsCleanupConfig + effective opsCleanupEffectiveConfig warnNoRedisOnce sync.Once } +type opsCleanupEffectiveConfig struct { + config.OpsCleanupConfig + SystemLogRetentionDays int +} + func NewOpsCleanupService( opsRepo OpsRepository, db *sql.DB, @@ -166,9 +170,10 @@ func (s *OpsCleanupService) applyScheduleLocked(ctx context.Context) error { c.Start() s.cron = c logger.LegacyPrintf("service.ops_cleanup", - "[OpsCleanup] scheduled (schedule=%q tz=%s retention_days=err:%d/min:%d/hour:%d)", + "[OpsCleanup] scheduled (schedule=%q tz=%s retention_days=err:%d/system:%d/min:%d/hour:%d)", schedule, loc.String(), s.effective.ErrorLogRetentionDays, + s.effective.SystemLogRetentionDays, s.effective.MinuteMetricsRetentionDays, s.effective.HourlyMetricsRetentionDays, ) @@ -198,11 +203,14 @@ func (s *OpsCleanupService) Reload(ctx context.Context) error { // - Schedule:settings 非空时覆盖,否则保留 cfg // - *RetentionDays:settings >=0 时覆盖(包括 0=TRUNCATE),<0 沿用 cfg // -// 若 settings 表无该 key(ErrSettingNotFound)或解析失败,整体 fallback 到 cfg.Ops.Cleanup。 +// System log retention has a separate owner: settings.ops_runtime_log_config.retention_days. +// Missing or invalid settings fall back independently instead of disabling all cleanup overlays. func (s *OpsCleanupService) computeEffectiveLocked(ctx context.Context) { - base := config.OpsCleanupConfig{} + base := opsCleanupEffectiveConfig{ + SystemLogRetentionDays: defaultOpsRuntimeLogConfig(s.cfg).RetentionDays, + } if s.cfg != nil { - base = s.cfg.Ops.Cleanup + base.OpsCleanupConfig = s.cfg.Ops.Cleanup } defer func() { s.effective = base }() @@ -212,38 +220,54 @@ func (s *OpsCleanupService) computeEffectiveLocked(ctx context.Context) { if ctx == nil { ctx = context.Background() } - raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsAdvancedSettings) + values, err := s.settingRepo.GetMultiple(ctx, []string{ + SettingKeyOpsAdvancedSettings, + SettingKeyOpsRuntimeLogConfig, + }) if err != nil { - if !errors.Is(err, ErrSettingNotFound) { - logger.LegacyPrintf("service.ops_cleanup", - "[OpsCleanup] read advanced settings failed, using cfg: %v", err) - } - return - } - var adv OpsAdvancedSettings - if err := json.Unmarshal([]byte(raw), &adv); err != nil { logger.LegacyPrintf("service.ops_cleanup", - "[OpsCleanup] parse advanced settings failed, using cfg: %v", err) + "[OpsCleanup] read cleanup settings failed, using defaults: %v", err) return } - dr := adv.DataRetention - base.Enabled = dr.CleanupEnabled - if sched := strings.TrimSpace(dr.CleanupSchedule); sched != "" { - base.Schedule = sched + if raw, ok := values[SettingKeyOpsAdvancedSettings]; ok { + adv := defaultOpsAdvancedSettingsForConfig(s.cfg) + if err := json.Unmarshal([]byte(raw), adv); err != nil { + logger.LegacyPrintf("service.ops_cleanup", + "[OpsCleanup] parse advanced settings failed, using cfg: %v", err) + } else { + dr := adv.DataRetention + base.Enabled = dr.CleanupEnabled + if sched := strings.TrimSpace(dr.CleanupSchedule); sched != "" { + base.Schedule = sched + } + if dr.ErrorLogRetentionDays >= 0 { + base.ErrorLogRetentionDays = dr.ErrorLogRetentionDays + } + if dr.MinuteMetricsRetentionDays >= 0 { + base.MinuteMetricsRetentionDays = dr.MinuteMetricsRetentionDays + } + if dr.HourlyMetricsRetentionDays >= 0 { + base.HourlyMetricsRetentionDays = dr.HourlyMetricsRetentionDays + } + } } - if dr.ErrorLogRetentionDays >= 0 { - base.ErrorLogRetentionDays = dr.ErrorLogRetentionDays - } - if dr.MinuteMetricsRetentionDays >= 0 { - base.MinuteMetricsRetentionDays = dr.MinuteMetricsRetentionDays - } - if dr.HourlyMetricsRetentionDays >= 0 { - base.HourlyMetricsRetentionDays = dr.HourlyMetricsRetentionDays + if raw, ok := values[SettingKeyOpsRuntimeLogConfig]; ok { + defaults := defaultOpsRuntimeLogConfig(s.cfg) + runtimeCfg := *defaults + if err := json.Unmarshal([]byte(raw), &runtimeCfg); err != nil { + logger.LegacyPrintf("service.ops_cleanup", + "[OpsCleanup] parse runtime log config failed, using default system-log retention: %v", err) + } else { + normalizeOpsRuntimeLogConfig(&runtimeCfg, defaults) + if runtimeCfg.RetentionDays >= 1 && runtimeCfg.RetentionDays <= 3650 { + base.SystemLogRetentionDays = runtimeCfg.RetentionDays + } + } } } // snapshotEffective 取一份 effective 副本(runCleanupOnce 等读路径使用)。 -func (s *OpsCleanupService) snapshotEffective() config.OpsCleanupConfig { +func (s *OpsCleanupService) snapshotEffective() opsCleanupEffectiveConfig { s.mu.Lock() defer s.mu.Unlock() return s.effective @@ -305,8 +329,8 @@ func (s *OpsCleanupService) runCleanupOnce(ctx context.Context) (opsCleanupDelet {effective.ErrorLogRetentionDays, "ops_error_logs", "created_at", false, &out.errorLogs}, {effective.ErrorLogRetentionDays, "ops_ingress_reject_aggregates", "bucket_start", false, &out.ingressRejects}, {effective.ErrorLogRetentionDays, "ops_alert_events", "created_at", false, &out.alertEvents}, - {effective.ErrorLogRetentionDays, "ops_system_logs", "created_at", false, &out.systemLogs}, - {effective.ErrorLogRetentionDays, "ops_system_log_cleanup_audits", "created_at", false, &out.logAudits}, + {effective.SystemLogRetentionDays, "ops_system_logs", "created_at", false, &out.systemLogs}, + {effective.SystemLogRetentionDays, "ops_system_log_cleanup_audits", "created_at", false, &out.logAudits}, {effective.MinuteMetricsRetentionDays, "ops_system_metrics", "created_at", false, &out.systemMetrics}, {effective.HourlyMetricsRetentionDays, "ops_metrics_hourly", "bucket_start", false, &out.hourlyPreagg}, {effective.HourlyMetricsRetentionDays, "ops_metrics_daily", "bucket_date", true, &out.dailyPreagg}, diff --git a/backend/internal/service/ops_log_runtime.go b/backend/internal/service/ops_log_runtime.go index ed8aefa9a3..637ab8d69f 100644 --- a/backend/internal/service/ops_log_runtime.go +++ b/backend/internal/service/ops_log_runtime.go @@ -15,13 +15,14 @@ import ( func defaultOpsRuntimeLogConfig(cfg *config.Config) *OpsRuntimeLogConfig { out := &OpsRuntimeLogConfig{ - Level: "info", - EnableSampling: false, - SamplingInitial: 100, - SamplingNext: 100, - Caller: true, - StacktraceLevel: "error", - RetentionDays: 30, + Level: "info", + PersistAccessLogs: false, + EnableSampling: false, + SamplingInitial: 100, + SamplingNext: 100, + Caller: true, + StacktraceLevel: "error", + RetentionDays: 30, } if cfg == nil { return out @@ -32,8 +33,8 @@ func defaultOpsRuntimeLogConfig(cfg *config.Config) *OpsRuntimeLogConfig { out.SamplingNext = cfg.Log.Sampling.Thereafter out.Caller = cfg.Log.Caller out.StacktraceLevel = strings.ToLower(strings.TrimSpace(cfg.Log.StacktraceLevel)) - if cfg.Ops.Cleanup.ErrorLogRetentionDays > 0 { - out.RetentionDays = cfg.Ops.Cleanup.ErrorLogRetentionDays + if cfg.Ops.Cleanup.SystemLogRetentionDays > 0 { + out.RetentionDays = cfg.Ops.Cleanup.SystemLogRetentionDays } return out } @@ -144,7 +145,7 @@ func (s *OpsService) UpdateRuntimeLogConfig(ctx context.Context, req *OpsRuntime return nil, err } - if err := applyOpsRuntimeLogConfig(&next); err != nil { + if err := s.applyRuntimeLogConfig(&next); err != nil { s.auditRuntimeLogConfigFailure(operatorID, oldCfg, &next, "apply_failed: "+err.Error()) return nil, err } @@ -159,12 +160,13 @@ func (s *OpsService) UpdateRuntimeLogConfig(ctx context.Context, req *OpsRuntime } if err := s.settingRepo.Set(ctx, SettingKeyOpsRuntimeLogConfig, string(encoded)); err != nil { // 存储失败时回滚到旧配置,避免内存状态与持久化状态不一致。 - _ = applyOpsRuntimeLogConfig(oldCfg) + _ = s.applyRuntimeLogConfig(oldCfg) s.auditRuntimeLogConfigFailure(operatorID, oldCfg, &next, "persist_failed: "+err.Error()) return nil, err } s.auditRuntimeLogConfigChange(operatorID, oldCfg, &next, "updated") + s.reloadOpsCleanup(ctx) return &next, nil } @@ -191,14 +193,14 @@ func (s *OpsService) ResetRuntimeLogConfig(ctx context.Context, operatorID int64 s.auditRuntimeLogConfigFailure(operatorID, oldCfg, resetCfg, "reset_validation_failed: "+err.Error()) return nil, err } - if err := applyOpsRuntimeLogConfig(resetCfg); err != nil { + if err := s.applyRuntimeLogConfig(resetCfg); err != nil { s.auditRuntimeLogConfigFailure(operatorID, oldCfg, resetCfg, "reset_apply_failed: "+err.Error()) return nil, err } // 清理 runtime 覆盖配置,回退到 env/yaml baseline。 if err := s.settingRepo.Delete(ctx, SettingKeyOpsRuntimeLogConfig); err != nil && !errors.Is(err, ErrSettingNotFound) { - _ = applyOpsRuntimeLogConfig(oldCfg) + _ = s.applyRuntimeLogConfig(oldCfg) s.auditRuntimeLogConfigFailure(operatorID, oldCfg, resetCfg, "reset_persist_failed: "+err.Error()) return nil, err } @@ -209,9 +211,29 @@ func (s *OpsService) ResetRuntimeLogConfig(ctx context.Context, operatorID int64 resetCfg.UpdatedByUserID = operatorID s.auditRuntimeLogConfigChange(operatorID, oldCfg, resetCfg, "reset") + s.reloadOpsCleanup(ctx) return resetCfg, nil } +func (s *OpsService) applyRuntimeLogConfig(cfg *OpsRuntimeLogConfig) error { + if err := applyOpsRuntimeLogConfig(cfg); err != nil { + return err + } + if s != nil && s.systemLogSink != nil { + s.systemLogSink.SetPersistAccessLogs(cfg.PersistAccessLogs) + } + return nil +} + +func (s *OpsService) reloadOpsCleanup(ctx context.Context) { + if s == nil || s.cleanupReloader == nil { + return + } + if err := s.cleanupReloader.Reload(ctx); err != nil { + logger.LegacyPrintf("service.ops_log_runtime", "[OpsLogRuntime] cleanup reload failed: %v", err) + } +} + func applyOpsRuntimeLogConfig(cfg *OpsRuntimeLogConfig) error { if cfg == nil { return fmt.Errorf("nil runtime log config") @@ -238,7 +260,7 @@ func (s *OpsService) applyRuntimeLogConfigOnStartup(ctx context.Context) { if err != nil { return } - _ = applyOpsRuntimeLogConfig(cfg) + _ = s.applyRuntimeLogConfig(cfg) } func (s *OpsService) auditRuntimeLogConfigChange(operatorID int64, oldCfg *OpsRuntimeLogConfig, newCfg *OpsRuntimeLogConfig, action string) { diff --git a/backend/internal/service/ops_log_runtime_test.go b/backend/internal/service/ops_log_runtime_test.go index 0a8cfa9067..f4d202ceb0 100644 --- a/backend/internal/service/ops_log_runtime_test.go +++ b/backend/internal/service/ops_log_runtime_test.go @@ -21,6 +21,15 @@ type runtimeSettingRepoStub struct { deleteFn func(key string) error } +type runtimeCleanupReloader struct { + calls int +} + +func (r *runtimeCleanupReloader) Reload(context.Context) error { + r.calls++ + return nil +} + func newRuntimeSettingRepoStub() *runtimeSettingRepoStub { return &runtimeSettingRepoStub{ values: map[string]string{}, @@ -181,7 +190,8 @@ func TestResetRuntimeLogConfig_ShouldFallbackToBaseline(t *testing.T) { }, Ops: config.OpsConfig{ Cleanup: config.OpsCleanupConfig{ - ErrorLogRetentionDays: 45, + ErrorLogRetentionDays: 7, + SystemLogRetentionDays: 45, }, }, }, @@ -282,7 +292,8 @@ func TestUpdateRuntimeLogConfig_PersistFailureRollback(t *testing.T) { } svc := &OpsService{ - settingRepo: repo, + settingRepo: repo, + systemLogSink: &OpsSystemLogSink{}, cfg: &config.Config{ Log: config.LogConfig{ Level: "info", @@ -311,13 +322,14 @@ func TestUpdateRuntimeLogConfig_PersistFailureRollback(t *testing.T) { } _, err := svc.UpdateRuntimeLogConfig(context.Background(), &OpsRuntimeLogConfig{ - Level: "debug", - EnableSampling: false, - SamplingInitial: 100, - SamplingNext: 100, - Caller: true, - StacktraceLevel: "error", - RetentionDays: 30, + Level: "debug", + PersistAccessLogs: true, + EnableSampling: false, + SamplingInitial: 100, + SamplingNext: 100, + Caller: true, + StacktraceLevel: "error", + RetentionDays: 30, }, 5) if err == nil { t.Fatalf("expected persist error") @@ -326,15 +338,19 @@ func TestUpdateRuntimeLogConfig_PersistFailureRollback(t *testing.T) { if logger.CurrentLevel() != "info" { t.Fatalf("logger level should rollback to info, got %s", logger.CurrentLevel()) } + if svc.systemLogSink.persistAccessLogs.Load() { + t.Fatal("access-log persistence should rollback after setting write failure") + } } func TestApplyRuntimeLogConfigOnStartup(t *testing.T) { repo := newRuntimeSettingRepoStub() - cfgRaw := `{"level":"debug","enable_sampling":false,"sampling_initial":100,"sampling_thereafter":100,"caller":true,"stacktrace_level":"error","retention_days":30}` + cfgRaw := `{"level":"debug","persist_access_logs":true,"enable_sampling":false,"sampling_initial":100,"sampling_thereafter":100,"caller":true,"stacktrace_level":"error","retention_days":30}` repo.values[SettingKeyOpsRuntimeLogConfig] = cfgRaw svc := &OpsService{ - settingRepo: repo, + settingRepo: repo, + systemLogSink: &OpsSystemLogSink{}, cfg: &config.Config{ Log: config.LogConfig{ Level: "info", @@ -366,6 +382,9 @@ func TestApplyRuntimeLogConfigOnStartup(t *testing.T) { if logger.CurrentLevel() != "debug" { t.Fatalf("expected startup apply debug, got %s", logger.CurrentLevel()) } + if !svc.systemLogSink.persistAccessLogs.Load() { + t.Fatal("startup config did not enable access-log persistence") + } } func TestDefaultNormalizeAndValidateRuntimeLogConfig(t *testing.T) { @@ -382,13 +401,17 @@ func TestDefaultNormalizeAndValidateRuntimeLogConfig(t *testing.T) { }, Ops: config.OpsConfig{ Cleanup: config.OpsCleanupConfig{ - ErrorLogRetentionDays: 7, + ErrorLogRetentionDays: 7, + SystemLogRetentionDays: 14, }, }, }) - if defaults.Level != "debug" || defaults.StacktraceLevel != "fatal" || defaults.RetentionDays != 7 { + if defaults.Level != "debug" || defaults.StacktraceLevel != "fatal" || defaults.RetentionDays != 14 { t.Fatalf("unexpected defaults: %+v", defaults) } + if defaults.PersistAccessLogs { + t.Fatal("access-log persistence should be disabled by default") + } cfg := &OpsRuntimeLogConfig{ Level: " ", @@ -403,7 +426,7 @@ func TestDefaultNormalizeAndValidateRuntimeLogConfig(t *testing.T) { if cfg.Level != "debug" || cfg.StacktraceLevel != "fatal" { t.Fatalf("normalize level/stacktrace failed: %+v", cfg) } - if cfg.SamplingInitial != 50 || cfg.SamplingNext != 20 || cfg.RetentionDays != 7 { + if cfg.SamplingInitial != 50 || cfg.SamplingNext != 20 || cfg.RetentionDays != 14 { t.Fatalf("normalize numeric defaults failed: %+v", cfg) } if err := validateOpsRuntimeLogConfig(cfg); err != nil { @@ -489,8 +512,11 @@ func TestUpdateRuntimeLogConfig_PreconditionErrors(t *testing.T) { func TestUpdateRuntimeLogConfig_Success(t *testing.T) { repo := newRuntimeSettingRepoStub() + reloader := &runtimeCleanupReloader{} svc := &OpsService{ - settingRepo: repo, + settingRepo: repo, + systemLogSink: &OpsSystemLogSink{}, + cleanupReloader: reloader, cfg: &config.Config{ Log: config.LogConfig{ Level: "info", @@ -519,13 +545,14 @@ func TestUpdateRuntimeLogConfig_Success(t *testing.T) { } next, err := svc.UpdateRuntimeLogConfig(context.Background(), &OpsRuntimeLogConfig{ - Level: "debug", - EnableSampling: false, - SamplingInitial: 100, - SamplingNext: 100, - Caller: true, - StacktraceLevel: "error", - RetentionDays: 30, + Level: "debug", + PersistAccessLogs: true, + EnableSampling: false, + SamplingInitial: 100, + SamplingNext: 100, + Caller: true, + StacktraceLevel: "error", + RetentionDays: 30, }, 2) if err != nil { t.Fatalf("UpdateRuntimeLogConfig() error: %v", err) @@ -536,6 +563,12 @@ func TestUpdateRuntimeLogConfig_Success(t *testing.T) { if logger.CurrentLevel() != "debug" { t.Fatalf("expected applied level debug, got %s", logger.CurrentLevel()) } + if !svc.systemLogSink.persistAccessLogs.Load() { + t.Fatal("runtime update did not enable access-log persistence") + } + if reloader.calls != 1 { + t.Fatalf("cleanup reload calls = %d, want 1", reloader.calls) + } } func TestResetRuntimeLogConfig_IgnoreNotFoundDelete(t *testing.T) { diff --git a/backend/internal/service/ops_runtime_snapshot_test.go b/backend/internal/service/ops_runtime_snapshot_test.go index 9e0631ecb1..99d8a28a6a 100644 --- a/backend/internal/service/ops_runtime_snapshot_test.go +++ b/backend/internal/service/ops_runtime_snapshot_test.go @@ -114,28 +114,60 @@ func TestOpsRuntimeSettingsAdministrativeUpdatesAreImmediatelyVisible(t *testing } func TestOpsRuntimeSettingsBackgroundRefreshConverges(t *testing.T) { - repo := &opsRuntimeRefreshRepo{values: map[string]string{SettingKeyOpsMonitoringEnabled: "false"}} - svc := &OpsService{settingRepo: repo} + repo := &opsRuntimeRefreshRepo{values: map[string]string{ + SettingKeyOpsMonitoringEnabled: "false", + SettingKeyOpsRuntimeLogConfig: `{"persist_access_logs":false}`, + }} + sink := &OpsSystemLogSink{} + svc := &OpsService{settingRepo: repo, systemLogSink: sink} svc.initRuntimeSettings(context.Background()) if svc.IsMonitoringEnabled(context.Background()) { t.Fatal("initial monitoring state = true, want false") } repo.set(SettingKeyOpsMonitoringEnabled, "true") + repo.set(SettingKeyOpsRuntimeLogConfig, `{"persist_access_logs":true}`) svc.startRuntimeSettingsRefresh(context.Background(), 5*time.Millisecond, 0, 50*time.Millisecond) t.Cleanup(svc.StopRuntimeSettingsRefresh) waitForOpsRefresh(t, time.Second, func() bool { - return svc.IsMonitoringEnabled(context.Background()) && svc.RuntimeSettingsRefreshHealth().SuccessTotal > 0 + return svc.IsMonitoringEnabled(context.Background()) && + sink.persistAccessLogs.Load() && + svc.RuntimeSettingsRefreshHealth().SuccessTotal > 0 }) } +func TestOpsRuntimeSettingsRefreshUsesSafeAccessLogDefault(t *testing.T) { + repo := &opsRuntimeRefreshRepo{values: map[string]string{ + SettingKeyOpsRuntimeLogConfig: `{"persist_access_logs":true}`, + }} + sink := &OpsSystemLogSink{} + svc := &OpsService{settingRepo: repo, systemLogSink: sink} + svc.initRuntimeSettings(context.Background()) + if !sink.persistAccessLogs.Load() { + t.Fatal("valid runtime config did not enable access-log persistence") + } + + repo.set(SettingKeyOpsRuntimeLogConfig, `{invalid-json}`) + if err := svc.RefreshRuntimeSettings(context.Background()); err != nil { + t.Fatalf("RefreshRuntimeSettings() error = %v", err) + } + if sink.persistAccessLogs.Load() { + t.Fatal("invalid runtime config should fail closed for access-log persistence") + } +} + func TestOpsRuntimeSettingsRefreshFailuresKeepLastKnownGoodSnapshot(t *testing.T) { repo := &opsRuntimeRefreshRepo{values: map[string]string{ SettingKeyOpsMonitoringEnabled: "false", SettingKeyOpsAdvancedSettings: `{"ignore_no_available_accounts":true}`, + SettingKeyOpsRuntimeLogConfig: `{"persist_access_logs":true}`, }} - svc := &OpsService{settingRepo: repo} + sink := &OpsSystemLogSink{} + svc := &OpsService{settingRepo: repo, systemLogSink: sink} svc.initRuntimeSettings(context.Background()) + if !sink.persistAccessLogs.Load() { + t.Fatal("initial access-log setting was not applied") + } repo.fail.Store(true) svc.startRuntimeSettingsRefresh(context.Background(), 5*time.Millisecond, 0, 50*time.Millisecond) t.Cleanup(svc.StopRuntimeSettingsRefresh) @@ -149,6 +181,9 @@ func TestOpsRuntimeSettingsRefreshFailuresKeepLastKnownGoodSnapshot(t *testing.T if !svc.OpsAdvancedSettingsSnapshot().IgnoreNoAvailableAccounts { t.Fatal("failed refresh overwrote last known advanced settings") } + if !sink.persistAccessLogs.Load() { + t.Fatal("failed refresh overwrote last known access-log setting") + } } func TestOpsRuntimeSettingsRefreshStopEndsLifecycle(t *testing.T) { diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index 0e110658b1..81cf3c33fe 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -177,7 +177,7 @@ func (s *OpsService) initRuntimeSettings(ctx context.Context) { if s == nil { return } - defaults := defaultOpsAdvancedSettings() + defaults := defaultOpsAdvancedSettingsForConfig(s.cfg) s.runtimeSettings.Store(&opsRuntimeSettingsSnapshot{monitoringEnabled: true, advanced: *defaults}) _ = s.RefreshRuntimeSettings(ctx) } @@ -198,6 +198,7 @@ func (s *OpsService) RefreshRuntimeSettings(ctx context.Context) error { values, err := s.settingRepo.GetMultiple(ctx, []string{ SettingKeyOpsMonitoringEnabled, SettingKeyOpsAdvancedSettings, + SettingKeyOpsRuntimeLogConfig, }) if err != nil { return err @@ -207,15 +208,27 @@ func (s *OpsService) RefreshRuntimeSettings(ctx context.Context) error { if raw, ok := values[SettingKeyOpsMonitoringEnabled]; ok { monitoringEnabled = parseOpsMonitoringEnabled(raw) } - advanced := defaultOpsAdvancedSettings() + advanced := defaultOpsAdvancedSettingsForConfig(s.cfg) if raw, ok := values[SettingKeyOpsAdvancedSettings]; ok { if err := json.Unmarshal([]byte(raw), advanced); err != nil { - advanced = defaultOpsAdvancedSettings() + advanced = defaultOpsAdvancedSettingsForConfig(s.cfg) } } normalizeOpsAdvancedSettings(advanced) s.runtimeSettings.Store(&opsRuntimeSettingsSnapshot{monitoringEnabled: monitoringEnabled, advanced: *advanced}) + if s.systemLogSink != nil { + persistAccessLogs := false + if raw, ok := values[SettingKeyOpsRuntimeLogConfig]; ok { + var runtimeCfg struct { + PersistAccessLogs bool `json:"persist_access_logs"` + } + if json.Unmarshal([]byte(raw), &runtimeCfg) == nil { + persistAccessLogs = runtimeCfg.PersistAccessLogs + } + } + s.systemLogSink.SetPersistAccessLogs(persistAccessLogs) + } return nil } @@ -360,7 +373,7 @@ func (s *OpsService) SetMonitoringEnabled(enabled bool) { } s.runtimeSettingsMu.Lock() current := s.runtimeSettings.Load() - next := &opsRuntimeSettingsSnapshot{monitoringEnabled: enabled, advanced: *defaultOpsAdvancedSettings()} + next := &opsRuntimeSettingsSnapshot{monitoringEnabled: enabled, advanced: *defaultOpsAdvancedSettingsForConfig(s.cfg)} if current != nil { next.advanced = current.advanced } diff --git a/backend/internal/service/ops_settings.go b/backend/internal/service/ops_settings.go index e2943be5ce..2b18b483c0 100644 --- a/backend/internal/service/ops_settings.go +++ b/backend/internal/service/ops_settings.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "errors" - "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "strings" "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" ) const ( @@ -360,7 +362,9 @@ func (s *OpsService) UpdateOpsAlertRuntimeSettings(ctx context.Context, cfg *Ops func defaultOpsAdvancedSettings() *OpsAdvancedSettings { return &OpsAdvancedSettings{ DataRetention: OpsDataRetentionSettings{ - CleanupEnabled: false, + // Keep the DB-backed default aligned with ops.cleanup.enabled so a + // first settings read cannot silently disable configured retention. + CleanupEnabled: true, CleanupSchedule: opsCleanupDefaultSchedule, ErrorLogRetentionDays: 30, MinuteMetricsRetentionDays: 30, @@ -382,6 +386,14 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings { } } +func defaultOpsAdvancedSettingsForConfig(cfg *config.Config) *OpsAdvancedSettings { + defaults := defaultOpsAdvancedSettings() + if cfg != nil { + defaults.DataRetention.CleanupEnabled = cfg.Ops.Cleanup.Enabled + } + return defaults +} + func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) { if cfg == nil { return @@ -456,6 +468,7 @@ func (s *OpsService) OpsAdvancedSettingsSnapshot() OpsAdvancedSettings { if snapshot := s.runtimeSettings.Load(); snapshot != nil { return snapshot.advanced } + return *defaultOpsAdvancedSettingsForConfig(s.cfg) } return *defaultOpsAdvancedSettings() } diff --git a/backend/internal/service/ops_settings_advanced_test.go b/backend/internal/service/ops_settings_advanced_test.go index 39d0b8722a..adcaca411c 100644 --- a/backend/internal/service/ops_settings_advanced_test.go +++ b/backend/internal/service/ops_settings_advanced_test.go @@ -23,11 +23,29 @@ func TestGetOpsAdvancedSettings_DefaultSnapshotHidesOpenAITokenStats(t *testing. if !cfg.DisplayAlertEvents { t.Fatalf("DisplayAlertEvents = false, want true by default") } + if !cfg.DataRetention.CleanupEnabled { + t.Fatal("data cleanup should be enabled by default") + } if repo.getValueCalls != 0 || repo.getMultipleCalls != 0 { t.Fatalf("hot-path snapshot read touched repository: get=%d get_multiple=%d", repo.getValueCalls, repo.getMultipleCalls) } } +func TestGetOpsAdvancedSettings_DefaultCleanupFollowsDeploymentConfig(t *testing.T) { + svc := &OpsService{cfg: &config.Config{Ops: config.OpsConfig{ + Cleanup: config.OpsCleanupConfig{Enabled: false}, + }}} + svc.initRuntimeSettings(context.Background()) + + cfg, err := svc.GetOpsAdvancedSettings(context.Background()) + if err != nil { + t.Fatalf("GetOpsAdvancedSettings() error = %v", err) + } + if cfg.DataRetention.CleanupEnabled { + t.Fatal("data cleanup should follow the disabled deployment baseline") + } +} + func TestUpdateOpsAdvancedSettings_PersistsOpenAITokenStatsVisibility(t *testing.T) { repo := newRuntimeSettingRepoStub() svc := &OpsService{settingRepo: repo} diff --git a/backend/internal/service/ops_settings_models.go b/backend/internal/service/ops_settings_models.go index 3057978007..93c364153c 100644 --- a/backend/internal/service/ops_settings_models.go +++ b/backend/internal/service/ops_settings_models.go @@ -69,12 +69,14 @@ type OpsMetricThresholds struct { } type OpsRuntimeLogConfig struct { - Level string `json:"level"` - EnableSampling bool `json:"enable_sampling"` - SamplingInitial int `json:"sampling_initial"` - SamplingNext int `json:"sampling_thereafter"` - Caller bool `json:"caller"` - StacktraceLevel string `json:"stacktrace_level"` + Level string `json:"level"` + PersistAccessLogs bool `json:"persist_access_logs"` + EnableSampling bool `json:"enable_sampling"` + SamplingInitial int `json:"sampling_initial"` + SamplingNext int `json:"sampling_thereafter"` + Caller bool `json:"caller"` + StacktraceLevel string `json:"stacktrace_level"` + // RetentionDays is the authoritative retention for ops_system_logs. RetentionDays int `json:"retention_days"` Source string `json:"source,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` diff --git a/backend/internal/service/ops_system_log_sink.go b/backend/internal/service/ops_system_log_sink.go index 7a247a290d..0bff194816 100644 --- a/backend/internal/service/ops_system_log_sink.go +++ b/backend/internal/service/ops_system_log_sink.go @@ -42,10 +42,11 @@ type OpsSystemLogSink struct { cancel context.CancelFunc wg sync.WaitGroup - droppedCount uint64 - writeFailed uint64 - writtenCount uint64 - totalDelayNs uint64 + droppedCount uint64 + writeFailed uint64 + writtenCount uint64 + totalDelayNs uint64 + persistAccessLogs atomic.Bool lastError atomic.Value } @@ -147,6 +148,15 @@ func (s *OpsSystemLogSink) WriteLogEvent(event *logger.LogEvent) { } } +// SetPersistAccessLogs controls whether high-volume request access logs are +// copied into PostgreSQL. Warning/error and audit events are always retained. +func (s *OpsSystemLogSink) SetPersistAccessLogs(enabled bool) { + if s == nil { + return + } + s.persistAccessLogs.Store(enabled) +} + func (s *OpsSystemLogSink) shouldIndex(event *logger.LogEvent) bool { if event != nil && event.Fields != nil { if skip, _ := event.Fields[logger.OpsSystemLogSkipField].(bool); skip { @@ -167,7 +177,7 @@ func (s *OpsSystemLogSink) shouldIndex(event *logger.LogEvent) bool { } } if strings.Contains(component, "http.access") { - return true + return s.persistAccessLogs.Load() } if strings.Contains(component, "audit") { return true diff --git a/backend/internal/service/ops_system_log_sink_test.go b/backend/internal/service/ops_system_log_sink_test.go index fbe61d3867..2362cb505d 100644 --- a/backend/internal/service/ops_system_log_sink_test.go +++ b/backend/internal/service/ops_system_log_sink_test.go @@ -32,9 +32,9 @@ func TestOpsSystemLogSink_ShouldIndex(t *testing.T) { want: true, }, { - name: "access component", + name: "access component disabled by default", event: &logger.LogEvent{Level: "info", Component: "http.access"}, - want: true, + want: false, }, { name: "rejected access excluded from database sink", @@ -46,13 +46,13 @@ func TestOpsSystemLogSink_ShouldIndex(t *testing.T) { want: false, }, { - name: "access component from fields (real zap path)", + name: "access component from fields disabled by default", event: &logger.LogEvent{ Level: "info", Component: "", Fields: map[string]any{"component": "http.access"}, }, - want: true, + want: false, }, { name: "audit component", @@ -82,6 +82,28 @@ func TestOpsSystemLogSink_ShouldIndex(t *testing.T) { } } +func TestOpsSystemLogSink_ShouldIndexAccessLogsOnlyWhenEnabled(t *testing.T) { + sink := &OpsSystemLogSink{} + sink.SetPersistAccessLogs(true) + + if !sink.shouldIndex(&logger.LogEvent{Level: "info", Component: "http.access"}) { + t.Fatal("access log should be indexed after explicit opt-in") + } + if !sink.shouldIndex(&logger.LogEvent{ + Level: "info", + Fields: map[string]any{"component": "http.access"}, + }) { + t.Fatal("field-based access log should be indexed after explicit opt-in") + } + if sink.shouldIndex(&logger.LogEvent{ + Level: "info", + Component: "http.access", + Fields: map[string]any{logger.OpsSystemLogSkipField: true}, + }) { + t.Fatal("explicit skip marker must override access-log persistence") + } +} + func TestOpsSystemLogSink_WriteLogEvent_ShouldDropWhenQueueFull(t *testing.T) { sink := &OpsSystemLogSink{ queue: make(chan *logger.LogEvent, 1), diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index bb8b31eb28..d9f818b385 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -814,6 +814,7 @@ export interface OpsAggregationSettings { export interface OpsRuntimeLogConfig { level: 'debug' | 'info' | 'warn' | 'error' + persist_access_logs: boolean enable_sampling: boolean sampling_initial: number sampling_thereafter: number diff --git a/frontend/src/i18n/locales/en/admin/ops.ts b/frontend/src/i18n/locales/en/admin/ops.ts index 46cd922b6b..2ce0bb38dc 100644 --- a/frontend/src/i18n/locales/en/admin/ops.ts +++ b/frontend/src/i18n/locales/en/admin/ops.ts @@ -42,8 +42,11 @@ export default { samplingInitial: 'Sampling initial', samplingThereafter: 'Sampling thereafter', retentionDays: 'Retention days', + retentionDaysHint: 'Applied by the scheduled data-cleanup job.', caller: 'caller', sampling: 'sampling', + persistAccessLogs: 'Store access logs in database', + persistAccessLogsHint: 'Disabled by default because access logs add one indexed database row per request. Warning, error, and audit logs are always stored.', saveAndApply: 'Save and apply', resetDefaults: 'Reset defaults', latestWriteError: 'Latest write error:', diff --git a/frontend/src/i18n/locales/zh/admin/ops.ts b/frontend/src/i18n/locales/zh/admin/ops.ts index c95ea7feee..19a077fd0d 100644 --- a/frontend/src/i18n/locales/zh/admin/ops.ts +++ b/frontend/src/i18n/locales/zh/admin/ops.ts @@ -42,8 +42,11 @@ export default { samplingInitial: '采样初始条数', samplingThereafter: '后续采样间隔', retentionDays: '保留天数', + retentionDaysHint: '由定时数据清理任务执行。', caller: '调用方', sampling: '采样', + persistAccessLogs: '将访问日志写入数据库', + persistAccessLogsHint: '默认关闭,因为访问日志会为每个请求新增一条带索引的数据库记录。警告、错误和审计日志始终会保留。', saveAndApply: '保存并应用', resetDefaults: '重置默认值', latestWriteError: '最近写入错误:', diff --git a/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue b/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue index 64b9e0dded..e360be3ce3 100644 --- a/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue +++ b/frontend/src/views/admin/ops/components/OpsSystemLogTable.vue @@ -41,6 +41,7 @@ const runtimeLoading = ref(false) const runtimeSaving = ref(false) const runtimeConfig = reactive({ level: 'info', + persist_access_logs: false, enable_sampling: false, sampling_initial: 100, sampling_thereafter: 100, @@ -231,6 +232,7 @@ const loadRuntimeConfig = async () => { try { const cfg = await opsAPI.getRuntimeLogConfig() runtimeConfig.level = cfg.level + runtimeConfig.persist_access_logs = cfg.persist_access_logs runtimeConfig.enable_sampling = cfg.enable_sampling runtimeConfig.sampling_initial = cfg.sampling_initial runtimeConfig.sampling_thereafter = cfg.sampling_thereafter @@ -249,6 +251,7 @@ const saveRuntimeConfig = async () => { try { const saved = await opsAPI.updateRuntimeLogConfig({ ...runtimeConfig }) runtimeConfig.level = saved.level + runtimeConfig.persist_access_logs = saved.persist_access_logs runtimeConfig.enable_sampling = saved.enable_sampling runtimeConfig.sampling_initial = saved.sampling_initial runtimeConfig.sampling_thereafter = saved.sampling_thereafter @@ -272,6 +275,7 @@ const resetRuntimeConfig = async () => { try { const saved = await opsAPI.resetRuntimeLogConfig() runtimeConfig.level = saved.level + runtimeConfig.persist_access_logs = saved.persist_access_logs runtimeConfig.enable_sampling = saved.enable_sampling runtimeConfig.sampling_initial = saved.sampling_initial runtimeConfig.sampling_thereafter = saved.sampling_thereafter @@ -419,6 +423,7 @@ onMounted(async () => {
@@ -431,6 +436,10 @@ onMounted(async () => { {{ t('admin.ops.systemLogs.sampling') }} +
+

{{ t('admin.ops.systemLogs.persistAccessLogsHint') }}

{{ t('admin.ops.systemLogs.latestWriteError') }} {{ health.last_error }}

diff --git a/frontend/src/views/admin/ops/components/__tests__/OpsSystemLogTable.spec.ts b/frontend/src/views/admin/ops/components/__tests__/OpsSystemLogTable.spec.ts index 98e32add24..6d2e8f5346 100644 --- a/frontend/src/views/admin/ops/components/__tests__/OpsSystemLogTable.spec.ts +++ b/frontend/src/views/admin/ops/components/__tests__/OpsSystemLogTable.spec.ts @@ -53,6 +53,7 @@ const PaginationStub = defineComponent({ const runtimeConfig = { level: 'info', + persist_access_logs: false, enable_sampling: false, sampling_initial: 100, sampling_thereafter: 100, @@ -126,6 +127,22 @@ describe('OpsSystemLogTable host support', () => { expect(mockCleanupSystemLogs).toHaveBeenCalledWith(expect.objectContaining({ host: 'api-node-2' })) }) + it('keeps database access-log persistence opt-in', async () => { + const wrapper = mount(OpsSystemLogTable, { + global: { + stubs: { + Select: SelectStub, + Pagination: PaginationStub, + }, + }, + }) + await flushPromises() + + const label = wrapper.findAll('label').find((item) => item.text().includes('admin.ops.systemLogs.persistAccessLogs')) + expect(label).toBeDefined() + expect((label!.find('input').element as HTMLInputElement).checked).toBe(false) + }) + it.each([ ['zh', zhLocale], ['en', enLocale],