Add configurable log retention (#279)

This commit is contained in:
Flaminel
2025-09-02 00:17:16 +03:00
committed by GitHub
parent 2a1e65e1af
commit 7201520411
28 changed files with 2097 additions and 644 deletions

View File

@@ -29,21 +29,18 @@ public class ConfigurationController : ControllerBase
{
private readonly ILogger<ConfigurationController> _logger;
private readonly DataContext _dataContext;
private readonly LoggingConfigManager _loggingConfigManager;
private readonly IJobManagementService _jobManagementService;
private readonly MemoryCache _cache;
public ConfigurationController(
ILogger<ConfigurationController> 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();
}
}
/// <summary>
/// Determines what type of logging reconfiguration is needed based on configuration changes
/// </summary>
/// <param name="oldConfig">The previous logging configuration</param>
/// <param name="newConfig">The new logging configuration</param>
/// <returns>A tuple indicating the type of reconfiguration needed</returns>
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);
}
}

View File

@@ -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<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.MalwareBlocker), nameof(JobType.DownloadCleaner)];
int jobPadding = jobNames.Max(x => x.Length) + 2;
// Determine instance name padding
List<string> 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;
}
}

View File

@@ -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<MemoryCache>()
.AddSingleton<IMemoryCache>(serviceProvider => serviceProvider.GetRequiredService<MemoryCache>())

View File

@@ -6,7 +6,7 @@ namespace Cleanuparr.Api;
public static class HostExtensions
{
public static async Task<IHost> Init(this WebApplication app)
public static async Task<IHost> InitAsync(this WebApplication app)
{
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
@@ -20,22 +20,25 @@ public static class HostExtensions
logger.LogInformation("timezone: {tz}", TimeZoneInfo.Local.DisplayName);
// Apply db migrations
var scopeFactory = app.Services.GetRequiredService<IServiceScopeFactory>();
await using var scope = scopeFactory.CreateAsyncScope();
await using var eventsContext = scope.ServiceProvider.GetRequiredService<EventsContext>();
return app;
}
public static async Task<WebApplicationBuilder> 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<DataContext>();
// Apply data db migrations
await using var configContext = DataContext.CreateStaticInstance();
if ((await configContext.Database.GetPendingMigrationsAsync()).Any())
{
await configContext.Database.MigrateAsync();
}
return app;
return builder;
}
}

View File

@@ -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<LoggingConfigManager>()
.AddSingleton<SignalRLogSink>();
// 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<IServiceScopeFactory>();
using (var scope = scopeFactory.CreateScope())
{
var configManager = scope.ServiceProvider.GetRequiredService<LoggingConfigManager>();
// Get the dynamic level switch for controlling log levels
var levelSwitch = configManager.GetLevelSwitch();
// Get the SignalRLogSink instance
var signalRSink = app.Services.GetRequiredService<SignalRLogSink>();
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<IHubContext<AppHub>>();
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();
await app.RunAsync();
await Log.CloseAndFlushAsync();

View File

@@ -18,6 +18,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
<PackageReference Include="Mono.Unix" Version="7.1.0-final.1.21458.1" />
<PackageReference Include="Quartz" Version="3.14.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -15,11 +15,11 @@ public class AppHub : Hub
private readonly ILogger<AppHub> _logger;
private readonly SignalRLogSink _logSink;
public AppHub(EventsContext context, ILogger<AppHub> logger, SignalRLogSink logSink)
public AppHub(EventsContext context, ILogger<AppHub> logger)
{
_context = context;
_logger = logger;
_logSink = logSink;
_logSink = SignalRLogSink.Instance;
}
/// <summary>

View File

@@ -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<FileInfo> filesToDeleteQuery = Directory.GetFiles(folder, searchPattern)
.Select((Func<string, FileInfo>)(f => new FileInfo(f)))
.OrderByDescending((Func<FileInfo, FileInfo>)(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<FileInfo> filesToDelete = filesToDeleteQuery.ToList();
foreach (FileInfo fileInfo in filesToDelete)
{
fileInfo.Delete();
}
}
private class LogFileComparer : IComparer<FileInfo>
{
public static readonly IComparer<FileInfo> 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;
}
}
}

View File

