improved stats layout and combined pages

This commit is contained in:
Flaminel
2026-03-25 23:42:51 +02:00
parent 0e9e09f352
commit 4657aabc45
23 changed files with 877 additions and 940 deletions

View File

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

View File

@@ -117,95 +117,17 @@ public sealed class SearchStatsController : ControllerBase
});
}
/// <summary>
/// Gets paginated search history from SeekerHistory.
/// Supports sorting by lastSearched (default) or searchCount.
/// </summary>
[HttpGet("history")]
public async Task<IActionResult> 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),
});
}
/// <summary>
/// Gets paginated search-triggered events with decoded data.
/// Supports optional text search across item names in event data.
/// </summary>
[HttpGet("events")]
public async Task<IActionResult> 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
});
}
/// <summary>
/// Gets individual search events for a specific item.
/// </summary>
[HttpGet("history/{instanceId}/{itemId}/detail")]
public async Task<IActionResult> 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<SearchEventResponse>() });
}
// 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<SearchEventResponse>() });
}
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<string> Items, SeekerSearchType SearchType, object? GrabbedItems) ParseEventData(string? data)
{
if (string.IsNullOrWhiteSpace(data))

View File

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

View File

@@ -88,10 +88,11 @@ export class CfScoreApi {
return this.http.get<CfScoreStats>('/api/seeker/cf-scores/stats');
}
getRecentUpgrades(page = 1, pageSize = 5): Observable<CfScoreUpgradesResponse> {
return this.http.get<CfScoreUpgradesResponse>('/api/seeker/cf-scores/upgrades', {
params: { page, pageSize },
});
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;
return this.http.get<CfScoreUpgradesResponse>('/api/seeker/cf-scores/upgrades', { params });
}
getScores(page = 1, pageSize = 50, search?: string, instanceId?: string, sortBy?: string, hideMet?: boolean): Observable<CfScoreEntriesResponse> {

View File

@@ -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<SearchStatsSummary>('/api/seeker/search-stats/summary');
}
getHistory(page = 1, pageSize = 50, instanceId?: string, sortBy = 'lastSearched'): Observable<PaginatedResult<SearchHistoryEntry>> {
const params: Record<string, string | number> = { page, pageSize, sortBy };
if (instanceId) params['instanceId'] = instanceId;
return this.http.get<PaginatedResult<SearchHistoryEntry>>('/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<PaginatedResult<SearchEvent>> {
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;
return this.http.get<PaginatedResult<SearchEvent>>('/api/seeker/search-stats/events', { params });
}
}

View File

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

View File

@@ -1,160 +0,0 @@
<app-page-header
title="Custom Format Scores"
subtitle="Track custom format score progress across your library"
/>
<div class="page-content">
<!-- Toolbar -->
<div class="toolbar">
<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"
[(value)]="searchQuery"
(blurred)="onFilterChange()"
/>
<app-select
[value]="sortBy()"
[options]="sortOptions"
(valueChange)="onSortChange($any($event))"
/>
<app-toggle
label="Hide met"
[checked]="hideMet()"
(checkedChange)="onHideMetChange($event)"
/>
</div>
<div class="toolbar__actions">
<app-button variant="ghost" size="sm" (clicked)="refresh()">
Refresh
</app-button>
</div>
</div>
<!-- Stats Bar -->
@if (stats(); as stats) {
<div class="stats-bar">
<div class="stats-bar__item">
<span class="stats-bar__value"><app-animated-counter [value]="stats.totalTracked" [duration]="400" /></span>
<span class="stats-bar__label">Tracked</span>
</div>
<div class="stats-bar__item">
<span class="stats-bar__value stats-bar__value--warning"><app-animated-counter [value]="stats.belowCutoff" [duration]="400" /></span>
<span class="stats-bar__label">Below Cutoff</span>
</div>
<div class="stats-bar__item">
<span class="stats-bar__value stats-bar__value--success"><app-animated-counter [value]="stats.atOrAboveCutoff" [duration]="400" /></span>
<span class="stats-bar__label">Met Cutoff</span>
</div>
<div class="stats-bar__item">
<span class="stats-bar__value"><app-animated-counter [value]="stats.recentUpgrades" [duration]="400" /></span>
<span class="stats-bar__label">Recent Upgrades</span>
</div>
</div>
}
<!-- Scores List -->
<app-card [noPadding]="true">
<div class="scores-list">
@for (item of items(); track item.id) {
<div
class="score-row"
[class.score-row--expanded]="expandedId() === item.id"
>
<div
class="score-row__main"
(click)="toggleExpand(item)"
>
<ng-icon name="tablerChartBar" class="score-row__icon" />
<span class="score-row__title">{{ item.title }}</span>
<span class="score-row__scores">
<span class="score-row__current">{{ item.currentScore }}</span>
<span class="score-row__separator">/</span>
<span class="score-row__cutoff">{{ item.cutoffScore }}</span>
</span>
<span class="score-row__profile">{{ item.qualityProfileName }}</span>
<app-badge [severity]="statusSeverity(item.isBelowCutoff)" size="sm">
{{ statusLabel(item.isBelowCutoff) }}
</app-badge>
<app-badge [severity]="itemTypeSeverity(item.itemType)" size="sm">
{{ item.itemType }}
</app-badge>
<span class="score-row__time">{{ item.lastSyncedAt | date:'yyyy-MM-dd HH:mm' }}</span>
<ng-icon
[name]="expandedId() === item.id ? 'tablerChevronUp' : 'tablerChevronDown'"
class="score-row__chevron"
/>
</div>
@if (expandedId() === item.id) {
<div class="score-row__details">
<div class="score-row__detail">
<span class="score-row__detail-label">Quality Profile</span>
<span class="score-row__detail-value">{{ item.qualityProfileName }}</span>
</div>
<div class="score-row__detail">
<span class="score-row__detail-label">Current Score</span>
<span class="score-row__detail-value">{{ item.currentScore }}</span>
</div>
<div class="score-row__detail">
<span class="score-row__detail-label">Cutoff Score</span>
<span class="score-row__detail-value">{{ item.cutoffScore }}</span>
</div>
<div class="score-row__detail">
<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>
<!-- Score History -->
<div class="score-row__detail">
<span class="score-row__detail-label">Score History</span>
@if (historyLoading()) {
<span class="score-row__detail-value">Loading...</span>
} @else if (historyEntries().length === 0) {
<span class="score-row__detail-value">No history available</span>
} @else {
<div class="history-table">
<div class="history-table__header">
<span>Score</span>
<span>Cutoff</span>
<span>Recorded At</span>
</div>
@for (entry of historyEntries(); track entry.recordedAt) {
<div class="history-table__row">
<span class="history-table__score">{{ entry.score }}</span>
<span class="history-table__cutoff">{{ entry.cutoffScore }}</span>
<span class="history-table__time">{{ entry.recordedAt | date:'yyyy-MM-dd HH:mm:ss' }}</span>
</div>
}
</div>
}
</div>
</div>
}
</div>
} @empty {
<app-empty-state
icon="tablerChartBar"
heading="No scores tracked"
description="Enable 'Use Custom Format Score' in Seeker settings to start tracking."
/>
}
</div>
</app-card>
<!-- Pagination -->
@if (totalRecords() > pageSize()) {
<app-paginator
[totalRecords]="totalRecords()"
[pageSize]="pageSize()"
[currentPage]="currentPage()"
(pageChange)="onPageChange($event)"
/>
}
</div>

View File

@@ -264,7 +264,7 @@
<ng-icon name="tablerGripVertical" class="drag-handle__icon" cdkDragHandle title="Drag to reorder" />
<h3 class="card-header__title">Custom Format Scores</h3>
</div>
<a class="card-header__link" routerLink="/cf-scores">View All</a>
<a class="card-header__link" routerLink="/seeker-stats" [queryParams]="{ tab: 'quality' }">View All</a>
</div>
<div class="cf-scores">
<div class="cf-scores__stats">
@@ -307,7 +307,10 @@
}
@if (cfScoreUpgrades().length > 0) {
<div class="cf-scores__upgrades">
<span class="cf-scores__upgrades-title">Recent Upgrades</span>
<div class="cf-scores__upgrades-header">
<span class="cf-scores__upgrades-title">Recent Upgrades</span>
<a class="card-header__link" routerLink="/seeker-stats" [queryParams]="{ tab: 'upgrades' }">View All</a>
</div>
@for (upgrade of cfScoreUpgrades(); track $index) {
<div class="cf-scores__upgrade-item">
<div class="cf-scores__upgrade-title">{{ upgrade.title }}</div>

View File

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

View File

@@ -1,300 +0,0 @@
<app-page-header
title="Search Statistics"
subtitle="Monitor search activity across your arr instances"
/>
<div class="page-content">
<!-- Stats Bar -->
@if (summary(); as stats) {
<div class="stats-bar">
<div class="stats-bar__item">
<span class="stats-bar__value"><app-animated-counter [value]="stats.totalSearchesAllTime" [duration]="400" /></span>
<span class="stats-bar__label">Total Searches</span>
</div>
<div class="stats-bar__item">
<span class="stats-bar__value stats-bar__value--info"><app-animated-counter [value]="stats.searchesLast7Days" [duration]="400" /></span>
<span class="stats-bar__label">Last 7 Days</span>
</div>
<div class="stats-bar__item">
<span class="stats-bar__value"><app-animated-counter [value]="stats.searchesLast30Days" [duration]="400" /></span>
<span class="stats-bar__label">Last 30 Days</span>
</div>
<div class="stats-bar__item">
<span class="stats-bar__value"><app-animated-counter [value]="stats.uniqueItemsSearched" [duration]="400" /></span>
<span class="stats-bar__label">Unique Items</span>
</div>
<div class="stats-bar__item">
<span class="stats-bar__value" [class.stats-bar__value--warning]="stats.pendingReplacementSearches > 0">
<app-animated-counter [value]="stats.pendingReplacementSearches" [duration]="400" />
</span>
<app-tooltip text="Searches queued after a download was removed, waiting to be processed">
<span class="stats-bar__label">Pending replacement searches</span>
</app-tooltip>
</div>
<div class="stats-bar__item">
<span class="stats-bar__value stats-bar__value--success"><app-animated-counter [value]="stats.enabledInstances" [duration]="400" /></span>
<span class="stats-bar__label">Instances</span>
</div>
</div>
<!-- Per-Instance Cards -->
@if (stats.perInstanceStats.length > 0) {
<div class="instance-cards">
@for (inst of sortedInstanceStats(); track inst.instanceId) {
<app-card class="instance-card">
<div class="instance-card__header">
<span class="instance-card__name">{{ inst.instanceName }}</span>
<div class="instance-card__header-right">
@if (instanceHealthWarning(inst); as warning) {
<app-tooltip [text]="warning">
<ng-icon name="tablerAlertTriangle" class="instance-card__warning-icon" />
</app-tooltip>
}
<app-badge [severity]="instanceTypeSeverity(inst.instanceType)" size="sm">
{{ inst.instanceType }}
</app-badge>
</div>
</div>
<div class="instance-card__progress">
<div class="instance-card__progress-row">
<app-tooltip text="Items searched in the current cycle out of total eligible items">
<span class="instance-card__progress-label">Cycle Progress</span>
</app-tooltip>
<span class="instance-card__progress-count">
<strong>{{ inst.cycleItemsSearched }}</strong> / {{ inst.cycleItemsTotal }}
</span>
</div>
<div class="instance-card__progress-track">
<div class="instance-card__progress-fill" [style.width.%]="cycleProgress(inst)"></div>
</div>
</div>
<div class="instance-card__stats">
<div class="instance-card__stat">
<span class="instance-card__stat-value">{{ inst.totalSearchCount }}</span>
<span class="instance-card__stat-label">Searches</span>
</div>
<div class="instance-card__stat">
<span class="instance-card__stat-value">
{{ inst.cycleStartedAt ? formatCycleDuration(inst.cycleStartedAt) : '—' }}
</span>
<app-tooltip text="How long the current search cycle has been running">
<span class="instance-card__stat-label">Cycle Duration</span>
</app-tooltip>
</div>
<div class="instance-card__stat">
<span class="instance-card__stat-value instance-card__stat-value--small">
{{ inst.lastSearchedAt ? (inst.lastSearchedAt | date:'MM/dd HH:mm') : 'Never' }}
</span>
<app-tooltip text="When the last search was triggered for this instance">
<span class="instance-card__stat-label">Last Search</span>
</app-tooltip>
</div>
</div>
<div class="instance-card__footer">
<app-tooltip text="Unique identifier for the current search cycle. Changes when all items have been searched">
<span class="instance-card__cycle-chip">{{ inst.currentCycleId ? inst.currentCycleId.substring(0, 8) : '—' }}</span>
</app-tooltip>
</div>
</app-card>
}
</div>
}
}
<!-- Toolbar -->
<div class="toolbar">
<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))"
/>
</div>
<div class="toolbar__actions">
<app-button variant="ghost" size="sm" (clicked)="refresh()">
Refresh
</app-button>
</div>
</div>
<!-- Tabs -->
<app-tabs [tabs]="tabs" [(activeTab)]="activeTab" (activeTabChange)="onTabChange($event)" />
<!-- Tab Content -->
@switch (activeTab()) {
<!-- Events Tab -->
@case ('events') {
<p class="tab-description">Search events triggered by the Seeker. Each entry represents a batch of items searched on an instance.</p>
<app-card [noPadding]="true">
<div class="list">
@for (event of events(); track event.id) {
<div class="list-row">
<div class="list-row__main">
<ng-icon name="tablerSearch" class="list-row__icon" />
<span class="list-row__title">
{{ event.items.length > 0 ? event.items[0] : 'Search triggered' }}
@if (event.items.length > 1) {
<span class="list-row__extra">+{{ event.items.length - 1 }} more</span>
}
</span>
<app-badge [severity]="searchTypeSeverity(event.searchType)" size="sm">
{{ event.searchType }}
</app-badge>
@if (event.searchStatus) {
<app-badge [severity]="searchStatusSeverity(event.searchStatus)" size="sm">
{{ event.searchStatus }}
</app-badge>
}
@if (event.isDryRun) {
<app-badge severity="accent" size="sm">Dry Run</app-badge>
}
@if (event.cycleId) {
<span class="list-row__cycle">{{ event.cycleId.substring(0, 8) }}</span>
}
<span class="list-row__meta">{{ event.instanceName }}</span>
<span class="list-row__time">{{ event.timestamp | date:'yyyy-MM-dd HH:mm' }}</span>
</div>
@if (event.grabbedItems && event.grabbedItems.length > 0) {
<div class="list-row__detail">
<ng-icon name="tablerDownload" class="list-row__detail-icon" />
<span class="list-row__detail-text">
Grabbed: {{ formatGrabbedItems(event.grabbedItems) }}
</span>
</div>
}
</div>
} @empty {
<app-empty-state
icon="tablerSearch"
heading="No search events"
description="Search events will appear here once the Seeker runs."
/>
}
</div>
</app-card>
@if (eventsTotalRecords() > pageSize()) {
<app-paginator
[totalRecords]="eventsTotalRecords()"
[pageSize]="pageSize()"
[currentPage]="eventsPage()"
(pageChange)="onEventsPageChange($event)"
/>
}
}
<!-- Items Tab -->
@case ('items') {
<p class="tab-description">Individual items tracked by the Seeker with their search count and last searched time.</p>
<div class="tab-toolbar">
<app-select
[value]="itemsSortBy()"
[options]="sortOptions"
(valueChange)="onItemsSortChange($any($event))"
/>
</div>
<app-card [noPadding]="true">
<div class="list">
@for (item of items(); track item.id) {
<div
class="list-row"
[class.list-row--expanded]="expandedItemId() === item.id"
>
<div
class="list-row__main list-row__main--clickable"
(click)="toggleItemExpand(item)"
>
<ng-icon name="tablerHistory" class="list-row__icon" />
<span class="list-row__title">
{{ itemDisplayName(item) }}
@if (item.seasonNumber > 0) {
<span class="list-row__extra">S{{ item.seasonNumber }}</span>
}
</span>
<app-badge [severity]="instanceTypeSeverity(item.instanceType)" size="sm">
{{ item.instanceType }}
</app-badge>
<span class="list-row__meta">{{ item.instanceName }}</span>
<span class="list-row__count">{{ item.totalSearchCount }}x</span>
@if (item.totalSearchCount !== item.searchCount) {
<span class="list-row__count-secondary">({{ item.searchCount }}x this cycle)</span>
}
<span class="list-row__time">{{ item.lastSearchedAt | date:'yyyy-MM-dd HH:mm' }}</span>
<ng-icon
[name]="expandedItemId() === item.id ? 'tablerChevronUp' : 'tablerChevronDown'"
class="list-row__chevron"
/>
</div>
@if (expandedItemId() === item.id) {
<div class="list-row__expand-details">
<div class="list-row__expand-header">Search History</div>
@if (detailLoading()) {
<span class="list-row__expand-loading">Loading...</span>
} @else if (detailEntries().length === 0) {
<span class="list-row__expand-loading">No search events found</span>
} @else {
<div class="expand-events">
@for (event of detailEntries(); track event.id) {
<div class="expand-events__row">
<ng-icon name="tablerSearch" class="list-row__icon" />
<app-badge [severity]="searchTypeSeverity(event.searchType)" size="sm">
{{ event.searchType }}
</app-badge>
@if (event.searchStatus) {
<app-badge [severity]="searchStatusSeverity(event.searchStatus)" size="sm">
{{ event.searchStatus }}
</app-badge>
}
@if (event.isDryRun) {
<app-badge severity="accent" size="sm">Dry Run</app-badge>
}
@if (event.cycleId) {
<span class="list-row__cycle">{{ event.cycleId.substring(0, 8) }}</span>
}
<span class="list-row__time">{{ event.timestamp | date:'yyyy-MM-dd HH:mm' }}</span>
</div>
@if (event.grabbedItems && event.grabbedItems.length > 0) {
<div class="list-row__detail">
<ng-icon name="tablerDownload" class="list-row__detail-icon" />
<span class="list-row__detail-text">
Grabbed: {{ formatGrabbedItems(event.grabbedItems) }}
</span>
</div>
}
}
</div>
}
</div>
}
</div>
} @empty {
<app-empty-state
icon="tablerHistory"
heading="No items tracked"
description="Items will appear here once the Seeker processes them."
/>
}
</div>
</app-card>
@if (itemsTotalRecords() > pageSize()) {
<app-paginator
[totalRecords]="itemsTotalRecords()"
[pageSize]="pageSize()"
[currentPage]="itemsPage()"
(pageChange)="onItemsPageChange($event)"
/>
}
}
}
</div>

