From 7201520411d281ab7df6c2d3d0d10f0cbb646de2 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Tue, 2 Sep 2025 00:17:16 +0300 Subject: [PATCH] Add configurable log retention (#279) --- .../Controllers/ConfigurationController.cs | 54 +- .../DependencyInjection/LoggingDI.cs | 85 +-- .../DependencyInjection/MainDI.cs | 1 - code/backend/Cleanuparr.Api/HostExtensions.cs | 19 +- code/backend/Cleanuparr.Api/Program.cs | 43 +- .../Cleanuparr.Infrastructure.csproj | 1 + .../Cleanuparr.Infrastructure/Hubs/AppHub.cs | 4 +- .../Logging/ArchiveHooks.cs | 107 +++ .../Logging/LoggingConfigManager.cs | 146 +++- .../Logging/SignalRLogSink.cs | 23 +- .../Cleanuparr.Persistence/DataContext.cs | 48 +- .../Cleanuparr.Persistence/EventsContext.cs | 48 +- ...837_AddAdvancedLoggingSettings.Designer.cs | 674 ++++++++++++++++++ ...250816183837_AddAdvancedLoggingSettings.cs | 88 +++ .../Data/DataContextModelSnapshot.cs | 293 ++++---- .../Configuration/General/GeneralConfig.cs | 7 +- .../Configuration/General/LoggingConfig.cs | 58 ++ .../core/services/documentation.service.ts | 8 +- .../src/app/core/utils/error-handler.util.ts | 36 - .../download-cleaner-settings.component.ts | 11 +- .../general-settings/general-config.store.ts | 55 +- .../general-settings.component.html | 571 +++++++++------ .../general-settings.component.ts | 251 ++++++- .../malware-blocker-settings.component.ts | 11 +- .../queue-cleaner-settings.component.ts | 13 +- .../app/shared/models/general-config.model.ts | 8 +- .../app/shared/models/logging-config.model.ts | 14 + docs/docs/configuration/general/index.mdx | 64 ++ 28 files changed, 2097 insertions(+), 644 deletions(-) create mode 100644 code/backend/Cleanuparr.Infrastructure/Logging/ArchiveHooks.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Data/20250816183837_AddAdvancedLoggingSettings.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Data/20250816183837_AddAdvancedLoggingSettings.cs create mode 100644 code/backend/Cleanuparr.Persistence/Models/Configuration/General/LoggingConfig.cs create mode 100644 code/frontend/src/app/shared/models/logging-config.model.ts diff --git a/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs b/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs index 7df908ac..5f5a58b1 100644 --- a/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs +++ b/code/backend/Cleanuparr.Api/Controllers/ConfigurationController.cs @@ -29,21 +29,18 @@ public class ConfigurationController : ControllerBase { private readonly ILogger _logger; private readonly DataContext _dataContext; - private readonly LoggingConfigManager _loggingConfigManager; private readonly IJobManagementService _jobManagementService; private readonly MemoryCache _cache; public ConfigurationController( ILogger logger, DataContext dataContext, - LoggingConfigManager loggingConfigManager, IJobManagementService jobManagementService, MemoryCache cache ) { _logger = logger; _dataContext = dataContext; - _loggingConfigManager = loggingConfigManager; _jobManagementService = jobManagementService; _cache = cache; } @@ -700,8 +697,19 @@ public class ConfigurationController : ControllerBase _logger.LogInformation("Updated all HTTP client configurations with new general settings"); - // Set the logging level based on the new configuration - _loggingConfigManager.SetLogLevel(newConfig.LogLevel); + // Handle logging configuration changes + var loggingChanged = HasLoggingConfigurationChanged(oldConfig.Log, newConfig.Log); + + if (loggingChanged.LevelOnly) + { + _logger.LogCritical("Setting global log level to {level}", newConfig.Log.Level); + LoggingConfigManager.SetLogLevel(newConfig.Log.Level); + } + else if (loggingChanged.FullReconfiguration) + { + _logger.LogCritical("Reconfiguring logger due to configuration changes"); + LoggingConfigManager.ReconfigureLogging(newConfig); + } return Ok(new { Message = "General configuration updated successfully" }); } @@ -1454,4 +1462,40 @@ public class ConfigurationController : ControllerBase DataContext.Lock.Release(); } } + + /// + /// Determines what type of logging reconfiguration is needed based on configuration changes + /// + /// The previous logging configuration + /// The new logging configuration + /// A tuple indicating the type of reconfiguration needed + private static (bool LevelOnly, bool FullReconfiguration) HasLoggingConfigurationChanged(LoggingConfig oldConfig, LoggingConfig newConfig) + { + // Check if only the log level changed + bool levelChanged = oldConfig.Level != newConfig.Level; + + // Check if other logging properties changed that require full reconfiguration + bool otherPropertiesChanged = + oldConfig.RollingSizeMB != newConfig.RollingSizeMB || + oldConfig.RetainedFileCount != newConfig.RetainedFileCount || + oldConfig.TimeLimitHours != newConfig.TimeLimitHours || + oldConfig.ArchiveEnabled != newConfig.ArchiveEnabled || + oldConfig.ArchiveRetainedCount != newConfig.ArchiveRetainedCount || + oldConfig.ArchiveTimeLimitHours != newConfig.ArchiveTimeLimitHours; + + if (otherPropertiesChanged) + { + // Full reconfiguration needed (includes level change if any) + return (false, true); + } + + if (levelChanged) + { + // Only level changed, simple level update is sufficient + return (true, false); + } + + // No logging configuration changes + return (false, false); + } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/LoggingDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/LoggingDI.cs index 303fcd43..89e1ea3b 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/LoggingDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/LoggingDI.cs @@ -1,10 +1,5 @@ -using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Models; -using Cleanuparr.Shared.Helpers; +using Cleanuparr.Infrastructure.Logging; using Serilog; -using Serilog.Events; -using Serilog.Templates; -using Serilog.Templates.Themes; namespace Cleanuparr.Api.DependencyInjection; @@ -12,82 +7,10 @@ public static class LoggingDI { public static ILoggingBuilder AddLogging(this ILoggingBuilder builder) { - Log.Logger = GetDefaultLoggerConfiguration().CreateLogger(); + Log.Logger = LoggingConfigManager + .CreateLoggerConfiguration() + .CreateLogger(); return builder.ClearProviders().AddSerilog(); } - - public static LoggerConfiguration GetDefaultLoggerConfiguration() - { - LoggerConfiguration logConfig = new(); - const string categoryTemplate = "{#if Category is not null} {Concat('[',Category,']'),CAT_PAD}{#end}"; - const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}"; - - const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m}}\n{{@x}}"; - const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}"; - - // Determine job name padding - List jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.MalwareBlocker), nameof(JobType.DownloadCleaner)]; - int jobPadding = jobNames.Max(x => x.Length) + 2; - - // Determine instance name padding - List categoryNames = [ - InstanceType.Sonarr.ToString(), - InstanceType.Radarr.ToString(), - InstanceType.Lidarr.ToString(), - InstanceType.Readarr.ToString(), - InstanceType.Whisparr.ToString(), - "SYSTEM" - ]; - int catPadding = categoryNames.Max(x => x.Length) + 2; - - // Apply padding values to templates - string consoleTemplate = consoleOutputTemplate - .Replace("JOB_PAD", jobPadding.ToString()) - .Replace("CAT_PAD", catPadding.ToString()); - - string fileTemplate = fileOutputTemplate - .Replace("JOB_PAD", jobPadding.ToString()) - .Replace("CAT_PAD", catPadding.ToString()); - - // Configure base logger with dynamic level control - logConfig - .MinimumLevel.Is(LogEventLevel.Information) - .Enrich.FromLogContext() - .WriteTo.Console(new ExpressionTemplate(consoleTemplate, theme: TemplateTheme.Literate)); - - // Create the logs directory - string logsPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "logs"); - if (!Directory.Exists(logsPath)) - { - try - { - Directory.CreateDirectory(logsPath); - } - catch (Exception exception) - { - throw new Exception($"Failed to create log directory | {logsPath}", exception); - } - } - - // Add main log file - logConfig.WriteTo.File( - path: Path.Combine(logsPath, "cleanuparr-.txt"), - formatter: new ExpressionTemplate(fileTemplate), - fileSizeLimitBytes: 10L * 1024 * 1024, - rollingInterval: RollingInterval.Day, - rollOnFileSizeLimit: true, - shared: true - ); - - logConfig - .MinimumLevel.Override("MassTransit", LogEventLevel.Warning) - .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information) - .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) - .MinimumLevel.Override("Quartz", LogEventLevel.Warning) - .MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error) - .Enrich.WithProperty("ApplicationName", "Cleanuparr"); - - return logConfig; - } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs index 2bc8f51a..3eefd8d6 100644 --- a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs +++ b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs @@ -17,7 +17,6 @@ public static class MainDI { public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) => services - .AddLogging(builder => builder.ClearProviders().AddConsole()) .AddHttpClients(configuration) .AddSingleton() .AddSingleton(serviceProvider => serviceProvider.GetRequiredService()) diff --git a/code/backend/Cleanuparr.Api/HostExtensions.cs b/code/backend/Cleanuparr.Api/HostExtensions.cs index 411343e3..7d046f3f 100644 --- a/code/backend/Cleanuparr.Api/HostExtensions.cs +++ b/code/backend/Cleanuparr.Api/HostExtensions.cs @@ -6,7 +6,7 @@ namespace Cleanuparr.Api; public static class HostExtensions { - public static async Task Init(this WebApplication app) + public static async Task InitAsync(this WebApplication app) { ILogger logger = app.Services.GetRequiredService>(); @@ -20,22 +20,25 @@ public static class HostExtensions logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName); - // Apply db migrations - var scopeFactory = app.Services.GetRequiredService(); - await using var scope = scopeFactory.CreateAsyncScope(); - - await using var eventsContext = scope.ServiceProvider.GetRequiredService(); + return app; + } + + public static async Task InitAsync(this WebApplicationBuilder builder) + { + // Apply events db migrations + await using var eventsContext = EventsContext.CreateStaticInstance(); if ((await eventsContext.Database.GetPendingMigrationsAsync()).Any()) { await eventsContext.Database.MigrateAsync(); } - await using var configContext = scope.ServiceProvider.GetRequiredService(); + // Apply data db migrations + await using var configContext = DataContext.CreateStaticInstance(); if ((await configContext.Database.GetPendingMigrationsAsync()).Any()) { await configContext.Database.MigrateAsync(); } - return app; + return builder; } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Api/Program.cs b/code/backend/Cleanuparr.Api/Program.cs index 32a77dce..f43d3778 100644 --- a/code/backend/Cleanuparr.Api/Program.cs +++ b/code/backend/Cleanuparr.Api/Program.cs @@ -2,14 +2,18 @@ using System.Runtime.InteropServices; using System.Text.Json.Serialization; using Cleanuparr.Api; using Cleanuparr.Api.DependencyInjection; +using Cleanuparr.Infrastructure.Hubs; using Cleanuparr.Infrastructure.Logging; using Cleanuparr.Shared.Helpers; using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.SignalR; using Serilog; var builder = WebApplication.CreateBuilder(args); +await builder.InitAsync(); +builder.Logging.AddLogging(); + // Fix paths for single-file deployment on macOS if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { @@ -68,14 +72,6 @@ builder.Services.AddCors(options => }); }); -// Register services needed for logging first -builder.Services - .AddScoped() - .AddSingleton(); - -// Add logging with proper service provider -builder.Logging.AddLogging(); - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { builder.Host.UseWindowsService(options => @@ -130,28 +126,11 @@ if (basePath is not null) logger.LogInformation("Server configuration: PORT={port}, BASE_PATH={basePath}", port, basePath ?? "/"); // Initialize the host -await app.Init(); +await app.InitAsync(); -// Get LoggingConfigManager (will be created if not already registered) -var scopeFactory = app.Services.GetRequiredService(); -using (var scope = scopeFactory.CreateScope()) -{ - var configManager = scope.ServiceProvider.GetRequiredService(); - - // Get the dynamic level switch for controlling log levels - var levelSwitch = configManager.GetLevelSwitch(); - - // Get the SignalRLogSink instance - var signalRSink = app.Services.GetRequiredService(); - - var logConfig = LoggingDI.GetDefaultLoggerConfiguration(); - logConfig.MinimumLevel.ControlledBy(levelSwitch); - - // Add to Serilog pipeline - logConfig.WriteTo.Sink(signalRSink); - - Log.Logger = logConfig.CreateLogger(); -} +// Configure the app hub for SignalR +var appHub = app.Services.GetRequiredService>(); +SignalRLogSink.Instance.SetAppHubContext(appHub); // Configure health check endpoints before the API configuration app.MapHealthChecks("/health", new HealthCheckOptions @@ -168,4 +147,6 @@ app.MapHealthChecks("/health/ready", new HealthCheckOptions app.ConfigureApi(); -await app.RunAsync(); \ No newline at end of file +await app.RunAsync(); + +await Log.CloseAndFlushAsync(); \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj b/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj index 1521138b..d1092c7f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj +++ b/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj @@ -18,6 +18,7 @@ + diff --git a/code/backend/Cleanuparr.Infrastructure/Hubs/AppHub.cs b/code/backend/Cleanuparr.Infrastructure/Hubs/AppHub.cs index 8fc68433..93174d9d 100644 --- a/code/backend/Cleanuparr.Infrastructure/Hubs/AppHub.cs +++ b/code/backend/Cleanuparr.Infrastructure/Hubs/AppHub.cs @@ -15,11 +15,11 @@ public class AppHub : Hub private readonly ILogger _logger; private readonly SignalRLogSink _logSink; - public AppHub(EventsContext context, ILogger logger, SignalRLogSink logSink) + public AppHub(EventsContext context, ILogger logger) { _context = context; _logger = logger; - _logSink = logSink; + _logSink = SignalRLogSink.Instance; } /// diff --git a/code/backend/Cleanuparr.Infrastructure/Logging/ArchiveHooks.cs b/code/backend/Cleanuparr.Infrastructure/Logging/ArchiveHooks.cs new file mode 100644 index 00000000..8946f569 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure/Logging/ArchiveHooks.cs @@ -0,0 +1,107 @@ +using System.IO.Compression; +using Serilog.Sinks.File; + +namespace Cleanuparr.Infrastructure.Logging; + +// Enhanced from Serilog.Sinks.File.Archive https://github.com/cocowalla/serilog-sinks-file-archive/blob/master/src/Serilog.Sinks.File.Archive/ArchiveHooks.cs +public class ArchiveHooks : FileLifecycleHooks +{ + private readonly CompressionLevel _compressionLevel; + private readonly ushort _retainedFileCountLimit; + private readonly TimeSpan? _retainedFileTimeLimit; + + public ArchiveHooks( + ushort retainedFileCountLimit, + TimeSpan? retainedFileTimeLimit, + CompressionLevel compressionLevel = CompressionLevel.Fastest + ) + { + if (compressionLevel is CompressionLevel.NoCompression) + { + throw new ArgumentException($"{nameof(compressionLevel)} cannot be {CompressionLevel.NoCompression}"); + } + + if (retainedFileCountLimit is 0 && retainedFileTimeLimit is null) + { + throw new ArgumentException($"At least one of {nameof(retainedFileCountLimit)} or {nameof(retainedFileTimeLimit)} must be set"); + } + + _retainedFileCountLimit = retainedFileCountLimit; + _retainedFileTimeLimit = retainedFileTimeLimit; + _compressionLevel = compressionLevel; + } + + public override void OnFileDeleting(string path) + { + FileInfo originalFileInfo = new FileInfo(path); + string newFilePath = $"{path}.gz"; + + using (FileStream originalFileStream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + using (FileStream newFileStream = new FileStream(newFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None)) + { + using (GZipStream archiveStream = new GZipStream(newFileStream, _compressionLevel)) + { + originalFileStream.CopyTo(archiveStream); + } + } + } + + File.SetLastWriteTime(newFilePath, originalFileInfo.LastWriteTime); + File.SetLastWriteTimeUtc(newFilePath, originalFileInfo.LastWriteTimeUtc); + + RemoveExcessFiles(Path.GetDirectoryName(path)!); + } + + private void RemoveExcessFiles(string folder) + { + string searchPattern = _compressionLevel != CompressionLevel.NoCompression ? "*.gz" : "*.*"; + IEnumerable filesToDeleteQuery = Directory.GetFiles(folder, searchPattern) + .Select((Func)(f => new FileInfo(f))) + .OrderByDescending((Func)(f => f), LogFileComparer.Default); + + if (_retainedFileCountLimit > 0) + { + filesToDeleteQuery = filesToDeleteQuery + .Skip(_retainedFileCountLimit); + } + + if (_retainedFileTimeLimit is not null) + { + filesToDeleteQuery = filesToDeleteQuery + .Where(file => file.LastWriteTimeUtc < DateTime.UtcNow - _retainedFileTimeLimit); + } + + List filesToDelete = filesToDeleteQuery.ToList(); + + foreach (FileInfo fileInfo in filesToDelete) + { + fileInfo.Delete(); + } + } + + private class LogFileComparer : IComparer + { + public static readonly IComparer Default = new LogFileComparer(); + + public int Compare(FileInfo? x, FileInfo? y) + { + if (x == null && y == null) + { + return 0; + } + + if (x == null) + { + return -1; + } + + if (y == null || x.LastWriteTimeUtc > y.LastWriteTimeUtc) + { + return 1; + } + + return x.LastWriteTimeUtc < y.LastWriteTimeUtc ? -1 : 0; + } + } +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Logging/LoggingConfigManager.cs b/code/backend/Cleanuparr.Infrastructure/Logging/LoggingConfigManager.cs index 80b7d196..c565f92c 100644 --- a/code/backend/Cleanuparr.Infrastructure/Logging/LoggingConfigManager.cs +++ b/code/backend/Cleanuparr.Infrastructure/Logging/LoggingConfigManager.cs @@ -1,63 +1,153 @@ +using System.IO.Compression; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Models; using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.General; +using Cleanuparr.Shared.Helpers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Serilog; using Serilog.Core; using Serilog.Events; +using Serilog.Templates; +using Serilog.Templates.Themes; namespace Cleanuparr.Infrastructure.Logging; /// /// Manages logging configuration and provides dynamic log level control /// -public class LoggingConfigManager +public static class LoggingConfigManager { - private readonly DataContext _dataContext; - private readonly ILogger _logger; - - private static LoggingLevelSwitch LevelSwitch = new(); - - public LoggingConfigManager(DataContext dataContext, ILogger logger) - { - _dataContext = dataContext; - _logger = logger; - - // Load settings from configuration - LoadConfiguration(); - } + /// + /// The level switch used to dynamically control log levels + /// + public static LoggingLevelSwitch LevelSwitch { get; } = new(); /// - /// Gets the level switch used to dynamically control log levels + /// Creates a logger configuration for startup before DI is available /// - public LoggingLevelSwitch GetLevelSwitch() => LevelSwitch; + /// Configured LoggerConfiguration + public static LoggerConfiguration CreateLoggerConfiguration() + { + using var context = DataContext.CreateStaticInstance(); + var config = context.GeneralConfigs.AsNoTracking().First(); + + const string categoryTemplate = "{#if Category is not null} {Concat('[',Category,']'),CAT_PAD}{#end}"; + const string jobNameTemplate = "{#if JobName is not null} {Concat('[',JobName,']'),JOB_PAD}{#end}"; + + const string consoleOutputTemplate = $"[{{@t:yyyy-MM-dd HH:mm:ss.fff}} {{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m}}\n{{@x}}"; + const string fileOutputTemplate = $"{{@t:yyyy-MM-dd HH:mm:ss.fff zzz}} [{{@l:u3}}]{jobNameTemplate}{categoryTemplate} {{@m:lj}}\n{{@x}}"; + + // Determine job name padding + List jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.MalwareBlocker), nameof(JobType.DownloadCleaner)]; + int jobPadding = jobNames.Max(x => x.Length) + 2; + + // Determine instance name padding + List categoryNames = [ + InstanceType.Sonarr.ToString(), + InstanceType.Radarr.ToString(), + InstanceType.Lidarr.ToString(), + InstanceType.Readarr.ToString(), + InstanceType.Whisparr.ToString(), + "SYSTEM" + ]; + int catPadding = categoryNames.Max(x => x.Length) + 2; + + // Apply padding values to templates + string consoleTemplate = consoleOutputTemplate + .Replace("JOB_PAD", jobPadding.ToString()) + .Replace("CAT_PAD", catPadding.ToString()); + + string fileTemplate = fileOutputTemplate + .Replace("JOB_PAD", jobPadding.ToString()) + .Replace("CAT_PAD", catPadding.ToString()); + + LoggerConfiguration logConfig = new LoggerConfiguration() + .MinimumLevel.ControlledBy(LevelSwitch) + .Enrich.FromLogContext() + .WriteTo.Console(new ExpressionTemplate(consoleTemplate, theme: TemplateTheme.Literate)); + + // Create the logs directory + string logsPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "logs"); + if (!Directory.Exists(logsPath)) + { + try + { + Directory.CreateDirectory(logsPath); + } + catch (Exception exception) + { + throw new Exception($"Failed to create logs directory | {logsPath}", exception); + } + } + + ArchiveHooks? archiveHooks = config.Log.ArchiveEnabled + ? new ArchiveHooks( + retainedFileCountLimit: config.Log.ArchiveRetainedCount, + retainedFileTimeLimit: config.Log.ArchiveTimeLimitHours > 0 ? TimeSpan.FromHours(config.Log.ArchiveTimeLimitHours) : null, + compressionLevel: CompressionLevel.SmallestSize + ) + : null; + + // Add file sink with archive hooks + logConfig.WriteTo.File( + path: Path.Combine(logsPath, "cleanuparr-.txt"), + formatter: new ExpressionTemplate(fileTemplate), + fileSizeLimitBytes: config.Log.RollingSizeMB is 0 ? null : config.Log.RollingSizeMB * 1024L * 1024L, + rollingInterval: RollingInterval.Day, + rollOnFileSizeLimit: config.Log.RollingSizeMB > 0, + retainedFileCountLimit: config.Log.RetainedFileCount is 0 ? null : config.Log.RetainedFileCount, + retainedFileTimeLimit: config.Log.TimeLimitHours is 0 ? null : TimeSpan.FromHours(config.Log.TimeLimitHours), + hooks: archiveHooks + ); + + // Add SignalR sink for real-time log updates + logConfig.WriteTo.Sink(SignalRLogSink.Instance); + + // Apply standard overrides + logConfig + .MinimumLevel.Override("MassTransit", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information) + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .MinimumLevel.Override("Quartz", LogEventLevel.Warning) + .MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Error) + .Enrich.WithProperty("ApplicationName", "Cleanuparr"); + + return logConfig; + } /// /// Updates the global log level and persists the change to configuration /// /// The new log level - public void SetLogLevel(LogEventLevel level) + public static void SetLogLevel(LogEventLevel level) { - _logger.LogCritical("Setting global log level to {level}", level); - // Change the level in the switch LevelSwitch.MinimumLevel = level; } - + /// - /// Loads logging settings from configuration + /// Reconfigures the entire logging system with new settings /// - private void LoadConfiguration() + /// The new general configuration + public static void ReconfigureLogging(GeneralConfig config) { try { - var config = _dataContext.GeneralConfigs - .AsNoTracking() - .First(); - LevelSwitch.MinimumLevel = config.LogLevel; + // Create new logger configuration + var newLoggerConfig = CreateLoggerConfiguration(); + + // Apply the new configuration to the global logger + Log.Logger = newLoggerConfig.CreateLogger(); + + // Update the level switch with the new level + LevelSwitch.MinimumLevel = config.Log.Level; } catch (Exception ex) { - // Just log and continue with defaults - _logger.LogError(ex, "Failed to load logging configuration, using defaults"); + // Log the error but don't throw to avoid breaking the application + Log.Error(ex, "Failed to reconfigure logger"); } } } diff --git a/code/backend/Cleanuparr.Infrastructure/Logging/SignalRLogSink.cs b/code/backend/Cleanuparr.Infrastructure/Logging/SignalRLogSink.cs index 00e971f1..2480855b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Logging/SignalRLogSink.cs +++ b/code/backend/Cleanuparr.Infrastructure/Logging/SignalRLogSink.cs @@ -2,7 +2,7 @@ using System.Collections.Concurrent; using System.Globalization; using Cleanuparr.Infrastructure.Hubs; using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Logging; +using Serilog; using Serilog.Core; using Serilog.Events; using Serilog.Formatting.Display; @@ -14,20 +14,24 @@ namespace Cleanuparr.Infrastructure.Logging; /// public class SignalRLogSink : ILogEventSink { - private readonly ILogger _logger; private readonly ConcurrentQueue _logBuffer; private readonly int _bufferSize; - private readonly IHubContext _appHubContext; private readonly MessageTemplateTextFormatter _formatter = new("{Message:l}", CultureInfo.InvariantCulture); + private IHubContext? _appHubContext; - public SignalRLogSink(ILogger logger, IHubContext appHubContext) + public static SignalRLogSink Instance { get; } = new(); + + private SignalRLogSink() { - _appHubContext = appHubContext; - _logger = logger; _bufferSize = 100; _logBuffer = new ConcurrentQueue(); } + public void SetAppHubContext(IHubContext appHubContext) + { + _appHubContext = appHubContext ?? throw new ArgumentNullException(nameof(appHubContext), "AppHub context cannot be null"); + } + /// /// Processes and emits a log event to SignalR clients /// @@ -52,11 +56,14 @@ public class SignalRLogSink : ILogEventSink AddToBuffer(logData); // Send to connected clients via the unified hub - _ = _appHubContext.Clients.All.SendAsync("LogReceived", logData); + if (_appHubContext is not null) + { + _ = _appHubContext.Clients.All.SendAsync("LogReceived", logData); + } } catch (Exception ex) { - _logger.LogError(ex, "Failed to send log event via SignalR"); + Log.Logger.Error(ex, "Failed to send log event via SignalR"); } } diff --git a/code/backend/Cleanuparr.Persistence/DataContext.cs b/code/backend/Cleanuparr.Persistence/DataContext.cs index abd28ffd..fe3246e7 100644 --- a/code/backend/Cleanuparr.Persistence/DataContext.cs +++ b/code/backend/Cleanuparr.Persistence/DataContext.cs @@ -10,6 +10,7 @@ using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Cleanuparr.Shared.Helpers; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Serilog.Events; namespace Cleanuparr.Persistence; @@ -39,23 +40,36 @@ public class DataContext : DbContext public DbSet AppriseConfigs { get; set; } public DbSet NotifiarrConfigs { get; set; } + + public DataContext() + { + } + + public DataContext(DbContextOptions options) : base(options) + { + } + + public static DataContext CreateStaticInstance() + { + var optionsBuilder = new DbContextOptionsBuilder(); + SetDbContextOptions(optionsBuilder); + return new DataContext(optionsBuilder.Options); + } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - if (optionsBuilder.IsConfigured) - { - return; - } - - var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "cleanuparr.db"); - optionsBuilder - .UseSqlite($"Data Source={dbPath}") - .UseLowerCaseNamingConvention() - .UseSnakeCaseNamingConvention(); + SetDbContextOptions(optionsBuilder); } protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity(entity => + entity.ComplexProperty(e => e.Log, cp => + { + cp.Property(l => l.Level).HasConversion>(); + }) + ); + modelBuilder.Entity(entity => { entity.ComplexProperty(e => e.FailedImport); @@ -115,4 +129,18 @@ public class DataContext : DbContext } } } + + private static void SetDbContextOptions(DbContextOptionsBuilder optionsBuilder) + { + if (optionsBuilder.IsConfigured) + { + return; + } + + var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "cleanuparr.db"); + optionsBuilder + .UseSqlite($"Data Source={dbPath}") + .UseLowerCaseNamingConvention() + .UseSnakeCaseNamingConvention(); + } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Persistence/EventsContext.cs b/code/backend/Cleanuparr.Persistence/EventsContext.cs index 25074d3f..7abee155 100644 --- a/code/backend/Cleanuparr.Persistence/EventsContext.cs +++ b/code/backend/Cleanuparr.Persistence/EventsContext.cs @@ -12,19 +12,34 @@ namespace Cleanuparr.Persistence; public class EventsContext : DbContext { public DbSet Events { get; set; } + + public EventsContext() + { + } + + public EventsContext(DbContextOptions options) : base(options) + { + } + + public static EventsContext CreateStaticInstance() + { + var optionsBuilder = new DbContextOptionsBuilder(); + SetDbContextOptions(optionsBuilder); + return new EventsContext(optionsBuilder.Options); + } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - if (optionsBuilder.IsConfigured) - { - return; - } + SetDbContextOptions(optionsBuilder); + } + + public static string GetLikePattern(string input) + { + input = input.Replace("[", "[[]") + .Replace("%", "[%]") + .Replace("_", "[_]"); - var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "events.db"); - optionsBuilder - .UseSqlite($"Data Source={dbPath}") - .UseLowerCaseNamingConvention() - .UseSnakeCaseNamingConvention(); + return $"%{input}%"; } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -59,12 +74,17 @@ public class EventsContext : DbContext } } - public static string GetLikePattern(string input) + private static void SetDbContextOptions(DbContextOptionsBuilder optionsBuilder) { - input = input.Replace("[", "[[]") - .Replace("%", "[%]") - .Replace("_", "[_]"); + if (optionsBuilder.IsConfigured) + { + return; + } - return $"%{input}%"; + var dbPath = Path.Combine(ConfigurationPathProvider.GetConfigPath(), "events.db"); + optionsBuilder + .UseSqlite($"Data Source={dbPath}") + .UseLowerCaseNamingConvention() + .UseSnakeCaseNamingConvention(); } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20250816183837_AddAdvancedLoggingSettings.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250816183837_AddAdvancedLoggingSettings.Designer.cs new file mode 100644 index 00000000..7ed91a01 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250816183837_AddAdvancedLoggingSettings.Designer.cs @@ -0,0 +1,674 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20250816183837_AddAdvancedLoggingSettings")] + partial class AddAdvancedLoggingSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_cleaner_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("pk_clean_categories"); + + b.HasIndex("DownloadCleanerConfigId") + .HasDatabaseName("ix_clean_categories_download_cleaner_config_id"); + + b.ToTable("clean_categories", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("UnlinkedCategories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_categories"); + + b.Property("UnlinkedEnabled") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_enabled"); + + b.Property("UnlinkedIgnoredRootDir") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_ignored_root_dir"); + + b.Property("UnlinkedTargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("unlinked_target_category"); + + b.Property("UnlinkedUseTag") + .HasColumnType("INTEGER") + .HasColumnName("unlinked_use_tag"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("SearchDelay") + .HasColumnType("INTEGER") + .HasColumnName("search_delay"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.ComplexProperty>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeleteKnownMalware") + .HasColumnType("INTEGER") + .HasColumnName("delete_known_malware"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FullUrl") + .HasColumnType("TEXT") + .HasColumnName("full_url"); + + b.Property("Key") + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Tags") + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.PrimitiveCollection("IgnoredPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_ignored_patterns"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + }); + + b.ComplexProperty>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("slow_delete_private"); + + b1.Property("IgnoreAboveSize") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("slow_ignore_above_size"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("slow_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("slow_max_strikes"); + + b1.Property("MaxTime") + .HasColumnType("REAL") + .HasColumnName("slow_max_time"); + + b1.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("slow_min_speed"); + + b1.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("slow_reset_strikes_on_progress"); + }); + + b.ComplexProperty>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("stalled_delete_private"); + + b1.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("stalled_downloading_metadata_max_strikes"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("stalled_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("stalled_max_strikes"); + + b1.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("stalled_reset_strikes_on_progress"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", "DownloadCleanerConfig") + .WithMany("Categories") + .HasForeignKey("DownloadCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_clean_categories_download_cleaner_configs_download_cleaner_config_id"); + + b.Navigation("DownloadCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Navigation("Categories"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20250816183837_AddAdvancedLoggingSettings.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250816183837_AddAdvancedLoggingSettings.cs new file mode 100644 index 00000000..842db48c --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20250816183837_AddAdvancedLoggingSettings.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddAdvancedLoggingSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "log_archive_enabled", + table: "general_configs", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "log_archive_retained_count", + table: "general_configs", + type: "INTEGER", + nullable: false, + defaultValue: (ushort)0); + + migrationBuilder.AddColumn( + name: "log_archive_time_limit_hours", + table: "general_configs", + type: "INTEGER", + nullable: false, + defaultValue: (ushort)0); + + migrationBuilder.AddColumn( + name: "log_retained_file_count", + table: "general_configs", + type: "INTEGER", + nullable: false, + defaultValue: (ushort)0); + + migrationBuilder.AddColumn( + name: "log_rolling_size_mb", + table: "general_configs", + type: "INTEGER", + nullable: false, + defaultValue: (ushort)0); + + migrationBuilder.AddColumn( + name: "log_time_limit_hours", + table: "general_configs", + type: "INTEGER", + nullable: false, + defaultValue: (ushort)0); + + migrationBuilder.Sql( + "UPDATE general_configs SET log_archive_enabled = 1, log_archive_retained_count = 60, log_archive_time_limit_hours = 720, log_retained_file_count = 5, log_rolling_size_mb = 10, log_time_limit_hours = 24" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "log_archive_enabled", + table: "general_configs"); + + migrationBuilder.DropColumn( + name: "log_archive_retained_count", + table: "general_configs"); + + migrationBuilder.DropColumn( + name: "log_archive_time_limit_hours", + table: "general_configs"); + + migrationBuilder.DropColumn( + name: "log_retained_file_count", + table: "general_configs"); + + migrationBuilder.DropColumn( + name: "log_rolling_size_mb", + table: "general_configs"); + + migrationBuilder.DropColumn( + name: "log_time_limit_hours", + table: "general_configs"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index 1d0c1d50..86ecfc2b 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -79,133 +79,6 @@ namespace Cleanuparr.Persistence.Migrations.Data b.ToTable("arr_instances", (string)null); }); - modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasColumnName("id"); - - b.Property("CronExpression") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("cron_expression"); - - b.Property("DeleteKnownMalware") - .HasColumnType("INTEGER") - .HasColumnName("delete_known_malware"); - - b.Property("DeletePrivate") - .HasColumnType("INTEGER") - .HasColumnName("delete_private"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("IgnorePrivate") - .HasColumnType("INTEGER") - .HasColumnName("ignore_private"); - - b.Property("UseAdvancedScheduling") - .HasColumnType("INTEGER") - .HasColumnName("use_advanced_scheduling"); - - b.ComplexProperty>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => - { - b1.IsRequired(); - - b1.Property("BlocklistPath") - .HasColumnType("TEXT") - .HasColumnName("lidarr_blocklist_path"); - - b1.Property("BlocklistType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("lidarr_blocklist_type"); - - b1.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("lidarr_enabled"); - }); - - b.ComplexProperty>("Radarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => - { - b1.IsRequired(); - - b1.Property("BlocklistPath") - .HasColumnType("TEXT") - .HasColumnName("radarr_blocklist_path"); - - b1.Property("BlocklistType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("radarr_blocklist_type"); - - b1.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("radarr_enabled"); - }); - - b.ComplexProperty>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => - { - b1.IsRequired(); - - b1.Property("BlocklistPath") - .HasColumnType("TEXT") - .HasColumnName("readarr_blocklist_path"); - - b1.Property("BlocklistType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("readarr_blocklist_type"); - - b1.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("readarr_enabled"); - }); - - b.ComplexProperty>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => - { - b1.IsRequired(); - - b1.Property("BlocklistPath") - .HasColumnType("TEXT") - .HasColumnName("sonarr_blocklist_path"); - - b1.Property("BlocklistType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("sonarr_blocklist_type"); - - b1.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("sonarr_enabled"); - }); - - b.ComplexProperty>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => - { - b1.IsRequired(); - - b1.Property("BlocklistPath") - .HasColumnType("TEXT") - .HasColumnName("whisparr_blocklist_path"); - - b1.Property("BlocklistType") - .HasColumnType("INTEGER") - .HasColumnName("whisparr_blocklist_type"); - - b1.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("whisparr_enabled"); - }); - - b.HasKey("Id") - .HasName("pk_content_blocker_configs"); - - b.ToTable("content_blocker_configs", (string)null); - }); - modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.CleanCategory", b => { b.Property("Id") @@ -382,11 +255,6 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("TEXT") .HasColumnName("ignored_downloads"); - b.Property("LogLevel") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("log_level"); - b.Property("SearchDelay") .HasColumnType("INTEGER") .HasColumnName("search_delay"); @@ -395,12 +263,173 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("INTEGER") .HasColumnName("search_enabled"); + b.ComplexProperty>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + b.HasKey("Id") .HasName("pk_general_configs"); b.ToTable("general_configs", (string)null); }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeleteKnownMalware") + .HasColumnType("INTEGER") + .HasColumnName("delete_known_malware"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => { b.Property("Id") diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs index f82cf1f0..7f67278a 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/GeneralConfig.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Cleanuparr.Domain.Enums; +using Serilog; using Serilog.Events; using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException; @@ -25,18 +26,20 @@ public sealed record GeneralConfig : IConfig public bool SearchEnabled { get; set; } = true; public ushort SearchDelay { get; set; } = 30; - - public LogEventLevel LogLevel { get; set; } = LogEventLevel.Information; public string EncryptionKey { get; set; } = Guid.NewGuid().ToString(); public List IgnoredDownloads { get; set; } = []; + public LoggingConfig Log { get; set; } = new(); + public void Validate() { if (HttpTimeout is 0) { throw new ValidationException("HTTP_TIMEOUT must be greater than 0"); } + + Log.Validate(); } } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/General/LoggingConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/LoggingConfig.cs new file mode 100644 index 00000000..1c10d71b --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/General/LoggingConfig.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Cleanuparr.Domain.Exceptions; +using Serilog; +using Serilog.Events; + +namespace Cleanuparr.Persistence.Models.Configuration.General; + +[ComplexType] +public sealed record LoggingConfig : IConfig +{ + public LogEventLevel Level { get; set; } = LogEventLevel.Information; + + public ushort RollingSizeMB { get; set; } = 10; // 0 = disabled + + public ushort RetainedFileCount { get; set; } = 5; // 0 = unlimited + + public ushort TimeLimitHours { get; set; } = 24; // 0 = unlimited + + // Archive Configuration + public bool ArchiveEnabled { get; set; } = true; + + public ushort ArchiveRetainedCount { get; set; } = 60; // 0 = unlimited + + public ushort ArchiveTimeLimitHours { get; set; } = 24 * 30; // 0 = unlimited + + public void Validate() + { + if (RollingSizeMB > 100) + { + throw new ValidationException("Log rolling size cannot exceed 100 MB"); + } + + if (RetainedFileCount > 50) + { + throw new ValidationException("Log retained file count cannot exceed 50"); + } + + if (TimeLimitHours > 1440) // 24 * 60 + { + throw new ValidationException("Log time limit cannot exceed 60 days"); + } + + if (ArchiveRetainedCount > 100) + { + throw new ValidationException("Log archive retained count cannot exceed 100"); + } + + if (ArchiveTimeLimitHours > 1440) // 24 * 60 + { + throw new ValidationException("Log archive time limit cannot exceed 60 days"); + } + + if (ArchiveRetainedCount is 0 && ArchiveTimeLimitHours is 0 && ArchiveEnabled) + { + throw new ValidationException("Archiving is enabled, but no retention policy is set. Please set either a retained file count or time limit"); + } + } +} \ No newline at end of file diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index 621800d1..d21fd73a 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -43,7 +43,13 @@ export class DocumentationService { 'httpCertificateValidation': 'http-certificate-validation', 'searchEnabled': 'search-enabled', 'searchDelay': 'search-delay', - 'logLevel': 'log-level', + 'log.level': 'log-level', + 'log.rollingSizeMB': 'log-rolling-size-mb', + 'log.retainedFileCount': 'log-retained-file-count', + 'log.timeLimitHours': 'log-time-limit-hours', + 'log.archiveEnabled': 'log-archive-enabled', + 'log.archiveRetainedCount': 'log-archive-retained-count', + 'log.archiveTimeLimitHours': 'log-archive-time-limit-hours', 'ignoredDownloads': 'ignored-downloads' }, 'download-cleaner': { diff --git a/code/frontend/src/app/core/utils/error-handler.util.ts b/code/frontend/src/app/core/utils/error-handler.util.ts index c53cc1a0..4f5ae516 100644 --- a/code/frontend/src/app/core/utils/error-handler.util.ts +++ b/code/frontend/src/app/core/utils/error-handler.util.ts @@ -75,40 +75,4 @@ export class ErrorHandlerUtil { return null; } - /** - * Determine if an error message represents a user-fixable validation error - * These should be shown as toast notifications so the user can correct them - */ - static isUserFixableError(errorMessage: string): boolean { - // Common validation error patterns that users can fix - const validationPatterns = [ - /does not exist/i, - /cannot be empty/i, - /invalid/i, - /required/i, - /must be/i, - /should not/i, - /duplicate/i, - /already exists/i, - /format/i, - /expression/i, - ]; - - // Network errors should not be shown as toast (shown in LoadingErrorStateComponent instead) - const networkErrorPatterns = [ - /unable to connect/i, - /network/i, - /connection/i, - /timeout/i, - /server error/i, - ]; - - // Check if it's a network error first - if (networkErrorPatterns.some(pattern => pattern.test(errorMessage))) { - return false; - } - - // Check if it matches validation patterns - return validationPatterns.some(pattern => pattern.test(errorMessage)); - } } \ No newline at end of file diff --git a/code/frontend/src/app/settings/download-cleaner/download-cleaner-settings.component.ts b/code/frontend/src/app/settings/download-cleaner/download-cleaner-settings.component.ts index 91e24f59..df19beef 100644 --- a/code/frontend/src/app/settings/download-cleaner/download-cleaner-settings.component.ts +++ b/code/frontend/src/app/settings/download-cleaner/download-cleaner-settings.component.ts @@ -179,15 +179,8 @@ export class DownloadCleanerSettingsComponent implements OnDestroy, CanComponent if (saveErrorMessage) { // Check if this looks like a validation error from the backend // These are typically user-fixable errors that should be shown as toasts - const isUserFixableError = ErrorHandlerUtil.isUserFixableError(saveErrorMessage); - - if (isUserFixableError) { - // Show validation errors as toast notifications so user can fix them - this.notificationService.showError(saveErrorMessage); - } else { - // For non-user-fixable save errors, also emit to parent - this.error.emit(saveErrorMessage); - } + // Always show save errors as a toast so the user sees the backend message. + this.notificationService.showError(saveErrorMessage); } }); diff --git a/code/frontend/src/app/settings/general-settings/general-config.store.ts b/code/frontend/src/app/settings/general-settings/general-config.store.ts index daef31e9..62ef9b7f 100644 --- a/code/frontend/src/app/settings/general-settings/general-config.store.ts +++ b/code/frontend/src/app/settings/general-settings/general-config.store.ts @@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core'; import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { GeneralConfig } from '../../shared/models/general-config.model'; +import { LoggingConfig } from '../../shared/models/logging-config.model'; import { ConfigurationService } from '../../core/services/configuration.service'; import { EMPTY, Observable, catchError, switchMap, tap } from 'rxjs'; @@ -9,14 +10,16 @@ export interface GeneralConfigState { config: GeneralConfig | null; loading: boolean; saving: boolean; - error: string | null; + loadError: string | null; // Only for load failures that should show "Not connected" + saveError: string | null; // Only for save failures that should show toast } const initialState: GeneralConfigState = { config: null, loading: false, saving: false, - error: null + loadError: null, + saveError: null }; @Injectable() @@ -29,18 +32,26 @@ export class GeneralConfigStore extends signalStore( */ loadConfig: rxMethod( pipe => pipe.pipe( - tap(() => patchState(store, { loading: true, error: null })), + tap(() => patchState(store, { loading: true, loadError: null, saveError: null })), switchMap(() => configService.getGeneralConfig().pipe( tap({ - next: (config) => patchState(store, { config, loading: false }), + next: (config) => patchState(store, { config, loading: false, loadError: null }), error: (error) => { + const errorMessage = error.message || 'Failed to load configuration'; patchState(store, { loading: false, - error: error.message || 'Failed to load configuration' + loadError: errorMessage // Only load errors should trigger "Not connected" state }); } }), - catchError(() => EMPTY) + catchError((error) => { + const errorMessage = error.message || 'Failed to load configuration'; + patchState(store, { + loading: false, + loadError: errorMessage // Only load errors should trigger "Not connected" state + }); + return EMPTY; + }) )) ) ), @@ -50,24 +61,31 @@ export class GeneralConfigStore extends signalStore( */ saveConfig: rxMethod( (config$: Observable) => config$.pipe( - tap(() => patchState(store, { saving: true, error: null })), + tap(() => patchState(store, { saving: true, saveError: null })), switchMap(config => configService.updateGeneralConfig(config).pipe( tap({ next: () => { - // Successfully saved - just update saving state - // Don't update config to avoid triggering form effects patchState(store, { - saving: false + saving: false, + saveError: null // Clear any previous save errors }); }, error: (error) => { - patchState(store, { - saving: false, - error: error.message || 'Failed to save configuration' + const errorMessage = error.message || 'Failed to save configuration'; + patchState(store, { + saving: false, + saveError: errorMessage // Save errors should NOT trigger "Not connected" state }); } }), - catchError(() => EMPTY) + catchError((error) => { + const errorMessage = error.message || 'Failed to save configuration'; + patchState(store, { + saving: false, + saveError: errorMessage // Save errors should NOT trigger "Not connected" state + }); + return EMPTY; + }) )) ) ), @@ -88,7 +106,14 @@ export class GeneralConfigStore extends signalStore( * Reset any errors */ resetError() { - patchState(store, { error: null }); + patchState(store, { loadError: null, saveError: null }); + }, + + /** + * Reset only save errors + */ + resetSaveError() { + patchState(store, { saveError: null }); } })), withHooks({ diff --git a/code/frontend/src/app/settings/general-settings/general-settings.component.html b/code/frontend/src/app/settings/general-settings/general-settings.component.html index 19879c62..6f470e7b 100644 --- a/code/frontend/src/app/settings/general-settings/general-settings.component.html +++ b/code/frontend/src/app/settings/general-settings/general-settings.component.html @@ -3,28 +3,30 @@

General Settings

- - -
-
-

General Configuration

- Configure general application settings + + + + +
+ + + + +
+
+

General Configuration

+ Configure general application settings +
-
- + -
- - - - - +
-
- -
- - When enabled, no changes will be made to the system -
-
- - -
- -
+
+
- + + When enabled, no changes will be made to the system
- This field is required - Maximum value is 5 - Number of retry attempts for failed HTTPS requests
-
-
- -
+ +
+ +
+
+ +
+ This field is required + Maximum value is 5 + Number of retry attempts for failed HTTPS requests +
+
+ +
+ +
+
+ +
+ This field is required + Maximum value is 100 + Timeout duration for HTTP requests in seconds +
+
+ +
+
- + + Enable or disable certificate validation for HTTPS requests
- This field is required - Maximum value is 100 - Timeout duration for HTTP requests in seconds
-
-
- -
- - Enable or disable certificate validation for HTTPS requests -
-
- - -
- -
- - When enabled, the application will trigger a search after removing a download -
-
- -
- -
+ +
+
- + + When enabled, the application will trigger a search after removing a download +
+
+ +
+ +
+
+ +
+ This field is required + Maximum value is 300 + Delay between search operations in seconds +
+
+ + +
+ +
+ + + + + + Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)
- This field is required - Maximum value is 300 - Delay between search operations in seconds
+ - -
- -
- - Select the minimum log level to display + + + +
+
+