@@ -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;
/// <summary>
/// Manages logging configuration and provides dynamic log level control
/// </summary>
public class LoggingConfigManager
public static class LoggingConfigManager
{
private readonly DataContext _dataContext;
private readonly ILogger<LoggingConfigManager> _logger;
private static LoggingLevelSwitch LevelSwitch = new();
public LoggingConfigManager(DataContext dataContext, ILogger<LoggingConfigManager> logger)
{
_dataContext = dataContext;
_logger = logger;
// Load settings from configuration
LoadConfiguration();
}
/// <summary>
/// The level switch used to dynamically control log levels
/// </summary>
public static LoggingLevelSwitch LevelSwitch { get; } = new();
/// <summary>
/// Gets the level switch used to dynamically control log levels
/// Creates a logger configuration for startup before DI is available
/// </summary>
public LoggingLevelSwitch GetLevelSwitch() => LevelSwitch;
/// <returns>Configured LoggerConfiguration</returns>
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<string> jobNames = [nameof(JobType.QueueCleaner), nameof(JobType.MalwareBlocker), nameof(JobType.DownloadCleaner)];
int jobPadding = jobNames.Max(x => x.Length) + 2;
// Determine instance name padding
List<string> 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;
}
/// <summary>
/// Updates the global log level and persists the change to configuration
/// </summary>
/// <param name="level">The new log level</param>
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;
}
/// <summary>
/// Loads logging settings from configuration
/// Reconfigures the entire logging system with new settings
/// </summary>
private void LoadConfiguration()
/// <param name="config">The new general configuration</param>
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");
}
}
}

View File

@@ -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;
/// </summary>
public class SignalRLogSink : ILogEventSink
{
private readonly ILogger<SignalRLogSink> _logger;
private readonly ConcurrentQueue<object> _logBuffer;
private readonly int _bufferSize;
private readonly IHubContext<AppHub> _appHubContext;
private readonly MessageTemplateTextFormatter _formatter = new("{Message:l}", CultureInfo.InvariantCulture);
private IHubContext<AppHub>? _appHubContext;
public SignalRLogSink(ILogger<SignalRLogSink> logger, IHubContext<AppHub> appHubContext)
public static SignalRLogSink Instance { get; } = new();
private SignalRLogSink()
{
_appHubContext = appHubContext;
_logger = logger;
_bufferSize = 100;
_logBuffer = new ConcurrentQueue<object>();
}
public void SetAppHubContext(IHubContext<AppHub> appHubContext)
{
_appHubContext = appHubContext ?? throw new ArgumentNullException(nameof(appHubContext), "AppHub context cannot be null");
}
/// <summary>
/// Processes and emits a log event to SignalR clients
/// </summary>
@@ -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");
}
}

View File

@@ -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<AppriseConfig> AppriseConfigs { get; set; }
public DbSet<NotifiarrConfig> NotifiarrConfigs { get; set; }
public DataContext()
{
}
public DataContext(DbContextOptions<DataContext> options) : base(options)
{
}
public static DataContext CreateStaticInstance()
{
var optionsBuilder = new DbContextOptionsBuilder<DataContext>();
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<GeneralConfig>(entity =>
entity.ComplexProperty(e => e.Log, cp =>
{
cp.Property(l => l.Level).HasConversion<LowercaseEnumConverter<LogEventLevel>>();
})
);
modelBuilder.Entity<QueueCleanerConfig>(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();
}
}

View File

@@ -12,19 +12,34 @@ namespace Cleanuparr.Persistence;
public class EventsContext : DbContext
{
public DbSet<AppEvent> Events { get; set; }
public EventsContext()
{
}
public EventsContext(DbContextOptions<EventsContext> options) : base(options)
{
}
public static EventsContext CreateStaticInstance()
{
var optionsBuilder = new DbContextOptionsBuilder<EventsContext>();
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();
}
}

View File