View File

@@ -0,0 +1,155 @@
<!-- Toolbar -->
<div class="toolbar">
<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"
[(value)]="searchQuery"
(blurred)="onFilterChange()"
/>
<app-select
[value]="sortBy()"
[options]="sortOptions"
(valueChange)="onSortChange($any($event))"
/>
<app-toggle
label="Hide met"
[checked]="hideMet()"
(checkedChange)="onHideMetChange($event)"
/>
</div>
<div class="toolbar__actions">
<app-button variant="ghost" size="sm" (clicked)="refresh()">
Refresh
</app-button>
</div>
</div>
<!-- Stats Bar -->
@if (stats(); as stats) {
<div class="stats-bar">
<div class="stats-bar__item">
<span class="stats-bar__value"><app-animated-counter [value]="stats.totalTracked" [duration]="400" /></span>
<span class="stats-bar__label">Tracked</span>
</div>
<div class="stats-bar__item">
<span class="stats-bar__value stats-bar__value--warning"><app-animated-counter [value]="stats.belowCutoff" [duration]="400" /></span>
<span class="stats-bar__label">Below Cutoff</span>
</div>
<div class="stats-bar__item">
<span class="stats-bar__value stats-bar__value--success"><app-animated-counter [value]="stats.atOrAboveCutoff" [duration]="400" /></span>
<span class="stats-bar__label">Met Cutoff</span>
</div>
<app-tooltip text="Items that improved their custom format score in the last 7 days">
<div class="stats-bar__item">
<span class="stats-bar__value"><app-animated-counter [value]="stats.recentUpgrades" [duration]="400" /></span>
<span class="stats-bar__label">Recent Upgrades</span>
</div>
</app-tooltip>
</div>
}
<!-- Scores List -->
<app-card [noPadding]="true">
<div class="scores-list">
@for (item of items(); track item.id) {
<div
class="score-row"
[class.score-row--expanded]="expandedId() === item.id"
>
<div
class="score-row__main"
(click)="toggleExpand(item)"
>
<ng-icon name="tablerChartBar" class="score-row__icon" />
<span class="score-row__title">{{ item.title }}</span>
<span class="score-row__scores">
<span class="score-row__current">{{ item.currentScore }}</span>
<span class="score-row__separator">/</span>
<span class="score-row__cutoff">{{ item.cutoffScore }}</span>
</span>
<span class="score-row__profile">{{ item.qualityProfileName }}</span>
<app-badge [severity]="statusSeverity(item.isBelowCutoff)" size="sm">
{{ statusLabel(item.isBelowCutoff) }}
</app-badge>
<app-badge [severity]="itemTypeSeverity(item.itemType)" size="sm">
{{ item.itemType }}
</app-badge>
<span class="score-row__time">{{ item.lastSyncedAt | date:'yyyy-MM-dd HH:mm' }}</span>
<ng-icon
[name]="expandedId() === item.id ? 'tablerChevronUp' : 'tablerChevronDown'"
class="score-row__chevron"
/>
</div>
@if (expandedId() === item.id) {
<div class="score-row__details">
<div class="score-row__detail">
<span class="score-row__detail-label">Quality Profile</span>
<span class="score-row__detail-value">{{ item.qualityProfileName }}</span>
</div>
<div class="score-row__detail">
<span class="score-row__detail-label">Current Score</span>
<span class="score-row__detail-value">{{ item.currentScore }}</span>
</div>
<div class="score-row__detail">
<span class="score-row__detail-label">Cutoff Score</span>
<span class="score-row__detail-value">{{ item.cutoffScore }}</span>
</div>
<div class="score-row__detail">
<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>
<!-- Score History -->
<div class="score-row__detail">
<span class="score-row__detail-label">Score History</span>
@if (historyLoading()) {
<span class="score-row__detail-value">Loading...</span>
} @else if (historyEntries().length === 0) {
<span class="score-row__detail-value">No history available</span>
} @else {
<div class="history-table">
<div class="history-table__header">
<span>Score</span>
<span>Cutoff</span>
<span>Recorded At</span>
</div>
@for (entry of historyEntries(); track entry.recordedAt) {
<div class="history-table__row">
<span class="history-table__score">{{ entry.score }}</span>
<span class="history-table__cutoff">{{ entry.cutoffScore }}</span>
<span class="history-table__time">{{ entry.recordedAt | date:'yyyy-MM-dd HH:mm:ss' }}</span>
</div>
}
</div>
}
</div>
</div>
}
</div>
} @empty {
<app-empty-state
icon="tablerChartBar"
heading="No scores tracked"
description="Enable 'Use Custom Format Score' in Seeker settings to start tracking."
/>
}
</div>
</app-card>
<!-- Pagination -->
@if (totalRecords() > pageSize()) {
<app-paginator
[totalRecords]="totalRecords()"
[pageSize]="pageSize()"
[currentPage]="currentPage()"
(pageChange)="onPageChange($event)"
/>
}

