From 4657aabc45df95ab6e450e663cb4605375e027f8 Mon Sep 17 00:00:00 2001 From: Flaminel Date: Wed, 25 Mar 2026 23:42:51 +0200 Subject: [PATCH] improved stats layout and combined pages --- .../Responses/SearchHistoryEntryResponse.cs | 15 - .../Controllers/SearchStatsController.cs | 172 +--------- code/frontend/src/app/app.routes.ts | 15 +- .../frontend/src/app/core/api/cf-score.api.ts | 9 +- .../src/app/core/api/search-stats.api.ts | 18 +- .../app/core/models/search-stats.models.ts | 13 - .../cf-scores/cf-scores.component.html | 160 ---------- .../dashboard/dashboard.component.html | 7 +- .../dashboard/dashboard.component.scss | 3 +- .../search-stats/search-stats.component.html | 300 ------------------ .../quality-tab/quality-tab.component.html | 155 +++++++++ .../quality-tab/quality-tab.component.scss} | 14 +- .../quality-tab/quality-tab.component.ts} | 14 +- .../searches-tab/searches-tab.component.html | 187 +++++++++++ .../searches-tab/searches-tab.component.scss} | 137 +------- .../searches-tab/searches-tab.component.ts} | 142 ++------- .../seeker-stats/seeker-stats.component.html | 22 ++ .../seeker-stats/seeker-stats.component.scss | 10 + .../seeker-stats/seeker-stats.component.ts | 53 ++++ .../upgrades-tab/upgrades-tab.component.html | 69 ++++ .../upgrades-tab/upgrades-tab.component.scss | 171 ++++++++++ .../upgrades-tab/upgrades-tab.component.ts | 128 ++++++++ .../nav-sidebar/nav-sidebar.component.ts | 3 +- 23 files changed, 877 insertions(+), 940 deletions(-) delete mode 100644 code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchHistoryEntryResponse.cs delete mode 100644 code/frontend/src/app/features/cf-scores/cf-scores.component.html delete mode 100644 code/frontend/src/app/features/search-stats/search-stats.component.html create mode 100644 code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.html rename code/frontend/src/app/features/{cf-scores/cf-scores.component.scss => seeker-stats/quality-tab/quality-tab.component.scss} (98%) rename code/frontend/src/app/features/{cf-scores/cf-scores.component.ts => seeker-stats/quality-tab/quality-tab.component.ts} (93%) create mode 100644 code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.html rename code/frontend/src/app/features/{search-stats/search-stats.component.scss => seeker-stats/searches-tab/searches-tab.component.scss} (73%) rename code/frontend/src/app/features/{search-stats/search-stats.component.ts => seeker-stats/searches-tab/searches-tab.component.ts} (61%) create mode 100644 code/frontend/src/app/features/seeker-stats/seeker-stats.component.html create mode 100644 code/frontend/src/app/features/seeker-stats/seeker-stats.component.scss create mode 100644 code/frontend/src/app/features/seeker-stats/seeker-stats.component.ts create mode 100644 code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.html create mode 100644 code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss create mode 100644 code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchHistoryEntryResponse.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchHistoryEntryResponse.cs deleted file mode 100644 index 15aa47f4..00000000 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Contracts/Responses/SearchHistoryEntryResponse.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Cleanuparr.Api.Features.Seeker.Contracts.Responses; - -public sealed record SearchHistoryEntryResponse -{ - public Guid Id { get; init; } - public Guid ArrInstanceId { get; init; } - public string InstanceName { get; init; } = string.Empty; - public string InstanceType { get; init; } = string.Empty; - public long ExternalItemId { get; init; } - public string ItemTitle { get; init; } = string.Empty; - public int SeasonNumber { get; init; } - public DateTime LastSearchedAt { get; init; } - public int SearchCount { get; init; } - public int TotalSearchCount { get; init; } -} diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs index dbb2a810..e1781384 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs @@ -117,95 +117,17 @@ public sealed class SearchStatsController : ControllerBase }); } - /// - /// Gets paginated search history from SeekerHistory. - /// Supports sorting by lastSearched (default) or searchCount. - /// - [HttpGet("history")] - public async Task GetHistory( - [FromQuery] int page = 1, - [FromQuery] int pageSize = 50, - [FromQuery] Guid? instanceId = null, - [FromQuery] string sortBy = "lastSearched") - { - if (page < 1) page = 1; - if (pageSize < 1) pageSize = 50; - if (pageSize > 100) pageSize = 100; - - var query = _dataContext.SeekerHistory - .AsNoTracking() - .Include(h => h.ArrInstance) - .ThenInclude(a => a.ArrConfig) - .AsQueryable(); - - if (instanceId.HasValue) - { - query = query.Where(h => h.ArrInstanceId == instanceId.Value); - } - - // Group by item across cycles to aggregate search counts - var grouped = query - .GroupBy(h => new { h.ArrInstanceId, h.ExternalItemId, h.ItemType, h.SeasonNumber }) - .Select(g => new - { - g.Key.ArrInstanceId, - g.Key.ExternalItemId, - g.Key.SeasonNumber, - TotalSearchCount = g.Sum(h => h.SearchCount), - LastSearchedAt = g.Max(h => h.LastSearchedAt), - // Pick the most recent row's data for display fields - ItemTitle = g.OrderByDescending(h => h.LastSearchedAt).First().ItemTitle, - SearchCount = g.OrderByDescending(h => h.LastSearchedAt).First().SearchCount, - InstanceName = g.OrderByDescending(h => h.LastSearchedAt).First().ArrInstance.Name, - InstanceType = g.OrderByDescending(h => h.LastSearchedAt).First().ArrInstance.ArrConfig.Type, - Id = g.OrderByDescending(h => h.LastSearchedAt).First().Id, - }); - - int totalCount = await grouped.CountAsync(); - - var ordered = sortBy switch - { - "searchCount" => grouped.OrderByDescending(g => g.TotalSearchCount), - _ => grouped.OrderByDescending(g => g.LastSearchedAt), - }; - - var items = await ordered - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(g => new SearchHistoryEntryResponse - { - Id = g.Id, - ArrInstanceId = g.ArrInstanceId, - InstanceName = g.InstanceName, - InstanceType = g.InstanceType.ToString(), - ExternalItemId = g.ExternalItemId, - ItemTitle = g.ItemTitle, - SeasonNumber = g.SeasonNumber, - LastSearchedAt = g.LastSearchedAt, - SearchCount = g.SearchCount, - TotalSearchCount = g.TotalSearchCount, - }) - .ToListAsync(); - - return Ok(new - { - Items = items, - Page = page, - PageSize = pageSize, - TotalCount = totalCount, - TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize), - }); - } - /// /// Gets paginated search-triggered events with decoded data. + /// Supports optional text search across item names in event data. /// [HttpGet("events")] public async Task GetEvents( [FromQuery] int page = 1, [FromQuery] int pageSize = 50, [FromQuery] Guid? instanceId = null, - [FromQuery] Guid? cycleId = null) + [FromQuery] Guid? cycleId = null, + [FromQuery] string? search = null) { if (page < 1) page = 1; if (pageSize < 1) pageSize = 50; @@ -235,6 +157,12 @@ public sealed class SearchStatsController : ControllerBase query = query.Where(e => e.CycleId == cycleId.Value); } + // Pre-filter by search term on the JSON data field + if (!string.IsNullOrWhiteSpace(search)) + { + query = query.Where(e => e.Data != null && e.Data.ToLower().Contains(search.ToLower())); + } + int totalCount = await query.CountAsync(); var rawEvents = await query @@ -273,88 +201,6 @@ public sealed class SearchStatsController : ControllerBase }); } - /// - /// Gets individual search events for a specific item. - /// - [HttpGet("history/{instanceId}/{itemId}/detail")] - public async Task GetHistoryDetail( - Guid instanceId, - long itemId, - [FromQuery] int seasonNumber = 0) - { - var historyItem = await _dataContext.SeekerHistory - .AsNoTracking() - .Where(h => h.ArrInstanceId == instanceId - && h.ExternalItemId == itemId - && h.SeasonNumber == seasonNumber) - .OrderByDescending(h => h.LastSearchedAt) - .FirstOrDefaultAsync(); - - if (historyItem is null) - { - return Ok(new { Entries = Array.Empty() }); - } - - // Build the display name matching the event Items format - string displayName = seasonNumber > 0 - ? $"{historyItem.ItemTitle} S{seasonNumber:D2}" - : historyItem.ItemTitle; - - // Get instance URL for event filtering - var instance = await _dataContext.ArrInstances - .AsNoTracking() - .FirstOrDefaultAsync(a => a.Id == instanceId); - - if (instance is null) - { - return Ok(new { Entries = Array.Empty() }); - } - - string url = (instance.ExternalUrl ?? instance.Url).ToString(); - - // Pre-filter events containing the item title, then verify with JSON parsing - var rawEvents = await _eventsContext.Events - .AsNoTracking() - .Where(e => e.EventType == EventType.SearchTriggered - && e.InstanceUrl == url - && e.Data != null - && e.Data.Contains(historyItem.ItemTitle)) - .OrderByDescending(e => e.Timestamp) - .Take(100) - .ToListAsync(); - - var entries = rawEvents - .Select(e => - { - var parsed = ParseEventData(e.Data); - bool containsItem = parsed.Items.Any(i => - i.Contains(displayName, StringComparison.OrdinalIgnoreCase) - || i.Contains(historyItem.ItemTitle, StringComparison.OrdinalIgnoreCase)); - - return containsItem - ? new SearchEventResponse - { - Id = e.Id, - Timestamp = e.Timestamp, - InstanceName = parsed.InstanceName, - InstanceType = e.InstanceType?.ToString(), - ItemCount = parsed.ItemCount, - Items = parsed.Items, - SearchType = parsed.SearchType, - SearchStatus = e.SearchStatus, - CompletedAt = e.CompletedAt, - GrabbedItems = parsed.GrabbedItems, - CycleId = e.CycleId, - IsDryRun = e.IsDryRun, - } - : null; - }) - .Where(e => e is not null) - .ToList(); - - return Ok(new { Entries = entries }); - } - private static (string InstanceName, int ItemCount, List Items, SeekerSearchType SearchType, object? GrabbedItems) ParseEventData(string? data) { if (string.IsNullOrWhiteSpace(data)) diff --git a/code/frontend/src/app/app.routes.ts b/code/frontend/src/app/app.routes.ts index 814d402c..65e55806 100644 --- a/code/frontend/src/app/app.routes.ts +++ b/code/frontend/src/app/app.routes.ts @@ -38,19 +38,14 @@ export const routes: Routes = [ ), }, { - path: 'cf-scores', + path: 'seeker-stats', loadComponent: () => - import('@features/cf-scores/cf-scores.component').then( - (m) => m.CfScoresComponent, - ), - }, - { - path: 'search-stats', - loadComponent: () => - import('@features/search-stats/search-stats.component').then( - (m) => m.SearchStatsComponent, + import('@features/seeker-stats/seeker-stats.component').then( + (m) => m.SeekerStatsComponent, ), }, + { path: 'cf-scores', redirectTo: 'seeker-stats', pathMatch: 'full' }, + { path: 'search-stats', redirectTo: 'seeker-stats', pathMatch: 'full' }, { path: 'settings', children: [ diff --git a/code/frontend/src/app/core/api/cf-score.api.ts b/code/frontend/src/app/core/api/cf-score.api.ts index c55b5dd5..05e17ea2 100644 --- a/code/frontend/src/app/core/api/cf-score.api.ts +++ b/code/frontend/src/app/core/api/cf-score.api.ts @@ -88,10 +88,11 @@ export class CfScoreApi { return this.http.get('/api/seeker/cf-scores/stats'); } - getRecentUpgrades(page = 1, pageSize = 5): Observable { - return this.http.get('/api/seeker/cf-scores/upgrades', { - params: { page, pageSize }, - }); + getRecentUpgrades(page = 1, pageSize = 5, instanceId?: string, days?: number): Observable { + const params: Record = { page, pageSize }; + if (instanceId) params['instanceId'] = instanceId; + if (days !== undefined) params['days'] = days; + return this.http.get('/api/seeker/cf-scores/upgrades', { params }); } getScores(page = 1, pageSize = 50, search?: string, instanceId?: string, sortBy?: string, hideMet?: boolean): Observable { diff --git a/code/frontend/src/app/core/api/search-stats.api.ts b/code/frontend/src/app/core/api/search-stats.api.ts index 42af4f40..79bf2895 100644 --- a/code/frontend/src/app/core/api/search-stats.api.ts +++ b/code/frontend/src/app/core/api/search-stats.api.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import type { SearchStatsSummary, SearchHistoryEntry, SearchEvent } from '@core/models/search-stats.models'; +import type { SearchStatsSummary, SearchEvent } from '@core/models/search-stats.models'; import type { PaginatedResult } from '@core/models/pagination.model'; @Injectable({ providedIn: 'root' }) @@ -12,23 +12,11 @@ export class SearchStatsApi { return this.http.get('/api/seeker/search-stats/summary'); } - getHistory(page = 1, pageSize = 50, instanceId?: string, sortBy = 'lastSearched'): Observable> { - const params: Record = { page, pageSize, sortBy }; - if (instanceId) params['instanceId'] = instanceId; - return this.http.get>('/api/seeker/search-stats/history', { params }); - } - - getItemDetail(instanceId: string, itemId: number, seasonNumber = 0): Observable<{ entries: SearchEvent[] }> { - return this.http.get<{ entries: SearchEvent[] }>( - `/api/seeker/search-stats/history/${instanceId}/${itemId}/detail`, - { params: { seasonNumber } }, - ); - } - - getEvents(page = 1, pageSize = 50, instanceId?: string, cycleId?: string): Observable> { + getEvents(page = 1, pageSize = 50, instanceId?: string, cycleId?: string, search?: string): Observable> { const params: Record = { page, pageSize }; if (instanceId) params['instanceId'] = instanceId; if (cycleId) params['cycleId'] = cycleId; + if (search) params['search'] = search; return this.http.get>('/api/seeker/search-stats/events', { params }); } } diff --git a/code/frontend/src/app/core/models/search-stats.models.ts b/code/frontend/src/app/core/models/search-stats.models.ts index 09236643..63d89def 100644 --- a/code/frontend/src/app/core/models/search-stats.models.ts +++ b/code/frontend/src/app/core/models/search-stats.models.ts @@ -22,19 +22,6 @@ export interface SearchStatsSummary { perInstanceStats: InstanceSearchStat[]; } -export interface SearchHistoryEntry { - id: string; - arrInstanceId: string; - instanceName: string; - instanceType: string; - externalItemId: number; - itemTitle: string; - seasonNumber: number; - lastSearchedAt: string; - searchCount: number; - totalSearchCount: number; -} - export enum SeekerSearchType { Proactive = 'Proactive', Replacement = 'Replacement', diff --git a/code/frontend/src/app/features/cf-scores/cf-scores.component.html b/code/frontend/src/app/features/cf-scores/cf-scores.component.html deleted file mode 100644 index 34287b61..00000000 --- a/code/frontend/src/app/features/cf-scores/cf-scores.component.html +++ /dev/null @@ -1,160 +0,0 @@ - - -
- -
-
- - - - -
-
- - Refresh - -
-
- - - @if (stats(); as stats) { -
-
- - Tracked -
-
- - Below Cutoff -
-
- - Met Cutoff -
-
- - Recent Upgrades -
-
- } - - - -
- @for (item of items(); track item.id) { -
-
- - {{ item.title }} - - {{ item.currentScore }} - / - {{ item.cutoffScore }} - - {{ item.qualityProfileName }} - - {{ statusLabel(item.isBelowCutoff) }} - - - {{ item.itemType }} - - {{ item.lastSyncedAt | date:'yyyy-MM-dd HH:mm' }} - -
- - @if (expandedId() === item.id) { -
-
- Quality Profile - {{ item.qualityProfileName }} -
-
- Current Score - {{ item.currentScore }} -
-
- Cutoff Score - {{ item.cutoffScore }} -
-
- Last Synced - {{ item.lastSyncedAt | date:'yyyy-MM-dd HH:mm:ss' }} -
- - -
- Score History - @if (historyLoading()) { - Loading... - } @else if (historyEntries().length === 0) { - No history available - } @else { -
-
- Score - Cutoff - Recorded At -
- @for (entry of historyEntries(); track entry.recordedAt) { -
- {{ entry.score }} - {{ entry.cutoffScore }} - {{ entry.recordedAt | date:'yyyy-MM-dd HH:mm:ss' }} -
- } -
- } -
-
- } -
- } @empty { - - } -
-
- - - @if (totalRecords() > pageSize()) { - - } -
diff --git a/code/frontend/src/app/features/dashboard/dashboard.component.html b/code/frontend/src/app/features/dashboard/dashboard.component.html index 9b254446..69569e31 100644 --- a/code/frontend/src/app/features/dashboard/dashboard.component.html +++ b/code/frontend/src/app/features/dashboard/dashboard.component.html @@ -264,7 +264,7 @@