@@ -0,0 +1,674 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<short>("FailedImportMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<Guid>("ArrConfigId")
.HasColumnType("TEXT")
.HasColumnName("arr_config_id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<Guid>("DownloadCleanerConfigId")
.HasColumnType("TEXT")
.HasColumnName("download_cleaner_config_id");
b.Property<double>("MaxRatio")
.HasColumnType("REAL")
.HasColumnName("max_ratio");
b.Property<double>("MaxSeedTime")
.HasColumnType("REAL")
.HasColumnName("max_seed_time");
b.Property<double>("MinSeedTime")
.HasColumnType("REAL")
.HasColumnName("min_seed_time");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.PrimitiveCollection<string>("UnlinkedCategories")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_categories");
b.Property<bool>("UnlinkedEnabled")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_enabled");
b.Property<string>("UnlinkedIgnoredRootDir")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_ignored_root_dir");
b.Property<string>("UnlinkedTargetCategory")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("unlinked_target_category");
b.Property<bool>("UnlinkedUseTag")
.HasColumnType("INTEGER")
.HasColumnName("unlinked_use_tag");
b.Property<bool>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<string>("Host")
.HasColumnType("TEXT")
.HasColumnName("host");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("Password")
.HasColumnType("TEXT")
.HasColumnName("password");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type");
b.Property<string>("TypeName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("type_name");
b.Property<string>("UrlBase")
.HasColumnType("TEXT")
.HasColumnName("url_base");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("DisplaySupportBanner")
.HasColumnType("INTEGER")
.HasColumnName("display_support_banner");
b.Property<bool>("DryRun")
.HasColumnType("INTEGER")
.HasColumnName("dry_run");
b.Property<string>("EncryptionKey")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("encryption_key");
b.Property<string>("HttpCertificateValidation")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("http_certificate_validation");
b.Property<ushort>("HttpMaxRetries")
.HasColumnType("INTEGER")
.HasColumnName("http_max_retries");
b.Property<ushort>("HttpTimeout")
.HasColumnType("INTEGER")
.HasColumnName("http_timeout");
b.PrimitiveCollection<string>("IgnoredDownloads")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
b.Property<bool>("SearchEnabled")
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("ArchiveEnabled")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_enabled");
b1.Property<ushort>("ArchiveRetainedCount")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_retained_count");
b1.Property<ushort>("ArchiveTimeLimitHours")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_time_limit_hours");
b1.Property<string>("Level")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b1.Property<ushort>("RetainedFileCount")
.HasColumnType("INTEGER")
.HasColumnName("log_retained_file_count");
b1.Property<ushort>("RollingSizeMB")
.HasColumnType("INTEGER")
.HasColumnName("log_rolling_size_mb");
b1.Property<ushort>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("whisparr_blocklist_path");
b1.Property<int>("BlocklistType")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_blocklist_type");
b1.Property<bool>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("FullUrl")
.HasColumnType("TEXT")
.HasColumnName("full_url");
b.Property<string>("Key")
.HasColumnType("TEXT")
.HasColumnName("key");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("OnStalledStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_stalled_strike");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ApiKey")
.HasColumnType("TEXT")
.HasColumnName("api_key");
b.Property<string>("ChannelId")
.HasColumnType("TEXT")
.HasColumnName("channel_id");
b.Property<bool>("OnCategoryChanged")
.HasColumnType("INTEGER")
.HasColumnName("on_category_changed");
b.Property<bool>("OnDownloadCleaned")
.HasColumnType("INTEGER")
.HasColumnName("on_download_cleaned");
b.Property<bool>("OnFailedImportStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_failed_import_strike");
b.Property<bool>("OnQueueItemDeleted")
.HasColumnType("INTEGER")
.HasColumnName("on_queue_item_deleted");
b.Property<bool>("OnSlowStrike")
.HasColumnType("INTEGER")
.HasColumnName("on_slow_strike");
b.Property<bool>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_delete_private");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_ignore_private");
b1.PrimitiveCollection<string>("IgnoredPatterns")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("failed_import_ignored_patterns");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("failed_import_max_strikes");
});
b.ComplexProperty<Dictionary<string, object>>("Slow", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Slow#SlowConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_delete_private");
b1.Property<string>("IgnoreAboveSize")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_ignore_above_size");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("slow_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("slow_max_strikes");
b1.Property<double>("MaxTime")
.HasColumnType("REAL")
.HasColumnName("slow_max_time");
b1.Property<string>("MinSpeed")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("slow_min_speed");
b1.Property<bool>("ResetStrikesOnProgress")
.HasColumnType("INTEGER")
.HasColumnName("slow_reset_strikes_on_progress");
});
b.ComplexProperty<Dictionary<string, object>>("Stalled", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.Stalled#StalledConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_delete_private");
b1.Property<ushort>("DownloadingMetadataMaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_downloading_metadata_max_strikes");
b1.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("stalled_ignore_private");
b1.Property<ushort>("MaxStrikes")
.HasColumnType("INTEGER")
.HasColumnName("stalled_max_strikes");
b1.Property<bool>("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
}
}
}

View File

