Add app status setting to general settings (#433)

This commit is contained in:
Flaminel
2026-02-12 23:12:26 +02:00
committed by GitHub
parent 6570f74b7e
commit 40f108d7ca
12 changed files with 1381 additions and 6 deletions

View File

@@ -24,6 +24,8 @@ public sealed record UpdateGeneralConfigRequest
public ushort SearchDelay { get; init; } = Constants.DefaultSearchDelaySeconds;
public bool StatusCheckEnabled { get; init; } = true;
public string EncryptionKey { get; init; } = Guid.NewGuid().ToString();
public List<string> IgnoredDownloads { get; init; } = [];
@@ -39,6 +41,7 @@ public sealed record UpdateGeneralConfigRequest
existingConfig.HttpCertificateValidation = HttpCertificateValidation;
existingConfig.SearchEnabled = SearchEnabled;
existingConfig.SearchDelay = SearchDelay;
existingConfig.StatusCheckEnabled = StatusCheckEnabled;
existingConfig.EncryptionKey = EncryptionKey;
existingConfig.IgnoredDownloads = IgnoredDownloads;

View File

@@ -5,6 +5,7 @@ using Cleanuparr.Domain.Entities.AppStatus;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Services;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
@@ -20,6 +21,7 @@ public class AppStatusRefreshServiceTests : IDisposable
private readonly AppStatusSnapshot _snapshot;
private readonly JsonSerializerOptions _jsonOptions;
private readonly Mock<HttpMessageHandler> _httpHandlerMock;
private readonly Mock<IServiceScopeFactory> _scopeFactoryMock;
private AppStatusRefreshService? _service;
public AppStatusRefreshServiceTests()
@@ -30,6 +32,7 @@ public class AppStatusRefreshServiceTests : IDisposable
_snapshot = new AppStatusSnapshot();
_jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
_httpHandlerMock = new Mock<HttpMessageHandler>();
_scopeFactoryMock = new Mock<IServiceScopeFactory>();
// Setup hub context
var clientsMock = new Mock<IHubClients>();
@@ -50,7 +53,8 @@ public class AppStatusRefreshServiceTests : IDisposable
_hubContextMock.Object,
_httpClientFactoryMock.Object,
_snapshot,
_jsonOptions);
_jsonOptions,
_scopeFactoryMock.Object);
return _service;
}

View File

@@ -2,11 +2,14 @@ using System.Text.Json;
using Cleanuparr.Domain.Entities.AppStatus;
using Cleanuparr.Infrastructure.Hubs;
using Cleanuparr.Infrastructure.Models;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Shared.Helpers;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Cleanuparr.Infrastructure.Services;
@@ -17,18 +20,20 @@ public sealed class AppStatusRefreshService : BackgroundService
private readonly IHttpClientFactory _httpClientFactory;
private readonly AppStatusSnapshot _snapshot;
private readonly JsonSerializerOptions _jsonOptions;
private readonly IServiceScopeFactory _scopeFactory;
private AppStatus? _lastBroadcast;
private static readonly Uri StatusUri = new("https://cleanuparr-status.pages.dev/status.json");
private static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(10);
private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(5);
public AppStatusRefreshService(
ILogger<AppStatusRefreshService> logger,
IHubContext<AppHub> hubContext,
IHttpClientFactory httpClientFactory,
AppStatusSnapshot snapshot,
JsonSerializerOptions jsonOptions
JsonSerializerOptions jsonOptions,
IServiceScopeFactory scopeFactory
)
{
_logger = logger;
@@ -36,6 +41,7 @@ public sealed class AppStatusRefreshService : BackgroundService
_httpClientFactory = httpClientFactory;
_snapshot = snapshot;
_jsonOptions = jsonOptions;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -66,13 +72,22 @@ public sealed class AppStatusRefreshService : BackgroundService
private async Task RefreshAsync(CancellationToken cancellationToken)
{
if (!await IsStatusCheckEnabledAsync(cancellationToken))
{
if (_snapshot.UpdateLatestVersion(null, out var status))
{
await BroadcastAsync(status, cancellationToken);
}
return;
}
try
{
using var client = _httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
using var response = await client.GetAsync(StatusUri, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var payload = await JsonSerializer.DeserializeAsync<Status>(stream, _jsonOptions, cancellationToken: cancellationToken);
var latest = payload?.Version;
@@ -96,6 +111,29 @@ public sealed class AppStatusRefreshService : BackgroundService
}
}
private async Task<bool> IsStatusCheckEnabledAsync(CancellationToken cancellationToken)
{
try
{
await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope();
await using DataContext dataContext = scope.ServiceProvider.GetRequiredService<DataContext>();
GeneralConfig config = await dataContext.GeneralConfigs
.AsNoTracking()
.FirstAsync(cancellationToken);
return config.StatusCheckEnabled;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read StatusCheckEnabled setting, proceeding with status check");
return true;
}
}
private async Task BroadcastAsync(AppStatus status, CancellationToken cancellationToken)
{
if (status.Equals(_lastBroadcast))

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddAppStatusSetting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "status_check_enabled",
table: "general_configs",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "status_check_enabled",
table: "general_configs");
}
}
}

