From 02a07d4fa3dc4448e435edbab90da2bbbd1d6c01 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Sat, 25 Apr 2026 11:57:38 +0300 Subject: [PATCH] Add sorting and filters for Seeker stats (#576) --- .../CustomFormatScoreControllerTests.cs | 170 +- .../Seeker/SearchStatsControllerTests.cs | 104 +- .../TestHelpers/SeekerTestDataFactory.cs | 4 + .../CustomFormatScoreEntryResponse.cs | 6 + .../CustomFormatScoreController.cs | 303 ++- .../Controllers/SearchStatsController.cs | 83 +- .../Cleanuparr.Domain/Enums/CfScoresSortBy.cs | 23 + .../Enums/CfUpgradesSortBy.cs | 23 + .../Cleanuparr.Domain/Enums/CutoffFilter.cs | 17 + .../Enums/MonitoredFilter.cs | 17 + .../Enums/SearchEventsSortBy.cs | 19 + .../Cleanuparr.Domain/Enums/SortDirection.cs | 15 + .../Features/Jobs/CustomFormatScoreSyncer.cs | 5 + .../Cleanuparr.Persistence/DataContext.cs | 2 + ...ddLastUpgradedAtToCfScoreEntry.Designer.cs | 2108 +++++++++++++++++ ...0134250_AddLastUpgradedAtToCfScoreEntry.cs | 73 + .../Data/DataContextModelSnapshot.cs | 7 + .../Models/State/CustomFormatScoreEntry.cs | 6 + code/frontend/src/app/app.config.ts | 2 + .../frontend/src/app/core/api/cf-score.api.ts | 96 +- .../src/app/core/api/search-stats.api.ts | 54 +- .../core/directives/sticky-aware.directive.ts | 47 + .../app/core/models/search-stats.models.ts | 8 + .../dashboard/dashboard.component.scss | 9 +- .../features/dashboard/dashboard.component.ts | 2 +- .../app/features/events/events.component.html | 2 +- .../app/features/events/events.component.scss | 18 +- .../app/features/events/events.component.ts | 2 + .../logs-component/logs.component.html | 2 +- .../logs-component/logs.component.scss | 14 +- .../features/logs-component/logs.component.ts | 4 +- .../quality-tab/quality-tab.component.html | 82 +- .../quality-tab/quality-tab.component.scss | 105 +- .../quality-tab/quality-tab.component.ts | 173 +- .../searches-tab/searches-tab.component.html | 106 +- .../searches-tab/searches-tab.component.scss | 127 +- .../searches-tab/searches-tab.component.ts | 199 +- .../seeker-stats/seeker-stats.component.scss | 12 +- .../upgrades-tab/upgrades-tab.component.html | 60 +- .../upgrades-tab/upgrades-tab.component.scss | 91 +- .../upgrades-tab/upgrades-tab.component.ts | 107 +- .../features/strikes/strikes.component.html | 2 +- .../features/strikes/strikes.component.scss | 18 +- .../app/features/strikes/strikes.component.ts | 2 + .../src/app/ui/drawer/drawer.component.html | 27 + .../src/app/ui/drawer/drawer.component.scss | 97 + .../src/app/ui/drawer/drawer.component.ts | 81 + code/frontend/src/app/ui/index.ts | 1 + .../app/ui/paginator/paginator.component.html | 1 + .../src/app/ui/select/select.component.html | 6 +- .../src/app/ui/select/select.component.scss | 5 + .../src/app/ui/select/select.component.ts | 1 + code/frontend/src/styles/_animations.scss | 4 +- code/frontend/src/styles/_data-toolbar.scss | 18 +- .../frontend/src/styles/_page-animations.scss | 26 + .../frontend/src/styles/_settings-layout.scss | 21 +- 56 files changed, 4284 insertions(+), 333 deletions(-) create mode 100644 code/backend/Cleanuparr.Domain/Enums/CfScoresSortBy.cs create mode 100644 code/backend/Cleanuparr.Domain/Enums/CfUpgradesSortBy.cs create mode 100644 code/backend/Cleanuparr.Domain/Enums/CutoffFilter.cs create mode 100644 code/backend/Cleanuparr.Domain/Enums/MonitoredFilter.cs create mode 100644 code/backend/Cleanuparr.Domain/Enums/SearchEventsSortBy.cs create mode 100644 code/backend/Cleanuparr.Domain/Enums/SortDirection.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Data/20260420134250_AddLastUpgradedAtToCfScoreEntry.Designer.cs create mode 100644 code/backend/Cleanuparr.Persistence/Migrations/Data/20260420134250_AddLastUpgradedAtToCfScoreEntry.cs create mode 100644 code/frontend/src/app/core/directives/sticky-aware.directive.ts create mode 100644 code/frontend/src/app/ui/drawer/drawer.component.html create mode 100644 code/frontend/src/app/ui/drawer/drawer.component.scss create mode 100644 code/frontend/src/app/ui/drawer/drawer.component.ts create mode 100644 code/frontend/src/styles/_page-animations.scss diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/CustomFormatScoreControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/CustomFormatScoreControllerTests.cs index fe0c0cd3..db9523dc 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/CustomFormatScoreControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/CustomFormatScoreControllerTests.cs @@ -70,7 +70,7 @@ public class CustomFormatScoreControllerTests : IDisposable AddScoreEntry(radarr.Id, 2, "At Cutoff", currentScore: 500, cutoffScore: 500); AddScoreEntry(radarr.Id, 3, "Above Cutoff", currentScore: 600, cutoffScore: 500); - var result = await _controller.GetCustomFormatScores(hideMet: true); + var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.Below); var body = GetResponseBody(result); body.GetProperty("TotalCount").GetInt32().ShouldBe(1); @@ -85,7 +85,7 @@ public class CustomFormatScoreControllerTests : IDisposable AddScoreEntry(radarr.Id, 2, "Unmonitored Movie", currentScore: 200, cutoffScore: 500, isMonitored: false); AddScoreEntry(radarr.Id, 3, "Another Monitored", currentScore: 300, cutoffScore: 500, isMonitored: true); - var result = await _controller.GetCustomFormatScores(hideUnmonitored: true); + var result = await _controller.GetCustomFormatScores(monitoredFilter: MonitoredFilter.Monitored); var body = GetResponseBody(result); body.GetProperty("TotalCount").GetInt32().ShouldBe(2); @@ -118,7 +118,7 @@ public class CustomFormatScoreControllerTests : IDisposable AddScoreEntry(radarr.Id, 2, "Newer", currentScore: 200, cutoffScore: 500, lastSynced: DateTime.UtcNow.AddHours(-1)); - var result = await _controller.GetCustomFormatScores(sortBy: "date"); + var result = await _controller.GetCustomFormatScores(sortBy: CfScoresSortBy.LastSyncedAt); var body = GetResponseBody(result); body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Newer"); @@ -158,6 +158,94 @@ public class CustomFormatScoreControllerTests : IDisposable body.GetProperty("Items").GetArrayLength().ShouldBe(3); } + [Fact] + public async Task GetCustomFormatScores_WithCutoffFilterMet_ExcludesBelowCutoff() + { + var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500); + AddScoreEntry(radarr.Id, 2, "At", currentScore: 500, cutoffScore: 500); + AddScoreEntry(radarr.Id, 3, "Above", currentScore: 600, cutoffScore: 500); + + var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.Met); + var body = GetResponseBody(result); + + body.GetProperty("TotalCount").GetInt32().ShouldBe(2); + } + + [Fact] + public async Task GetCustomFormatScores_WithCutoffFilterAll_IncludesEverything() + { + var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + AddScoreEntry(radarr.Id, 1, "Below", currentScore: 100, cutoffScore: 500); + AddScoreEntry(radarr.Id, 2, "Above", currentScore: 600, cutoffScore: 500); + + var result = await _controller.GetCustomFormatScores(cutoffFilter: CutoffFilter.All); + var body = GetResponseBody(result); + + body.GetProperty("TotalCount").GetInt32().ShouldBe(2); + } + + [Fact] + public async Task GetCustomFormatScores_WithMonitoredFilterUnmonitored_ReturnsOnlyUnmonitored() + { + var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + AddScoreEntry(radarr.Id, 1, "A", currentScore: 100, cutoffScore: 500, isMonitored: true); + AddScoreEntry(radarr.Id, 2, "B", currentScore: 100, cutoffScore: 500, isMonitored: false); + + var result = await _controller.GetCustomFormatScores(monitoredFilter: MonitoredFilter.Unmonitored); + var body = GetResponseBody(result); + + body.GetProperty("TotalCount").GetInt32().ShouldBe(1); + body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("B"); + } + + [Fact] + public async Task GetCustomFormatScores_WithQualityProfileFilter_ReturnsOnlyMatchingProfile() + { + var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + AddScoreEntry(radarr.Id, 1, "HD Movie", currentScore: 100, cutoffScore: 500, qualityProfileName: "HD"); + AddScoreEntry(radarr.Id, 2, "UHD Movie", currentScore: 200, cutoffScore: 500, qualityProfileName: "UHD"); + + var result = await _controller.GetCustomFormatScores(qualityProfile: "UHD"); + var body = GetResponseBody(result); + + body.GetProperty("TotalCount").GetInt32().ShouldBe(1); + body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("UHD Movie"); + } + + [Fact] + public async Task GetCustomFormatScores_WithExplicitSortDirectionAsc_OverridesDefault() + { + var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + AddScoreEntry(radarr.Id, 1, "A", currentScore: 100, cutoffScore: 500); + AddScoreEntry(radarr.Id, 2, "B", currentScore: 300, cutoffScore: 500); + + // CurrentScore default is descending; overriding with Asc should flip it. + var result = await _controller.GetCustomFormatScores( + sortBy: CfScoresSortBy.CurrentScore, + sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc); + var body = GetResponseBody(result); + + var items = body.GetProperty("Items"); + items[0].GetProperty("CurrentScore").GetInt32().ShouldBe(100); + items[1].GetProperty("CurrentScore").GetInt32().ShouldBe(300); + } + + [Fact] + public async Task GetCustomFormatScores_WithSortByTitleDescending_OrdersReverseAlphabetically() + { + var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + AddScoreEntry(radarr.Id, 1, "Apple", currentScore: 100, cutoffScore: 500); + AddScoreEntry(radarr.Id, 2, "Banana", currentScore: 200, cutoffScore: 500); + + var result = await _controller.GetCustomFormatScores( + sortBy: CfScoresSortBy.Title, + sortDirection: Cleanuparr.Domain.Enums.SortDirection.Desc); + var body = GetResponseBody(result); + + body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Banana"); + } + #endregion #region GetRecentUpgrades Tests @@ -245,6 +333,77 @@ public class CustomFormatScoreControllerTests : IDisposable body.GetProperty("TotalCount").GetInt32().ShouldBe(1); } + [Fact] + public async Task GetRecentUpgrades_WithUpgradeCrossingWindowBoundary_IsDetected() + { + // CR2: pre-window baseline must still participate so the first in-window + // row can be recognised as an upgrade. + var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-10)); + AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-3)); + + var result = await _controller.GetRecentUpgrades(days: 7); + var body = GetResponseBody(result); + + body.GetProperty("TotalCount").GetInt32().ShouldBe(1); + var upgrade = body.GetProperty("Items")[0]; + upgrade.GetProperty("PreviousScore").GetInt32().ShouldBe(100); + upgrade.GetProperty("NewScore").GetInt32().ShouldBe(200); + } + + [Fact] + public async Task GetRecentUpgrades_WithSortByScoreDeltaDescending_OrdersByLargestDelta() + { + var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + // Item 1: +50 + AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3)); + AddHistoryEntry(radarr.Id, externalItemId: 1, score: 150, recordedAt: DateTime.UtcNow.AddDays(-2)); + // Item 2: +400 + AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3)); + AddHistoryEntry(radarr.Id, externalItemId: 2, score: 500, recordedAt: DateTime.UtcNow.AddDays(-2)); + + var result = await _controller.GetRecentUpgrades(sortBy: CfUpgradesSortBy.ScoreDelta); + var body = GetResponseBody(result); + + var items = body.GetProperty("Items"); + items[0].GetProperty("NewScore").GetInt32().ShouldBe(500); + items[1].GetProperty("NewScore").GetInt32().ShouldBe(150); + } + + [Fact] + public async Task GetRecentUpgrades_WithSortByTitleAscending_OrdersAlphabetically() + { + var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + AddHistoryEntry(radarr.Id, externalItemId: 2, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3)); + AddHistoryEntry(radarr.Id, externalItemId: 2, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2)); + AddHistoryEntry(radarr.Id, externalItemId: 1, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3)); + AddHistoryEntry(radarr.Id, externalItemId: 1, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2)); + + var result = await _controller.GetRecentUpgrades(sortBy: CfUpgradesSortBy.Title); + var body = GetResponseBody(result); + + var items = body.GetProperty("Items"); + items[0].GetProperty("Title").GetString().ShouldBe("Item 1"); + items[1].GetProperty("Title").GetString().ShouldBe("Item 2"); + } + + [Fact] + public async Task GetRecentUpgrades_WithSearchFilter_ReturnsMatchingTitlesOnly() + { + var radarr = SeekerTestDataFactory.AddRadarrInstance(_dataContext); + // AddHistoryEntry titles as "Item {externalItemId}". + AddHistoryEntry(radarr.Id, externalItemId: 42, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3)); + AddHistoryEntry(radarr.Id, externalItemId: 42, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2)); + AddHistoryEntry(radarr.Id, externalItemId: 99, score: 100, recordedAt: DateTime.UtcNow.AddDays(-3)); + AddHistoryEntry(radarr.Id, externalItemId: 99, score: 200, recordedAt: DateTime.UtcNow.AddDays(-2)); + + var result = await _controller.GetRecentUpgrades(search: "42"); + var body = GetResponseBody(result); + + body.GetProperty("TotalCount").GetInt32().ShouldBe(1); + body.GetProperty("Items")[0].GetProperty("Title").GetString().ShouldBe("Item 42"); + } + [Fact] public async Task GetRecentUpgrades_ReturnsSortedByMostRecentFirst() { @@ -333,7 +492,8 @@ public class CustomFormatScoreControllerTests : IDisposable int cutoffScore, InstanceType itemType = InstanceType.Radarr, DateTime? lastSynced = null, - bool isMonitored = true) + bool isMonitored = true, + string qualityProfileName = "HD") { _dataContext.CustomFormatScoreEntries.Add(new CustomFormatScoreEntry { @@ -345,7 +505,7 @@ public class CustomFormatScoreControllerTests : IDisposable FileId = externalItemId * 10, CurrentScore = currentScore, CutoffScore = cutoffScore, - QualityProfileName = "HD", + QualityProfileName = qualityProfileName, IsMonitored = isMonitored, LastSyncedAt = lastSynced ?? DateTime.UtcNow }); diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs index 2abfe33b..96fe8824 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/SearchStatsControllerTests.cs @@ -155,6 +155,105 @@ public class SearchStatsControllerTests : IDisposable body.GetProperty("Items").GetArrayLength().ShouldBe(2); } + [Fact] + public async Task GetEvents_WithSortByTitleAscending_OrdersAlphabetically() + { + AddSearchEvent(itemTitle: "Charlie"); + AddSearchEvent(itemTitle: "Alpha"); + AddSearchEvent(itemTitle: "Bravo"); + + var result = await _controller.GetEvents( + sortBy: SearchEventsSortBy.Title, + sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc); + var body = GetResponseBody(result); + + var items = body.GetProperty("Items"); + items[0].GetProperty("ItemTitle").GetString().ShouldBe("Alpha"); + items[1].GetProperty("ItemTitle").GetString().ShouldBe("Bravo"); + items[2].GetProperty("ItemTitle").GetString().ShouldBe("Charlie"); + } + + [Fact] + public async Task GetEvents_WithSortByTimestampAscending_OldestFirst() + { + AddSearchEvent(itemTitle: "Newest", timestamp: DateTime.UtcNow); + AddSearchEvent(itemTitle: "Oldest", timestamp: DateTime.UtcNow.AddHours(-2)); + AddSearchEvent(itemTitle: "Middle", timestamp: DateTime.UtcNow.AddHours(-1)); + + var result = await _controller.GetEvents(sortDirection: Cleanuparr.Domain.Enums.SortDirection.Asc); + var body = GetResponseBody(result); + + var items = body.GetProperty("Items"); + items[0].GetProperty("ItemTitle").GetString().ShouldBe("Oldest"); + items[2].GetProperty("ItemTitle").GetString().ShouldBe("Newest"); + } + + [Fact] + public async Task GetEvents_WithSearchStatusFilter_ReturnsOnlyMatchingStatuses() + { + AddSearchEvent(itemTitle: "A", searchStatus: SearchCommandStatus.Completed); + AddSearchEvent(itemTitle: "B", searchStatus: SearchCommandStatus.Failed); + AddSearchEvent(itemTitle: "C", searchStatus: SearchCommandStatus.TimedOut); + + var result = await _controller.GetEvents( + searchStatus: [SearchCommandStatus.Completed, SearchCommandStatus.Failed]); + var body = GetResponseBody(result); + + body.GetProperty("TotalCount").GetInt32().ShouldBe(2); + } + + [Fact] + public async Task GetEvents_WithSearchTypeFilter_ReturnsOnlyMatchingType() + { + AddSearchEvent(itemTitle: "Proactive Movie", searchType: SeekerSearchType.Proactive); + AddSearchEvent(itemTitle: "Replacement Movie", searchType: SeekerSearchType.Replacement); + + var result = await _controller.GetEvents(searchType: SeekerSearchType.Replacement); + var body = GetResponseBody(result); + + body.GetProperty("TotalCount").GetInt32().ShouldBe(1); + body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Replacement Movie"); + } + + [Fact] + public async Task GetEvents_WithSearchReasonFilter_ReturnsOnlyMatchingReason() + { + AddSearchEvent(itemTitle: "Missing", searchReason: SeekerSearchReason.Missing); + AddSearchEvent(itemTitle: "Cutoff", searchReason: SeekerSearchReason.QualityCutoffNotMet); + + var result = await _controller.GetEvents(searchReason: SeekerSearchReason.QualityCutoffNotMet); + var body = GetResponseBody(result); + + body.GetProperty("TotalCount").GetInt32().ShouldBe(1); + body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("Cutoff"); + } + + [Fact] + public async Task GetEvents_WithGrabbedTrue_KeepsOnlyEventsWithGrabbedItems() + { + AddSearchEvent(itemTitle: "With Grabs", grabbedItems: ["movie (2024)"]); + AddSearchEvent(itemTitle: "No Grabs", grabbedItems: []); + + var result = await _controller.GetEvents(grabbed: true); + var body = GetResponseBody(result); + + body.GetProperty("TotalCount").GetInt32().ShouldBe(1); + body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("With Grabs"); + } + + [Fact] + public async Task GetEvents_WithGrabbedFalse_KeepsOnlyEventsWithoutGrabbedItems() + { + AddSearchEvent(itemTitle: "With Grabs", grabbedItems: ["movie (2024)"]); + AddSearchEvent(itemTitle: "No Grabs", grabbedItems: []); + + var result = await _controller.GetEvents(grabbed: false); + var body = GetResponseBody(result); + + body.GetProperty("TotalCount").GetInt32().ShouldBe(1); + body.GetProperty("Items")[0].GetProperty("ItemTitle").GetString().ShouldBe("No Grabs"); + } + #endregion #region Helpers @@ -166,7 +265,8 @@ public class SearchStatsControllerTests : IDisposable List? grabbedItems = null, Guid? arrInstanceId = null, Guid? cycleId = null, - SearchCommandStatus? searchStatus = null) + SearchCommandStatus? searchStatus = null, + DateTime? timestamp = null) { var appEvent = new AppEvent { @@ -176,7 +276,7 @@ public class SearchStatsControllerTests : IDisposable ArrInstanceId = arrInstanceId, CycleId = cycleId, SearchStatus = searchStatus, - Timestamp = DateTime.UtcNow + Timestamp = timestamp ?? DateTime.UtcNow }; _eventsContext.Events.Add(appEvent); diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/TestHelpers/SeekerTestDataFactory.cs b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/TestHelpers/SeekerTestDataFactory.cs index 793ff2ba..58f73a30 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/Seeker/TestHelpers/SeekerTestDataFactory.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/Seeker/TestHelpers/SeekerTestDataFactory.cs @@ -24,6 +24,8 @@ public static class SeekerTestDataFactory var options = new DbContextOptionsBuilder() .UseSqlite(connection) + .UseLowerCaseNamingConvention() + .UseSnakeCaseNamingConvention() .Options; var context = new DataContext(options); @@ -40,6 +42,8 @@ public static class SeekerTestDataFactory var options = new DbContextOptionsBuilder() .UseSqlite(connection) + .UseLowerCaseNamingConvention() + .UseSnakeCaseNamingConvention() .Options; var context = new EventsContext(options); diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreEntryResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreEntryResponse.cs index 13042286..225e3c64 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreEntryResponse.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/CustomFormatScoreEntryResponse.cs @@ -17,4 +17,10 @@ public sealed record CustomFormatScoreEntryResponse public bool IsBelowCutoff { get; init; } public bool IsMonitored { get; init; } public DateTime LastSyncedAt { get; init; } + + /// + /// Timestamp at which this item last saw its custom format score strictly + /// exceed the prior recorded score. Null when no upgrade has been recorded. + /// + public DateTime? LastUpgradedAt { get; init; } } diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/CustomFormatScoreController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/CustomFormatScoreController.cs index 2c5ef508..f72182f9 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/CustomFormatScoreController.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/CustomFormatScoreController.cs @@ -1,7 +1,10 @@ using Cleanuparr.Api.Features.Seeker.Contracts.Responses; +using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.State; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace Cleanuparr.Api.Features.Seeker.Controllers; @@ -27,9 +30,12 @@ public sealed class CustomFormatScoreController : ControllerBase [FromQuery] int pageSize = 50, [FromQuery] Guid? instanceId = null, [FromQuery] string? search = null, - [FromQuery] string sortBy = "title", - [FromQuery] bool hideMet = false, - [FromQuery] bool hideUnmonitored = false) + [FromQuery] CfScoresSortBy sortBy = CfScoresSortBy.Title, + [FromQuery] SortDirection? sortDirection = null, + [FromQuery] string? qualityProfile = null, + [FromQuery] InstanceType? itemType = null, + [FromQuery] CutoffFilter cutoffFilter = CutoffFilter.All, + [FromQuery] MonitoredFilter monitoredFilter = MonitoredFilter.All) { if (page < 1) { @@ -57,24 +63,71 @@ public sealed class CustomFormatScoreController : ControllerBase if (!string.IsNullOrWhiteSpace(search)) { - query = query.Where(e => e.Title.ToLower().Contains(search.ToLower())); + string pattern = EventsContext.GetLikePattern(search); + query = query.Where(e => EF.Functions.Like(e.Title, pattern)); } - if (hideMet) + if (!string.IsNullOrWhiteSpace(qualityProfile)) { - query = query.Where(e => e.CurrentScore < e.CutoffScore); + query = query.Where(e => e.QualityProfileName == qualityProfile); } - if (hideUnmonitored) + if (itemType.HasValue) { - query = query.Where(e => e.IsMonitored); + InstanceType typeValue = itemType.Value; + query = query.Where(e => e.ItemType == typeValue); + } + + switch (cutoffFilter) + { + case CutoffFilter.Below: + query = query.Where(e => e.CurrentScore < e.CutoffScore); + break; + case CutoffFilter.Met: + query = query.Where(e => e.CurrentScore >= e.CutoffScore); + break; + } + + switch (monitoredFilter) + { + case MonitoredFilter.Monitored: + query = query.Where(e => e.IsMonitored); + break; + case MonitoredFilter.Unmonitored: + query = query.Where(e => !e.IsMonitored); + break; } int totalCount = await query.CountAsync(); - var items = await (sortBy == "date" - ? query.OrderByDescending(e => e.LastSyncedAt) - : query.OrderBy(e => e.Title)) + bool ascending = sortDirection.HasValue + ? sortDirection.Value == SortDirection.Asc + : DefaultAscendingForScoreSortBy(sortBy); + + IOrderedQueryable ordered = sortBy switch + { + CfScoresSortBy.CurrentScore => ascending + ? query.OrderBy(e => e.CurrentScore) + : query.OrderByDescending(e => e.CurrentScore), + CfScoresSortBy.CutoffScore => ascending + ? query.OrderBy(e => e.CutoffScore) + : query.OrderByDescending(e => e.CutoffScore), + CfScoresSortBy.QualityProfile => ascending + ? query.OrderBy(e => e.QualityProfileName) + : query.OrderByDescending(e => e.QualityProfileName), + CfScoresSortBy.LastSyncedAt => ascending + ? query.OrderBy(e => e.LastSyncedAt) + : query.OrderByDescending(e => e.LastSyncedAt), + CfScoresSortBy.LastUpgradedAt => ascending + ? query.OrderBy(e => e.LastUpgradedAt) + : query.OrderByDescending(e => e.LastUpgradedAt), + _ => ascending + ? query.OrderBy(e => e.Title) + : query.OrderByDescending(e => e.Title), + }; + + var items = await ordered + .ThenBy(e => e.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(e => new CustomFormatScoreEntryResponse @@ -92,6 +145,7 @@ public sealed class CustomFormatScoreController : ControllerBase IsBelowCutoff = e.CurrentScore < e.CutoffScore, IsMonitored = e.IsMonitored, LastSyncedAt = e.LastSyncedAt, + LastUpgradedAt = e.LastUpgradedAt, }) .ToListAsync(); @@ -105,73 +159,139 @@ public sealed class CustomFormatScoreController : ControllerBase }); } + private static bool DefaultAscendingForScoreSortBy(CfScoresSortBy sortBy) + { + // Default directions match user expectations: + // - textual fields sort ascending (A→Z) + // - numeric/date fields sort descending (most recent / highest first) + return sortBy switch + { + CfScoresSortBy.CurrentScore => false, + CfScoresSortBy.CutoffScore => false, + CfScoresSortBy.LastSyncedAt => false, + CfScoresSortBy.LastUpgradedAt => false, + _ => true, + }; + } + /// - /// Gets recent CF score upgrades (where score improved in history). + /// Gets recent CF score upgrades (where score strictly exceeded the prior recorded score). /// + /// + /// Upgrade detection runs in SQL via LAG() over the full per-item history so + /// an improvement crossing the window boundary is still + /// detected. Sorting and pagination happen at the database level. + /// [HttpGet("upgrades")] public async Task GetRecentUpgrades( [FromQuery] int page = 1, [FromQuery] int pageSize = 50, [FromQuery] Guid? instanceId = null, - [FromQuery] int days = 30) + [FromQuery] int days = 30, + [FromQuery] string? search = null, + [FromQuery] CfUpgradesSortBy sortBy = CfUpgradesSortBy.UpgradedAt, + [FromQuery] SortDirection? sortDirection = null) { if (page < 1) page = 1; if (pageSize < 1) pageSize = 50; if (pageSize > 500) pageSize = 500; - // Find history entries where a newer entry has a higher score than an older one - // We group by item and look for score increases between consecutive records - var query = _dataContext.CustomFormatScoreHistory - .AsNoTracking() - .AsQueryable(); + bool ascending = sortDirection.HasValue + ? sortDirection.Value == SortDirection.Asc + : DefaultAscendingForUpgradeSortBy(sortBy); - if (instanceId.HasValue) + string orderByClause = BuildUpgradeOrderByClause(sortBy, ascending); + + DateTime? cutoff = days > 0 ? DateTime.UtcNow.AddDays(-days) : null; + string? searchPattern = string.IsNullOrWhiteSpace(search) + ? null + : EventsContext.GetLikePattern(search); + + const string upgradesCte = + """ + WITH scored AS ( + SELECT + arr_instance_id, + external_item_id, + episode_id, + item_type, + title, + score, + cutoff_score, + recorded_at, + LAG(score) OVER ( + PARTITION BY arr_instance_id, external_item_id, episode_id + ORDER BY recorded_at + ) AS prev_score + FROM custom_format_score_history + ), + upgrades AS ( + SELECT + arr_instance_id AS arr_instance_id, + external_item_id AS external_item_id, + episode_id AS episode_id, + item_type AS item_type, + title AS title, + prev_score AS previous_score, + score AS new_score, + cutoff_score AS cutoff_score, + recorded_at AS upgraded_at + FROM scored + WHERE prev_score IS NOT NULL AND score > prev_score + ) + """; + + const string filterClause = + """ + WHERE (@instanceId IS NULL OR arr_instance_id = @instanceId) + AND (@search IS NULL OR title LIKE @search ESCAPE '\') + AND (@cutoff IS NULL OR upgraded_at >= @cutoff) + """; + + SqliteParameter[] BuildCommonParameters() => new[] { - query = query.Where(h => h.ArrInstanceId == instanceId.Value); - } + new SqliteParameter("@instanceId", instanceId.HasValue ? instanceId.Value : DBNull.Value), + new SqliteParameter("@search", (object?)searchPattern ?? DBNull.Value), + new SqliteParameter("@cutoff", (object?)cutoff ?? DBNull.Value), + }; - var allHistory = await query - .Where(h => h.RecordedAt >= DateTime.UtcNow.AddDays(-days)) - .OrderByDescending(h => h.RecordedAt) + string listSql = + $""" + {upgradesCte} + SELECT * FROM upgrades + {filterClause} + ORDER BY {orderByClause}, upgraded_at DESC + LIMIT @take OFFSET @skip + """; + + SqliteParameter[] listParams = + [ + ..BuildCommonParameters(), + new("@take", pageSize), + new("@skip", (page - 1) * pageSize), + ]; + + var rows = await _dataContext.Database + .SqlQueryRaw(listSql, listParams) .ToListAsync(); - var upgrades = new List(); + string countSql = $"{upgradesCte} SELECT COUNT(*) AS value FROM upgrades {filterClause}"; + int totalCount = await _dataContext.Database + .SqlQueryRaw(countSql, BuildCommonParameters()) + .FirstAsync(); - // Group by (ArrInstanceId, ExternalItemId, EpisodeId) and find score increases - var grouped = allHistory - .GroupBy(h => new { h.ArrInstanceId, h.ExternalItemId, h.EpisodeId }); - - foreach (var group in grouped) + var paged = rows.Select(r => new CustomFormatScoreUpgradeResponse { - var entries = group.OrderBy(h => h.RecordedAt).ToList(); - for (int i = 1; i < entries.Count; i++) - { - if (entries[i].Score > entries[i - 1].Score) - { - upgrades.Add(new CustomFormatScoreUpgradeResponse - { - ArrInstanceId = entries[i].ArrInstanceId, - ExternalItemId = entries[i].ExternalItemId, - EpisodeId = entries[i].EpisodeId, - ItemType = entries[i].ItemType, - Title = entries[i].Title, - PreviousScore = entries[i - 1].Score, - NewScore = entries[i].Score, - CutoffScore = entries[i].CutoffScore, - UpgradedAt = entries[i].RecordedAt, - }); - } - } - } - - // Sort by most recent upgrade first - upgrades = upgrades.OrderByDescending(u => u.UpgradedAt).ToList(); - - int totalCount = upgrades.Count; - var paged = upgrades - .Skip((page - 1) * pageSize) - .Take(pageSize) - .ToList(); + ArrInstanceId = r.ArrInstanceId, + ExternalItemId = r.ExternalItemId, + EpisodeId = r.EpisodeId, + ItemType = Enum.Parse(r.ItemType, ignoreCase: true), + Title = r.Title, + PreviousScore = r.PreviousScore, + NewScore = r.NewScore, + CutoffScore = r.CutoffScore, + UpgradedAt = DateTime.SpecifyKind(r.UpgradedAt, DateTimeKind.Utc), + }).ToList(); return Ok(new { @@ -183,13 +303,52 @@ public sealed class CustomFormatScoreController : ControllerBase }); } + private static bool DefaultAscendingForUpgradeSortBy(CfUpgradesSortBy sortBy) + { + return sortBy switch + { + CfUpgradesSortBy.Title => true, + _ => false, + }; + } + + private static string BuildUpgradeOrderByClause(CfUpgradesSortBy sortBy, bool ascending) + { + string column = sortBy switch + { + CfUpgradesSortBy.Title => "LOWER(title)", + CfUpgradesSortBy.NewScore => "new_score", + CfUpgradesSortBy.PreviousScore => "previous_score", + CfUpgradesSortBy.ScoreDelta => "(new_score - previous_score)", + CfUpgradesSortBy.CutoffScore => "cutoff_score", + _ => "upgraded_at", + }; + return $"{column} {(ascending ? "ASC" : "DESC")}"; + } + + private sealed class UpgradeSqlRow + { + public Guid ArrInstanceId { get; set; } + public long ExternalItemId { get; set; } + public long EpisodeId { get; set; } + public string ItemType { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public int PreviousScore { get; set; } + public int NewScore { get; set; } + public int CutoffScore { get; set; } + public DateTime UpgradedAt { get; set; } + } + + /// + /// Gets the *arr instances that currently have tracked CF scores, along with + /// the set of quality profile names observed for each instance. Used to + /// populate instance and profile filter controls. + /// [HttpGet("instances")] public async Task GetInstances() { - var instances = await _dataContext.CustomFormatScoreEntries + var raw = await _dataContext.CustomFormatScoreEntries .AsNoTracking() - .Select(e => new { e.ArrInstanceId, e.ItemType }) - .Distinct() .Join( _dataContext.ArrInstances.AsNoTracking(), e => e.ArrInstanceId, @@ -199,10 +358,28 @@ public sealed class CustomFormatScoreController : ControllerBase Id = e.ArrInstanceId, a.Name, e.ItemType, + e.QualityProfileName, }) - .OrderBy(x => x.Name) + .Distinct() .ToListAsync(); + var instances = raw + .GroupBy(x => new { x.Id, x.Name, x.ItemType }) + .Select(g => new + { + g.Key.Id, + g.Key.Name, + ItemType = g.Key.ItemType, + QualityProfiles = g + .Select(x => x.QualityProfileName) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList(), + }) + .OrderBy(x => x.Name) + .ToList(); + return Ok(new { Instances = instances }); } diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs index ff9bb7ba..f693b78a 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs @@ -3,6 +3,7 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration.Seeker; using Cleanuparr.Persistence.Models.State; +using AppEvent = Cleanuparr.Persistence.Models.Events.AppEvent; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -117,15 +118,34 @@ public sealed class SearchStatsController : ControllerBase } /// - /// Gets paginated search-triggered events + /// Gets paginated search-triggered events with optional filtering and sorting. + /// Results default to newest-first by timestamp. Ties on non-timestamp sort keys + /// fall back to Timestamp descending for stable ordering. /// + /// 1-based page number. Clamped to at least 1. + /// Rows per page. Clamped to the inclusive range [1, 100]; defaults to 50. + /// When set, restricts results to events produced by this *arr instance. + /// When set, restricts results to events from this seeker cycle. + /// Case-insensitive substring match against the stored item title. + /// Primary sort column. Defaults to . + /// Sort direction for the primary column. Defaults to descending. + /// When supplied, keeps only events whose appears in this list. + /// When supplied, keeps only events matching this . + /// When supplied, keeps only events matching this . + /// When true, keeps only events that recorded at least one grabbed item; when false, keeps only events with none. [HttpGet("events")] public async Task GetEvents( [FromQuery] int page = 1, [FromQuery] int pageSize = 50, [FromQuery] Guid? instanceId = null, [FromQuery] Guid? cycleId = null, - [FromQuery] string? search = null) + [FromQuery] string? search = null, + [FromQuery] SearchEventsSortBy sortBy = SearchEventsSortBy.Timestamp, + [FromQuery] SortDirection sortDirection = SortDirection.Desc, + [FromQuery] SearchCommandStatus[]? searchStatus = null, + [FromQuery] SeekerSearchType? searchType = null, + [FromQuery] SeekerSearchReason? searchReason = null, + [FromQuery] bool? grabbed = null) { if (page < 1) { @@ -167,10 +187,65 @@ public sealed class SearchStatsController : ControllerBase && EF.Functions.Like(e.SearchEventData.ItemTitle, pattern)); } + // Filter by search status (multi-valued) + if (searchStatus is { Length: > 0 }) + { + SearchCommandStatus[] statuses = searchStatus.Distinct().ToArray(); + query = query.Where(e => e.SearchStatus.HasValue && statuses.Contains(e.SearchStatus.Value)); + } + + if (searchType.HasValue) + { + SeekerSearchType typeValue = searchType.Value; + query = query.Where(e => e.SearchEventData != null && e.SearchEventData.SearchType == typeValue); + } + + if (searchReason.HasValue) + { + SeekerSearchReason reasonValue = searchReason.Value; + query = query.Where(e => e.SearchEventData != null && e.SearchEventData.SearchReason == reasonValue); + } + + // Filter by grabbed-result presence + if (grabbed.HasValue) + { + if (grabbed.Value) + { + query = query.Where(e => e.SearchEventData != null && e.SearchEventData.GrabbedItems.Count > 0); + } + else + { + query = query.Where(e => e.SearchEventData == null || e.SearchEventData.GrabbedItems.Count == 0); + } + } + int totalCount = await query.CountAsync(); - var rawEvents = await query - .OrderByDescending(e => e.Timestamp) + bool ascending = sortDirection == SortDirection.Asc; + + IOrderedQueryable ordered = sortBy switch + { + SearchEventsSortBy.Title => ascending + ? query.OrderBy(e => e.SearchEventData != null ? e.SearchEventData.ItemTitle : string.Empty) + : query.OrderByDescending(e => e.SearchEventData != null ? e.SearchEventData.ItemTitle : string.Empty), + SearchEventsSortBy.Status => ascending + ? query.OrderBy(e => e.SearchStatus) + : query.OrderByDescending(e => e.SearchStatus), + SearchEventsSortBy.Type => ascending + ? query.OrderBy(e => e.SearchEventData != null ? (int)e.SearchEventData.SearchType : 0) + : query.OrderByDescending(e => e.SearchEventData != null ? (int)e.SearchEventData.SearchType : 0), + _ => ascending + ? query.OrderBy(e => e.Timestamp) + : query.OrderByDescending(e => e.Timestamp), + }; + + // Secondary sort by timestamp desc for stable ordering when primary ties + if (sortBy != SearchEventsSortBy.Timestamp) + { + ordered = ordered.ThenByDescending(e => e.Timestamp); + } + + var rawEvents = await ordered .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); diff --git a/code/backend/Cleanuparr.Domain/Enums/CfScoresSortBy.cs b/code/backend/Cleanuparr.Domain/Enums/CfScoresSortBy.cs new file mode 100644 index 00000000..e36c94db --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/CfScoresSortBy.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Enums; + +/// +/// Sorting fields available for custom format score listings. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CfScoresSortBy +{ + /// Sort by item title. + Title, + /// Sort by the item's current custom format score. + CurrentScore, + /// Sort by the quality profile's configured cutoff score. + CutoffScore, + /// Sort by quality profile name. + QualityProfile, + /// Sort by the timestamp of the last score sync. + LastSyncedAt, + /// Sort by the timestamp of the most recent score upgrade. + LastUpgradedAt, +} diff --git a/code/backend/Cleanuparr.Domain/Enums/CfUpgradesSortBy.cs b/code/backend/Cleanuparr.Domain/Enums/CfUpgradesSortBy.cs new file mode 100644 index 00000000..5d1e87f6 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/CfUpgradesSortBy.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Enums; + +/// +/// Sorting fields available for custom format score upgrades. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CfUpgradesSortBy +{ + /// Sort by the timestamp at which the upgrade was recorded. + UpgradedAt, + /// Sort by item title. + Title, + /// Sort by the score recorded after the upgrade. + NewScore, + /// Sort by the score recorded immediately before the upgrade. + PreviousScore, + /// Sort by the difference between the new and previous scores. + ScoreDelta, + /// Sort by the quality profile's configured cutoff score. + CutoffScore, +} diff --git a/code/backend/Cleanuparr.Domain/Enums/CutoffFilter.cs b/code/backend/Cleanuparr.Domain/Enums/CutoffFilter.cs new file mode 100644 index 00000000..9c6de382 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/CutoffFilter.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Enums; + +/// +/// Filters custom format score rows by their relation to the configured quality cutoff. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CutoffFilter +{ + /// Include all items regardless of cutoff status. + All, + /// Include only items whose current score is below the cutoff. + Below, + /// Include only items whose current score meets or exceeds the cutoff. + Met, +} diff --git a/code/backend/Cleanuparr.Domain/Enums/MonitoredFilter.cs b/code/backend/Cleanuparr.Domain/Enums/MonitoredFilter.cs new file mode 100644 index 00000000..f285e46f --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/MonitoredFilter.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Enums; + +/// +/// Filters items by their monitored state in the source *arr application. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum MonitoredFilter +{ + /// Include both monitored and unmonitored items. + All, + /// Include only monitored items. + Monitored, + /// Include only unmonitored items. + Unmonitored, +} diff --git a/code/backend/Cleanuparr.Domain/Enums/SearchEventsSortBy.cs b/code/backend/Cleanuparr.Domain/Enums/SearchEventsSortBy.cs new file mode 100644 index 00000000..b7ca4812 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/SearchEventsSortBy.cs @@ -0,0 +1,19 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Enums; + +/// +/// Sorting fields available for search event listings. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SearchEventsSortBy +{ + /// Sort by the event timestamp. + Timestamp, + /// Sort by the item title associated with the search. + Title, + /// Sort by the search command status. + Status, + /// Sort by the search type (proactive, replacement, etc.). + Type, +} diff --git a/code/backend/Cleanuparr.Domain/Enums/SortDirection.cs b/code/backend/Cleanuparr.Domain/Enums/SortDirection.cs new file mode 100644 index 00000000..60c70507 --- /dev/null +++ b/code/backend/Cleanuparr.Domain/Enums/SortDirection.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Cleanuparr.Domain.Enums; + +/// +/// Direction used when ordering a result set. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SortDirection +{ + /// Ascending order. + Asc, + /// Descending order. + Desc, +} diff --git a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/CustomFormatScoreSyncer.cs b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/CustomFormatScoreSyncer.cs index 5e609838..a92918f6 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/Jobs/CustomFormatScoreSyncer.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/Jobs/CustomFormatScoreSyncer.cs @@ -344,6 +344,11 @@ public sealed class CustomFormatScoreSyncer : IHandler CutoffScore = cutoffScore, RecordedAt = now, }); + + if (cfScore > existing.CurrentScore) + { + existing.LastUpgradedAt = now; + } } existing.CurrentScore = cfScore; diff --git a/code/backend/Cleanuparr.Persistence/DataContext.cs b/code/backend/Cleanuparr.Persistence/DataContext.cs index 328014ea..c92637b6 100644 --- a/code/backend/Cleanuparr.Persistence/DataContext.cs +++ b/code/backend/Cleanuparr.Persistence/DataContext.cs @@ -277,8 +277,10 @@ public class DataContext : DbContext .OnDelete(DeleteBehavior.Cascade); entity.HasIndex(s => new { s.ArrInstanceId, s.ExternalItemId, s.EpisodeId }).IsUnique(); + entity.HasIndex(s => s.LastUpgradedAt); entity.Property(s => s.LastSyncedAt).HasConversion(new UtcDateTimeConverter()); + entity.Property(s => s.LastUpgradedAt).HasConversion(new UtcDateTimeConverter()); }); modelBuilder.Entity(entity => diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260420134250_AddLastUpgradedAtToCfScoreEntry.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260420134250_AddLastUpgradedAtToCfScoreEntry.Designer.cs new file mode 100644 index 00000000..41d5c214 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260420134250_AddLastUpgradedAtToCfScoreEntry.Designer.cs @@ -0,0 +1,2108 @@ +// +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("20260420134250_AddLastUpgradedAtToCfScoreEntry")] + partial class AddLastUpgradedAtToCfScoreEntry + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("REAL") + .HasColumnName("version"); + + 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.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_deluge_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_deluge_seeding_rules_download_client_config_id"); + + b.ToTable("deluge_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_q_bit_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_q_bit_seeding_rules_download_client_config_id"); + + b.ToTable("q_bit_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_r_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_r_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("r_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_transmission_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_transmission_seeding_rules_download_client_config_id"); + + b.ToTable("transmission_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_u_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_u_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("u_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.PrimitiveCollection("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("DownloadDirectorySource") + .HasColumnType("TEXT") + .HasColumnName("download_directory_source"); + + b.Property("DownloadDirectoryTarget") + .HasColumnType("TEXT") + .HasColumnName("download_directory_target"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredRootDirs") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_root_dirs"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_category"); + + b.Property("UseTag") + .HasColumnType("INTEGER") + .HasColumnName("use_tag"); + + b.HasKey("Id") + .HasName("pk_unlinked_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_unlinked_configs_download_client_config_id"); + + b.ToTable("unlinked_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("StatusCheckEnabled") + .HasColumnType("INTEGER") + .HasColumnName("status_check_enabled"); + + b.Property("StrikeInactivityWindowHours") + .HasColumnType("INTEGER") + .HasColumnName("strike_inactivity_window_hours"); + + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("INTEGER") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("INTEGER") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("auth_trusted_networks"); + }); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_discord_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_discord_configs_notification_config_id"); + + b.ToTable("discord_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApplicationToken") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("application_token"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.HasKey("Id") + .HasName("pk_gotify_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_gotify_configs_notification_config_id"); + + b.ToTable("gotify_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSearchItemGrabbed") + .HasColumnType("INTEGER") + .HasColumnName("on_search_item_grabbed"); + + b.Property("OnSearchTriggered") + .HasColumnType("INTEGER") + .HasColumnName("on_search_triggered"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BotToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("bot_token"); + + b.Property("ChatId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("chat_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("SendSilently") + .HasColumnType("INTEGER") + .HasColumnName("send_silently"); + + b.Property("TopicId") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_telegram_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_telegram_configs_notification_config_id"); + + b.ToTable("telegram_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("TEXT") + .HasColumnName("ignore_above_size"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("REAL") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("TEXT") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("PostReleaseGraceHours") + .HasColumnType("INTEGER") + .HasColumnName("post_release_grace_hours"); + + b.Property("ProactiveSearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("proactive_search_enabled"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.Property("SearchInterval") + .HasColumnType("INTEGER") + .HasColumnName("search_interval"); + + b.Property("SelectionStrategy") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("selection_strategy"); + + b.Property("UseRoundRobin") + .HasColumnType("INTEGER") + .HasColumnName("use_round_robin"); + + b.HasKey("Id") + .HasName("pk_seeker_configs"); + + b.ToTable("seeker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActiveDownloadLimit") + .HasColumnType("INTEGER") + .HasColumnName("active_download_limit"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentCycleId") + .HasColumnType("TEXT") + .HasColumnName("current_cycle_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastProcessedAt") + .HasColumnType("TEXT") + .HasColumnName("last_processed_at"); + + b.Property("MinCycleTimeDays") + .HasColumnType("INTEGER") + .HasColumnName("min_cycle_time_days"); + + b.Property("MonitoredOnly") + .HasColumnType("INTEGER") + .HasColumnName("monitored_only"); + + b.PrimitiveCollection("SkipTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("skip_tags"); + + b.Property("TotalEligibleItems") + .HasColumnType("INTEGER") + .HasColumnName("total_eligible_items"); + + b.Property("UseCustomFormatScore") + .HasColumnType("INTEGER") + .HasColumnName("use_custom_format_score"); + + b.Property("UseCutoff") + .HasColumnType("INTEGER") + .HasColumnName("use_cutoff"); + + b.HasKey("Id") + .HasName("pk_seeker_instance_configs"); + + b.HasIndex("ArrInstanceId") + .IsUnique() + .HasDatabaseName("ix_seeker_instance_configs_arr_instance_id"); + + b.ToTable("seeker_instance_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentScore") + .HasColumnType("INTEGER") + .HasColumnName("current_score"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("FileId") + .HasColumnType("INTEGER") + .HasColumnName("file_id"); + + b.Property("IsMonitored") + .HasColumnType("INTEGER") + .HasColumnName("is_monitored"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT") + .HasColumnName("last_synced_at"); + + b.Property("LastUpgradedAt") + .HasColumnType("TEXT") + .HasColumnName("last_upgraded_at"); + + b.Property("QualityProfileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("quality_profile_name"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_entries"); + + b.HasIndex("LastUpgradedAt") + .HasDatabaseName("ix_custom_format_score_entries_last_upgraded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .IsUnique() + .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_entries", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("RecordedAt") + .HasColumnType("TEXT") + .HasColumnName("recorded_at"); + + b.Property("Score") + .HasColumnType("INTEGER") + .HasColumnName("score"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_history"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_custom_format_score_history_recorded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .HasDatabaseName("ix_custom_format_score_history_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ItemId") + .HasColumnType("INTEGER") + .HasColumnName("item_id"); + + b.Property("SearchType") + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.Property("SeriesId") + .HasColumnType("INTEGER") + .HasColumnName("series_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_search_queue"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_search_queue_arr_instance_id"); + + b.ToTable("search_queue", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CommandId") + .HasColumnType("INTEGER") + .HasColumnName("command_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EventId") + .HasColumnType("TEXT") + .HasColumnName("event_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_seeker_command_trackers"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_command_trackers_arr_instance_id"); + + b.ToTable("seeker_command_trackers", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSearchedAt") + .HasColumnType("TEXT") + .HasColumnName("last_searched_at"); + + b.Property("SearchCount") + .HasColumnType("INTEGER") + .HasColumnName("search_count"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.HasKey("Id") + .HasName("pk_seeker_history"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "CycleId") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id"); + + b.ToTable("seeker_history", (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.DelugeSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_deluge_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_q_bit_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_r_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_transmission_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_u_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_unlinked_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("DiscordConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_discord_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("GotifyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_instance_configs_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_entries_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_search_queue_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_command_trackers_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("GotifyConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260420134250_AddLastUpgradedAtToCfScoreEntry.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260420134250_AddLastUpgradedAtToCfScoreEntry.cs new file mode 100644 index 00000000..7c90bd97 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260420134250_AddLastUpgradedAtToCfScoreEntry.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddLastUpgradedAtToCfScoreEntry : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "last_upgraded_at", + table: "custom_format_score_entries", + type: "TEXT", + nullable: true); + + // Backfill last_upgraded_at from existing history: per item, the most recent + // recorded_at at which the score strictly exceeded the preceding score. + migrationBuilder.Sql(@" +WITH scored AS ( + SELECT + arr_instance_id, + external_item_id, + episode_id, + score, + recorded_at, + LAG(score) OVER ( + PARTITION BY arr_instance_id, external_item_id, episode_id + ORDER BY recorded_at + ) AS prev_score + FROM custom_format_score_history +), +upgrades AS ( + SELECT + arr_instance_id, + external_item_id, + episode_id, + MAX(recorded_at) AS last_upgraded_at + FROM scored + WHERE prev_score IS NOT NULL AND score > prev_score + GROUP BY arr_instance_id, external_item_id, episode_id +) +UPDATE custom_format_score_entries +SET last_upgraded_at = ( + SELECT last_upgraded_at FROM upgrades u + WHERE u.arr_instance_id = custom_format_score_entries.arr_instance_id + AND u.external_item_id = custom_format_score_entries.external_item_id + AND u.episode_id = custom_format_score_entries.episode_id +); +"); + + migrationBuilder.CreateIndex( + name: "ix_custom_format_score_entries_last_upgraded_at", + table: "custom_format_score_entries", + column: "last_upgraded_at"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_custom_format_score_entries_last_upgraded_at", + table: "custom_format_score_entries"); + + migrationBuilder.DropColumn( + name: "last_upgraded_at", + table: "custom_format_score_entries"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index 3eb2cf74..436455a1 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -1569,6 +1569,10 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("TEXT") .HasColumnName("last_synced_at"); + b.Property("LastUpgradedAt") + .HasColumnType("TEXT") + .HasColumnName("last_upgraded_at"); + b.Property("QualityProfileName") .IsRequired() .HasColumnType("TEXT") @@ -1582,6 +1586,9 @@ namespace Cleanuparr.Persistence.Migrations.Data b.HasKey("Id") .HasName("pk_custom_format_score_entries"); + b.HasIndex("LastUpgradedAt") + .HasDatabaseName("ix_custom_format_score_entries_last_upgraded_at"); + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") .IsUnique() .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id"); diff --git a/code/backend/Cleanuparr.Persistence/Models/State/CustomFormatScoreEntry.cs b/code/backend/Cleanuparr.Persistence/Models/State/CustomFormatScoreEntry.cs index 9331f628..b2c7cd7e 100644 --- a/code/backend/Cleanuparr.Persistence/Models/State/CustomFormatScoreEntry.cs +++ b/code/backend/Cleanuparr.Persistence/Models/State/CustomFormatScoreEntry.cs @@ -74,4 +74,10 @@ public sealed record CustomFormatScoreEntry /// When this entry was last synced from the arr API /// public DateTime LastSyncedAt { get; set; } + + /// + /// When this item last saw a score upgrade (current score strictly exceeded the prior recorded score). + /// Null when the item has no recorded upgrades. + /// + public DateTime? LastUpgradedAt { get; set; } } diff --git a/code/frontend/src/app/app.config.ts b/code/frontend/src/app/app.config.ts index 1f4d9468..0ab4b938 100644 --- a/code/frontend/src/app/app.config.ts +++ b/code/frontend/src/app/app.config.ts @@ -51,6 +51,7 @@ import { tablerChartDots, tablerHistory, tablerGripVertical, + tablerFilter, } from '@ng-icons/tabler-icons'; import { routes } from './app.routes'; @@ -112,6 +113,7 @@ export const appConfig: ApplicationConfig = { tablerChartDots, tablerHistory, tablerGripVertical, + tablerFilter, }), ], }; diff --git a/code/frontend/src/app/core/api/cf-score.api.ts b/code/frontend/src/app/core/api/cf-score.api.ts index c32a5756..80288ecc 100644 --- a/code/frontend/src/app/core/api/cf-score.api.ts +++ b/code/frontend/src/app/core/api/cf-score.api.ts @@ -1,6 +1,9 @@ import { Injectable, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; +import { SortDirection } from '@core/api/search-stats.api'; + +export { SortDirection }; export interface CfScoreStats { totalTracked: number; @@ -58,6 +61,7 @@ export interface CfScoreEntry { isBelowCutoff: boolean; isMonitored: boolean; lastSyncedAt: string; + lastUpgradedAt: string | null; } export interface CfScoreEntriesResponse { @@ -82,6 +86,60 @@ export interface CfScoreInstance { id: string; name: string; itemType: string; + qualityProfiles?: string[]; +} + +export enum CutoffFilter { + All = 'All', + Below = 'Below', + Met = 'Met', +} + +export enum MonitoredFilter { + All = 'All', + Monitored = 'Monitored', + Unmonitored = 'Unmonitored', +} + +export enum CfScoresSortBy { + Title = 'Title', + CurrentScore = 'CurrentScore', + CutoffScore = 'CutoffScore', + QualityProfile = 'QualityProfile', + LastSyncedAt = 'LastSyncedAt', + LastUpgradedAt = 'LastUpgradedAt', +} + +export enum CfUpgradesSortBy { + UpgradedAt = 'UpgradedAt', + Title = 'Title', + NewScore = 'NewScore', + PreviousScore = 'PreviousScore', + ScoreDelta = 'ScoreDelta', + CutoffScore = 'CutoffScore', +} + +export interface CfScoresQuery { + page?: number; + pageSize?: number; + instanceId?: string; + search?: string; + sortBy?: CfScoresSortBy; + sortDirection?: SortDirection; + qualityProfile?: string; + itemType?: string; + cutoffFilter?: CutoffFilter; + monitoredFilter?: MonitoredFilter; +} + +export interface CfScoreUpgradesQuery { + page?: number; + pageSize?: number; + instanceId?: string; + days?: number; + search?: string; + sortBy?: CfUpgradesSortBy; + sortDirection?: SortDirection; } @Injectable({ providedIn: 'root' }) @@ -92,20 +150,34 @@ export class CfScoreApi { return this.http.get('/api/seeker/cf-scores/stats'); } - getRecentUpgrades(page = 1, pageSize = 5, instanceId?: string, days?: number): Observable { - const params: Record = { page, pageSize }; - if (instanceId) params['instanceId'] = instanceId; - if (days !== undefined) params['days'] = days; + getRecentUpgrades(query: CfScoreUpgradesQuery = {}): Observable { + let params = new HttpParams() + .set('page', String(query.page ?? 1)) + .set('pageSize', String(query.pageSize ?? 20)); + + if (query.instanceId) params = params.set('instanceId', query.instanceId); + if (query.days !== undefined) params = params.set('days', String(query.days)); + if (query.search) params = params.set('search', query.search); + if (query.sortBy) params = params.set('sortBy', query.sortBy); + if (query.sortDirection) params = params.set('sortDirection', query.sortDirection); + return this.http.get('/api/seeker/cf-scores/upgrades', { params }); } - getScores(page = 1, pageSize = 50, search?: string, instanceId?: string, sortBy?: string, hideMet?: boolean, hideUnmonitored?: boolean): Observable { - const params: Record = { page, pageSize }; - if (search) params['search'] = search; - if (instanceId) params['instanceId'] = instanceId; - if (sortBy) params['sortBy'] = sortBy; - if (hideMet) params['hideMet'] = true; - if (hideUnmonitored) params['hideUnmonitored'] = true; + getScores(query: CfScoresQuery = {}): Observable { + let params = new HttpParams() + .set('page', String(query.page ?? 1)) + .set('pageSize', String(query.pageSize ?? 50)); + + if (query.search) params = params.set('search', query.search); + if (query.instanceId) params = params.set('instanceId', query.instanceId); + if (query.sortBy) params = params.set('sortBy', query.sortBy); + if (query.sortDirection) params = params.set('sortDirection', query.sortDirection); + if (query.qualityProfile) params = params.set('qualityProfile', query.qualityProfile); + if (query.itemType) params = params.set('itemType', query.itemType); + if (query.cutoffFilter && query.cutoffFilter !== CutoffFilter.All) params = params.set('cutoffFilter', query.cutoffFilter); + if (query.monitoredFilter && query.monitoredFilter !== MonitoredFilter.All) params = params.set('monitoredFilter', query.monitoredFilter); + return this.http.get('/api/seeker/cf-scores', { params }); } diff --git a/code/frontend/src/app/core/api/search-stats.api.ts b/code/frontend/src/app/core/api/search-stats.api.ts index 79bf2895..28d433ec 100644 --- a/code/frontend/src/app/core/api/search-stats.api.ts +++ b/code/frontend/src/app/core/api/search-stats.api.ts @@ -1,9 +1,36 @@ import { Injectable, inject } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import type { SearchStatsSummary, SearchEvent } from '@core/models/search-stats.models'; +import { SeekerSearchType, SeekerSearchReason, SearchCommandStatus } from '@core/models/search-stats.models'; import type { PaginatedResult } from '@core/models/pagination.model'; +export enum SortDirection { + Asc = 'Asc', + Desc = 'Desc', +} + +export enum SearchEventsSortBy { + Timestamp = 'Timestamp', + Title = 'Title', + Status = 'Status', + Type = 'Type', +} + +export interface SearchEventsQuery { + page?: number; + pageSize?: number; + instanceId?: string; + cycleId?: string; + search?: string; + sortBy?: SearchEventsSortBy; + sortDirection?: SortDirection; + searchStatus?: SearchCommandStatus[]; + searchType?: SeekerSearchType; + searchReason?: SeekerSearchReason; + grabbed?: boolean; +} + @Injectable({ providedIn: 'root' }) export class SearchStatsApi { private http = inject(HttpClient); @@ -12,11 +39,26 @@ export class SearchStatsApi { return this.http.get('/api/seeker/search-stats/summary'); } - getEvents(page = 1, pageSize = 50, instanceId?: string, cycleId?: string, search?: string): Observable> { - const params: Record = { page, pageSize }; - if (instanceId) params['instanceId'] = instanceId; - if (cycleId) params['cycleId'] = cycleId; - if (search) params['search'] = search; + getEvents(query: SearchEventsQuery = {}): Observable> { + let params = new HttpParams() + .set('page', String(query.page ?? 1)) + .set('pageSize', String(query.pageSize ?? 50)); + + if (query.instanceId) params = params.set('instanceId', query.instanceId); + if (query.cycleId) params = params.set('cycleId', query.cycleId); + if (query.search) params = params.set('search', query.search); + if (query.sortBy) params = params.set('sortBy', query.sortBy); + if (query.sortDirection) params = params.set('sortDirection', query.sortDirection); + if (query.searchType) params = params.set('searchType', query.searchType); + if (query.searchReason) params = params.set('searchReason', query.searchReason); + if (query.grabbed !== undefined) params = params.set('grabbed', String(query.grabbed)); + + if (query.searchStatus?.length) { + for (const status of query.searchStatus) { + params = params.append('searchStatus', status); + } + } + return this.http.get>('/api/seeker/search-stats/events', { params }); } } diff --git a/code/frontend/src/app/core/directives/sticky-aware.directive.ts b/code/frontend/src/app/core/directives/sticky-aware.directive.ts new file mode 100644 index 00000000..c131d36c --- /dev/null +++ b/code/frontend/src/app/core/directives/sticky-aware.directive.ts @@ -0,0 +1,47 @@ +import { Directive, ElementRef, OnDestroy, OnInit, inject } from '@angular/core'; + +/** + * Toggles `.is-stuck` on the host element when it becomes pinned to the top + * of its nearest scrollable ancestor by `position: sticky; top: 0`. + * + * Implementation uses IntersectionObserver with a 1px negative top rootMargin: + * while the host is at its natural position it's fully inside the (shrunk) + * root, so intersectionRatio stays at 1. Once scrolled and stuck at top: 0, + * the top 1px of the host falls outside the shrunk root, intersectionRatio + * drops below 1, and the class flips on. + */ +@Directive({ + selector: '[stickyAware]', + standalone: true, +}) +export class StickyAwareDirective implements OnInit, OnDestroy { + private readonly el: ElementRef = inject(ElementRef); + private observer?: IntersectionObserver; + + ngOnInit(): void { + const root = this.findScrollParent(this.el.nativeElement); + this.observer = new IntersectionObserver( + ([entry]) => { + this.el.nativeElement.classList.toggle('is-stuck', entry.intersectionRatio < 1); + }, + { root, rootMargin: '-1px 0px 0px 0px', threshold: [1] }, + ); + this.observer.observe(this.el.nativeElement); + } + + ngOnDestroy(): void { + this.observer?.disconnect(); + } + + private findScrollParent(el: HTMLElement): HTMLElement | null { + let parent: HTMLElement | null = el.parentElement; + while (parent) { + const style = getComputedStyle(parent); + if (style.overflowY === 'auto' || style.overflowY === 'scroll') { + return parent; + } + parent = parent.parentElement; + } + return null; + } +} diff --git a/code/frontend/src/app/core/models/search-stats.models.ts b/code/frontend/src/app/core/models/search-stats.models.ts index 2ef8554e..79eace02 100644 --- a/code/frontend/src/app/core/models/search-stats.models.ts +++ b/code/frontend/src/app/core/models/search-stats.models.ts @@ -34,6 +34,14 @@ export enum SeekerSearchReason { Replacement = 'Replacement', } +export enum SearchCommandStatus { + Pending = 'Pending', + Started = 'Started', + Completed = 'Completed', + Failed = 'Failed', + TimedOut = 'TimedOut', +} + export interface SearchEvent { id: string; timestamp: string; diff --git a/code/frontend/src/app/features/dashboard/dashboard.component.scss b/code/frontend/src/app/features/dashboard/dashboard.component.scss index 18eed75f..1df23d48 100644 --- a/code/frontend/src/app/features/dashboard/dashboard.component.scss +++ b/code/frontend/src/app/features/dashboard/dashboard.component.scss @@ -1,3 +1,5 @@ +@use 'page-animations' as *; + // Support section .support-section { display: grid; @@ -111,6 +113,7 @@ // Dashboard rows (drag-and-drop) .dashboard-rows { + @include page-section-stagger($increment: 80ms); display: flex; flex-direction: column; gap: var(--space-6); @@ -118,12 +121,6 @@ .dashboard-row { min-width: 0; - animation: slide-up var(--duration-normal) var(--ease-default) both; - - &:nth-child(1) { animation-delay: 0ms; } - &:nth-child(2) { animation-delay: 80ms; } - &:nth-child(3) { animation-delay: 160ms; } - &:nth-child(4) { animation-delay: 240ms; } } // Logs + Events side-by-side row diff --git a/code/frontend/src/app/features/dashboard/dashboard.component.ts b/code/frontend/src/app/features/dashboard/dashboard.component.ts index 733067ae..21900ece 100644 --- a/code/frontend/src/app/features/dashboard/dashboard.component.ts +++ b/code/frontend/src/app/features/dashboard/dashboard.component.ts @@ -95,7 +95,7 @@ export class DashboardComponent implements OnInit { this.cfScoreApi.getStats().subscribe({ next: (stats) => this.cfScoreStats.set(stats), }); - this.cfScoreApi.getRecentUpgrades(1, 5).subscribe({ + this.cfScoreApi.getRecentUpgrades({ page: 1, pageSize: 5 }).subscribe({ next: (res) => this.cfScoreUpgrades.set(res.items), }); } diff --git a/code/frontend/src/app/features/events/events.component.html b/code/frontend/src/app/features/events/events.component.html index 133b6499..7e14cfd7 100644 --- a/code/frontend/src/app/features/events/events.component.html +++ b/code/frontend/src/app/features/events/events.component.html @@ -5,7 +5,7 @@
-
+
.toolbar { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 0ms; @include sticky-page-header; } - > .event-count { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 40ms; - } - > app-card { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 80ms; - } - > app-paginator { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 120ms; - } } .toolbar { diff --git a/code/frontend/src/app/features/events/events.component.ts b/code/frontend/src/app/features/events/events.component.ts index f4add7ca..378e559c 100644 --- a/code/frontend/src/app/features/events/events.component.ts +++ b/code/frontend/src/app/features/events/events.component.ts @@ -11,6 +11,7 @@ import { import { EventsApi } from '@core/api/events.api'; import { ToastService } from '@core/services/toast.service'; import { PaginationService } from '@core/services/pagination.service'; +import { StickyAwareDirective } from '@core/directives/sticky-aware.directive'; import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component'; import { AppEvent, EventFilter } from '@core/models/event.models'; @@ -31,6 +32,7 @@ import { AppEvent, EventFilter } from '@core/models/event.models'; PaginatorComponent, EmptyStateComponent, AnimatedCounterComponent, + StickyAwareDirective, ], templateUrl: './events.component.html', styleUrl: './events.component.scss', diff --git a/code/frontend/src/app/features/logs-component/logs.component.html b/code/frontend/src/app/features/logs-component/logs.component.html index b809ca47..9400260e 100644 --- a/code/frontend/src/app/features/logs-component/logs.component.html +++ b/code/frontend/src/app/features/logs-component/logs.component.html @@ -5,7 +5,7 @@
-
+
.toolbar { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 0ms; @include sticky-page-header; } - > .log-count { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 40ms; - } - > app-card { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 80ms; - } } .toolbar { diff --git a/code/frontend/src/app/features/logs-component/logs.component.ts b/code/frontend/src/app/features/logs-component/logs.component.ts index c502d036..2740f80d 100644 --- a/code/frontend/src/app/features/logs-component/logs.component.ts +++ b/code/frontend/src/app/features/logs-component/logs.component.ts @@ -7,6 +7,7 @@ import { PageHeaderComponent } from '@layout/page-header/page-header.component'; import { CardComponent, BadgeComponent, ButtonComponent, SelectComponent, InputComponent, EmptyStateComponent, type SelectOption } from '@ui'; import { AppHubService } from '@core/realtime/app-hub.service'; import { ToastService } from '@core/services/toast.service'; +import { StickyAwareDirective } from '@core/directives/sticky-aware.directive'; import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component'; import { LogEntry } from '@core/models/signalr.models'; @@ -33,7 +34,8 @@ const LOG_LEVELS: SelectOption[] = [ SelectComponent, InputComponent, EmptyStateComponent, - AnimatedCounterComponent + AnimatedCounterComponent, + StickyAwareDirective, ], templateUrl: './logs.component.html', styleUrl: './logs.component.scss', diff --git a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.html b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.html index 84428864..2a3f28c3 100644 --- a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.html +++ b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.html @@ -1,12 +1,6 @@ -
+
- - -
- - Refresh + + + Filters + @if (activeFilterCount() > 0) { + {{ activeFilterCount() }} + } + Refresh
@@ -124,6 +120,10 @@ Last Synced {{ item.lastSyncedAt | date:'yyyy-MM-dd HH:mm:ss' }}
+
+ Last Upgraded + {{ item.lastUpgradedAt ? (item.lastUpgradedAt | date:'yyyy-MM-dd HH:mm:ss') : 'Never' }} +
@@ -172,3 +172,47 @@ (pageSizeChange)="onPageSizeChange($event)" /> } + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ Reset + Apply +
+
diff --git a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss index 24569126..57596791 100644 --- a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss +++ b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss @@ -1,29 +1,13 @@ @use 'data-toolbar' as *; +@use 'page-animations' as *; -// Staggered animations :host { - > .toolbar { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 40ms; - position: relative; - z-index: 1; - } - > .stats-bar { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 80ms; - } - > app-card { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 120ms; - } - > app-paginator { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 160ms; - } + @include page-section-stagger; } .toolbar { @include data-toolbar; + @include sticky-page-header; &__filters { app-input { @@ -280,3 +264,86 @@ display: none; } } + +// Filter drawer +.filter-group { + display: flex; + flex-direction: column; + gap: var(--space-2); + + &__label { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; + } +} + +// Table cell content +.cell-title { + display: flex; + flex-direction: column; + gap: var(--space-1); + + &__text { + font-weight: 500; + color: var(--text-primary); + overflow-wrap: anywhere; + word-break: normal; + } + + &__chips { + display: flex; + gap: var(--space-1); + flex-wrap: wrap; + } +} + +.score-value { + font-family: var(--font-mono); + font-weight: 600; + + &--below { + color: var(--color-warning); + } +} + +// Expanded detail +.score-detail { + display: flex; + flex-direction: column; + gap: var(--space-4); + + &__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-3); + } + + &__item { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + &__label { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__value { + font-size: var(--font-size-sm); + color: var(--text-primary); + overflow-wrap: anywhere; + word-break: normal; + } + + &__history { + display: flex; + flex-direction: column; + gap: var(--space-2); + } +} diff --git a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts index eb19806c..6cc2b10d 100644 --- a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts +++ b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts @@ -3,17 +3,36 @@ import { DatePipe } from '@angular/common'; import { NgIcon } from '@ng-icons/core'; import { CardComponent, BadgeComponent, ButtonComponent, InputComponent, - PaginatorComponent, EmptyStateComponent, SelectComponent, ToggleComponent, - TooltipComponent, + PaginatorComponent, EmptyStateComponent, SelectComponent, + TooltipComponent, DrawerComponent, } from '@ui'; import type { SelectOption } from '@ui'; import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component'; import { - CfScoreApi, CfScoreEntry, CfScoreStats, CfScoreHistoryEntry, + CfScoreApi, CfScoreEntry, CfScoreStats, CfScoreHistoryEntry, CfScoreInstance, + CutoffFilter, MonitoredFilter, CfScoresSortBy, SortDirection, } from '@core/api/cf-score.api'; import { AppHubService } from '@core/realtime/app-hub.service'; import { ToastService } from '@core/services/toast.service'; import { PaginationService } from '@core/services/pagination.service'; +import { StickyAwareDirective } from '@core/directives/sticky-aware.directive'; + +const DEFAULT_SORT_BY = CfScoresSortBy.Title; +const DEFAULT_SORT_DIRECTION = SortDirection.Asc; + +interface AdvancedFilters { + instanceId: string; + qualityProfile: string; + cutoffFilter: CutoffFilter; + monitoredFilter: MonitoredFilter; +} + +const EMPTY_FILTERS: AdvancedFilters = { + instanceId: '', + qualityProfile: '', + cutoffFilter: CutoffFilter.All, + monitoredFilter: MonitoredFilter.All, +}; @Component({ selector: 'app-quality-tab', @@ -26,11 +45,12 @@ import { PaginationService } from '@core/services/pagination.service'; ButtonComponent, InputComponent, SelectComponent, - ToggleComponent, PaginatorComponent, EmptyStateComponent, AnimatedCounterComponent, TooltipComponent, + DrawerComponent, + StickyAwareDirective, ], templateUrl: './quality-tab.component.html', styleUrl: './quality-tab.component.scss', @@ -44,6 +64,7 @@ export class QualityTabComponent implements OnInit { private readonly toast = inject(ToastService); private readonly pagination = inject(PaginationService); private initialLoad = true; + private latestLoadToken = 0; readonly items = signal([]); readonly stats = signal(null); @@ -54,16 +75,30 @@ export class QualityTabComponent implements OnInit { readonly pageSize = signal(this.pagination.getPageSize(QualityTabComponent.PAGE_SIZE_KEY, 50)); readonly searchQuery = signal(''); readonly selectedInstanceId = signal(''); + readonly instances = signal([]); readonly instanceOptions = signal([]); - readonly sortBy = signal('title'); - readonly hideMet = signal(false); - readonly hideUnmonitored = signal(false); + readonly sortBy = signal(DEFAULT_SORT_BY); + readonly sortDirection = signal(DEFAULT_SORT_DIRECTION); + readonly sortOptions: SelectOption[] = [ - { label: 'Title', value: 'title' }, - { label: 'Last Synced', value: 'date' }, + { label: 'Title', value: CfScoresSortBy.Title }, + { label: 'Current Score', value: CfScoresSortBy.CurrentScore }, + { label: 'Cutoff', value: CfScoresSortBy.CutoffScore }, + { label: 'Quality Profile', value: CfScoresSortBy.QualityProfile }, + { label: 'Last Synced', value: CfScoresSortBy.LastSyncedAt }, + { label: 'Last Upgraded', value: CfScoresSortBy.LastUpgradedAt }, ]; + readonly sortOrderOptions: SelectOption[] = [ + { label: 'Ascending', value: SortDirection.Asc }, + { label: 'Descending', value: SortDirection.Desc }, + ]; + + readonly applied = signal({ ...EMPTY_FILTERS }); + readonly draft = signal({ ...EMPTY_FILTERS }); + readonly drawerOpen = signal(false); + readonly displayStats = computed(() => { const s = this.stats(); if (!s) return null; @@ -78,6 +113,46 @@ export class QualityTabComponent implements OnInit { readonly historyEntries = signal([]); readonly historyLoading = signal(false); + readonly cutoffOptions: SelectOption[] = [ + { label: 'Any', value: CutoffFilter.All }, + { label: 'Below cutoff', value: CutoffFilter.Below }, + { label: 'Met cutoff', value: CutoffFilter.Met }, + ]; + + readonly monitoredOptions: SelectOption[] = [ + { label: 'Any', value: MonitoredFilter.All }, + { label: 'Monitored only', value: MonitoredFilter.Monitored }, + { label: 'Unmonitored only', value: MonitoredFilter.Unmonitored }, + ]; + + readonly qualityProfileOptions = computed(() => { + // Narrow to the drafted instance while the drawer is open so the profile + // list stays consistent with the instance the user is composing. + const instanceId = this.drawerOpen() ? this.draft().instanceId : this.selectedInstanceId(); + const profiles = new Set(); + for (const inst of this.instances()) { + if (instanceId && inst.id !== instanceId) continue; + for (const p of inst.qualityProfiles ?? []) { + profiles.add(p); + } + } + const sorted = [...profiles].sort((a, b) => a.localeCompare(b)); + return [ + { label: 'Any', value: '' }, + ...sorted.map(p => ({ label: p, value: p })), + ]; + }); + + readonly activeFilterCount = computed(() => { + const a = this.applied(); + let n = 0; + if (a.instanceId) n++; + if (a.qualityProfile) n++; + if (a.cutoffFilter !== CutoffFilter.All) n++; + if (a.monitoredFilter !== MonitoredFilter.All) n++; + return n; + }); + constructor() { effect(() => { this.hub.cfScoresVersion(); @@ -100,13 +175,27 @@ export class QualityTabComponent implements OnInit { loadScores(): void { this.loading.set(true); - this.api.getScores(this.currentPage(), this.pageSize(), this.searchQuery() || undefined, this.selectedInstanceId() || undefined, this.sortBy(), this.hideMet(), this.hideUnmonitored()).subscribe({ + const loadToken = ++this.latestLoadToken; + const a = this.applied(); + this.api.getScores({ + page: this.currentPage(), + pageSize: this.pageSize(), + search: this.searchQuery() || undefined, + instanceId: this.selectedInstanceId() || undefined, + sortBy: this.sortBy(), + sortDirection: this.sortDirection(), + qualityProfile: a.qualityProfile || undefined, + cutoffFilter: a.cutoffFilter, + monitoredFilter: a.monitoredFilter, + }).subscribe({ next: (result) => { + if (loadToken !== this.latestLoadToken) return; this.items.set(result.items); this.totalRecords.set(result.totalCount); this.loading.set(false); }, error: () => { + if (loadToken !== this.latestLoadToken) return; this.loading.set(false); this.toast.error('Failed to load CF scores'); }, @@ -116,6 +205,7 @@ export class QualityTabComponent implements OnInit { private loadInstances(): void { this.api.getInstances().subscribe({ next: (result) => { + this.instances.set(result.instances); this.instanceOptions.set([ { label: 'All Instances', value: '' }, ...result.instances.map(i => ({ @@ -128,10 +218,6 @@ export class QualityTabComponent implements OnInit { }); } - onInstanceFilterChange(value: string): void { - this.applyFilterChange(this.selectedInstanceId, value); - } - private loadStats(): void { this.api.getStats().subscribe({ next: (stats) => this.stats.set(stats), @@ -144,20 +230,14 @@ export class QualityTabComponent implements OnInit { this.loadScores(); } - onSortChange(value: string): void { - this.applyFilterChange(this.sortBy, value); + onSortByChange(value: CfScoresSortBy): void { + this.sortBy.set(value); + this.currentPage.set(1); + this.loadScores(); } - onHideMetChange(value: boolean): void { - this.applyFilterChange(this.hideMet, value); - } - - onHideUnmonitoredChange(value: boolean): void { - this.applyFilterChange(this.hideUnmonitored, value); - } - - private applyFilterChange(setter: { set: (v: T) => void }, value: T): void { - setter.set(value); + onSortOrderChange(value: SortDirection): void { + this.sortDirection.set(value); this.currentPage.set(1); this.loadScores(); } @@ -174,6 +254,47 @@ export class QualityTabComponent implements OnInit { () => this.loadScores(), ); + openFilters(): void { + this.draft.set({ ...this.applied(), instanceId: this.selectedInstanceId() }); + this.drawerOpen.set(true); + } + + resetFilters(): void { + this.draft.set({ ...EMPTY_FILTERS }); + } + + applyFilters(): void { + const draft = { ...this.draft() }; + // Quality profile options narrow to the chosen instance — clear any stale + // selection that no longer belongs to the drafted instance's profiles. + if (draft.qualityProfile) { + const profiles = this.collectProfilesFor(draft.instanceId); + if (!profiles.has(draft.qualityProfile)) { + draft.qualityProfile = ''; + } + } + this.applied.set(draft); + this.selectedInstanceId.set(draft.instanceId); + this.drawerOpen.set(false); + this.currentPage.set(1); + this.loadScores(); + } + + private collectProfilesFor(instanceId: string): Set { + const profiles = new Set(); + for (const inst of this.instances()) { + if (instanceId && inst.id !== instanceId) continue; + for (const p of inst.qualityProfiles ?? []) { + profiles.add(p); + } + } + return profiles; + } + + updateDraft(key: K, value: AdvancedFilters[K]): void { + this.draft.update(d => ({ ...d, [key]: value })); + } + refresh(): void { this.loadScores(); this.loadStats(); diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.html b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.html index 7e774e27..b8a2ec3e 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.html +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.html @@ -101,30 +101,36 @@ } -
+
- - + +
- - Refresh + + + Filters + @if (activeFilterCount() > 0) { + {{ activeFilterCount() }} + } + Refresh
@@ -190,3 +196,75 @@ (pageSizeChange)="onPageSizeChange($event)" /> } + + + +
+ + +
+ +
+ + + @if (!draft().instanceId) { + Select an instance to filter by cycle. + } +
+ +
+ +
+ @for (opt of statusOptions; track opt.value) { + + } +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ Reset + Apply +
+
diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss index 05f397d2..8bef8dcd 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss @@ -1,33 +1,13 @@ @use 'data-toolbar' as *; +@use 'page-animations' as *; -// Staggered animations :host { - > .stats-bar { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 40ms; - } - > .instance-cards { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 80ms; - } - > .toolbar { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 120ms; - position: relative; - z-index: 1; - } - > app-card { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 160ms; - } - > app-paginator { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 200ms; - } + @include page-section-stagger; } .toolbar { @include data-toolbar; + @include sticky-page-header; &__filters { app-input { @@ -215,7 +195,106 @@ } } -// List rows +// Filter drawer + +.filter-group { + display: flex; + flex-direction: column; + gap: var(--space-2); + + &__label { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__hint { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } +} + +.chip-group { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +.chip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 4px 10px; + font-size: var(--font-size-xs); + font-weight: 500; + color: var(--text-secondary); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 999px; + cursor: pointer; + transition: background var(--duration-fast) var(--ease-default), + border-color var(--duration-fast) var(--ease-default), + color var(--duration-fast) var(--ease-default); + + &:hover { + color: var(--text-primary); + border-color: var(--color-primary); + } + + &--active { + color: var(--color-primary); + background: var(--color-primary-subtle); + border-color: var(--color-primary); + } +} + +// Expanded row detail + +.event-detail { + display: flex; + flex-direction: column; + gap: var(--space-3); + + &__chips { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; + align-items: center; + } + + &__section { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + &__label { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__value { + font-size: var(--font-size-sm); + color: var(--text-primary); + overflow-wrap: anywhere; + word-break: normal; + } + + &__cycle { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-tertiary); + background: var(--glass-bg); + padding: 2px 8px; + border-radius: var(--radius-sm); + } +} + +// List rows (legacy styles — kept for potential reuse) .list-row { border-bottom: 1px solid var(--divider); diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts index 4886c832..b07cb9cc 100644 --- a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts @@ -4,18 +4,49 @@ import { NgIcon } from '@ng-icons/core'; import { CardComponent, BadgeComponent, ButtonComponent, SelectComponent, InputComponent, PaginatorComponent, EmptyStateComponent, TooltipComponent, + DrawerComponent, } from '@ui'; import type { SelectOption } from '@ui'; import type { BadgeSeverity } from '@ui/badge/badge.component'; import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component'; -import { SearchStatsApi } from '@core/api/search-stats.api'; +import { SearchStatsApi, SearchEventsSortBy, SortDirection } from '@core/api/search-stats.api'; import type { SearchStatsSummary, SearchEvent, InstanceSearchStat } from '@core/models/search-stats.models'; -import { SeekerSearchType, SeekerSearchReason } from '@core/models/search-stats.models'; +import { SeekerSearchType, SeekerSearchReason, SearchCommandStatus } from '@core/models/search-stats.models'; import { AppHubService } from '@core/realtime/app-hub.service'; import { ToastService } from '@core/services/toast.service'; import { PaginationService } from '@core/services/pagination.service'; +import { StickyAwareDirective } from '@core/directives/sticky-aware.directive'; type CycleFilter = 'current' | 'all'; +type TriState = 'any' | 'true' | 'false'; + +const DEFAULT_SORT_BY = SearchEventsSortBy.Timestamp; +const DEFAULT_SORT_DIRECTION = SortDirection.Desc; + +interface AdvancedFilters { + instanceId: string; + cycleFilter: CycleFilter; + statuses: SearchCommandStatus[]; + searchType: SeekerSearchType | ''; + searchReason: SeekerSearchReason | ''; + grabbed: TriState; +} + +const EMPTY_FILTERS: AdvancedFilters = { + instanceId: '', + cycleFilter: 'all', + statuses: [], + searchType: '', + searchReason: '', + grabbed: 'any', +}; + +const STATUS_OPTIONS: ReadonlyArray<{ value: SearchCommandStatus; label: string }> = [ + { value: SearchCommandStatus.Started, label: 'Started' }, + { value: SearchCommandStatus.Completed, label: 'Completed' }, + { value: SearchCommandStatus.Failed, label: 'Failed' }, + { value: SearchCommandStatus.TimedOut, label: 'Timed Out' }, +]; @Component({ selector: 'app-searches-tab', @@ -32,6 +63,8 @@ type CycleFilter = 'current' | 'all'; EmptyStateComponent, AnimatedCounterComponent, TooltipComponent, + DrawerComponent, + StickyAwareDirective, ], templateUrl: './searches-tab.component.html', styleUrl: './searches-tab.component.scss', @@ -45,6 +78,7 @@ export class SearchesTabComponent implements OnInit { private readonly toast = inject(ToastService); private readonly pagination = inject(PaginationService); private initialLoad = true; + private latestLoadToken = 0; readonly summary = signal(null); readonly loading = signal(false); @@ -56,25 +90,74 @@ export class SearchesTabComponent implements OnInit { }) ); - // Instance filter readonly selectedInstanceId = signal(''); readonly instanceOptions = signal([]); - // Cycle filter - readonly cycleFilter = signal('current'); + readonly searchQuery = signal(''); + + readonly sortBy = signal(DEFAULT_SORT_BY); + readonly sortDirection = signal(DEFAULT_SORT_DIRECTION); + + // Applied filters drive the query; draft lives inside the open drawer. + readonly applied = signal({ ...EMPTY_FILTERS }); + readonly draft = signal({ ...EMPTY_FILTERS }); + readonly drawerOpen = signal(false); + + readonly events = signal([]); + readonly eventsTotalRecords = signal(0); + readonly eventsPage = signal(1); + readonly pageSize = signal(this.pagination.getPageSize(SearchesTabComponent.PAGE_SIZE_KEY, 50)); + + readonly sortOptions: SelectOption[] = [ + { label: 'Timestamp', value: SearchEventsSortBy.Timestamp }, + { label: 'Title', value: SearchEventsSortBy.Title }, + { label: 'Status', value: SearchEventsSortBy.Status }, + { label: 'Type', value: SearchEventsSortBy.Type }, + ]; + + readonly sortOrderOptions: SelectOption[] = [ + { label: 'Descending', value: SortDirection.Desc }, + { label: 'Ascending', value: SortDirection.Asc }, + ]; + readonly cycleFilterOptions: SelectOption[] = [ { label: 'Current Cycle', value: 'current' }, { label: 'All Time', value: 'all' }, ]; - // Search filter - readonly searchQuery = signal(''); + readonly searchTypeOptions: SelectOption[] = [ + { label: 'Any', value: '' }, + { label: 'Proactive', value: SeekerSearchType.Proactive }, + { label: 'Replacement', value: SeekerSearchType.Replacement }, + ]; - // Events - readonly events = signal([]); - readonly eventsTotalRecords = signal(0); - readonly eventsPage = signal(1); - readonly pageSize = signal(this.pagination.getPageSize(SearchesTabComponent.PAGE_SIZE_KEY, 50)); + readonly searchReasonOptions: SelectOption[] = [ + { label: 'Any', value: '' }, + { label: 'Missing', value: SeekerSearchReason.Missing }, + { label: 'Cutoff Unmet', value: SeekerSearchReason.QualityCutoffNotMet }, + { label: 'CF Below Cutoff', value: SeekerSearchReason.CustomFormatScoreBelowCutoff }, + { label: 'Replacement', value: SeekerSearchReason.Replacement }, + ]; + + readonly triStateOptions: SelectOption[] = [ + { label: 'Any', value: 'any' }, + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, + ]; + + readonly statusOptions = STATUS_OPTIONS; + + readonly activeFilterCount = computed(() => { + const a = this.applied(); + let n = 0; + if (a.instanceId) n++; + if (a.cycleFilter !== EMPTY_FILTERS.cycleFilter) n++; + if (a.statuses.length) n++; + if (a.searchType) n++; + if (a.searchReason) n++; + if (a.grabbed !== 'any') n++; + return n; + }); constructor() { effect(() => { @@ -95,21 +178,6 @@ export class SearchesTabComponent implements OnInit { this.loadEvents(); } - onInstanceFilterChange(value: string): void { - this.selectedInstanceId.set(value); - if (!value) { - this.cycleFilter.set('all'); - } - this.eventsPage.set(1); - this.loadEvents(); - } - - onCycleFilterChange(value: string): void { - this.cycleFilter.set(value as CycleFilter); - this.eventsPage.set(1); - this.loadEvents(); - } - onSearchFilterChange(): void { this.eventsPage.set(1); this.loadEvents(); @@ -120,6 +188,18 @@ export class SearchesTabComponent implements OnInit { this.loadEvents(); } + onSortByChange(value: SearchEventsSortBy): void { + this.sortBy.set(value); + this.eventsPage.set(1); + this.loadEvents(); + } + + onSortOrderChange(value: SortDirection): void { + this.sortDirection.set(value); + this.eventsPage.set(1); + this.loadEvents(); + } + readonly onPageSizeChange = this.pagination.createPageSizeHandler( SearchesTabComponent.PAGE_SIZE_KEY, this.pageSize, @@ -127,6 +207,47 @@ export class SearchesTabComponent implements OnInit { () => this.loadEvents(), ); + openFilters(): void { + this.draft.set({ ...this.applied(), instanceId: this.selectedInstanceId() }); + this.drawerOpen.set(true); + } + + resetFilters(): void { + this.draft.set({ ...EMPTY_FILTERS }); + } + + applyFilters(): void { + const draft = { ...this.draft() }; + this.applied.set(draft); + this.selectedInstanceId.set(draft.instanceId); + this.drawerOpen.set(false); + this.eventsPage.set(1); + this.loadEvents(); + } + + toggleStatus(value: SearchCommandStatus): void { + this.draft.update(d => { + const has = d.statuses.includes(value); + return { ...d, statuses: has ? d.statuses.filter(s => s !== value) : [...d.statuses, value] }; + }); + } + + isStatusDrafted(value: SearchCommandStatus): boolean { + return this.draft().statuses.includes(value); + } + + updateDraft(key: K, value: AdvancedFilters[K]): void { + this.draft.update(d => { + const next = { ...d, [key]: value }; + // 'Current Cycle' only makes sense against a specific instance — clearing + // the instance must fall the cycle filter back to 'All Time'. + if (key === 'instanceId' && !value && next.cycleFilter === 'current') { + next.cycleFilter = 'all'; + } + return next; + }); + } + refresh(): void { this.loadSummary(); this.loadEvents(); @@ -223,22 +344,40 @@ export class SearchesTabComponent implements OnInit { private loadEvents(): void { this.loading.set(true); + const loadToken = ++this.latestLoadToken; const instanceId = this.selectedInstanceId() || undefined; const search = this.searchQuery() || undefined; - let cycleId: string | undefined; + const a = this.applied(); - if (this.cycleFilter() === 'current' && instanceId) { + let cycleId: string | undefined; + if (a.cycleFilter === 'current' && instanceId) { const instance = this.summary()?.perInstanceStats.find(s => s.instanceId === instanceId); cycleId = instance?.currentCycleId ?? undefined; } - this.api.getEvents(this.eventsPage(), this.pageSize(), instanceId, cycleId, search).subscribe({ + const triToBool = (v: TriState): boolean | undefined => v === 'any' ? undefined : v === 'true'; + + this.api.getEvents({ + page: this.eventsPage(), + pageSize: this.pageSize(), + instanceId, + cycleId, + search, + sortBy: this.sortBy(), + sortDirection: this.sortDirection(), + searchStatus: a.statuses.length ? a.statuses : undefined, + searchType: a.searchType || undefined, + searchReason: a.searchReason || undefined, + grabbed: triToBool(a.grabbed), + }).subscribe({ next: (result) => { + if (loadToken !== this.latestLoadToken) return; this.events.set(result.items); this.eventsTotalRecords.set(result.totalCount); this.loading.set(false); }, error: () => { + if (loadToken !== this.latestLoadToken) return; this.loading.set(false); this.toast.error('Failed to load search events'); }, diff --git a/code/frontend/src/app/features/seeker-stats/seeker-stats.component.scss b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.scss index fc7b839d..7c1419ac 100644 --- a/code/frontend/src/app/features/seeker-stats/seeker-stats.component.scss +++ b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.scss @@ -1,14 +1,18 @@ -@use 'data-toolbar' as *; +@use 'page-animations' as *; .page-content { + @include page-section-stagger; + > app-tabs { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 0ms; display: block; - @include sticky-page-header; } > .tab-content { margin-top: var(--space-6); + + // Chromium retains the compositor layer from slide-up's compositing properties, + // which persists as a backdrop root and clips the descendant toolbar's + // backdrop-filter. No animated ancestor = no backdrop root = correct frosting. + animation: none; } } diff --git a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.html b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.html index 3e77bd7c..1b4ce96f 100644 --- a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.html +++ b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.html @@ -1,22 +1,34 @@ -
+
- +
- - Refresh + + + Filters + @if (activeFilterCount() > 0) { + {{ activeFilterCount() }} + } + Refresh
@@ -70,3 +82,29 @@ (pageSizeChange)="onPageSizeChange($event)" /> } + + + +
+ + +
+ +
+ + +
+ +
+ Reset + Apply +
+
diff --git a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss index fbdd78d7..2860b12c 100644 --- a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss +++ b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss @@ -1,29 +1,13 @@ @use 'data-toolbar' as *; +@use 'page-animations' as *; -// Staggered animations :host { - > .toolbar { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 40ms; - position: relative; - z-index: 1; - } - > .stats-bar { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 80ms; - } - > app-card { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 120ms; - } - > app-paginator { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 160ms; - } + @include page-section-stagger; } .toolbar { @include data-toolbar; + @include sticky-page-header; } // Stats bar @@ -158,3 +142,72 @@ padding: 0 var(--space-3) var(--space-2); } } + +// Filter drawer +.filter-group { + display: flex; + flex-direction: column; + gap: var(--space-2); + + &__label { + font-size: var(--font-size-xs); + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; + } +} + +// Table cell content +.cell-title { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; + + &__text { + font-weight: 500; + color: var(--text-primary); + overflow-wrap: anywhere; + word-break: normal; + } +} + +.score-value { + font-family: var(--font-mono); + font-weight: 600; + + &--new { color: var(--color-success); } + &--old { color: var(--text-tertiary); } +} + +.score-delta { + font-family: var(--font-mono); + font-weight: 600; + color: var(--color-success); +} + +// Expanded detail +.upgrade-detail { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: var(--space-3); + + &__item { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + &__label { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__value { + font-size: var(--font-size-sm); + color: var(--text-primary); + } +} diff --git a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts index f87ced8e..4790d1bd 100644 --- a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts +++ b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts @@ -1,16 +1,31 @@ -import { Component, ChangeDetectionStrategy, inject, signal, effect, untracked, OnInit } from '@angular/core'; +import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, OnInit } from '@angular/core'; import { DatePipe } from '@angular/common'; import { NgIcon } from '@ng-icons/core'; import { CardComponent, BadgeComponent, ButtonComponent, SelectComponent, - PaginatorComponent, EmptyStateComponent, + InputComponent, PaginatorComponent, EmptyStateComponent, + DrawerComponent, } from '@ui'; import type { SelectOption } from '@ui'; import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component'; -import { CfScoreApi, CfScoreUpgrade } from '@core/api/cf-score.api'; +import { CfScoreApi, CfScoreUpgrade, CfUpgradesSortBy, SortDirection } from '@core/api/cf-score.api'; import { AppHubService } from '@core/realtime/app-hub.service'; import { ToastService } from '@core/services/toast.service'; import { PaginationService } from '@core/services/pagination.service'; +import { StickyAwareDirective } from '@core/directives/sticky-aware.directive'; + +const DEFAULT_SORT_BY = CfUpgradesSortBy.UpgradedAt; +const DEFAULT_SORT_DIRECTION = SortDirection.Desc; + +interface AdvancedFilters { + instanceId: string; + timeRange: string; +} + +const EMPTY_FILTERS: AdvancedFilters = { + instanceId: '', + timeRange: '30', +}; @Component({ selector: 'app-upgrades-tab', @@ -22,9 +37,12 @@ import { PaginationService } from '@core/services/pagination.service'; BadgeComponent, ButtonComponent, SelectComponent, + InputComponent, PaginatorComponent, EmptyStateComponent, AnimatedCounterComponent, + DrawerComponent, + StickyAwareDirective, ], templateUrl: './upgrades-tab.component.html', styleUrl: './upgrades-tab.component.scss', @@ -38,6 +56,7 @@ export class UpgradesTabComponent implements OnInit { private readonly toast = inject(ToastService); private readonly pagination = inject(PaginationService); private initialLoad = true; + private latestLoadToken = 0; readonly upgrades = signal([]); readonly totalRecords = signal(0); @@ -45,10 +64,31 @@ export class UpgradesTabComponent implements OnInit { readonly pageSize = signal(this.pagination.getPageSize(UpgradesTabComponent.PAGE_SIZE_KEY, 50)); readonly loading = signal(false); - readonly timeRange = signal('30'); + readonly searchQuery = signal(''); readonly selectedInstanceId = signal(''); readonly instanceOptions = signal([]); + readonly sortBy = signal(DEFAULT_SORT_BY); + readonly sortDirection = signal(DEFAULT_SORT_DIRECTION); + + readonly applied = signal({ ...EMPTY_FILTERS }); + readonly draft = signal({ ...EMPTY_FILTERS }); + readonly drawerOpen = signal(false); + + readonly sortOptions: SelectOption[] = [ + { label: 'Upgraded At', value: CfUpgradesSortBy.UpgradedAt }, + { label: 'Title', value: CfUpgradesSortBy.Title }, + { label: 'New Score', value: CfUpgradesSortBy.NewScore }, + { label: 'Previous Score', value: CfUpgradesSortBy.PreviousScore }, + { label: 'Score Delta', value: CfUpgradesSortBy.ScoreDelta }, + { label: 'Cutoff', value: CfUpgradesSortBy.CutoffScore }, + ]; + + readonly sortOrderOptions: SelectOption[] = [ + { label: 'Descending', value: SortDirection.Desc }, + { label: 'Ascending', value: SortDirection.Asc }, + ]; + readonly timeRangeOptions: SelectOption[] = [ { label: 'Last 7 Days', value: '7' }, { label: 'Last 30 Days', value: '30' }, @@ -56,6 +96,14 @@ export class UpgradesTabComponent implements OnInit { { label: 'All Time', value: '0' }, ]; + readonly activeFilterCount = computed(() => { + const a = this.applied(); + let n = 0; + if (a.instanceId) n++; + if (a.timeRange !== EMPTY_FILTERS.timeRange) n++; + return n; + }); + constructor() { effect(() => { this.hub.cfScoresVersion(); @@ -74,14 +122,19 @@ export class UpgradesTabComponent implements OnInit { this.loadUpgrades(); } - onTimeRangeChange(value: string): void { - this.timeRange.set(value); + onSearchFilterChange(): void { this.currentPage.set(1); this.loadUpgrades(); } - onInstanceFilterChange(value: string): void { - this.selectedInstanceId.set(value); + onSortByChange(value: CfUpgradesSortBy): void { + this.sortBy.set(value); + this.currentPage.set(1); + this.loadUpgrades(); + } + + onSortOrderChange(value: SortDirection): void { + this.sortDirection.set(value); this.currentPage.set(1); this.loadUpgrades(); } @@ -98,6 +151,28 @@ export class UpgradesTabComponent implements OnInit { () => this.loadUpgrades(), ); + openFilters(): void { + this.draft.set({ ...this.applied(), instanceId: this.selectedInstanceId() }); + this.drawerOpen.set(true); + } + + resetFilters(): void { + this.draft.set({ ...EMPTY_FILTERS }); + } + + applyFilters(): void { + const draft = { ...this.draft() }; + this.applied.set(draft); + this.selectedInstanceId.set(draft.instanceId); + this.drawerOpen.set(false); + this.currentPage.set(1); + this.loadUpgrades(); + } + + updateDraft(key: K, value: AdvancedFilters[K]): void { + this.draft.update(d => ({ ...d, [key]: value })); + } + refresh(): void { this.loadUpgrades(); } @@ -123,16 +198,28 @@ export class UpgradesTabComponent implements OnInit { private loadUpgrades(): void { this.loading.set(true); - const days = parseInt(this.timeRange(), 10) || undefined; + const loadToken = ++this.latestLoadToken; + const a = this.applied(); + const days = parseInt(a.timeRange, 10); const instanceId = this.selectedInstanceId() || undefined; - this.api.getRecentUpgrades(this.currentPage(), this.pageSize(), instanceId, days).subscribe({ + this.api.getRecentUpgrades({ + page: this.currentPage(), + pageSize: this.pageSize(), + instanceId, + days: Number.isFinite(days) ? days : undefined, + search: this.searchQuery() || undefined, + sortBy: this.sortBy(), + sortDirection: this.sortDirection(), + }).subscribe({ next: (result) => { + if (loadToken !== this.latestLoadToken) return; this.upgrades.set(result.items); this.totalRecords.set(result.totalCount); this.loading.set(false); }, error: () => { + if (loadToken !== this.latestLoadToken) return; this.loading.set(false); this.toast.error('Failed to load upgrades'); }, diff --git a/code/frontend/src/app/features/strikes/strikes.component.html b/code/frontend/src/app/features/strikes/strikes.component.html index d7f1b82b..760b5792 100644 --- a/code/frontend/src/app/features/strikes/strikes.component.html +++ b/code/frontend/src/app/features/strikes/strikes.component.html @@ -5,7 +5,7 @@
-
+
.toolbar { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 0ms; @include sticky-page-header; } - > .strike-count { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 40ms; - } - > app-card { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 80ms; - } - > app-paginator { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 120ms; - } } .toolbar { diff --git a/code/frontend/src/app/features/strikes/strikes.component.ts b/code/frontend/src/app/features/strikes/strikes.component.ts index da2ea85e..6441bec6 100644 --- a/code/frontend/src/app/features/strikes/strikes.component.ts +++ b/code/frontend/src/app/features/strikes/strikes.component.ts @@ -11,6 +11,7 @@ import { StrikesApi } from '@core/api/strikes.api'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { PaginationService } from '@core/services/pagination.service'; +import { StickyAwareDirective } from '@core/directives/sticky-aware.directive'; import { DownloadItemStrikes, StrikeFilter } from '@core/models/strike.models'; @Component({ @@ -28,6 +29,7 @@ import { DownloadItemStrikes, StrikeFilter } from '@core/models/strike.models'; PaginatorComponent, EmptyStateComponent, AnimatedCounterComponent, + StickyAwareDirective, ], templateUrl: './strikes.component.html', styleUrl: './strikes.component.scss', diff --git a/code/frontend/src/app/ui/drawer/drawer.component.html b/code/frontend/src/app/ui/drawer/drawer.component.html new file mode 100644 index 00000000..34530cca --- /dev/null +++ b/code/frontend/src/app/ui/drawer/drawer.component.html @@ -0,0 +1,27 @@ +@if (visible()) { +
+ +
+} diff --git a/code/frontend/src/app/ui/drawer/drawer.component.scss b/code/frontend/src/app/ui/drawer/drawer.component.scss new file mode 100644 index 00000000..bf5a5a53 --- /dev/null +++ b/code/frontend/src/app/ui/drawer/drawer.component.scss @@ -0,0 +1,97 @@ +@use 'glass' as *; + +@keyframes drawer-slide-in { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +.drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: var(--z-modal); + display: flex; + justify-content: flex-end; + animation: fade-in var(--duration-fast) var(--ease-default), + backdrop-blur-in var(--duration-normal) var(--ease-default) both; +} + +.drawer { + @include glass('elevated'); + + position: relative; + width: 420px; + max-width: 100vw; + height: 100%; + display: flex; + flex-direction: column; + border-radius: 0; + animation: drawer-slide-in var(--duration-normal) var(--ease-default); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-5) var(--space-6); + border-bottom: 1px solid var(--divider); + } + + &__title { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--text-primary); + } + + &__close { + background: none; + border: none; + color: var(--text-tertiary); + font-size: var(--font-size-2xl); + cursor: pointer; + padding: 0; + line-height: 1; + transition: color var(--duration-fast) var(--ease-default); + + &:hover { + color: var(--text-primary); + } + } + + &__body { + flex: 1; + overflow-y: auto; + padding: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-4); + } + + &__footer { + padding: var(--space-4) var(--space-6); + border-top: 1px solid var(--divider); + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-3); + + &:empty { + display: none; + } + } + + @media (max-width: 768px) { + width: 100vw; + + &__header { + padding: var(--space-4); + } + + &__body { + padding: var(--space-4); + } + + &__footer { + padding: var(--space-3) var(--space-4); + } + } +} diff --git a/code/frontend/src/app/ui/drawer/drawer.component.ts b/code/frontend/src/app/ui/drawer/drawer.component.ts new file mode 100644 index 00000000..43a8d66f --- /dev/null +++ b/code/frontend/src/app/ui/drawer/drawer.component.ts @@ -0,0 +1,81 @@ +import { Component, ChangeDetectionStrategy, input, output, model, HostListener, effect, ElementRef, inject, OnInit, OnDestroy } from '@angular/core'; +import { A11yModule } from '@angular/cdk/a11y'; + +@Component({ + selector: 'app-drawer', + standalone: true, + imports: [A11yModule], + templateUrl: './drawer.component.html', + styleUrl: './drawer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DrawerComponent implements OnInit, OnDestroy { + private static nextId = 0; + + private readonly host: ElementRef = inject(ElementRef); + private previousFocus: HTMLElement | null = null; + + readonly titleId = `drawer-title-${++DrawerComponent.nextId}`; + + title = input(); + visible = model(false); + closeOnBackdrop = input(true); + + closed = output(); + + constructor() { + effect(() => { + if (this.visible()) { + this.previousFocus = document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + queueMicrotask(() => this.focusFirstControl()); + } + }); + } + + ngOnInit(): void { + document.body.appendChild(this.host.nativeElement); + } + + ngOnDestroy(): void { + this.restoreFocus(); + this.host.nativeElement.remove(); + } + + @HostListener('document:keydown.escape') + onEscapeKey(): void { + if (this.visible()) { + this.close(); + } + } + + close(): void { + this.visible.set(false); + this.restoreFocus(); + this.closed.emit(); + } + + onBackdropClick(): void { + if (this.closeOnBackdrop()) { + this.close(); + } + } + + private focusFirstControl(): void { + const panel = this.host.nativeElement.querySelector('.drawer__body') as HTMLElement | null; + if (!panel) return; + const focusable = panel.querySelector( + 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])' + ) as HTMLElement | null; + focusable?.focus(); + } + + private restoreFocus(): void { + const target = this.previousFocus; + this.previousFocus = null; + if (target && document.body.contains(target)) { + target.focus(); + } + } +} diff --git a/code/frontend/src/app/ui/index.ts b/code/frontend/src/app/ui/index.ts index d95df325..7661bfa6 100644 --- a/code/frontend/src/app/ui/index.ts +++ b/code/frontend/src/app/ui/index.ts @@ -16,6 +16,7 @@ export { TextareaComponent } from './textarea/textarea.component'; export { ChipInputComponent } from './chip-input/chip-input.component'; export { AccordionComponent } from './accordion/accordion.component'; export { ModalComponent } from './modal/modal.component'; +export { DrawerComponent } from './drawer/drawer.component'; export { PaginatorComponent } from './paginator/paginator.component'; export { TabsComponent } from './tabs/tabs.component'; export type { Tab } from './tabs/tabs.component'; diff --git a/code/frontend/src/app/ui/paginator/paginator.component.html b/code/frontend/src/app/ui/paginator/paginator.component.html index eb9c8e81..583a767a 100644 --- a/code/frontend/src/app/ui/paginator/paginator.component.html +++ b/code/frontend/src/app/ui/paginator/paginator.component.html @@ -6,6 +6,7 @@
diff --git a/code/frontend/src/app/ui/select/select.component.html b/code/frontend/src/app/ui/select/select.component.html index b10084fa..85aa8bc8 100644 --- a/code/frontend/src/app/ui/select/select.component.html +++ b/code/frontend/src/app/ui/select/select.component.html @@ -28,7 +28,11 @@ @if (isOpen()) { -
+
@for (option of options(); track option.value) {