@@ -0,0 +1,88 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddAdvancedLoggingSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "log_archive_enabled",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<ushort>(
name: "log_archive_retained_count",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.AddColumn<ushort>(
name: "log_archive_time_limit_hours",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.AddColumn<ushort>(
name: "log_retained_file_count",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.AddColumn<ushort>(
name: "log_rolling_size_mb",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: (ushort)0);
migrationBuilder.AddColumn<ushort>(
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"
);
}
/// <inheritdoc />
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");
}
}
}

View File

@@ -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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.ContentBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("whisparr_blocklist_path");
b1.Property<int>("BlocklistType")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_blocklist_type");
b1.Property<bool>("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<Guid>("Id")
@@ -382,11 +255,6 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("TEXT")
.HasColumnName("ignored_downloads");
b.Property<string>("LogLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b.Property<ushort>("SearchDelay")
.HasColumnType("INTEGER")
.HasColumnName("search_delay");
@@ -395,12 +263,173 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();
b1.Property<bool>("ArchiveEnabled")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_enabled");
b1.Property<ushort>("ArchiveRetainedCount")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_retained_count");
b1.Property<ushort>("ArchiveTimeLimitHours")
.HasColumnType("INTEGER")
.HasColumnName("log_archive_time_limit_hours");
b1.Property<string>("Level")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("log_level");
b1.Property<ushort>("RetainedFileCount")
.HasColumnType("INTEGER")
.HasColumnName("log_retained_file_count");
b1.Property<ushort>("RollingSizeMB")
.HasColumnType("INTEGER")
.HasColumnName("log_rolling_size_mb");
b1.Property<ushort>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("CronExpression")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cron_expression");
b.Property<bool>("DeleteKnownMalware")
.HasColumnType("INTEGER")
.HasColumnName("delete_known_malware");
b.Property<bool>("DeletePrivate")
.HasColumnType("INTEGER")
.HasColumnName("delete_private");
b.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("enabled");
b.Property<bool>("IgnorePrivate")
.HasColumnType("INTEGER")
.HasColumnName("ignore_private");
b.Property<bool>("UseAdvancedScheduling")
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("lidarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("radarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("readarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_path");
b1.Property<string>("BlocklistType")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("sonarr_blocklist_type");
b1.Property<bool>("Enabled")
.HasColumnType("INTEGER")
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
b1.Property<string>("BlocklistPath")
.HasColumnType("TEXT")
.HasColumnName("whisparr_blocklist_path");
b1.Property<int>("BlocklistType")
.HasColumnType("INTEGER")
.HasColumnName("whisparr_blocklist_type");
b1.Property<bool>("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<Guid>("Id")

View File

@@ -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<string> 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();
}
}

View File

@@ -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");
}
}
}

View File

@@ -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': {

View File

@@ -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));
}
}

View File

@@ -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);
}
});

View File

