From 5b158b2fbd4749295afd5c4fa971799a9fb04e40 Mon Sep 17 00:00:00 2001 From: Deepratna Awale Date: Fri, 6 Nov 2026 10:24:56 +0000 Subject: [PATCH 1/3] Add a log level setting for the log file and console The file log was fixed at Debug and the console at Trace, with no way for users to reduce how much gets written (LykosAI/StabilityMatrix#1790). Add a Log Level option under Settings > System Settings. It sets the minimum level of the catch-all file and console rules, is applied when settings load and whenever it changes, and leaves the per-logger filters (System.*, Microsoft.*, etc.) untouched. The default stays at Debug so existing behaviour is unchanged. --- StabilityMatrix.Avalonia/App.axaml.cs | 8 ++ .../Languages/Resources.Designer.cs | 18 ++++ .../Languages/Resources.resx | 6 ++ .../Logging/AppLogLevelApplier.cs | 73 ++++++++++++++++ .../Settings/MainSettingsViewModel.cs | 12 +++ .../Views/Settings/MainSettingsPage.axaml | 7 ++ .../Models/Settings/AppLogLevel.cs | 16 ++++ .../Models/Settings/Settings.cs | 5 ++ .../Avalonia/AppLogLevelApplierTests.cs | 83 +++++++++++++++++++ 9 files changed, 228 insertions(+) create mode 100644 StabilityMatrix.Avalonia/Logging/AppLogLevelApplier.cs create mode 100644 StabilityMatrix.Core/Models/Settings/AppLogLevel.cs create mode 100644 StabilityMatrix.Tests/Avalonia/AppLogLevelApplierTests.cs diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 3910cc874..ad87be718 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); 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..1367b8afa 100644 --- a/StabilityMatrix.Core/Models/Settings/Settings.cs +++ b/StabilityMatrix.Core/Models/Settings/Settings.cs @@ -238,6 +238,11 @@ 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; } = AppLogLevel.Debug; 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)); + } +} From afa2449c1aa9f8216a563e4369704c51167bde64 Mon Sep 17 00:00:00 2001 From: Deepratna Awale Date: Fri, 6 Nov 2026 10:39:42 +0000 Subject: [PATCH 2/3] Default the log level to Info Use Info as the default for new and existing settings files, and start the file and console rules at the same level so messages logged before settings load match the default. --- StabilityMatrix.Avalonia/App.axaml.cs | 6 ++++-- StabilityMatrix.Core/Models/Settings/Settings.cs | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index ad87be718..7a39d4a96 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -1332,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.Core/Models/Settings/Settings.cs b/StabilityMatrix.Core/Models/Settings/Settings.cs index 1367b8afa..f84f45fa2 100644 --- a/StabilityMatrix.Core/Models/Settings/Settings.cs +++ b/StabilityMatrix.Core/Models/Settings/Settings.cs @@ -242,7 +242,12 @@ public bool IsEnvironmentVariableUserOverridden(string key) /// /// Minimum level of messages written to the log file and console /// - public AppLogLevel LogLevel { get; set; } = AppLogLevel.Debug; + 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; From ed9e80c8ba3dd0766d5658d72bce20a383969bc3 Mon Sep 17 00:00:00 2001 From: Deepratna Awale Date: Sat, 7 Nov 2026 10:55:00 +0000 Subject: [PATCH 3/3] Stop logging unhandled ComfyUI websocket messages at Warn HandleTextMessage only acts on executing, status, progress and execution_error. Every other message fell through to a Warn that included the full pretty-printed JSON payload, so normal ComfyUI traffic (execution_start, execution_cached, executed, execution_success, progress_state) and custom node broadcasts such as resource monitors that send a message every second filled app.log. Recognise the standard ComfyUI message types the client doesn't use and skip them silently, and log any other type at Trace with only its type. The full message is still available at Trace from the existing "Received json message" line. Invalid JSON is still logged at Warn. --- StabilityMatrix.Core/Inference/ComfyClient.cs | 18 +++- .../Api/Comfy/ComfyWebSocketResponseType.cs | 9 ++ .../Core/ComfyClientMessageLoggingTests.cs | 92 +++++++++++++++++++ 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 StabilityMatrix.Tests/Core/ComfyClientMessageLoggingTests.cs diff --git a/StabilityMatrix.Core/Inference/ComfyClient.cs b/StabilityMatrix.Core/Inference/ComfyClient.cs index 197adde6e..c65334106 100644 --- a/StabilityMatrix.Core/Inference/ComfyClient.cs +++ b/StabilityMatrix.Core/Inference/ComfyClient.cs @@ -148,7 +148,7 @@ private void OnMessageReceived(ResponseMessage message) } } - private void HandleTextMessage(string text) + internal void HandleTextMessage(string text) { ComfyWebSocketResponse? json; try @@ -254,9 +254,23 @@ is not { } errorData Logger.Warn($"Could not find task for prompt {errorData.PromptId}, skipping"); } } + else if ( + json.Type + is ComfyWebSocketResponseType.ExecutionStart + or ComfyWebSocketResponseType.ExecutionCached + or ComfyWebSocketResponseType.Executed + or ComfyWebSocketResponseType.ExecutionSuccess + or ComfyWebSocketResponseType.ExecutionInterrupted + or ComfyWebSocketResponseType.ProgressState + ) + { + // Standard ComfyUI messages this client doesn't use + } else { - Logger.Warn($"Unknown message type {json.Type} ({json.Data}), skipping"); + // Includes custom node broadcasts (e.g. resource monitors) which can arrive every second, + // full message content is already logged at Trace above + Logger.Trace("Skipping unhandled message type {Type}", json.Type); } } diff --git a/StabilityMatrix.Core/Models/Api/Comfy/ComfyWebSocketResponseType.cs b/StabilityMatrix.Core/Models/Api/Comfy/ComfyWebSocketResponseType.cs index 7cbe40b8e..c96255780 100644 --- a/StabilityMatrix.Core/Models/Api/Comfy/ComfyWebSocketResponseType.cs +++ b/StabilityMatrix.Core/Models/Api/Comfy/ComfyWebSocketResponseType.cs @@ -29,4 +29,13 @@ public enum ComfyWebSocketResponseType [EnumMember(Value = "executed")] Executed, + + [EnumMember(Value = "execution_success")] + ExecutionSuccess, + + [EnumMember(Value = "execution_interrupted")] + ExecutionInterrupted, + + [EnumMember(Value = "progress_state")] + ProgressState, } diff --git a/StabilityMatrix.Tests/Core/ComfyClientMessageLoggingTests.cs b/StabilityMatrix.Tests/Core/ComfyClientMessageLoggingTests.cs new file mode 100644 index 000000000..23cce0ff7 --- /dev/null +++ b/StabilityMatrix.Tests/Core/ComfyClientMessageLoggingTests.cs @@ -0,0 +1,92 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using NLog; +using NLog.Config; +using NLog.Targets; +using StabilityMatrix.Core.Inference; +using StabilityMatrix.Core.Models.Api.Comfy; + +namespace StabilityMatrix.Tests.Core; + +[TestClass] +[DoNotParallelize] +public class ComfyClientMessageLoggingTests +{ + private LoggingConfiguration? previousConfiguration; + private MemoryTarget memory = null!; + + [TestInitialize] + public void Setup() + { + previousConfiguration = LogManager.Configuration; + + memory = new MemoryTarget("memory") { Layout = "${level}|${message}" }; + var config = new LoggingConfiguration(); + config.AddRule(LogLevel.Trace, LogLevel.Fatal, memory, typeof(ComfyClient).FullName!); + LogManager.Configuration = config; + } + + [TestCleanup] + public void Cleanup() + { + LogManager.Configuration = previousConfiguration; + } + + private static ComfyClient CreateClient() => + (ComfyClient)RuntimeHelpers.GetUninitializedObject(typeof(ComfyClient)); + + private string[] LogsAtOrAbove(LogLevel level) => + memory.Logs.Where(l => LogLevel.FromString(l.Split('|')[0]) >= level).ToArray(); + + [DataTestMethod] + [DataRow("""{"type":"execution_start","data":{"prompt_id":"p1","timestamp":1}}""")] + [DataRow("""{"type":"execution_cached","data":{"nodes":["1","2"],"prompt_id":"p1"}}""")] + [DataRow("""{"type":"executed","data":{"node":"9","output":{"images":[]},"prompt_id":"p1"}}""")] + [DataRow("""{"type":"execution_success","data":{"prompt_id":"p1","timestamp":2}}""")] + [DataRow("""{"type":"execution_interrupted","data":{"prompt_id":"p1","node_id":"3"}}""")] + [DataRow( + """{"type":"progress_state","data":{"prompt_id":"p1","nodes":{"3":{"value":1,"max":20,"state":"running"}}}}""" + )] + [DataRow("""{"type":"crystools.monitor","data":{"cpu_utilization":12.5}}""")] + public void HandleTextMessage_UnhandledTypes_DoNotLogAboveTrace(string message) + { + CreateClient().HandleTextMessage(message); + LogManager.Flush(); + + CollectionAssert.AreEqual(Array.Empty(), LogsAtOrAbove(LogLevel.Debug)); + } + + [TestMethod] + public void HandleTextMessage_CustomNodeType_LogsTypeWithoutPayloadAtTrace() + { + CreateClient().HandleTextMessage("""{"type":"crystools.monitor","data":{"cpu_utilization":12.5}}"""); + LogManager.Flush(); + + var skipped = memory.Logs.Single(l => l.Contains("Skipping unhandled message type")); + StringAssert.StartsWith(skipped, "Trace|"); + Assert.IsFalse(skipped.Contains("cpu_utilization")); + } + + [TestMethod] + public void HandleTextMessage_InvalidJson_StillWarns() + { + CreateClient().HandleTextMessage("not json"); + LogManager.Flush(); + + Assert.AreEqual(1, LogsAtOrAbove(LogLevel.Warn).Length); + } + + [DataTestMethod] + [DataRow("execution_success", ComfyWebSocketResponseType.ExecutionSuccess)] + [DataRow("execution_interrupted", ComfyWebSocketResponseType.ExecutionInterrupted)] + [DataRow("progress_state", ComfyWebSocketResponseType.ProgressState)] + [DataRow("crystools.monitor", ComfyWebSocketResponseType.Unknown)] + public void ResponseType_DeserializesNewTypes(string type, ComfyWebSocketResponseType expected) + { + var response = JsonSerializer.Deserialize( + $$$"""{"type":"{{{type}}}","data":{}}""" + ); + + Assert.AreEqual(expected, response!.Type); + } +}