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