Add sorting and filters for Seeker stats (#576)

This commit is contained in:
Flaminel
2026-04-25 11:57:38 +03:00
committed by GitHub
parent 41ca55d615
commit 02a07d4fa3
56 changed files with 4284 additions and 333 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View 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,
}

View File

@@ -344,6 +344,11 @@ public sealed class CustomFormatScoreSyncer : IHandler
CutoffScore = cutoffScore,
RecordedAt = now,
});
if (cfScore > existing.CurrentScore)
{
existing.LastUpgradedAt = now;
}
}
existing.CurrentScore = cfScore;

View File

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

View File

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View File

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

View File

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

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',

View 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">&times;</button>
</header>
}
<div class="drawer__body">
<ng-content />
</div>
<footer class="drawer__footer">
<ng-content select="[drawer-footer]" />
</footer>
</aside>
</div>
}

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

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

View File

@@ -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';

View File

@@ -6,6 +6,7 @@
<app-select
[options]="pageSizeSelectOptions()"
[value]="pageSize()"
placement="top"
(valueChange)="onPageSizeChange($event)"
/>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}
}
}

View File

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