View File

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

View File

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

View File

@@ -0,0 +1,187 @@
<!-- Stats Bar -->
@if (summary(); as stats) {
<div class="stats-bar">
<div class="stats-bar__item">
<span class="stats-bar__value"><app-animated-counter [value]="stats.totalSearchesAllTime" [duration]="400" /></span>
<span class="stats-bar__label">Total Searches</span>
</div>
<div class="stats-bar__item">
<span class="stats-bar__value stats-bar__value--info"><app-animated-counter [value]="stats.searchesLast7Days" [duration]="400" /></span>
<span class="stats-bar__label">Last 7 Days</span>
</div>
<div class="stats-bar__item">
<span class="stats-bar__value"><app-animated-counter [value]="stats.searchesLast30Days" [duration]="400" /></span>
<span class="stats-bar__label">Last 30 Days</span>
</div>
<app-tooltip text="Total unique items that have been searched across all cycles">
<div class="stats-bar__item">
<span class="stats-bar__value"><app-animated-counter [value]="stats.uniqueItemsSearched" [duration]="400" /></span>
<span class="stats-bar__label">Unique Items</span>
</div>
</app-tooltip>
<app-tooltip text="Searches queued after a download was removed, waiting to be processed">
<div class="stats-bar__item">
<span class="stats-bar__value" [class.stats-bar__value--warning]="stats.pendingReplacementSearches > 0">
<app-animated-counter [value]="stats.pendingReplacementSearches" [duration]="400" />
</span>
<span class="stats-bar__label">Pending Replacements</span>
</div>
</app-tooltip>
<div class="stats-bar__item">
<span class="stats-bar__value stats-bar__value--success"><app-animated-counter [value]="stats.enabledInstances" [duration]="400" /></span>
<span class="stats-bar__label">Instances</span>
</div>
</div>
<!-- Per-Instance Cards -->
@if (stats.perInstanceStats.length > 0) {
<div class="instance-cards">
@for (inst of sortedInstanceStats(); track inst.instanceId) {
<app-card class="instance-card">
<div class="instance-card__header">
<span class="instance-card__name">{{ inst.instanceName }}</span>
<div class="instance-card__header-right">
@if (instanceHealthWarning(inst); as warning) {
<app-tooltip [text]="warning">
<ng-icon name="tablerAlertTriangle" class="instance-card__warning-icon" />
</app-tooltip>
}
<app-badge [severity]="instanceTypeSeverity(inst.instanceType)" size="sm">
{{ inst.instanceType }}
</app-badge>
</div>
</div>
<div class="instance-card__progress">
<div class="instance-card__progress-row">
<app-tooltip text="Items searched in the current cycle out of total eligible items">
<span class="instance-card__progress-label">Cycle Progress</span>
</app-tooltip>
<span class="instance-card__progress-count">
<strong>{{ inst.cycleItemsSearched }}</strong> / {{ inst.cycleItemsTotal }}
</span>
</div>
<div class="instance-card__progress-track">
<div class="instance-card__progress-fill" [style.width.%]="cycleProgress(inst)"></div>
</div>
</div>
<div class="instance-card__stats">
<div class="instance-card__stat">
<span class="instance-card__stat-value">{{ inst.totalSearchCount }}</span>
<span class="instance-card__stat-label">Searches</span>
</div>
<div class="instance-card__stat">
<span class="instance-card__stat-value">
{{ inst.cycleStartedAt ? formatCycleDuration(inst.cycleStartedAt) : '—' }}
</span>
<app-tooltip text="How long the current search cycle has been running">
<span class="instance-card__stat-label">Cycle Duration</span>
</app-tooltip>
</div>
<div class="instance-card__stat">
<span class="instance-card__stat-value instance-card__stat-value--small">
{{ inst.lastSearchedAt ? (inst.lastSearchedAt | date:'MM/dd HH:mm') : 'Never' }}
</span>
<app-tooltip text="When the last search was triggered for this instance">
<span class="instance-card__stat-label">Last Search</span>
</app-tooltip>
</div>
</div>
<div class="instance-card__footer">
<app-tooltip text="Unique identifier for the current search cycle. Changes when all items have been searched">
<span class="instance-card__cycle-chip">{{ inst.currentCycleId ? inst.currentCycleId.substring(0, 8) : '—' }}</span>
</app-tooltip>
</div>
</app-card>
}
</div>
}
}
<!-- Toolbar -->
<div class="toolbar">
<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"
(blurred)="onSearchFilterChange()"
/>
</div>
<div class="toolbar__actions">
<app-button variant="ghost" size="sm" (clicked)="refresh()">
Refresh
</app-button>
</div>
</div>
<!-- Events List -->
<app-card [noPadding]="true">
<div class="list">
@for (event of events(); track event.id) {
<div class="list-row">
<div class="list-row__main">
<ng-icon name="tablerSearch" class="list-row__icon" />
<span class="list-row__title">
{{ event.items.length > 0 ? event.items[0] : 'Search triggered' }}
@if (event.items.length > 1) {
<span class="list-row__extra">+{{ event.items.length - 1 }} more</span>
}
</span>
<app-badge [severity]="searchTypeSeverity(event.searchType)" size="sm">
{{ event.searchType }}
</app-badge>
@if (event.searchStatus) {
<app-badge [severity]="searchStatusSeverity(event.searchStatus)" size="sm">
{{ event.searchStatus }}
</app-badge>
}
@if (event.isDryRun) {
<app-badge severity="accent" size="sm">Dry Run</app-badge>
}
@if (event.cycleId) {
<span class="list-row__cycle">{{ event.cycleId.substring(0, 8) }}</span>
}
<span class="list-row__meta">{{ event.instanceName }}</span>
<span class="list-row__time">{{ event.timestamp | date:'yyyy-MM-dd HH:mm' }}</span>
</div>
@if (event.grabbedItems && event.grabbedItems.length > 0) {
<div class="list-row__detail">
<ng-icon name="tablerDownload" class="list-row__detail-icon" />
<span class="list-row__detail-text">
Grabbed: {{ formatGrabbedItems(event.grabbedItems) }}
</span>
</div>
}
</div>
} @empty {
<app-empty-state
icon="tablerSearch"
heading="No search events"
description="Search events will appear here once the Seeker runs."
/>
}
</div>
</app-card>
@if (eventsTotalRecords() > pageSize()) {
<app-paginator
[totalRecords]="eventsTotalRecords()"
[pageSize]="pageSize()"
[currentPage]="eventsPage()"
(pageChange)="onEventsPageChange($event)"
/>
}

