mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-05-08 14:52:56 -04:00
Add configurable log retention (#279)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>())
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
code/backend/Cleanuparr.Infrastructure/Logging/ArchiveHooks.cs
Normal file
107
code/backend/Cleanuparr.Infrastructure/Logging/ArchiveHooks.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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': {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
14
code/frontend/src/app/shared/models/logging-config.model.ts
Normal file
14
code/frontend/src/app/shared/models/logging-config.model.ts
Normal 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
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user