mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-05-09 07:13:59 -04:00
Add sorting and filters for Seeker stats (#576)
This commit is contained in:
@@ -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
|
||||
});
|
||||
|
||||
@@ -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<string>? 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);
|
||||
|
||||
@@ -24,6 +24,8 @@ public static class SeekerTestDataFactory
|
||||
|
||||
var options = new DbContextOptionsBuilder<DataContext>()
|
||||
.UseSqlite(connection)
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
|
||||
var context = new DataContext(options);
|
||||
@@ -40,6 +42,8 @@ public static class SeekerTestDataFactory
|
||||
|
||||
var options = new DbContextOptionsBuilder<EventsContext>()
|
||||
.UseSqlite(connection)
|
||||
.UseLowerCaseNamingConvention()
|
||||
.UseSnakeCaseNamingConvention()
|
||||
.Options;
|
||||
|
||||
var context = new EventsContext(options);
|
||||
|
||||
@@ -17,4 +17,10 @@ public sealed record CustomFormatScoreEntryResponse
|
||||
public bool IsBelowCutoff { get; init; }
|
||||
public bool IsMonitored { get; init; }
|
||||
public DateTime LastSyncedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp at which this item last saw its custom format score strictly
|
||||
/// exceed the prior recorded score. Null when no upgrade has been recorded.
|
||||
/// </summary>
|
||||
public DateTime? LastUpgradedAt { get; init; }
|
||||
}
|
||||
|
||||
@@ -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<CustomFormatScoreEntry> 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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent CF score upgrades (where score improved in history).
|
||||
/// Gets recent CF score upgrades (where score strictly exceeded the prior recorded score).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Upgrade detection runs in SQL via <c>LAG()</c> over the full per-item history so
|
||||
/// an improvement crossing the <paramref name="days"/> window boundary is still
|
||||
/// detected. Sorting and pagination happen at the database level.
|
||||
/// </remarks>
|
||||
[HttpGet("upgrades")]
|
||||
public async Task<IActionResult> 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<UpgradeSqlRow>(listSql, listParams)
|
||||
.ToListAsync();
|
||||
|
||||
var upgrades = new List<CustomFormatScoreUpgradeResponse>();
|
||||
string countSql = $"{upgradesCte} SELECT COUNT(*) AS value FROM upgrades {filterClause}";
|
||||
int totalCount = await _dataContext.Database
|
||||
.SqlQueryRaw<int>(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<InstanceType>(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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("instances")]
|
||||
public async Task<IActionResult> 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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>Timestamp</c> descending for stable ordering.
|
||||
/// </summary>
|
||||
/// <param name="page">1-based page number. Clamped to at least 1.</param>
|
||||
/// <param name="pageSize">Rows per page. Clamped to the inclusive range [1, 100]; defaults to 50.</param>
|
||||
/// <param name="instanceId">When set, restricts results to events produced by this *arr instance.</param>
|
||||
/// <param name="cycleId">When set, restricts results to events from this seeker cycle.</param>
|
||||
/// <param name="search">Case-insensitive substring match against the stored item title.</param>
|
||||
/// <param name="sortBy">Primary sort column. Defaults to <see cref="SearchEventsSortBy.Timestamp"/>.</param>
|
||||
/// <param name="sortDirection">Sort direction for the primary column. Defaults to descending.</param>
|
||||
/// <param name="searchStatus">When supplied, keeps only events whose <see cref="SearchCommandStatus"/> appears in this list.</param>
|
||||
/// <param name="searchType">When supplied, keeps only events matching this <see cref="SeekerSearchType"/>.</param>
|
||||
/// <param name="searchReason">When supplied, keeps only events matching this <see cref="SeekerSearchReason"/>.</param>
|
||||
/// <param name="grabbed">When <c>true</c>, keeps only events that recorded at least one grabbed item; when <c>false</c>, keeps only events with none.</param>
|
||||
[HttpGet("events")]
|
||||
public async Task<IActionResult> 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<AppEvent> 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();
|
||||
|
||||
23
code/backend/Cleanuparr.Domain/Enums/CfScoresSortBy.cs
Normal file
23
code/backend/Cleanuparr.Domain/Enums/CfScoresSortBy.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Sorting fields available for custom format score listings.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CfScoresSortBy
|
||||
{
|
||||
/// <summary>Sort by item title.</summary>
|
||||
Title,
|
||||
/// <summary>Sort by the item's current custom format score.</summary>
|
||||
CurrentScore,
|
||||
/// <summary>Sort by the quality profile's configured cutoff score.</summary>
|
||||
CutoffScore,
|
||||
/// <summary>Sort by quality profile name.</summary>
|
||||
QualityProfile,
|
||||
/// <summary>Sort by the timestamp of the last score sync.</summary>
|
||||
LastSyncedAt,
|
||||
/// <summary>Sort by the timestamp of the most recent score upgrade.</summary>
|
||||
LastUpgradedAt,
|
||||
}
|
||||
23
code/backend/Cleanuparr.Domain/Enums/CfUpgradesSortBy.cs
Normal file
23
code/backend/Cleanuparr.Domain/Enums/CfUpgradesSortBy.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Sorting fields available for custom format score upgrades.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CfUpgradesSortBy
|
||||
{
|
||||
/// <summary>Sort by the timestamp at which the upgrade was recorded.</summary>
|
||||
UpgradedAt,
|
||||
/// <summary>Sort by item title.</summary>
|
||||
Title,
|
||||
/// <summary>Sort by the score recorded after the upgrade.</summary>
|
||||
NewScore,
|
||||
/// <summary>Sort by the score recorded immediately before the upgrade.</summary>
|
||||
PreviousScore,
|
||||
/// <summary>Sort by the difference between the new and previous scores.</summary>
|
||||
ScoreDelta,
|
||||
/// <summary>Sort by the quality profile's configured cutoff score.</summary>
|
||||
CutoffScore,
|
||||
}
|
||||
17
code/backend/Cleanuparr.Domain/Enums/CutoffFilter.cs
Normal file
17
code/backend/Cleanuparr.Domain/Enums/CutoffFilter.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Filters custom format score rows by their relation to the configured quality cutoff.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum CutoffFilter
|
||||
{
|
||||
/// <summary>Include all items regardless of cutoff status.</summary>
|
||||
All,
|
||||
/// <summary>Include only items whose current score is below the cutoff.</summary>
|
||||
Below,
|
||||
/// <summary>Include only items whose current score meets or exceeds the cutoff.</summary>
|
||||
Met,
|
||||
}
|
||||
17
code/backend/Cleanuparr.Domain/Enums/MonitoredFilter.cs
Normal file
17
code/backend/Cleanuparr.Domain/Enums/MonitoredFilter.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Filters items by their monitored state in the source *arr application.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum MonitoredFilter
|
||||
{
|
||||
/// <summary>Include both monitored and unmonitored items.</summary>
|
||||
All,
|
||||
/// <summary>Include only monitored items.</summary>
|
||||
Monitored,
|
||||
/// <summary>Include only unmonitored items.</summary>
|
||||
Unmonitored,
|
||||
}
|
||||
19
code/backend/Cleanuparr.Domain/Enums/SearchEventsSortBy.cs
Normal file
19
code/backend/Cleanuparr.Domain/Enums/SearchEventsSortBy.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Sorting fields available for search event listings.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SearchEventsSortBy
|
||||
{
|
||||
/// <summary>Sort by the event timestamp.</summary>
|
||||
Timestamp,
|
||||
/// <summary>Sort by the item title associated with the search.</summary>
|
||||
Title,
|
||||
/// <summary>Sort by the search command status.</summary>
|
||||
Status,
|
||||
/// <summary>Sort by the search type (proactive, replacement, etc.).</summary>
|
||||
Type,
|
||||
}
|
||||
15
code/backend/Cleanuparr.Domain/Enums/SortDirection.cs
Normal file
15
code/backend/Cleanuparr.Domain/Enums/SortDirection.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Cleanuparr.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Direction used when ordering a result set.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum SortDirection
|
||||
{
|
||||
/// <summary>Ascending order.</summary>
|
||||
Asc,
|
||||
/// <summary>Descending order.</summary>
|
||||
Desc,
|
||||
}
|
||||
@@ -344,6 +344,11 @@ public sealed class CustomFormatScoreSyncer : IHandler
|
||||
CutoffScore = cutoffScore,
|
||||
RecordedAt = now,
|
||||
});
|
||||
|
||||
if (cfScore > existing.CurrentScore)
|
||||
{
|
||||
existing.LastUpgradedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
existing.CurrentScore = cfScore;
|
||||
|
||||
@@ -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<CustomFormatScoreHistory>(entity =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLastUpgradedAtToCfScoreEntry : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1569,6 +1569,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_synced_at");
|
||||
|
||||
b.Property<DateTime?>("LastUpgradedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_upgraded_at");
|
||||
|
||||
b.Property<string>("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");
|
||||
|
||||
@@ -74,4 +74,10 @@ public sealed record CustomFormatScoreEntry
|
||||
/// When this entry was last synced from the arr API
|
||||
/// </summary>
|
||||
public DateTime LastSyncedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this item last saw a score upgrade (current score strictly exceeded the prior recorded score).
|
||||
/// Null when the item has no recorded upgrades.
|
||||
/// </summary>
|
||||
public DateTime? LastUpgradedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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<CfScoreStats>('/api/seeker/cf-scores/stats');
|
||||
}
|
||||
|
||||
getRecentUpgrades(page = 1, pageSize = 5, instanceId?: string, days?: number): Observable<CfScoreUpgradesResponse> {
|
||||
const params: Record<string, string | number> = { page, pageSize };
|
||||
if (instanceId) params['instanceId'] = instanceId;
|
||||
if (days !== undefined) params['days'] = days;
|
||||
getRecentUpgrades(query: CfScoreUpgradesQuery = {}): Observable<CfScoreUpgradesResponse> {
|
||||
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<CfScoreUpgradesResponse>('/api/seeker/cf-scores/upgrades', { params });
|
||||
}
|
||||
|
||||
getScores(page = 1, pageSize = 50, search?: string, instanceId?: string, sortBy?: string, hideMet?: boolean, hideUnmonitored?: boolean): Observable<CfScoreEntriesResponse> {
|
||||
const params: Record<string, string | number | boolean> = { 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<CfScoreEntriesResponse> {
|
||||
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<CfScoreEntriesResponse>('/api/seeker/cf-scores', { params });
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SearchStatsSummary>('/api/seeker/search-stats/summary');
|
||||
}
|
||||
|
||||
getEvents(page = 1, pageSize = 50, instanceId?: string, cycleId?: string, search?: string): Observable<PaginatedResult<SearchEvent>> {
|
||||
const params: Record<string, string | number> = { page, pageSize };
|
||||
if (instanceId) params['instanceId'] = instanceId;
|
||||
if (cycleId) params['cycleId'] = cycleId;
|
||||
if (search) params['search'] = search;
|
||||
getEvents(query: SearchEventsQuery = {}): Observable<PaginatedResult<SearchEvent>> {
|
||||
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<PaginatedResult<SearchEvent>>('/api/seeker/search-stats/events', { params });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement> = 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<div class="page-content">
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar" stickyAware>
|
||||
<div class="toolbar__filters">
|
||||
<app-select
|
||||
placeholder="All Severities"
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
// Staggered page content animations
|
||||
.page-content {
|
||||
@include page-section-stagger;
|
||||
|
||||
> .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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<div class="page-content">
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar" stickyAware>
|
||||
<div class="toolbar__filters">
|
||||
<app-select
|
||||
placeholder="All Levels"
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
// Staggered page content animations
|
||||
.page-content {
|
||||
@include page-section-stagger;
|
||||
|
||||
> .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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar" stickyAware>
|
||||
<div class="toolbar__filters">
|
||||
<app-select
|
||||
placeholder="All Instances"
|
||||
[options]="instanceOptions()"
|
||||
[value]="selectedInstanceId()"
|
||||
(valueChange)="onInstanceFilterChange($any($event))"
|
||||
/>
|
||||
<app-input
|
||||
placeholder="Search by title..."
|
||||
type="search"
|
||||
@@ -14,25 +8,27 @@
|
||||
(entered)="onFilterChange()"
|
||||
/>
|
||||
<app-select
|
||||
label="Sort by"
|
||||
[value]="sortBy()"
|
||||
[options]="sortOptions"
|
||||
(valueChange)="onSortChange($any($event))"
|
||||
(valueChange)="onSortByChange($any($event))"
|
||||
/>
|
||||
<app-toggle
|
||||
label="Hide met"
|
||||
[checked]="hideMet()"
|
||||
(checkedChange)="onHideMetChange($event)"
|
||||
/>
|
||||
<app-toggle
|
||||
label="Hide unmonitored"
|
||||
[checked]="hideUnmonitored()"
|
||||
(checkedChange)="onHideUnmonitoredChange($event)"
|
||||
<app-select
|
||||
label="Sort order"
|
||||
[value]="sortDirection()"
|
||||
[options]="sortOrderOptions"
|
||||
(valueChange)="onSortOrderChange($any($event))"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar__actions">
|
||||
<app-button variant="ghost" size="sm" (clicked)="refresh()">
|
||||
Refresh
|
||||
<app-button variant="secondary" size="sm" (clicked)="openFilters()">
|
||||
<ng-icon name="tablerFilter" />
|
||||
Filters
|
||||
@if (activeFilterCount() > 0) {
|
||||
<app-badge severity="accent" size="sm">{{ activeFilterCount() }}</app-badge>
|
||||
}
|
||||
</app-button>
|
||||
<app-button variant="ghost" size="sm" (clicked)="refresh()">Refresh</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,6 +120,10 @@
|
||||
<span class="score-row__detail-label">Last Synced</span>
|
||||
<span class="score-row__detail-value">{{ item.lastSyncedAt | date:'yyyy-MM-dd HH:mm:ss' }}</span>
|
||||
</div>
|
||||
<div class="score-row__detail">
|
||||
<span class="score-row__detail-label">Last Upgraded</span>
|
||||
<span class="score-row__detail-value">{{ item.lastUpgradedAt ? (item.lastUpgradedAt | date:'yyyy-MM-dd HH:mm:ss') : 'Never' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Score History -->
|
||||
<div class="score-row__detail">
|
||||
@@ -172,3 +172,47 @@
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Filter drawer -->
|
||||
<app-drawer title="Filter quality scores" [(visible)]="drawerOpen">
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Instance</label>
|
||||
<app-select
|
||||
[value]="draft().instanceId"
|
||||
[options]="instanceOptions()"
|
||||
(valueChange)="updateDraft('instanceId', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Quality profile</label>
|
||||
<app-select
|
||||
[value]="draft().qualityProfile"
|
||||
[options]="qualityProfileOptions()"
|
||||
(valueChange)="updateDraft('qualityProfile', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Cutoff status</label>
|
||||
<app-select
|
||||
[value]="draft().cutoffFilter"
|
||||
[options]="cutoffOptions"
|
||||
(valueChange)="updateDraft('cutoffFilter', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Monitored</label>
|
||||
<app-select
|
||||
[value]="draft().monitoredFilter"
|
||||
[options]="monitoredOptions"
|
||||
(valueChange)="updateDraft('monitoredFilter', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div drawer-footer>
|
||||
<app-button variant="ghost" (clicked)="resetFilters()">Reset</app-button>
|
||||
<app-button variant="primary" (clicked)="applyFilters()">Apply</app-button>
|
||||
</div>
|
||||
</app-drawer>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CfScoreEntry[]>([]);
|
||||
readonly stats = signal<CfScoreStats | null>(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<string>('');
|
||||
readonly instances = signal<CfScoreInstance[]>([]);
|
||||
readonly instanceOptions = signal<SelectOption[]>([]);
|
||||
|
||||
readonly sortBy = signal<string>('title');
|
||||
readonly hideMet = signal(false);
|
||||
readonly hideUnmonitored = signal(false);
|
||||
readonly sortBy = signal<CfScoresSortBy>(DEFAULT_SORT_BY);
|
||||
readonly sortDirection = signal<SortDirection>(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<AdvancedFilters>({ ...EMPTY_FILTERS });
|
||||
readonly draft = signal<AdvancedFilters>({ ...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<CfScoreHistoryEntry[]>([]);
|
||||
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<SelectOption[]>(() => {
|
||||
// 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<string>();
|
||||
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<T>(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<string> {
|
||||
const profiles = new Set<string>();
|
||||
for (const inst of this.instances()) {
|
||||
if (instanceId && inst.id !== instanceId) continue;
|
||||
for (const p of inst.qualityProfiles ?? []) {
|
||||
profiles.add(p);
|
||||
}
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
updateDraft<K extends keyof AdvancedFilters>(key: K, value: AdvancedFilters[K]): void {
|
||||
this.draft.update(d => ({ ...d, [key]: value }));
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.loadScores();
|
||||
this.loadStats();
|
||||
|
||||
@@ -101,30 +101,36 @@
|
||||
}
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar" stickyAware>
|
||||
<div class="toolbar__filters">
|
||||
<app-select
|
||||
[value]="selectedInstanceId()"
|
||||
[options]="instanceOptions()"
|
||||
(valueChange)="onInstanceFilterChange($any($event))"
|
||||
/>
|
||||
<app-select
|
||||
[value]="cycleFilter()"
|
||||
[options]="cycleFilterOptions"
|
||||
[disabled]="!selectedInstanceId()"
|
||||
(valueChange)="onCycleFilterChange($any($event))"
|
||||
/>
|
||||
<app-input
|
||||
placeholder="Search by title..."
|
||||
type="search"
|
||||
[(value)]="searchQuery"
|
||||
(entered)="onSearchFilterChange()"
|
||||
/>
|
||||
<app-select
|
||||
label="Sort by"
|
||||
[value]="sortBy()"
|
||||
[options]="sortOptions"
|
||||
(valueChange)="onSortByChange($any($event))"
|
||||
/>
|
||||
<app-select
|
||||
label="Sort order"
|
||||
[value]="sortDirection()"
|
||||
[options]="sortOrderOptions"
|
||||
(valueChange)="onSortOrderChange($any($event))"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar__actions">
|
||||
<app-button variant="ghost" size="sm" (clicked)="refresh()">
|
||||
Refresh
|
||||
<app-button variant="secondary" size="sm" (clicked)="openFilters()">
|
||||
<ng-icon name="tablerFilter" />
|
||||
Filters
|
||||
@if (activeFilterCount() > 0) {
|
||||
<app-badge severity="accent" size="sm">{{ activeFilterCount() }}</app-badge>
|
||||
}
|
||||
</app-button>
|
||||
<app-button variant="ghost" size="sm" (clicked)="refresh()">Refresh</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,3 +196,75 @@
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Filter drawer -->
|
||||
<app-drawer title="Filter searches" [(visible)]="drawerOpen">
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Instance</label>
|
||||
<app-select
|
||||
[value]="draft().instanceId"
|
||||
[options]="instanceOptions()"
|
||||
(valueChange)="updateDraft('instanceId', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Cycle</label>
|
||||
<app-select
|
||||
[value]="draft().cycleFilter"
|
||||
[options]="cycleFilterOptions"
|
||||
[disabled]="!draft().instanceId"
|
||||
(valueChange)="updateDraft('cycleFilter', $any($event))"
|
||||
/>
|
||||
@if (!draft().instanceId) {
|
||||
<span class="filter-group__hint">Select an instance to filter by cycle.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Status</label>
|
||||
<div class="chip-group">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
[class.chip--active]="isStatusDrafted(opt.value)"
|
||||
[attr.aria-pressed]="isStatusDrafted(opt.value)"
|
||||
(click)="toggleStatus(opt.value)"
|
||||
>{{ opt.label }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Search type</label>
|
||||
<app-select
|
||||
[value]="draft().searchType"
|
||||
[options]="searchTypeOptions"
|
||||
(valueChange)="updateDraft('searchType', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Search reason</label>
|
||||
<app-select
|
||||
[value]="draft().searchReason"
|
||||
[options]="searchReasonOptions"
|
||||
(valueChange)="updateDraft('searchReason', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Grabbed</label>
|
||||
<app-select
|
||||
[value]="draft().grabbed"
|
||||
[options]="triStateOptions"
|
||||
(valueChange)="updateDraft('grabbed', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div drawer-footer>
|
||||
<app-button variant="ghost" (clicked)="resetFilters()">Reset</app-button>
|
||||
<app-button variant="primary" (clicked)="applyFilters()">Apply</app-button>
|
||||
</div>
|
||||
</app-drawer>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<SearchStatsSummary | null>(null);
|
||||
readonly loading = signal(false);
|
||||
@@ -56,25 +90,74 @@ export class SearchesTabComponent implements OnInit {
|
||||
})
|
||||
);
|
||||
|
||||
// Instance filter
|
||||
readonly selectedInstanceId = signal<string>('');
|
||||
readonly instanceOptions = signal<SelectOption[]>([]);
|
||||
|
||||
// Cycle filter
|
||||
readonly cycleFilter = signal<CycleFilter>('current');
|
||||
readonly searchQuery = signal('');
|
||||
|
||||
readonly sortBy = signal<SearchEventsSortBy>(DEFAULT_SORT_BY);
|
||||
readonly sortDirection = signal<SortDirection>(DEFAULT_SORT_DIRECTION);
|
||||
|
||||
// Applied filters drive the query; draft lives inside the open drawer.
|
||||
readonly applied = signal<AdvancedFilters>({ ...EMPTY_FILTERS });
|
||||
readonly draft = signal<AdvancedFilters>({ ...EMPTY_FILTERS });
|
||||
readonly drawerOpen = signal(false);
|
||||
|
||||
readonly events = signal<SearchEvent[]>([]);
|
||||
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<SearchEvent[]>([]);
|
||||
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<K extends keyof AdvancedFilters>(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');
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar" stickyAware>
|
||||
<div class="toolbar__filters">
|
||||
<app-select
|
||||
[value]="timeRange()"
|
||||
[options]="timeRangeOptions"
|
||||
(valueChange)="onTimeRangeChange($any($event))"
|
||||
<app-input
|
||||
placeholder="Search by title..."
|
||||
type="search"
|
||||
[(value)]="searchQuery"
|
||||
(entered)="onSearchFilterChange()"
|
||||
/>
|
||||
<app-select
|
||||
placeholder="All Instances"
|
||||
[options]="instanceOptions()"
|
||||
[value]="selectedInstanceId()"
|
||||
(valueChange)="onInstanceFilterChange($any($event))"
|
||||
label="Sort by"
|
||||
[value]="sortBy()"
|
||||
[options]="sortOptions"
|
||||
(valueChange)="onSortByChange($any($event))"
|
||||
/>
|
||||
<app-select
|
||||
label="Sort order"
|
||||
[value]="sortDirection()"
|
||||
[options]="sortOrderOptions"
|
||||
(valueChange)="onSortOrderChange($any($event))"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar__actions">
|
||||
<app-button variant="ghost" size="sm" (clicked)="refresh()">
|
||||
Refresh
|
||||
<app-button variant="secondary" size="sm" (clicked)="openFilters()">
|
||||
<ng-icon name="tablerFilter" />
|
||||
Filters
|
||||
@if (activeFilterCount() > 0) {
|
||||
<app-badge severity="accent" size="sm">{{ activeFilterCount() }}</app-badge>
|
||||
}
|
||||
</app-button>
|
||||
<app-button variant="ghost" size="sm" (clicked)="refresh()">Refresh</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,3 +82,29 @@
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Filter drawer -->
|
||||
<app-drawer title="Filter upgrades" [(visible)]="drawerOpen">
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Instance</label>
|
||||
<app-select
|
||||
[value]="draft().instanceId"
|
||||
[options]="instanceOptions()"
|
||||
(valueChange)="updateDraft('instanceId', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Time range</label>
|
||||
<app-select
|
||||
[value]="draft().timeRange"
|
||||
[options]="timeRangeOptions"
|
||||
(valueChange)="updateDraft('timeRange', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div drawer-footer>
|
||||
<app-button variant="ghost" (clicked)="resetFilters()">Reset</app-button>
|
||||
<app-button variant="primary" (clicked)="applyFilters()">Apply</app-button>
|
||||
</div>
|
||||
</app-drawer>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CfScoreUpgrade[]>([]);
|
||||
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<string>('30');
|
||||
readonly searchQuery = signal('');
|
||||
readonly selectedInstanceId = signal<string>('');
|
||||
readonly instanceOptions = signal<SelectOption[]>([]);
|
||||
|
||||
readonly sortBy = signal<CfUpgradesSortBy>(DEFAULT_SORT_BY);
|
||||
readonly sortDirection = signal<SortDirection>(DEFAULT_SORT_DIRECTION);
|
||||
|
||||
readonly applied = signal<AdvancedFilters>({ ...EMPTY_FILTERS });
|
||||
readonly draft = signal<AdvancedFilters>({ ...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<K extends keyof AdvancedFilters>(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');
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<div class="page-content">
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar" stickyAware>
|
||||
<div class="toolbar__filters">
|
||||
<app-select
|
||||
placeholder="All Types"
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
// Staggered page content animations
|
||||
.page-content {
|
||||
@include page-section-stagger;
|
||||
|
||||
> .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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
27
code/frontend/src/app/ui/drawer/drawer.component.html
Normal file
27
code/frontend/src/app/ui/drawer/drawer.component.html
Normal file
@@ -0,0 +1,27 @@
|
||||
@if (visible()) {
|
||||
<div class="drawer-backdrop" (click)="onBackdropClick()">
|
||||
<aside
|
||||
class="drawer"
|
||||
(click)="$event.stopPropagation()"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-labelledby]="title() ? titleId : null"
|
||||
[attr.aria-label]="title() ? null : 'Drawer'"
|
||||
cdkTrapFocus
|
||||
[cdkTrapFocusAutoCapture]="false"
|
||||
>
|
||||
@if (title()) {
|
||||
<header class="drawer__header">
|
||||
<h2 [id]="titleId" class="drawer__title">{{ title() }}</h2>
|
||||
<button class="drawer__close" type="button" (click)="close()" aria-label="Close">×</button>
|
||||
</header>
|
||||
}
|
||||
<div class="drawer__body">
|
||||
<ng-content />
|
||||
</div>
|
||||
<footer class="drawer__footer">
|
||||
<ng-content select="[drawer-footer]" />
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
97
code/frontend/src/app/ui/drawer/drawer.component.scss
Normal file
97
code/frontend/src/app/ui/drawer/drawer.component.scss
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
81
code/frontend/src/app/ui/drawer/drawer.component.ts
Normal file
81
code/frontend/src/app/ui/drawer/drawer.component.ts
Normal file
@@ -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<HTMLElement> = inject(ElementRef);
|
||||
private previousFocus: HTMLElement | null = null;
|
||||
|
||||
readonly titleId = `drawer-title-${++DrawerComponent.nextId}`;
|
||||
|
||||
title = input<string>();
|
||||
visible = model(false);
|
||||
closeOnBackdrop = input(true);
|
||||
|
||||
closed = output<void>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<app-select
|
||||
[options]="pageSizeSelectOptions()"
|
||||
[value]="pageSize()"
|
||||
placement="top"
|
||||
(valueChange)="onPageSizeChange($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,11 @@
|
||||
</button>
|
||||
|
||||
@if (isOpen()) {
|
||||
<div class="select-dropdown" role="listbox">
|
||||
<div
|
||||
class="select-dropdown"
|
||||
[class.select-dropdown--top]="placement() === 'top'"
|
||||
role="listbox"
|
||||
>
|
||||
@for (option of options(); track option.value) {
|
||||
<button
|
||||
class="select-option"
|
||||
|
||||
@@ -82,6 +82,11 @@
|
||||
overflow-y: auto;
|
||||
padding: var(--space-1);
|
||||
animation: scale-in var(--duration-fast) var(--ease-default);
|
||||
|
||||
&--top {
|
||||
top: auto;
|
||||
bottom: calc(100% + var(--space-1));
|
||||
}
|
||||
}
|
||||
|
||||
.select-option {
|
||||
|
||||
@@ -27,6 +27,7 @@ export class SelectComponent {
|
||||
error = input<string>();
|
||||
hint = input<string>();
|
||||
helpKey = input<string>();
|
||||
placement = input<'bottom' | 'top'>('bottom');
|
||||
value = model<unknown>(null);
|
||||
|
||||
readonly isOpen = signal(false);
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
|
||||
to { opacity: 1; transform: none; filter: none; }
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
|
||||
@@ -7,11 +7,8 @@
|
||||
// Pins a page header bar to the top of the shell scroll container
|
||||
@mixin sticky-page-header {
|
||||
position: sticky;
|
||||
top: calc(-1 * var(--content-padding, var(--space-6)));
|
||||
top: 0;
|
||||
z-index: var(--z-sticky);
|
||||
background: var(--surface-overlay);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
padding-top: var(--content-padding, var(--space-6));
|
||||
padding-bottom: var(--space-3);
|
||||
padding-left: var(--content-padding, var(--space-6));
|
||||
@@ -19,12 +16,21 @@
|
||||
margin-top: calc(-1 * var(--content-padding, var(--space-6)));
|
||||
margin-left: calc(-1 * var(--content-padding, var(--space-6)));
|
||||
margin-right: calc(-1 * var(--content-padding, var(--space-6)));
|
||||
box-shadow: 0 10px 18px -14px rgba(0, 0, 0, 0.35);
|
||||
transition:
|
||||
backdrop-filter var(--duration-fast) var(--ease-default),
|
||||
-webkit-backdrop-filter var(--duration-fast) var(--ease-default),
|
||||
box-shadow var(--duration-fast) var(--ease-default);
|
||||
|
||||
&.is-stuck {
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
box-shadow: 0 10px 18px -14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin data-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
26
code/frontend/src/styles/_page-animations.scss
Normal file
26
code/frontend/src/styles/_page-animations.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
// =============================================================================
|
||||
// Page-level animation utilities
|
||||
// Usage: @use 'page-animations' as *;
|
||||
// =============================================================================
|
||||
|
||||
// Staggered entry for direct children of a page container.
|
||||
// Default 40ms increments up to 10 children; pages that need wider spacing can
|
||||
// override $increment (e.g. dashboard uses 80ms).
|
||||
@mixin page-section-stagger($increment: 40ms, $start: 0ms, $count: 10) {
|
||||
> * {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
}
|
||||
|
||||
@for $i from 1 through $count {
|
||||
> :nth-child(#{$i}) {
|
||||
animation-delay: #{$start + ($i - 1) * $increment};
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
> * {
|
||||
animation: none;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,22 +70,35 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-2);
|
||||
padding: var(--space-4);
|
||||
|
||||
// Sticky at bottom of scroll container (shell__content)
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: var(--z-sticky);
|
||||
|
||||
// Transparent — button floats over content without blocking it
|
||||
// Row stays transparent; each action gets its own glass pedestal so the
|
||||
// backdrop hugs the save button instead of spanning the full row.
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
|
||||
// Re-enable pointer events on the button itself
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: calc(-1 * var(--space-10));
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur-sm, 8px));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-sm, 8px));
|
||||
mask-image: radial-gradient(ellipse at center, black 40%, transparent 85%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, black 40%, transparent 85%);
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin form-section {
|
||||
|
||||
Reference in New Issue
Block a user