Custom Format Scores

- View All + View All
@@ -307,7 +307,10 @@ } @if (cfScoreUpgrades().length > 0) {
- Recent Upgrades +
+ Recent Upgrades + View All +
@for (upgrade of cfScoreUpgrades(); track $index) {
{{ upgrade.title }}
diff --git a/code/frontend/src/app/features/dashboard/dashboard.component.scss b/code/frontend/src/app/features/dashboard/dashboard.component.scss index bcaf6551..18eed75f 100644 --- a/code/frontend/src/app/features/dashboard/dashboard.component.scss +++ b/code/frontend/src/app/features/dashboard/dashboard.component.scss @@ -247,7 +247,8 @@ } &__upgrades { border-top: 1px solid var(--divider); padding-top: var(--space-3); } - &__upgrades-title { font-size: var(--font-size-sm); font-weight: 600; color: var(--text-secondary); margin-bottom: var(--space-2); } + &__upgrades-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-2); } + &__upgrades-title { font-size: var(--font-size-sm); font-weight: 600; color: var(--text-secondary); } &__upgrade-item { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; &:not(:last-child) { border-bottom: 1px solid var(--divider); } } &__upgrade-title { flex: 1; min-width: 0; font-size: var(--font-size-sm); color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } &__upgrade-scores { display: flex; align-items: center; gap: var(--space-1); flex-shrink: 0; } diff --git a/code/frontend/src/app/features/search-stats/search-stats.component.html b/code/frontend/src/app/features/search-stats/search-stats.component.html deleted file mode 100644 index 46ec9890..00000000 --- a/code/frontend/src/app/features/search-stats/search-stats.component.html +++ /dev/null @@ -1,300 +0,0 @@ - - -
- - @if (summary(); as stats) { -
-
- - Total Searches -
-
- - Last 7 Days -
-
- - Last 30 Days -
-
- - Unique Items -
-
- - - - - Pending replacement searches - -
-
- - Instances -
-
- - - @if (stats.perInstanceStats.length > 0) { -
- @for (inst of sortedInstanceStats(); track inst.instanceId) { - -
- {{ inst.instanceName }} -
- @if (instanceHealthWarning(inst); as warning) { - - - - } - - {{ inst.instanceType }} - -
-
- -
-
- - Cycle Progress - - - {{ inst.cycleItemsSearched }} / {{ inst.cycleItemsTotal }} - -
-
-
-
-
- -
-
- {{ inst.totalSearchCount }} - Searches -
-
- - {{ inst.cycleStartedAt ? formatCycleDuration(inst.cycleStartedAt) : '—' }} - - - Cycle Duration - -
-
- - {{ inst.lastSearchedAt ? (inst.lastSearchedAt | date:'MM/dd HH:mm') : 'Never' }} - - - Last Search - -
-
- - -
- } -
- } - } - - -
-
- - -
-
- - Refresh - -
-
- - - - - - @switch (activeTab()) { - - @case ('events') { -

Search events triggered by the Seeker. Each entry represents a batch of items searched on an instance.

- -
- @for (event of events(); track event.id) { -
-
- - - {{ event.items.length > 0 ? event.items[0] : 'Search triggered' }} - @if (event.items.length > 1) { - +{{ event.items.length - 1 }} more - } - - - {{ event.searchType }} - - @if (event.searchStatus) { - - {{ event.searchStatus }} - - } - @if (event.isDryRun) { - Dry Run - } - @if (event.cycleId) { - {{ event.cycleId.substring(0, 8) }} - } - {{ event.instanceName }} - {{ event.timestamp | date:'yyyy-MM-dd HH:mm' }} -
- @if (event.grabbedItems && event.grabbedItems.length > 0) { -
- - - Grabbed: {{ formatGrabbedItems(event.grabbedItems) }} - -
- } -
- } @empty { - - } -
-
- - @if (eventsTotalRecords() > pageSize()) { - - } - } - - - @case ('items') { -

Individual items tracked by the Seeker with their search count and last searched time.

-
- -
- -
- @for (item of items(); track item.id) { -
-
- - - {{ itemDisplayName(item) }} - @if (item.seasonNumber > 0) { - S{{ item.seasonNumber }} - } - - - {{ item.instanceType }} - - {{ item.instanceName }} - {{ item.totalSearchCount }}x - @if (item.totalSearchCount !== item.searchCount) { - ({{ item.searchCount }}x this cycle) - } - {{ item.lastSearchedAt | date:'yyyy-MM-dd HH:mm' }} - -
- - @if (expandedItemId() === item.id) { -
-
Search History
- @if (detailLoading()) { - Loading... - } @else if (detailEntries().length === 0) { - No search events found - } @else { -
- @for (event of detailEntries(); track event.id) { -
- - - {{ event.searchType }} - - @if (event.searchStatus) { - - {{ event.searchStatus }} - - } - @if (event.isDryRun) { - Dry Run - } - @if (event.cycleId) { - {{ event.cycleId.substring(0, 8) }} - } - {{ event.timestamp | date:'yyyy-MM-dd HH:mm' }} -
- @if (event.grabbedItems && event.grabbedItems.length > 0) { -
- - - Grabbed: {{ formatGrabbedItems(event.grabbedItems) }} - -
- } - } -
- } -
- } -
- } @empty { - - } -
-
- - @if (itemsTotalRecords() > pageSize()) { - - } - } - } -
diff --git a/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.html b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.html new file mode 100644 index 00000000..719d3064 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.html @@ -0,0 +1,155 @@ + +
+
+ + + + +
+
+ + Refresh + +
+
+ + +@if (stats(); as stats) { +
+
+ + Tracked +
+
+ + Below Cutoff +
+
+ + Met Cutoff +
+ +
+ + Recent Upgrades +
+
+
+} + + + +
+ @for (item of items(); track item.id) { +
+
+ + {{ item.title }} + + {{ item.currentScore }} + / + {{ item.cutoffScore }} + + {{ item.qualityProfileName }} + + {{ statusLabel(item.isBelowCutoff) }} + + + {{ item.itemType }} + + {{ item.lastSyncedAt | date:'yyyy-MM-dd HH:mm' }} + +
+ + @if (expandedId() === item.id) { +
+
+ Quality Profile + {{ item.qualityProfileName }} +
+
+ Current Score + {{ item.currentScore }} +
+
+ Cutoff Score + {{ item.cutoffScore }} +
+
+ Last Synced + {{ item.lastSyncedAt | date:'yyyy-MM-dd HH:mm:ss' }} +
+ + +
+ Score History + @if (historyLoading()) { + Loading... + } @else if (historyEntries().length === 0) { + No history available + } @else { +
+
+ Score + Cutoff + Recorded At +
+ @for (entry of historyEntries(); track entry.recordedAt) { +
+ {{ entry.score }} + {{ entry.cutoffScore }} + {{ entry.recordedAt | date:'yyyy-MM-dd HH:mm:ss' }} +
+ } +
+ } +
+
+ } +
+ } @empty { + + } +
+
+ + +@if (totalRecords() > pageSize()) { + +} diff --git a/code/frontend/src/app/features/cf-scores/cf-scores.component.scss b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss similarity index 98% rename from code/frontend/src/app/features/cf-scores/cf-scores.component.scss rename to code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss index 0bf142c7..ec03e2d5 100644 --- a/code/frontend/src/app/features/cf-scores/cf-scores.component.scss +++ b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.scss @@ -1,24 +1,24 @@ @use 'data-toolbar' as *; -// Staggered page content animations -.page-content { +// Staggered animations +:host { > .toolbar { animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 0ms; + animation-delay: 40ms; position: relative; z-index: 1; } > .stats-bar { animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 40ms; + animation-delay: 80ms; } > app-card { animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 80ms; + animation-delay: 120ms; } > app-paginator { animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 120ms; + animation-delay: 160ms; } } @@ -29,7 +29,7 @@ app-input { flex: 1; min-width: 150px; - max-width: 400px; + max-width: 300px; } } } diff --git a/code/frontend/src/app/features/cf-scores/cf-scores.component.ts b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts similarity index 93% rename from code/frontend/src/app/features/cf-scores/cf-scores.component.ts rename to code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts index f0adf5a7..d129824b 100644 --- a/code/frontend/src/app/features/cf-scores/cf-scores.component.ts +++ b/code/frontend/src/app/features/seeker-stats/quality-tab/quality-tab.component.ts @@ -1,10 +1,10 @@ import { Component, ChangeDetectionStrategy, inject, signal, effect, OnInit } from '@angular/core'; import { DatePipe } from '@angular/common'; import { NgIcon } from '@ng-icons/core'; -import { PageHeaderComponent } from '@layout/page-header/page-header.component'; import { CardComponent, BadgeComponent, ButtonComponent, InputComponent, PaginatorComponent, EmptyStateComponent, SelectComponent, ToggleComponent, + TooltipComponent, } from '@ui'; import type { SelectOption } from '@ui'; import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component'; @@ -15,12 +15,11 @@ import { AppHubService } from '@core/realtime/app-hub.service'; import { ToastService } from '@core/services/toast.service'; @Component({ - selector: 'app-cf-scores', + selector: 'app-quality-tab', standalone: true, imports: [ DatePipe, NgIcon, - PageHeaderComponent, CardComponent, BadgeComponent, ButtonComponent, @@ -30,12 +29,13 @@ import { ToastService } from '@core/services/toast.service'; PaginatorComponent, EmptyStateComponent, AnimatedCounterComponent, + TooltipComponent, ], - templateUrl: './cf-scores.component.html', - styleUrl: './cf-scores.component.scss', + templateUrl: './quality-tab.component.html', + styleUrl: './quality-tab.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CfScoresComponent implements OnInit { +export class QualityTabComponent implements OnInit { private readonly api = inject(CfScoreApi); private readonly hub = inject(AppHubService); private readonly toast = inject(ToastService); @@ -65,7 +65,7 @@ export class CfScoresComponent implements OnInit { constructor() { effect(() => { - this.hub.cfScoresVersion(); // subscribe to changes + this.hub.cfScoresVersion(); if (this.initialLoad) { this.initialLoad = false; return; diff --git a/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.html b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.html new file mode 100644 index 00000000..2473d716 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.html @@ -0,0 +1,187 @@ + +@if (summary(); as stats) { +
+
+ + Total Searches +
+
+ + Last 7 Days +
+
+ + Last 30 Days +
+ +
+ + Unique Items +
+
+ +
+ + + + Pending Replacements +
+
+
+ + Instances +
+
+ + + @if (stats.perInstanceStats.length > 0) { +
+ @for (inst of sortedInstanceStats(); track inst.instanceId) { + +
+ {{ inst.instanceName }} +
+ @if (instanceHealthWarning(inst); as warning) { + + + + } + + {{ inst.instanceType }} + +
+
+ +
+
+ + Cycle Progress + + + {{ inst.cycleItemsSearched }} / {{ inst.cycleItemsTotal }} + +
+
+
+
+
+ +
+
+ {{ inst.totalSearchCount }} + Searches +
+
+ + {{ inst.cycleStartedAt ? formatCycleDuration(inst.cycleStartedAt) : '—' }} + + + Cycle Duration + +
+
+ + {{ inst.lastSearchedAt ? (inst.lastSearchedAt | date:'MM/dd HH:mm') : 'Never' }} + + + Last Search + +
+
+ + +
+ } +
+ } +} + + +
+
+ + + +
+
+ + Refresh + +
+
+ + + +
+ @for (event of events(); track event.id) { +
+
+ + + {{ event.items.length > 0 ? event.items[0] : 'Search triggered' }} + @if (event.items.length > 1) { + +{{ event.items.length - 1 }} more + } + + + {{ event.searchType }} + + @if (event.searchStatus) { + + {{ event.searchStatus }} + + } + @if (event.isDryRun) { + Dry Run + } + @if (event.cycleId) { + {{ event.cycleId.substring(0, 8) }} + } + {{ event.instanceName }} + {{ event.timestamp | date:'yyyy-MM-dd HH:mm' }} +
+ @if (event.grabbedItems && event.grabbedItems.length > 0) { +
+ + + Grabbed: {{ formatGrabbedItems(event.grabbedItems) }} + +
+ } +
+ } @empty { + + } +
+
+ +@if (eventsTotalRecords() > pageSize()) { + +} diff --git a/code/frontend/src/app/features/search-stats/search-stats.component.scss b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss similarity index 73% rename from code/frontend/src/app/features/search-stats/search-stats.component.scss rename to code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss index 742759a7..f87daaeb 100644 --- a/code/frontend/src/app/features/search-stats/search-stats.component.scss +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.scss @@ -1,32 +1,20 @@ @use 'data-toolbar' as *; -// Staggered page content animations -.page-content { +// Staggered animations +:host { > .stats-bar { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 0ms; - } - > .instance-cards { animation: slide-up var(--duration-normal) var(--ease-default) both; animation-delay: 40ms; } - > .toolbar { + > .instance-cards { animation: slide-up var(--duration-normal) var(--ease-default) both; animation-delay: 80ms; - position: relative; - z-index: 1; } - > app-tabs { + > .toolbar { animation: slide-up var(--duration-normal) var(--ease-default) both; animation-delay: 120ms; - } - > .tab-description { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 140ms; - } - > .tab-toolbar { - animation: slide-up var(--duration-normal) var(--ease-default) both; - animation-delay: 150ms; + position: relative; + z-index: 1; } > app-card { animation: slide-up var(--duration-normal) var(--ease-default) both; @@ -40,21 +28,14 @@ .toolbar { @include data-toolbar; -} -// Tab description -.tab-description { - font-size: var(--font-size-sm); - color: var(--text-tertiary); - margin: 0 0 var(--space-3); - line-height: 1.5; -} - -// Tab toolbar (sort toggle) -.tab-toolbar { - display: flex; - justify-content: flex-end; - margin-bottom: var(--space-2); + &__filters { + app-input { + flex: 1; + min-width: 150px; + max-width: 300px; + } + } } // Stats bar @@ -132,7 +113,6 @@ flex-shrink: 0; } - // Progress bar section &__progress { margin-bottom: var(--space-3); } @@ -181,7 +161,6 @@ min-width: 0; } - // Stats grid &__stats { display: grid; grid-template-columns: repeat(3, 1fr); @@ -216,7 +195,6 @@ color: var(--text-tertiary); } - // Footer &__footer { display: flex; justify-content: flex-end; @@ -237,7 +215,7 @@ } } -// List rows (shared across tabs) +// List rows .list { max-height: 70vh; overflow-y: auto; @@ -308,18 +286,6 @@ white-space: nowrap; } - &__count { - font-family: var(--font-mono); - font-size: var(--font-size-xs); - color: var(--text-secondary); - flex-shrink: 0; - - &--prominent { - font-weight: 600; - color: var(--color-primary); - } - } - &__cycle { font-family: var(--font-mono); font-size: var(--font-size-xs); @@ -330,12 +296,6 @@ flex-shrink: 0; } - &__count-secondary { - font-size: var(--font-size-xs); - color: var(--text-tertiary); - flex-shrink: 0; - } - &__time { font-size: var(--font-size-xs); color: var(--text-tertiary); @@ -363,71 +323,6 @@ } } -// Expanded state for list rows -.list-row { - &--expanded { - background: var(--glass-bg); - } - - &__main--clickable { - cursor: pointer; - } - - &__chevron { - font-size: 14px; - color: var(--text-tertiary); - flex-shrink: 0; - margin-left: auto; - transition: color var(--duration-fast) var(--ease-default); - } - - &__main--clickable:hover &__chevron { - color: var(--text-secondary); - } - - &__expand-details { - padding: var(--space-2) var(--space-4) var(--space-3); - display: flex; - flex-direction: column; - gap: var(--space-2); - animation: fade-in var(--duration-fast) var(--ease-default); - } - - &__expand-header { - font-size: var(--font-size-xs); - font-weight: 600; - color: var(--text-tertiary); - text-transform: uppercase; - letter-spacing: 0.05em; - } - - &__expand-loading { - font-size: var(--font-size-sm); - color: var(--text-secondary); - } -} - -// Expanded event rows -.expand-events { - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: var(--radius-md); - overflow: hidden; - - &__row { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - font-size: var(--font-size-sm); - border-bottom: 1px solid var(--divider); - - &:last-child { - border-bottom: none; - } - } -} - // Tablet @media (max-width: 1024px) { .list-row__main { @@ -463,8 +358,4 @@ order: 4; flex-basis: 100%; } - - .expand-events__row { - flex-wrap: wrap; - } } diff --git a/code/frontend/src/app/features/search-stats/search-stats.component.ts b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts similarity index 61% rename from code/frontend/src/app/features/search-stats/search-stats.component.ts rename to code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts index a3732e70..c62f1eca 100644 --- a/code/frontend/src/app/features/search-stats/search-stats.component.ts +++ b/code/frontend/src/app/features/seeker-stats/searches-tab/searches-tab.component.ts @@ -1,46 +1,42 @@ import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnInit } from '@angular/core'; import { DatePipe } from '@angular/common'; import { NgIcon } from '@ng-icons/core'; -import { PageHeaderComponent } from '@layout/page-header/page-header.component'; import { CardComponent, BadgeComponent, ButtonComponent, SelectComponent, - PaginatorComponent, EmptyStateComponent, TabsComponent, TooltipComponent, + InputComponent, PaginatorComponent, EmptyStateComponent, TooltipComponent, } from '@ui'; -import type { Tab, SelectOption } 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 type { SearchStatsSummary, SearchHistoryEntry, SearchEvent, InstanceSearchStat } from '@core/models/search-stats.models'; +import type { SearchStatsSummary, SearchEvent, InstanceSearchStat } from '@core/models/search-stats.models'; import { SeekerSearchType } from '@core/models/search-stats.models'; import { AppHubService } from '@core/realtime/app-hub.service'; import { ToastService } from '@core/services/toast.service'; -type TabId = 'events' | 'items'; -type ItemsSortBy = 'lastSearched' | 'searchCount'; type CycleFilter = 'current' | 'all'; @Component({ - selector: 'app-search-stats', + selector: 'app-searches-tab', standalone: true, imports: [ DatePipe, NgIcon, - PageHeaderComponent, CardComponent, BadgeComponent, ButtonComponent, SelectComponent, + InputComponent, PaginatorComponent, EmptyStateComponent, - TabsComponent, AnimatedCounterComponent, TooltipComponent, ], - templateUrl: './search-stats.component.html', - styleUrl: './search-stats.component.scss', + templateUrl: './searches-tab.component.html', + styleUrl: './searches-tab.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SearchStatsComponent implements OnInit { +export class SearchesTabComponent implements OnInit { private readonly api = inject(SearchStatsApi); private readonly hub = inject(AppHubService); private readonly toast = inject(ToastService); @@ -56,13 +52,6 @@ export class SearchStatsComponent implements OnInit { }) ); - // Tabs - readonly activeTab = signal('events'); - readonly tabs: Tab[] = [ - { id: 'events', label: 'Events' }, - { id: 'items', label: 'Items' }, - ]; - // Instance filter readonly selectedInstanceId = signal(''); readonly instanceOptions = signal([]); @@ -74,49 +63,30 @@ export class SearchStatsComponent implements OnInit { { label: 'All Time', value: 'all' }, ]; - // Events tab + // Search filter + readonly searchQuery = signal(''); + + // Events readonly events = signal([]); readonly eventsTotalRecords = signal(0); readonly eventsPage = signal(1); - - // Items tab - readonly items = signal([]); - readonly itemsTotalRecords = signal(0); - readonly itemsPage = signal(1); - readonly itemsSortBy = signal('lastSearched'); - - readonly sortOptions: SelectOption[] = [ - { label: 'Last Searched', value: 'lastSearched' }, - { label: 'Most Searched', value: 'searchCount' }, - ]; - readonly pageSize = signal(50); - // Item expand - readonly expandedItemId = signal(null); - readonly detailEntries = signal([]); - readonly detailLoading = signal(false); - constructor() { effect(() => { - this.hub.searchStatsVersion(); // subscribe to changes + this.hub.searchStatsVersion(); if (this.initialLoad) { this.initialLoad = false; return; } this.loadSummary(); - this.loadActiveTab(); + this.loadEvents(); }); } ngOnInit(): void { this.loadSummary(); - this.loadActiveTab(); - } - - onTabChange(tabId: string): void { - this.activeTab.set(tabId); - this.loadActiveTab(); + this.loadEvents(); } onInstanceFilterChange(value: string): void { @@ -125,8 +95,7 @@ export class SearchStatsComponent implements OnInit { this.cycleFilter.set('all'); } this.eventsPage.set(1); - this.itemsPage.set(1); - this.loadActiveTab(); + this.loadEvents(); } onCycleFilterChange(value: string): void { @@ -135,49 +104,19 @@ export class SearchStatsComponent implements OnInit { this.loadEvents(); } + onSearchFilterChange(): void { + this.eventsPage.set(1); + this.loadEvents(); + } + onEventsPageChange(page: number): void { this.eventsPage.set(page); this.loadEvents(); } - onItemsPageChange(page: number): void { - this.itemsPage.set(page); - this.loadItems(); - } - - onItemsSortChange(value: string): void { - this.itemsSortBy.set(value as ItemsSortBy); - this.itemsPage.set(1); - this.loadItems(); - } - refresh(): void { this.loadSummary(); - this.loadActiveTab(); - } - - toggleItemExpand(item: SearchHistoryEntry): void { - const id = item.id; - if (this.expandedItemId() === id) { - this.expandedItemId.set(null); - this.detailEntries.set([]); - return; - } - - this.expandedItemId.set(id); - this.detailLoading.set(true); - this.detailEntries.set([]); - - this.api.getItemDetail(item.arrInstanceId, item.externalItemId, item.seasonNumber).subscribe({ - next: (res) => { - this.detailEntries.set(res.entries); - this.detailLoading.set(false); - }, - error: () => { - this.detailLoading.set(false); - this.toast.error('Failed to load item detail'); - }, - }); + this.loadEvents(); } searchTypeSeverity(type: SeekerSearchType): 'info' | 'warning' { @@ -190,10 +129,6 @@ export class SearchStatsComponent implements OnInit { return 'default'; } - itemDisplayName(item: { itemTitle: string; externalItemId: number }): string { - return item.itemTitle || `Item #${item.externalItemId}`; - } - searchStatusSeverity(status: string): BadgeSeverity { switch (status) { case 'Completed': return 'success'; @@ -253,21 +188,10 @@ export class SearchStatsComponent implements OnInit { }); } - private loadActiveTab(): void { - const tab = this.activeTab() as TabId; - switch (tab) { - case 'events': - this.loadEvents(); - break; - case 'items': - this.loadItems(); - break; - } - } - private loadEvents(): void { this.loading.set(true); const instanceId = this.selectedInstanceId() || undefined; + const search = this.searchQuery() || undefined; let cycleId: string | undefined; if (this.cycleFilter() === 'current' && instanceId) { @@ -275,7 +199,7 @@ export class SearchStatsComponent implements OnInit { cycleId = instance?.currentCycleId ?? undefined; } - this.api.getEvents(this.eventsPage(), this.pageSize(), instanceId, cycleId).subscribe({ + this.api.getEvents(this.eventsPage(), this.pageSize(), instanceId, cycleId, search).subscribe({ next: (result) => { this.events.set(result.items); this.eventsTotalRecords.set(result.totalCount); @@ -287,22 +211,4 @@ export class SearchStatsComponent implements OnInit { }, }); } - - private loadItems(): void { - this.loading.set(true); - this.expandedItemId.set(null); - this.detailEntries.set([]); - const instanceId = this.selectedInstanceId() || undefined; - this.api.getHistory(this.itemsPage(), this.pageSize(), instanceId, this.itemsSortBy()).subscribe({ - next: (result) => { - this.items.set(result.items); - this.itemsTotalRecords.set(result.totalCount); - this.loading.set(false); - }, - error: () => { - this.loading.set(false); - this.toast.error('Failed to load items'); - }, - }); - } } diff --git a/code/frontend/src/app/features/seeker-stats/seeker-stats.component.html b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.html new file mode 100644 index 00000000..65880000 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.html @@ -0,0 +1,22 @@ + + +
+ + +
+ @switch (activeTab()) { + @case ('searches') { + + } + @case ('quality') { + + } + @case ('upgrades') { + + } + } +
+
diff --git a/code/frontend/src/app/features/seeker-stats/seeker-stats.component.scss b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.scss new file mode 100644 index 00000000..df9b7e47 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.scss @@ -0,0 +1,10 @@ +.page-content { + > app-tabs { + animation: slide-up var(--duration-normal) var(--ease-default) both; + animation-delay: 0ms; + } + + > .tab-content { + margin-top: var(--space-6); + } +} diff --git a/code/frontend/src/app/features/seeker-stats/seeker-stats.component.ts b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.ts new file mode 100644 index 00000000..3d88cd63 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/seeker-stats.component.ts @@ -0,0 +1,53 @@ +import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { PageHeaderComponent } from '@layout/page-header/page-header.component'; +import { TabsComponent } from '@ui'; +import type { Tab } from '@ui'; +import { SearchesTabComponent } from './searches-tab/searches-tab.component'; +import { QualityTabComponent } from './quality-tab/quality-tab.component'; +import { UpgradesTabComponent } from './upgrades-tab/upgrades-tab.component'; + +type SeekerTab = 'searches' | 'quality' | 'upgrades'; + +@Component({ + selector: 'app-seeker-stats', + standalone: true, + imports: [ + PageHeaderComponent, + TabsComponent, + SearchesTabComponent, + QualityTabComponent, + UpgradesTabComponent, + ], + templateUrl: './seeker-stats.component.html', + styleUrl: './seeker-stats.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SeekerStatsComponent implements OnInit { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + readonly activeTab = signal('searches'); + + readonly tabs: Tab[] = [ + { id: 'searches', label: 'Searches' }, + { id: 'quality', label: 'Quality Scores' }, + { id: 'upgrades', label: 'Upgrades' }, + ]; + + ngOnInit(): void { + const tab = this.route.snapshot.queryParamMap.get('tab'); + if (tab && ['searches', 'quality', 'upgrades'].includes(tab)) { + this.activeTab.set(tab); + } + } + + onTabChange(tabId: string): void { + this.activeTab.set(tabId); + this.router.navigate([], { + relativeTo: this.route, + queryParams: { tab: tabId }, + queryParamsHandling: 'merge', + }); + } +} diff --git a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.html b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.html new file mode 100644 index 00000000..d975b9fb --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.html @@ -0,0 +1,69 @@ + +
+
+ + +
+
+ + Refresh + +
+
+ + +
+
+ + Total Upgrades +
+
+ + + +
+ @for (upgrade of upgrades(); track $index) { +
+
+ + {{ upgrade.title }} +
+ {{ upgrade.previousScore }} + + {{ upgrade.newScore }} + (cutoff: {{ upgrade.cutoffScore }}) +
+ + {{ upgrade.itemType }} + + {{ upgrade.upgradedAt | date:'yyyy-MM-dd HH:mm' }} +
+
+ } @empty { + + } +
+
+ + +@if (totalRecords() > pageSize()) { + +} diff --git a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss new file mode 100644 index 00000000..30fb111d --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.scss @@ -0,0 +1,171 @@ +@use 'data-toolbar' 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; + } +} + +.toolbar { + @include data-toolbar; +} + +// Stats bar +.stats-bar { + display: flex; + gap: var(--space-4); + margin-bottom: var(--space-3); + + &__item { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + &__value { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--text-primary); + + &--success { color: var(--color-success); } + } + + &__label { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } +} + +// Upgrades list +.upgrades-list { + max-height: 70vh; + overflow-y: auto; +} + +.upgrade-row { + border-bottom: 1px solid var(--divider); + transition: background var(--duration-fast) var(--ease-default); + font-size: var(--font-size-sm); + position: relative; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(126, 87, 194, 0.06), transparent); + transform: translateX(-100%); + transition: transform var(--duration-normal) var(--ease-default); + pointer-events: none; + z-index: 0; + } + + &:hover::before { + transform: translateX(100%); + } + + &:hover { + background: var(--glass-bg); + } + + &__main { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + min-height: 44px; + } + + &__icon { + color: var(--color-success); + font-size: 18px; + flex-shrink: 0; + } + + &__title { + font-weight: 500; + color: var(--text-primary); + min-width: 0; + flex: 1; + word-break: break-word; + } + + &__scores { + display: flex; + align-items: center; + gap: var(--space-1); + flex-shrink: 0; + } + + &__score { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + font-weight: 600; + + &--old { + color: var(--text-tertiary); + } + + &--new { + color: var(--color-success); + } + } + + &__arrow { + font-size: 14px; + color: var(--text-tertiary); + } + + &__cutoff { + font-family: var(--font-mono); + font-size: var(--font-size-xs); + color: var(--text-tertiary); + } + + &__time { + font-size: var(--font-size-xs); + color: var(--text-tertiary); + flex-shrink: 0; + } +} + +// Tablet +@media (max-width: 1024px) { + .upgrade-row__main { + flex-wrap: wrap; + } +} + +// Mobile +@media (max-width: 768px) { + .upgrade-row__main { + flex-wrap: wrap; + padding: var(--space-2) var(--space-3); + } + + .upgrade-row__scores { + flex-basis: 100%; + order: 3; + } + + .upgrade-row__time { + order: 4; + flex-basis: 100%; + } +} diff --git a/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts new file mode 100644 index 00000000..e1a46f03 --- /dev/null +++ b/code/frontend/src/app/features/seeker-stats/upgrades-tab/upgrades-tab.component.ts @@ -0,0 +1,128 @@ +import { Component, ChangeDetectionStrategy, inject, signal, effect, OnInit } from '@angular/core'; +import { DatePipe } from '@angular/common'; +import { NgIcon } from '@ng-icons/core'; +import { + CardComponent, BadgeComponent, ButtonComponent, SelectComponent, + PaginatorComponent, EmptyStateComponent, +} 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 { AppHubService } from '@core/realtime/app-hub.service'; +import { ToastService } from '@core/services/toast.service'; + +@Component({ + selector: 'app-upgrades-tab', + standalone: true, + imports: [ + DatePipe, + NgIcon, + CardComponent, + BadgeComponent, + ButtonComponent, + SelectComponent, + PaginatorComponent, + EmptyStateComponent, + AnimatedCounterComponent, + ], + templateUrl: './upgrades-tab.component.html', + styleUrl: './upgrades-tab.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UpgradesTabComponent implements OnInit { + private readonly api = inject(CfScoreApi); + private readonly hub = inject(AppHubService); + private readonly toast = inject(ToastService); + private initialLoad = true; + + readonly upgrades = signal([]); + readonly totalRecords = signal(0); + readonly currentPage = signal(1); + readonly pageSize = signal(50); + readonly loading = signal(false); + + readonly timeRange = signal('30'); + readonly selectedInstanceId = signal(''); + readonly instanceOptions = signal([]); + + readonly timeRangeOptions: SelectOption[] = [ + { label: 'Last 7 Days', value: '7' }, + { label: 'Last 30 Days', value: '30' }, + { label: 'Last 90 Days', value: '90' }, + { label: 'All Time', value: '0' }, + ]; + + constructor() { + effect(() => { + this.hub.cfScoresVersion(); + if (this.initialLoad) { + this.initialLoad = false; + return; + } + this.loadUpgrades(); + }); + } + + ngOnInit(): void { + this.loadInstances(); + this.loadUpgrades(); + } + + onTimeRangeChange(value: string): void { + this.timeRange.set(value); + this.currentPage.set(1); + this.loadUpgrades(); + } + + onInstanceFilterChange(value: string): void { + this.selectedInstanceId.set(value); + this.currentPage.set(1); + this.loadUpgrades(); + } + + onPageChange(page: number): void { + this.currentPage.set(page); + this.loadUpgrades(); + } + + refresh(): void { + this.loadUpgrades(); + } + + itemTypeSeverity(itemType: string): 'info' | 'default' { + return itemType === 'Radarr' || itemType === 'Sonarr' ? 'info' : 'default'; + } + + private loadInstances(): void { + this.api.getInstances().subscribe({ + next: (result) => { + this.instanceOptions.set([ + { label: 'All Instances', value: '' }, + ...result.instances.map(i => ({ + label: `${i.name} (${i.itemType})`, + value: i.id, + })), + ]); + }, + error: () => this.toast.error('Failed to load instances'), + }); + } + + private loadUpgrades(): void { + this.loading.set(true); + const days = parseInt(this.timeRange(), 10) || undefined; + const instanceId = this.selectedInstanceId() || undefined; + + this.api.getRecentUpgrades(this.currentPage(), this.pageSize(), instanceId, days).subscribe({ + next: (result) => { + this.upgrades.set(result.items); + this.totalRecords.set(result.totalCount); + this.loading.set(false); + }, + error: () => { + this.loading.set(false); + this.toast.error('Failed to load upgrades'); + }, + }); + } +} diff --git a/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts b/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts index 42dd3d86..e382dcee 100644 --- a/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts +++ b/code/frontend/src/app/layout/nav-sidebar/nav-sidebar.component.ts @@ -50,8 +50,7 @@ export class NavSidebarComponent { { label: 'Logs', icon: 'tablerFileText', route: '/logs' }, { label: 'Events', icon: 'tablerBell', route: '/events' }, { label: 'Strikes', icon: 'tablerBolt', route: '/strikes' }, - { label: 'Search Stats', icon: 'tablerChartDots', route: '/search-stats' }, - { label: 'CF Scores', icon: 'tablerChartBar', route: '/cf-scores' }, + { label: 'Seeker Stats', icon: 'tablerChartDots', route: '/seeker-stats' }, ]; settingsItems: NavItem[] = [