Logging Configuration

+ Configure application logging and retention settings +
+
+
+ +
+ +
+ +
+ + Select the minimum log level to display +
+
+ + +
+ +
+
+ +
+ This field is required + Maximum value is 100 MB + Maximum size of each log file in megabytes (0 = disabled) +
+
+ + +
+ +
+
+ +
+ This field is required + Maximum value is 50 + Number of old log files to retain (0 = unlimited) + Files exceeding this limit will be deleted or archived +
+
+ + +
+ +
+
+ +
+ This field is required + Maximum value is 1440 hours (60 days) + Maximum age of old log files in hours (0 = unlimited) + Files exceeding this limit will be deleted or archived +
+
+ + +
+ +
+ + Enable archiving of old log files +
+
+ + +
+ +
+
+ +
+ This field is required + Maximum value is 100 + Number of archive files to retain (0 = unlimited) +
+
+ + +
+ +
+
+ +
+ This field is required + Maximum value is 1440 hours (60 days) + Maximum age of archive files in hours (0 = unlimited) +
+
- -
- -
- - - - - - Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker) -
-
- - - - -
- - - - - + + + + + +
diff --git a/code/frontend/src/app/settings/general-settings/general-settings.component.ts b/code/frontend/src/app/settings/general-settings/general-settings.component.ts index 7080b77e..d4b894f9 100644 --- a/code/frontend/src/app/settings/general-settings/general-settings.component.ts +++ b/code/frontend/src/app/settings/general-settings/general-settings.component.ts @@ -5,6 +5,7 @@ import { Subject, takeUntil } from "rxjs"; import { GeneralConfigStore } from "./general-config.store"; import { CanComponentDeactivate } from "../../core/guards"; import { GeneralConfig } from "../../shared/models/general-config.model"; +import { LoggingConfig } from "../../shared/models/logging-config.model"; import { LogEventLevel } from "../../shared/models/log-event-level.enum"; import { CertificateValidationType } from "../../shared/models/certificate-validation-type.enum"; @@ -25,6 +26,7 @@ import { LoadingErrorStateComponent } from "../../shared/components/loading-erro import { ConfirmDialogModule } from "primeng/confirmdialog"; import { ConfirmationService } from "primeng/api"; import { MobileAutocompleteComponent } from "../../shared/components/mobile-autocomplete/mobile-autocomplete.component"; +import { ErrorHandlerUtil } from "../../core/utils/error-handler.util"; @Component({ selector: "app-general-settings", @@ -57,6 +59,11 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva // General Configuration Form generalForm: FormGroup; + // Getter for easy access to the log form group + get logForm(): FormGroup { + return this.generalForm.get('log') as FormGroup; + } + // Original form values for tracking changes private originalFormValues: any; @@ -91,7 +98,8 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva readonly generalConfig = this.generalConfigStore.config; readonly generalLoading = this.generalConfigStore.loading; readonly generalSaving = this.generalConfigStore.saving; - readonly generalError = this.generalConfigStore.error; + readonly generalLoadError = this.generalConfigStore.loadError; // Only for "Not connected" state + readonly generalSaveError = this.generalConfigStore.saveError; // Only for toast notifications // Subject for unsubscribing from observables when component is destroyed private destroy$ = new Subject(); @@ -127,8 +135,19 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva httpCertificateValidation: [CertificateValidationType.Enabled], searchEnabled: [true], searchDelay: [30, [Validators.required, Validators.min(1), Validators.max(300)]], - logLevel: [LogEventLevel.Information], ignoredDownloads: [[]], + // Nested logging configuration form group + log: this.formBuilder.group({ + level: [LogEventLevel.Information], + rollingSizeMB: [10, [Validators.required, Validators.min(0), Validators.max(100)]], + retainedFileCount: [5, [Validators.required, Validators.min(0), Validators.max(50)]], + timeLimitHours: [24, [Validators.required, Validators.min(0), Validators.max(1440)]], // max 60 days + archiveEnabled: [true], + archiveRetainedCount: [{ value: 60, disabled: false }, [Validators.required, Validators.min(0), Validators.max(100)]], + archiveTimeLimitHours: [{ value: 720, disabled: false }, [Validators.required, Validators.min(0), Validators.max(1440)]], // max 60 days + }), + // Temporary backward compatibility - will be removed + logLevel: [LogEventLevel.Information], }); // Effect to handle configuration changes @@ -144,13 +163,28 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva httpCertificateValidation: config.httpCertificateValidation, searchEnabled: config.searchEnabled, searchDelay: config.searchDelay, - logLevel: config.logLevel, ignoredDownloads: config.ignoredDownloads || [], + // New nested logging configuration + log: config.log || { + level: config.logLevel || LogEventLevel.Information, // Fall back to old property + rollingSizeMB: 10, + retainedFileCount: 5, + timeLimitHours: 24, + archiveEnabled: true, + archiveRetainedCount: 60, + archiveTimeLimitHours: 720, + }, + // Temporary backward compatibility + logLevel: config.logLevel || config.log?.level || LogEventLevel.Information, }); // Store original values for dirty checking this.storeOriginalValues(); + // Update archive controls state based on loaded configuration + const archiveEnabled = config.log?.archiveEnabled ?? true; + this.updateArchiveControlsState(archiveEnabled); + // Track the support banner state for confirmation dialog logic this.previousSupportBannerState = config.displaySupportBanner; @@ -162,12 +196,21 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva } }); - // Effect to handle errors + // Effect to handle load errors - emit to LoadingErrorStateComponent for "Not connected" display effect(() => { - const errorMessage = this.generalError(); - if (errorMessage) { - // Only emit the error for parent components - this.error.emit(errorMessage); + const loadErrorMessage = this.generalLoadError(); + if (loadErrorMessage) { + // Load errors should be shown as "Not connected to server" in LoadingErrorStateComponent + this.error.emit(loadErrorMessage); + } + }); + + // Effect to handle save errors - show as toast notifications for user to fix + effect(() => { + const saveErrorMessage = this.generalSaveError(); + if (saveErrorMessage) { + // Always show save errors as a toast so the user sees the backend message. + this.notificationService.showError(saveErrorMessage); } }); @@ -200,6 +243,16 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva .subscribe(() => { this.hasActualChanges = this.formValuesChanged(); }); + + // Listen for changes to the 'archiveEnabled' control + const archiveEnabledControl = this.generalForm.get('log.archiveEnabled'); + if (archiveEnabledControl) { + archiveEnabledControl.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((enabled: boolean) => { + this.updateArchiveControlsState(enabled); + }); + } } /** @@ -220,6 +273,101 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva return !this.isEqual(currentValues, this.originalFormValues); } + /** + * Update the state of archive-related controls based on the 'archiveEnabled' control value + */ + private updateArchiveControlsState(archiveEnabled: boolean): void { + const archiveRetainedCountControl = this.generalForm.get('log.archiveRetainedCount'); + const archiveTimeLimitHoursControl = this.generalForm.get('log.archiveTimeLimitHours'); + + if (archiveEnabled) { + archiveRetainedCountControl?.enable({ emitEvent: false }); + archiveTimeLimitHoursControl?.enable({ emitEvent: false }); + } else { + // Disable controls but ensure they can still show validation errors + archiveRetainedCountControl?.disable({ emitEvent: false }); + archiveTimeLimitHoursControl?.disable({ emitEvent: false }); + } + } + + /** + * Validate all form controls, including disabled ones + */ + private validateAllFormControls(formGroup: FormGroup): void { + Object.keys(formGroup.controls).forEach(key => { + const control = formGroup.get(key); + if (control instanceof FormGroup) { + this.validateAllFormControls(control); + } else { + // Force validation even on disabled controls + control?.updateValueAndValidity({ onlySelf: true }); + control?.markAsTouched(); + } + }); + } + + /** + * Validate archive controls specifically, even when disabled + * Returns true if archive controls have validation errors + */ + private validateArchiveControls(): boolean { + const archiveEnabledControl = this.generalForm.get('log.archiveEnabled'); + const archiveRetainedCountControl = this.generalForm.get('log.archiveRetainedCount'); + const archiveTimeLimitHoursControl = this.generalForm.get('log.archiveTimeLimitHours'); + + if (!archiveEnabledControl || !archiveRetainedCountControl || !archiveTimeLimitHoursControl) { + return false; + } + + const isArchiveEnabled = archiveEnabledControl.value; + + // If archive is disabled, we need to manually validate the disabled controls + if (!isArchiveEnabled) { + const retainedCountValue = archiveRetainedCountControl.value; + const timeLimitValue = archiveTimeLimitHoursControl.value; + + // Check archive retained count validation (required, min: 0, max: 100) + const retainedCountErrors: any = {}; + if (retainedCountValue === null || retainedCountValue === undefined || retainedCountValue === '') { + retainedCountErrors.required = true; + } else if (retainedCountValue < 0) { + retainedCountErrors.min = { min: 0, actual: retainedCountValue }; + } else if (retainedCountValue > 100) { + retainedCountErrors.max = { max: 100, actual: retainedCountValue }; + } + + // Check archive time limit validation (required, min: 0, max: 1440) + const timeLimitErrors: any = {}; + if (timeLimitValue === null || timeLimitValue === undefined || timeLimitValue === '') { + timeLimitErrors.required = true; + } else if (timeLimitValue < 0) { + timeLimitErrors.min = { min: 0, actual: timeLimitValue }; + } else if (timeLimitValue > 1440) { + timeLimitErrors.max = { max: 1440, actual: timeLimitValue }; + } + + // Manually set errors and mark as touched to show validation messages + if (Object.keys(retainedCountErrors).length > 0) { + archiveRetainedCountControl.setErrors(retainedCountErrors); + archiveRetainedCountControl.markAsTouched(); + } else { + archiveRetainedCountControl.setErrors(null); + } + + if (Object.keys(timeLimitErrors).length > 0) { + archiveTimeLimitHoursControl.setErrors(timeLimitErrors); + archiveTimeLimitHoursControl.markAsTouched(); + } else { + archiveTimeLimitHoursControl.setErrors(null); + } + + // Return true if there are validation errors + return Object.keys(retainedCountErrors).length > 0 || Object.keys(timeLimitErrors).length > 0; + } + + return false; + } + /** * Deep compare two objects for equality */ @@ -266,23 +414,36 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva * Save the general configuration */ saveGeneralConfig(): void { - // Mark all form controls as touched to trigger validation + // Force validation on all controls, including disabled ones + this.validateAllFormControls(this.generalForm); + + // Specifically validate archive controls even when disabled + const archiveValidationErrors = this.validateArchiveControls(); + + // Mark all form controls as touched to trigger validation messages this.markFormGroupTouched(this.generalForm); - if (this.generalForm.valid) { - const formValues = this.generalForm.getRawValue(); + if (this.generalForm.invalid || archiveValidationErrors) { + this.notificationService.showValidationError(); + return; + } - const config: GeneralConfig = { - displaySupportBanner: formValues.displaySupportBanner, - dryRun: formValues.dryRun, - httpMaxRetries: formValues.httpMaxRetries, - httpTimeout: formValues.httpTimeout, - httpCertificateValidation: formValues.httpCertificateValidation, - searchEnabled: formValues.searchEnabled, - searchDelay: formValues.searchDelay, - logLevel: formValues.logLevel, - ignoredDownloads: formValues.ignoredDownloads || [], - }; + const formValues = this.generalForm.getRawValue(); + + const config: GeneralConfig = { + displaySupportBanner: formValues.displaySupportBanner, + dryRun: formValues.dryRun, + httpMaxRetries: formValues.httpMaxRetries, + httpTimeout: formValues.httpTimeout, + httpCertificateValidation: formValues.httpCertificateValidation, + searchEnabled: formValues.searchEnabled, + searchDelay: formValues.searchDelay, + ignoredDownloads: formValues.ignoredDownloads || [], + // New nested logging configuration + log: formValues.log as LoggingConfig, + // Temporary backward compatibility - keep logLevel for now + logLevel: formValues.log?.level || formValues.logLevel, + }; // Save the configuration this.generalConfigStore.saveConfig(config); @@ -290,9 +451,9 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva // Setup a one-time check to mark form as pristine after successful save const checkSaveCompletion = () => { const saving = this.generalSaving(); - const error = this.generalError(); + const saveError = this.generalSaveError(); - if (!saving && !error) { + if (!saving && !saveError) { // Mark form as pristine after successful save this.generalForm.markAsPristine(); // Update original values reference @@ -301,9 +462,9 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva this.saved.emit(); // Display success message this.notificationService.showSuccess('General configuration saved successfully.'); - } else if (!saving && error) { - // If there's an error, we can stop checking - // No need to show error toast here, it's handled by the LoadingErrorStateComponent + } else if (!saving && saveError) { + // If there's a save error, we can stop checking + // Toast notification is already handled by the effect above } else { // If still saving, check again in a moment setTimeout(checkSaveCompletion, 100); @@ -312,9 +473,6 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva // Start checking for save completion checkSaveCompletion(); - } else { - this.notificationService.showValidationError(); - } } /** @@ -329,10 +487,24 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva httpCertificateValidation: CertificateValidationType.Enabled, searchEnabled: true, searchDelay: 30, - logLevel: LogEventLevel.Information, ignoredDownloads: [], + // Reset nested logging configuration to defaults + log: { + level: LogEventLevel.Information, + rollingSizeMB: 10, + retainedFileCount: 5, + timeLimitHours: 24, + archiveEnabled: true, + archiveRetainedCount: 60, + archiveTimeLimitHours: 720, + }, + // Temporary backward compatibility + logLevel: LogEventLevel.Information, }); + // Update archive controls state after reset + this.updateArchiveControlsState(true); // archiveEnabled defaults to true + // Mark form as dirty so the save button is enabled after reset this.generalForm.markAsDirty(); } @@ -355,7 +527,22 @@ export class GeneralSettingsComponent implements OnDestroy, CanComponentDeactiva */ hasError(controlName: string, errorName: string): boolean { const control = this.generalForm.get(controlName); - return control ? control.dirty && control.hasError(errorName) : false; + // Check for errors on both enabled and disabled controls that have been touched + return control ? (control.dirty || control.touched) && control.hasError(errorName) : false; + } + + /** + * Get nested form control errors + */ + hasNestedError(parentName: string, controlName: string, errorName: string): boolean { + const parentControl = this.generalForm.get(parentName); + if (!parentControl || !(parentControl instanceof FormGroup)) { + return false; + } + + const control = parentControl.get(controlName); + // Check for errors on both enabled and disabled controls that have been touched + return control ? (control.dirty || control.touched) && control.hasError(errorName) : false; } /** diff --git a/code/frontend/src/app/settings/malware-blocker/malware-blocker-settings.component.ts b/code/frontend/src/app/settings/malware-blocker/malware-blocker-settings.component.ts index 513d7793..b0309e9d 100644 --- a/code/frontend/src/app/settings/malware-blocker/malware-blocker-settings.component.ts +++ b/code/frontend/src/app/settings/malware-blocker/malware-blocker-settings.component.ts @@ -220,15 +220,8 @@ export class MalwareBlockerSettingsComponent implements OnDestroy, CanComponentD if (saveErrorMessage) { // Check if this looks like a validation error from the backend // These are typically user-fixable errors that should be shown as toasts - const isUserFixableError = ErrorHandlerUtil.isUserFixableError(saveErrorMessage); - - if (isUserFixableError) { - // Show validation errors as toast notifications so user can fix them - this.notificationService.showError(saveErrorMessage); - } else { - // For non-user-fixable save errors, also emit to parent - this.error.emit(saveErrorMessage); - } + // Always show save errors as a toast so the user sees the backend message. + this.notificationService.showError(saveErrorMessage); } }); diff --git a/code/frontend/src/app/settings/queue-cleaner/queue-cleaner-settings.component.ts b/code/frontend/src/app/settings/queue-cleaner/queue-cleaner-settings.component.ts index 1a5e3e57..9c566740 100644 --- a/code/frontend/src/app/settings/queue-cleaner/queue-cleaner-settings.component.ts +++ b/code/frontend/src/app/settings/queue-cleaner/queue-cleaner-settings.component.ts @@ -232,17 +232,8 @@ export class QueueCleanerSettingsComponent implements OnDestroy, CanComponentDea effect(() => { const saveErrorMessage = this.queueCleanerSaveError(); if (saveErrorMessage) { - // Check if this looks like a validation error from the backend - // These are typically user-fixable errors that should be shown as toasts - const isUserFixableError = ErrorHandlerUtil.isUserFixableError(saveErrorMessage); - - if (isUserFixableError) { - // Show validation errors as toast notifications so user can fix them - this.notificationService.showError(saveErrorMessage); - } else { - // For non-user-fixable save errors, also emit to parent - this.error.emit(saveErrorMessage); - } + // Always show save errors as a toast so the user sees the backend message. + this.notificationService.showError(saveErrorMessage); } }); diff --git a/code/frontend/src/app/shared/models/general-config.model.ts b/code/frontend/src/app/shared/models/general-config.model.ts index 2623cd5f..bbee51ad 100644 --- a/code/frontend/src/app/shared/models/general-config.model.ts +++ b/code/frontend/src/app/shared/models/general-config.model.ts @@ -1,5 +1,6 @@ -import { LogEventLevel } from './log-event-level.enum'; import { CertificateValidationType } from './certificate-validation-type.enum'; +import { LoggingConfig } from './logging-config.model'; +import { LogEventLevel } from './log-event-level.enum'; export interface GeneralConfig { displaySupportBanner: boolean; @@ -9,6 +10,9 @@ export interface GeneralConfig { httpCertificateValidation: CertificateValidationType; searchEnabled: boolean; searchDelay: number; - logLevel: LogEventLevel; + // New logging configuration structure + log?: LoggingConfig; + // Temporary backward compatibility - will be removed in task 7 + logLevel?: LogEventLevel; ignoredDownloads: string[]; } diff --git a/code/frontend/src/app/shared/models/logging-config.model.ts b/code/frontend/src/app/shared/models/logging-config.model.ts new file mode 100644 index 00000000..97b5c0c4 --- /dev/null +++ b/code/frontend/src/app/shared/models/logging-config.model.ts @@ -0,0 +1,14 @@ +import { LogEventLevel } from './log-event-level.enum'; + +/** + * Interface representing logging configuration options + */ +export interface LoggingConfig { + level: LogEventLevel; + rollingSizeMB: number; // 0 = disabled + retainedFileCount: number; // 0 = unlimited + timeLimitHours: number; // 0 = unlimited + archiveEnabled: boolean; + archiveRetainedCount: number; // 0 = unlimited + archiveTimeLimitHours: number; // 0 = unlimited +} \ No newline at end of file diff --git a/docs/docs/configuration/general/index.mdx b/docs/docs/configuration/general/index.mdx index a7cfe4bf..4e343af9 100644 --- a/docs/docs/configuration/general/index.mdx +++ b/docs/docs/configuration/general/index.mdx @@ -143,6 +143,70 @@ Controls the detail level of application logs. Lower levels include all higher l + + +Maximum size (in megabytes) for a single log file before the logger rolls over to a new file. Larger values reduce the number of files created but increase individual file sizes. + + + + + +The number of non-archived log files to keep on disk. Older files beyond this count will be removed or archived, according to the settings. + + + + + +Maximum age (in hours) for non-archived log files to keep on disk. Older files beyond this age will be removed or archived, according to the settings. + + + + + +When enabled, log files that exceed retention limits will be archived instead of being immediately deleted. Archiving helps preserve historical logs while reducing the disk size used by them. + + +Some setups will find that a lot of logs are generated, so archiving is recommended to prevent excessive disk usage. + + + + + + +The number of archived log files to keep. Older files beyond this count will be removed. + + + + + +Maximum age (in hours) for archived logs before they are deleted. Older files beyond this age will be removed. + + +