mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-03-26 01:52:41 -04:00
improved stats layout and combined pages
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
@@ -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)"
|
||||
/>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)"
|
||||
/>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
/>
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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[] = [
|
||||
|
||||
Reference in New Issue
Block a user