View File

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

View File

@@ -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<string>('events');
readonly tabs: Tab[] = [
{ id: 'events', label: 'Events' },
{ id: 'items', label: 'Items' },
];
// Instance filter
readonly selectedInstanceId = signal<string>('');
readonly instanceOptions = signal<SelectOption[]>([]);
@@ -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<SearchEvent[]>([]);
readonly eventsTotalRecords = signal(0);
readonly eventsPage = signal(1);
// Items tab
readonly items = signal<SearchHistoryEntry[]>([]);
readonly itemsTotalRecords = signal(0);
readonly itemsPage = signal(1);
readonly itemsSortBy = signal<ItemsSortBy>('lastSearched');
readonly sortOptions: SelectOption[] = [
{ label: 'Last Searched', value: 'lastSearched' },
{ label: 'Most Searched', value: 'searchCount' },
];
readonly pageSize = signal(50);
// Item expand
readonly expandedItemId = signal<string | null>(null);
readonly detailEntries = signal<SearchEvent[]>([]);
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');
},
});
}
}

View File

@@ -0,0 +1,22 @@
<app-page-header
title="Seeker Stats"
subtitle="Monitor search activity and quality score progress"
/>
<div class="page-content">
<app-tabs [tabs]="tabs" [(activeTab)]="activeTab" (activeTabChange)="onTabChange($event)" />
<div class="tab-content">
@switch (activeTab()) {
@case ('searches') {
<app-searches-tab />
}
@case ('quality') {
<app-quality-tab />
}
@case ('upgrades') {
<app-upgrades-tab />
}
}
</div>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,69 @@
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar__filters">
<app-select
[value]="timeRange()"
[options]="timeRangeOptions"
(valueChange)="onTimeRangeChange($any($event))"
/>
<app-select
placeholder="All Instances"
[options]="instanceOptions()"
[value]="selectedInstanceId()"
(valueChange)="onInstanceFilterChange($any($event))"
/>
</div>
<div class="toolbar__actions">
<app-button variant="ghost" size="sm" (clicked)="refresh()">
Refresh
</app-button>
</div>
</div>
<!-- Stats Bar -->
<div class="stats-bar">
<div class="stats-bar__item">
<span class="stats-bar__value stats-bar__value--success"><app-animated-counter [value]="totalRecords()" [duration]="400" /></span>
<span class="stats-bar__label">Total Upgrades</span>
</div>
</div>
<!-- Upgrades List -->
<app-card [noPadding]="true">
<div class="upgrades-list">
@for (upgrade of upgrades(); track $index) {
<div class="upgrade-row">
<div class="upgrade-row__main">
<ng-icon name="tablerTrendingUp" class="upgrade-row__icon" />
<span class="upgrade-row__title">{{ upgrade.title }}</span>
<div class="upgrade-row__scores">
<span class="upgrade-row__score upgrade-row__score--old">{{ upgrade.previousScore }}</span>
<ng-icon name="tablerArrowRight" class="upgrade-row__arrow" />
<span class="upgrade-row__score upgrade-row__score--new">{{ upgrade.newScore }}</span>
<span class="upgrade-row__cutoff">(cutoff: {{ upgrade.cutoffScore }})</span>
</div>
<app-badge [severity]="itemTypeSeverity(upgrade.itemType)" size="sm">
{{ upgrade.itemType }}
</app-badge>
<span class="upgrade-row__time">{{ upgrade.upgradedAt | date:'yyyy-MM-dd HH:mm' }}</span>
</div>
</div>
} @empty {
<app-empty-state
icon="tablerTrendingUp"
heading="No upgrades found"
description="Score upgrades will appear here when items improve their custom format scores."
/>
}
</div>
</app-card>
<!-- Pagination -->
@if (totalRecords() > pageSize()) {
<app-paginator
[totalRecords]="totalRecords()"
[pageSize]="pageSize()"
[currentPage]="currentPage()"
(pageChange)="onPageChange($event)"
/>
}

View File

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

View File

@@ -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<CfScoreUpgrade[]>([]);
readonly totalRecords = signal(0);
readonly currentPage = signal(1);
readonly pageSize = signal(50);
readonly loading = signal(false);
readonly timeRange = signal<string>('30');
readonly selectedInstanceId = signal<string>('');
readonly instanceOptions = signal<SelectOption[]>([]);
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');
},
});
}
}

View File

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