@@ -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<void>(
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<GeneralConfig>(
(config$: Observable<GeneralConfig>) => 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({

View File

@@ -3,28 +3,30 @@
<h1>General Settings</h1>
</div>
<p-card styleClass="settings-card h-full">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">General Configuration</h2>
<span class="card-subtitle">Configure general application settings</span>
<!-- Loading/Error Component -->
<app-loading-error-state
*ngIf="generalLoading() || generalLoadError()"
[loading]="generalLoading()"
[error]="generalLoadError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
<!-- Settings Form -->
<form *ngIf="!generalLoading() && !generalLoadError()" [formGroup]="generalForm" class="p-fluid">
<!-- General Configuration Card -->
<p-card styleClass="settings-card mb-4">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">General Configuration</h2>
<span class="card-subtitle">Configure general application settings</span>
</div>
</div>
</div>
</ng-template>
</ng-template>
<div class="card-content">
<!-- Loading/Error Component -->
<app-loading-error-state
*ngIf="generalLoading() || generalError()"
[loading]="generalLoading()"
[error]="generalError()"
loadingMessage="Loading settings..."
errorMessage="Could not connect to server"
></app-loading-error-state>
<!-- Settings Form -->
<form *ngIf="!generalLoading() && !generalError()" [formGroup]="generalForm" class="p-fluid">
<div class="card-content">
<!-- Display Support Banner -->
<div class="field-row">
<label class="field-label">
@@ -41,207 +43,362 @@
</div>
<!-- Dry Run -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('dryRun')"
title="Click for documentation">
</i>
Dry Run
</label>
<div class="field-input">
<p-checkbox formControlName="dryRun" [binary]="true" inputId="dryRun"></p-checkbox>
<small class="form-helper-text">When enabled, no changes will be made to the system</small>
</div>
</div>
<!-- HTTP Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpMaxRetries')"
title="Click for documentation">
</i>
Maximum HTTP Retries
</label>
<div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('dryRun')"
title="Click for documentation">
</i>
Dry Run
</label>
<div class="field-input">
<p-inputNumber
formControlName="httpMaxRetries"
inputId="httpMaxRetries"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
<p-checkbox formControlName="dryRun" [binary]="true" inputId="dryRun"></p-checkbox>
<small class="form-helper-text">When enabled, no changes will be made to the system</small>
</div>
<small *ngIf="hasError('httpMaxRetries', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('httpMaxRetries', 'max')" class="p-error">Maximum value is 5</small>
<small class="form-helper-text">Number of retry attempts for failed HTTPS requests</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpTimeout')"
title="Click for documentation">
</i>
HTTP Timeout (seconds)
</label>
<div>
<!-- HTTP Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpMaxRetries')"
title="Click for documentation">
</i>
Maximum HTTP Retries
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="httpMaxRetries"
inputId="httpMaxRetries"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasError('httpMaxRetries', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('httpMaxRetries', 'max')" class="p-error">Maximum value is 5</small>
<small class="form-helper-text">Number of retry attempts for failed HTTPS requests</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpTimeout')"
title="Click for documentation">
</i>
HTTP Timeout (seconds)
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="httpTimeout"
inputId="httpTimeout"
[showButtons]="true"
[min]="1"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasError('httpTimeout', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('httpTimeout', 'max')" class="p-error">Maximum value is 100</small>
<small class="form-helper-text">Timeout duration for HTTP requests in seconds</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpCertificateValidation')"
title="Click for documentation">
</i>
Certificate Validation
</label>
<div class="field-input">
<p-inputNumber
formControlName="httpTimeout"
inputId="httpTimeout"
[showButtons]="true"
[min]="1"
buttonLayout="horizontal"
></p-inputNumber>
<p-select
formControlName="httpCertificateValidation"
inputId="httpCertificateValidation"
[options]="certificateValidationOptions"
optionLabel="label"
optionValue="value"
></p-select>
<small class="form-helper-text">Enable or disable certificate validation for HTTPS requests</small>
</div>
<small *ngIf="hasError('httpTimeout', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('httpTimeout', 'max')" class="p-error">Maximum value is 100</small>
<small class="form-helper-text">Timeout duration for HTTP requests in seconds</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('httpCertificateValidation')"
title="Click for documentation">
</i>
Certificate Validation
</label>
<div class="field-input">
<p-select
formControlName="httpCertificateValidation"
inputId="httpCertificateValidation"
[options]="certificateValidationOptions"
optionLabel="label"
optionValue="value"
></p-select>
<small class="form-helper-text">Enable or disable certificate validation for HTTPS requests</small>
</div>
</div>
<!-- Search Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('searchEnabled')"
title="Click for documentation">
</i>
Enable Search
</label>
<div class="field-input">
<p-checkbox formControlName="searchEnabled" [binary]="true" inputId="searchEnabled"></p-checkbox>
<small class="form-helper-text">When enabled, the application will trigger a search after removing a download</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('searchDelay')"
title="Click for documentation">
</i>
Search Delay (seconds)
</label>
<div>
<!-- Search Settings -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('searchEnabled')"
title="Click for documentation">
</i>
Enable Search
</label>
<div class="field-input">
<p-inputNumber
formControlName="searchDelay"
inputId="searchDelay"
[showButtons]="true"
[min]="1"
buttonLayout="horizontal"
></p-inputNumber>
<p-checkbox formControlName="searchEnabled" [binary]="true" inputId="searchEnabled"></p-checkbox>
<small class="form-helper-text">When enabled, the application will trigger a search after removing a download</small>
</div>
</div>
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('searchDelay')"
title="Click for documentation">
</i>
Search Delay (seconds)
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="searchDelay"
inputId="searchDelay"
[showButtons]="true"
[min]="1"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasError('searchDelay', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('searchDelay', 'max')" class="p-error">Maximum value is 300</small>
<small class="form-helper-text">Delay between search operations in seconds</small>
</div>
</div>
<!-- Ignored Downloads -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('ignoredDownloads')"
title="Click for documentation">
</i>
Ignored Downloads
</label>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="ignoredDownloads"
placeholder="Add download pattern"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="ignoredDownloads"
inputId="ignoredDownloads"
multiple
fluid
[typeahead]="false"
placeholder="Add download pattern and press enter"
class="desktop-only"
></p-autocomplete>
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
</div>
<small *ngIf="hasError('searchDelay', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasError('searchDelay', 'max')" class="p-error">Maximum value is 300</small>
<small class="form-helper-text">Delay between search operations in seconds</small>
</div>
</div>
</p-card>
<!-- Log Level -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('logLevel')"
title="Click for documentation">
</i>
Log Level
</label>
<div class="field-input">
<p-select
formControlName="logLevel"
inputId="logLevel"
[options]="logLevelOptions"
optionLabel="label"
optionValue="value"
></p-select>
<small class="form-helper-text">Select the minimum log level to display</small>
<!-- Logging Configuration Card -->
<p-card styleClass="settings-card mb-4">
<ng-template pTemplate="header">
<div class="flex align-items-center justify-content-between p-3 border-bottom-1 surface-border">
<div class="header-title-container">
<h2 class="card-title m-0">Logging Configuration</h2>
<span class="card-subtitle">Configure application logging and retention settings</span>
</div>
</div>
</ng-template>
<div class="card-content" formGroupName="log">
<!-- Log Level -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.level')"
title="Click for documentation">
</i>
Log Level
</label>
<div class="field-input">
<p-select
formControlName="level"
inputId="logLevel"
[options]="logLevelOptions"
optionLabel="label"
optionValue="value"
></p-select>
<small class="form-helper-text">Select the minimum log level to display</small>
</div>
</div>
<!-- Rolling Size -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.rollingSizeMB')"
title="Click for documentation">
</i>
Rolling Size (MB)
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="rollingSizeMB"
inputId="rollingSizeMB"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasNestedError('log', 'rollingSizeMB', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('log', 'rollingSizeMB', 'max')" class="p-error">Maximum value is 100 MB</small>
<small class="form-helper-text">Maximum size of each log file in megabytes (0 = disabled)</small>
</div>
</div>
<!-- Retained File Count -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.retainedFileCount')"
title="Click for documentation">
</i>
Retained File Count
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="retainedFileCount"
inputId="retainedFileCount"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasNestedError('log', 'retainedFileCount', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('log', 'retainedFileCount', 'max')" class="p-error">Maximum value is 50</small>
<small class="form-helper-text">Number of old log files to retain (0 = unlimited)</small>
<small class="form-helper-text">Files exceeding this limit will be deleted or archived</small>
</div>
</div>
<!-- Time Limit Hours -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.timeLimitHours')"
title="Click for documentation">
</i>
Time Limit (hours)
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="timeLimitHours"
inputId="timeLimitHours"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasNestedError('log', 'timeLimitHours', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('log', 'timeLimitHours', 'max')" class="p-error">Maximum value is 1440 hours (60 days)</small>
<small class="form-helper-text">Maximum age of old log files in hours (0 = unlimited)</small>
<small class="form-helper-text">Files exceeding this limit will be deleted or archived</small>
</div>
</div>
<!-- Archive Enabled -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.archiveEnabled')"
title="Click for documentation">
</i>
Enable Archive
</label>
<div class="field-input">
<p-checkbox formControlName="archiveEnabled" [binary]="true" inputId="archiveEnabled"></p-checkbox>
<small class="form-helper-text">Enable archiving of old log files</small>
</div>
</div>
<!-- Archive Retained Count (disabled when archiving disabled) -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.archiveRetainedCount')"
title="Click for documentation">
</i>
Archive Retained Count
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="archiveRetainedCount"
inputId="archiveRetainedCount"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasNestedError('log', 'archiveRetainedCount', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('log', 'archiveRetainedCount', 'max')" class="p-error">Maximum value is 100</small>
<small class="form-helper-text">Number of archive files to retain (0 = unlimited)</small>
</div>
</div>
<!-- Archive Time Limit Hours (disabled when archiving disabled) -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('log.archiveTimeLimitHours')"
title="Click for documentation">
</i>
Archive Time Limit (hours)
</label>
<div>
<div class="field-input">
<p-inputNumber
formControlName="archiveTimeLimitHours"
inputId="archiveTimeLimitHours"
[showButtons]="true"
[min]="0"
buttonLayout="horizontal"
></p-inputNumber>
</div>
<small *ngIf="hasNestedError('log', 'archiveTimeLimitHours', 'required')" class="p-error">This field is required</small>
<small *ngIf="hasNestedError('log', 'archiveTimeLimitHours', 'max')" class="p-error">Maximum value is 1440 hours (60 days)</small>
<small class="form-helper-text">Maximum age of archive files in hours (0 = unlimited)</small>
</div>
</div>
</div>
</p-card>
<!-- Ignored Downloads -->
<div class="field-row">
<label class="field-label">
<i class="pi pi-question-circle field-info-icon"
(click)="openFieldDocs('ignoredDownloads')"
title="Click for documentation">
</i>
Ignored Downloads
</label>
<div class="field-input">
<!-- Mobile-friendly autocomplete -->
<app-mobile-autocomplete
formControlName="ignoredDownloads"
placeholder="Add download pattern"
></app-mobile-autocomplete>
<!-- Desktop autocomplete -->
<p-autocomplete
formControlName="ignoredDownloads"
inputId="ignoredDownloads"
multiple
fluid
[typeahead]="false"
placeholder="Add download pattern and press enter"
class="desktop-only"
></p-autocomplete>
<small class="form-helper-text">Downloads matching these patterns will be ignored (e.g. hash, tag, category, label, tracker)</small>
</div>
</div>
<!-- Action buttons -->
<div class="card-footer mt-3">
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="(!generalForm.dirty || !hasActualChanges) || generalForm.invalid || generalSaving()"
[loading]="generalSaving()"
(click)="saveGeneralConfig()"
></button>
<button
pButton
type="button"
label="Reset"
icon="pi pi-refresh"
class="p-button-secondary p-button-outlined ml-2"
(click)="resetGeneralConfig()"
></button>
</div>
</form>
</div>
</p-card>
<!-- Confirmation Dialog -->
<p-confirmDialog
[style]="{ width: '500px', maxWidth: '90vw' }"
[baseZIndex]="10000">
</p-confirmDialog>
<!-- Action buttons moved outside cards -->
<div class="card-footer mt-3">
<button
pButton
type="button"
label="Save"
icon="pi pi-save"
class="p-button-primary"
[disabled]="(!generalForm.dirty || !hasActualChanges) || generalForm.invalid || generalSaving()"
[loading]="generalSaving()"
(click)="saveGeneralConfig()"
></button>
<button
pButton
type="button"
label="Reset"
icon="pi pi-refresh"
class="p-button-secondary p-button-outlined ml-2"
(click)="resetGeneralConfig()"
></button>
</div>
</form>
<!-- Confirmation Dialog -->
<p-confirmDialog
[style]="{ width: '500px', maxWidth: '90vw' }"
[baseZIndex]="10000">
</p-confirmDialog>
</div>

View File

@@ -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<void>();
@@ -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;
}
/**

View File

@@ -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);
}
});

View File

@@ -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);
}
});

View File

@@ -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[];
}

View File

@@ -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
}

View File

@@ -143,6 +143,70 @@ Controls the detail level of application logs. Lower levels include all higher l
</ConfigSection>
<ConfigSection
id="log-rolling-size-mb"
title="Log Rolling Size (MB)"
icon="📐"
>
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.
</ConfigSection>
<ConfigSection
id="log-retained-file-count"
title="Retained Log Files"
icon="🗂️"
>
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.
</ConfigSection>
<ConfigSection
id="log-time-limit-hours"
title="Log Time Limit (Hours)"
icon="⏳"
>
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.
</ConfigSection>
<ConfigSection
id="log-archive-enabled"
title="Archive Logs"
icon="🗄️"
>
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.
<EnhancedNote>
Some setups will find that a lot of logs are generated, so archiving is recommended to prevent excessive disk usage.
</EnhancedNote>
</ConfigSection>
<ConfigSection
id="log-archive-retained-count"
title="Archived Log Retention (Count)"
icon="🧾"
>
The number of archived log files to keep. Older files beyond this count will be removed.
</ConfigSection>
<ConfigSection
id="log-archive-time-limit-hours"
title="Archived Log Retention (Hours)"
icon="🕒"
>
Maximum age (in hours) for archived logs before they are deleted. Older files beyond this age will be removed.
</ConfigSection>
</div>
<div className={styles.section}>