diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 3910cc874..7a39d4a96 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -413,6 +413,14 @@ private void ConfigureServiceProvider() var settingsManager = Services.GetRequiredService(); + // Apply the user's log level once settings load, and whenever it changes + settingsManager.Loaded += (_, _) => + AppLogLevelApplier.ApplyToActiveConfiguration(settingsManager.Settings.LogLevel); + settingsManager.RegisterPropertyChangedHandler( + settings => settings.LogLevel, + AppLogLevelApplier.ApplyToActiveConfiguration + ); + if (Program.Args.DataDirectoryOverride is not null) { var normalizedDataDirPath = Path.GetFullPath(Program.Args.DataDirectoryOverride); @@ -1324,12 +1332,14 @@ private static LoggingConfiguration ConfigureLogging() // Console logging var consoleTarget = new RichConsoleTarget("console") { Theme = RichNLogTheme.Code2 }; - builder.ForLogger().FilterMinLevel(NLog.LogLevel.Trace).WriteTo(consoleTarget).WithAsync(); + // Minimum levels for the console and file are replaced by the user's setting once settings load + var defaultMinLevel = Settings.DefaultLogLevel.ToNLogLevel(); + builder.ForLogger().FilterMinLevel(defaultMinLevel).WriteTo(consoleTarget).WithAsync(); // File logging // Use Compat.AppDataHome so logs follow --home-dir overrides var fileTarget = LogFileTarget.Create(Compat.LogsDir); - builder.ForLogger().FilterMinLevel(NLog.LogLevel.Debug).WriteTo(fileTarget).WithAsync(); + builder.ForLogger().FilterMinLevel(defaultMinLevel).WriteTo(fileTarget).WithAsync(); #if SM_LOG_WINDOW // LogViewer target when debug mode diff --git a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs index 44bca5b52..b0d5d3b22 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs +++ b/StabilityMatrix.Avalonia/Languages/Resources.Designer.cs @@ -2643,6 +2643,24 @@ public static string Label_LoginRequiredMessage { } } + /// + /// Looks up a localized string similar to Log Level. + /// + public static string Label_LogLevel { + get { + return ResourceManager.GetString("Label_LogLevel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Minimum level of messages written to the log files and console. Lower levels include more detail but produce larger logs. + /// + public static string Label_LogLevel_Description { + get { + return ResourceManager.GetString("Label_LogLevel_Description", resourceCulture); + } + } + /// /// Looks up a localized string similar to Logs. /// diff --git a/StabilityMatrix.Avalonia/Languages/Resources.resx b/StabilityMatrix.Avalonia/Languages/Resources.resx index ede9d4b94..e88e5837d 100644 --- a/StabilityMatrix.Avalonia/Languages/Resources.resx +++ b/StabilityMatrix.Avalonia/Languages/Resources.resx @@ -1883,4 +1883,10 @@ Account tokens will not be viewable after saving, please make a note of them if Could not load this page. Check your connection and try again. + + Log Level + + + Minimum level of messages written to the log files and console. Lower levels include more detail but produce larger logs. + diff --git a/StabilityMatrix.Avalonia/Logging/AppLogLevelApplier.cs b/StabilityMatrix.Avalonia/Logging/AppLogLevelApplier.cs new file mode 100644 index 000000000..f70ee9cca --- /dev/null +++ b/StabilityMatrix.Avalonia/Logging/AppLogLevelApplier.cs @@ -0,0 +1,73 @@ +using NLog; +using NLog.Config; +using NLog.Targets; +using NLog.Targets.Wrappers; +using StabilityMatrix.Core.Models.Settings; + +namespace StabilityMatrix.Avalonia.Logging; + +/// +/// Applies the user's to the app's file and console log rules at runtime. +/// +public static class AppLogLevelApplier +{ + public static LogLevel ToNLogLevel(this AppLogLevel level) => + level switch + { + AppLogLevel.Trace => LogLevel.Trace, + AppLogLevel.Debug => LogLevel.Debug, + AppLogLevel.Info => LogLevel.Info, + AppLogLevel.Warn => LogLevel.Warn, + AppLogLevel.Error => LogLevel.Error, + _ => LogLevel.Debug, + }; + + /// + /// Sets the minimum level of the catch-all rules writing to the log file or console in + /// . Rules for specific loggers (such as the filters that + /// limit System.* and Microsoft.* to warnings) are left unchanged. + /// + /// The number of rules updated. + public static int Apply(LoggingConfiguration? configuration, AppLogLevel level) + { + if (configuration is null) + return 0; + + var minLevel = level.ToNLogLevel(); + var updated = 0; + + foreach (var rule in configuration.LoggingRules) + { + if (rule.LoggerNamePattern != "*") + continue; + + if (!rule.Targets.Select(Unwrap).Any(t => t is FileTarget or RichConsoleTarget)) + continue; + + rule.SetLoggingLevels(minLevel, LogLevel.Fatal); + updated++; + } + + return updated; + } + + /// + /// Applies to the active NLog configuration and refreshes existing loggers. + /// + public static void ApplyToActiveConfiguration(AppLogLevel level) + { + if (Apply(LogManager.Configuration, level) > 0) + { + LogManager.ReconfigExistingLoggers(); + } + } + + private static Target Unwrap(Target target) + { + while (target is WrapperTargetBase { WrappedTarget: { } inner }) + { + target = inner; + } + return target; + } +} diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs index efb77e2e8..b4cd5e10c 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/MainSettingsViewModel.cs @@ -126,6 +126,11 @@ public partial class MainSettingsViewModel : PageViewModelBase public IReadOnlyList HolidayModes { get; } = Enum.GetValues().ToList(); + public IReadOnlyList LogLevels { get; } = Enum.GetValues().ToList(); + + [ObservableProperty] + private AppLogLevel selectedLogLevel; + [ObservableProperty] private float selectedAnimationScale; @@ -312,6 +317,13 @@ ICivitBaseModelTypeService baseModelTypeService true ); + settingsManager.RelayPropertyFor( + this, + vm => vm.SelectedLogLevel, + settings => settings.LogLevel, + true + ); + settingsManager.RelayPropertyFor( this, vm => vm.AutoLoadCivitModels, diff --git a/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml index 0bb34f7aa..bdac71c45 100644 --- a/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml +++ b/StabilityMatrix.Avalonia/Views/Settings/MainSettingsPage.axaml @@ -662,6 +662,13 @@ + + + + + + + +/// Minimum level of messages written to the app log file and console. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AppLogLevel +{ + Trace, + Debug, + Info, + Warn, + Error, +} diff --git a/StabilityMatrix.Core/Models/Settings/Settings.cs b/StabilityMatrix.Core/Models/Settings/Settings.cs index 527af435e..f84f45fa2 100644 --- a/StabilityMatrix.Core/Models/Settings/Settings.cs +++ b/StabilityMatrix.Core/Models/Settings/Settings.cs @@ -238,6 +238,16 @@ public bool IsEnvironmentVariableUserOverridden(string key) [Obsolete("Use OutputsPageResizeFactor instead")] public Size OutputsImageSize { get; set; } = new(300, 300); public HolidayMode HolidayModeSetting { get; set; } = HolidayMode.Automatic; + + /// + /// Minimum level of messages written to the log file and console + /// + public AppLogLevel LogLevel { get; set; } = DefaultLogLevel; + + /// + /// Log level used until settings are loaded, and for settings files without a + /// + public const AppLogLevel DefaultLogLevel = AppLogLevel.Info; public bool IsWorkflowInfiniteScrollEnabled { get; set; } = true; public bool IsOutputsTreeViewEnabled { get; set; } = true; public CheckpointSortMode CheckpointSortMode { get; set; } = CheckpointSortMode.SharedFolderType; diff --git a/StabilityMatrix.Tests/Avalonia/AppLogLevelApplierTests.cs b/StabilityMatrix.Tests/Avalonia/AppLogLevelApplierTests.cs new file mode 100644 index 000000000..9aa20bb21 --- /dev/null +++ b/StabilityMatrix.Tests/Avalonia/AppLogLevelApplierTests.cs @@ -0,0 +1,83 @@ +using NLog; +using NLog.Config; +using NLog.Targets; +using NLog.Targets.Wrappers; +using StabilityMatrix.Avalonia.Logging; +using StabilityMatrix.Core.Models.Settings; + +namespace StabilityMatrix.Tests.Avalonia; + +[TestClass] +public class AppLogLevelApplierTests +{ + private static (LoggingConfiguration Config, MemoryTarget Memory) CreateConfig() + { + var memory = new MemoryTarget("memory") { Layout = "${level}|${logger}|${message}" }; + var factory = new LogFactory(); + var config = new LoggingConfiguration(factory); + + // Mirrors App.ConfigureLogging: a filter for a specific logger, then catch-all file and console rules + config.AddRule( + new LoggingRule("System.*", LogLevel.Trace, LogLevel.Info, new NullTarget()) { Final = true } + ); + config.AddRule( + LogLevel.Debug, + LogLevel.Fatal, + new AsyncTargetWrapper( + new FileTarget("logfile") { FileName = Path.Combine(Path.GetTempPath(), "unused.log") } + ) + ); + config.AddRule( + LogLevel.Trace, + LogLevel.Fatal, + new AsyncTargetWrapper(new RichConsoleTarget("console")) + ); + config.AddRule(LogLevel.Trace, LogLevel.Fatal, memory); + + return (config, memory); + } + + [DataTestMethod] + [DataRow(AppLogLevel.Trace)] + [DataRow(AppLogLevel.Debug)] + [DataRow(AppLogLevel.Info)] + [DataRow(AppLogLevel.Warn)] + [DataRow(AppLogLevel.Error)] + public void Apply_SetsFileAndConsoleRules(AppLogLevel level) + { + var (config, _) = CreateConfig(); + + var updated = AppLogLevelApplier.Apply(config, level); + + Assert.AreEqual(2, updated); + var expected = level.ToNLogLevel(); + foreach (var rule in config.LoggingRules.Skip(1).Take(2)) + { + foreach (var l in LogLevel.AllLoggingLevels) + { + Assert.AreEqual(l >= expected, rule.IsLoggingEnabledForLevel(l), $"{l} with setting {level}"); + } + } + } + + [TestMethod] + public void Apply_LeavesOtherRulesUnchanged() + { + var (config, _) = CreateConfig(); + + AppLogLevelApplier.Apply(config, AppLogLevel.Error); + + var systemRule = config.LoggingRules[0]; + Assert.IsTrue(systemRule.IsLoggingEnabledForLevel(LogLevel.Trace)); + Assert.IsTrue(systemRule.IsLoggingEnabledForLevel(LogLevel.Info)); + + var memoryRule = config.LoggingRules[3]; + Assert.IsTrue(memoryRule.IsLoggingEnabledForLevel(LogLevel.Trace)); + } + + [TestMethod] + public void Apply_NullConfiguration_ReturnsZero() + { + Assert.AreEqual(0, AppLogLevelApplier.Apply(null, AppLogLevel.Info)); + } +}