View File

@@ -302,6 +302,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.Property<bool>("StatusCheckEnabled")
.HasColumnType("INTEGER")
.HasColumnName("status_check_enabled");
b.ComplexProperty(typeof(Dictionary<string, object>), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();

View File

@@ -26,6 +26,8 @@ public sealed record GeneralConfig : IConfig
public ushort SearchDelay { get; set; } = Constants.DefaultSearchDelaySeconds;
public bool StatusCheckEnabled { get; set; } = true;
public string EncryptionKey { get; set; } = Guid.NewGuid().ToString();
public List<string> IgnoredDownloads { get; set; } = [];

View File

@@ -49,6 +49,7 @@ export class DocumentationService {
'httpCertificateValidation': 'http-certificate-validation',
'searchEnabled': 'search-enabled',
'searchDelay': 'search-delay',
'statusCheckEnabled': 'status-check',
'log.level': 'log-level',
'log.rollingSizeMB': 'rolling-size-mb',
'log.retainedFileCount': 'retained-file-count',

View File

@@ -25,6 +25,9 @@
<app-toggle label="Display Support Banner" [(checked)]="displaySupportBanner"
hint="Show the support section on the dashboard with links to GitHub and sponsors"
helpKey="general:displaySupportBanner" />
<app-toggle label="Status Check" [(checked)]="statusCheckEnabled"
hint="When enabled, Cleanuparr will periodically check for new versions. Disable this if your environment has restricted outbound network access."
helpKey="general:statusCheckEnabled" />
<div class="form-divider"></div>

View File

@@ -62,6 +62,7 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
readonly httpCertificateValidation = signal<unknown>(CertificateValidationType.Enabled);
readonly searchEnabled = signal(true);
readonly searchDelay = signal<number | null>(5);
readonly statusCheckEnabled = signal(true);
readonly ignoredDownloads = signal<string[]>([]);
// Logging
@@ -165,6 +166,7 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
this.httpCertificateValidation.set(config.httpCertificateValidation);
this.searchEnabled.set(config.searchEnabled);
this.searchDelay.set(config.searchDelay);
this.statusCheckEnabled.set(config.statusCheckEnabled);
this.ignoredDownloads.set(config.ignoredDownloads ?? []);
if (config.log) {
this.logLevel.set(config.log.level);
@@ -200,6 +202,7 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
httpCertificateValidation: this.httpCertificateValidation() as CertificateValidationType,
searchEnabled: this.searchEnabled(),
searchDelay: this.searchDelay() ?? 5,
statusCheckEnabled: this.statusCheckEnabled(),
ignoredDownloads: this.ignoredDownloads(),
log: {
level: this.logLevel() as LogEventLevel,
@@ -237,6 +240,7 @@ export class GeneralSettingsComponent implements OnInit, HasPendingChanges {
httpCertificateValidation: this.httpCertificateValidation(),
searchEnabled: this.searchEnabled(),
searchDelay: this.searchDelay(),
statusCheckEnabled: this.statusCheckEnabled(),
ignoredDownloads: this.ignoredDownloads(),
logLevel: this.logLevel(),
logRollingSizeMB: this.logRollingSizeMB(),

View File

@@ -18,6 +18,7 @@ export interface GeneralConfig {
httpCertificateValidation: CertificateValidationType;
searchEnabled: boolean;
searchDelay: number;
statusCheckEnabled: boolean;
log?: LoggingConfig;
ignoredDownloads: string[];
}

View File

@@ -41,6 +41,19 @@ Logs all operations without making changes. Test your configuration safely befor
</ConfigSection>
<ConfigSection
title="Status Check"
icon="📡"
>
Periodically checks for new Cleanuparr versions. Disable this if your environment has restricted outbound network access.
<Note>
When disabled, the version check and "update available" notification in the sidebar will not appear.
</Note>
</ConfigSection>
</div>
<div className={styles.section}>