diff --git a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs index cacc3e95..85d8586a 100644 --- a/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs +++ b/code/backend/Cleanuparr.Api/Features/Seeker/Controllers/SearchStatsController.cs @@ -271,6 +271,88 @@ public sealed class SearchStatsController : ControllerBase }); } + /// + /// Gets individual search events for a specific item. + /// + [HttpGet("history/{instanceId}/{itemId}/detail")] + public async Task GetHistoryDetail( + Guid instanceId, + long itemId, + [FromQuery] int seasonNumber = 0) + { + var historyItem = await _dataContext.SeekerHistory + .AsNoTracking() + .Where(h => h.ArrInstanceId == instanceId + && h.ExternalItemId == itemId + && h.SeasonNumber == seasonNumber) + .OrderByDescending(h => h.LastSearchedAt) + .FirstOrDefaultAsync(); + + if (historyItem is null) + { + return Ok(new { Entries = Array.Empty() }); + } + + // Build the display name matching the event Items format + string displayName = seasonNumber > 0 + ? $"{historyItem.ItemTitle} S{seasonNumber:D2}" + : historyItem.ItemTitle; + + // Get instance URL for event filtering + var instance = await _dataContext.ArrInstances + .AsNoTracking() + .FirstOrDefaultAsync(a => a.Id == instanceId); + + if (instance is null) + { + return Ok(new { Entries = Array.Empty() }); + } + + string url = (instance.ExternalUrl ?? instance.Url).ToString(); + + // Pre-filter events containing the item title, then verify with JSON parsing + var rawEvents = await _eventsContext.Events + .AsNoTracking() + .Where(e => e.EventType == EventType.SearchTriggered + && e.InstanceUrl == url + && e.Data != null + && e.Data.Contains(historyItem.ItemTitle)) + .OrderByDescending(e => e.Timestamp) + .Take(100) + .ToListAsync(); + + var entries = rawEvents + .Select(e => + { + var parsed = ParseEventData(e.Data); + bool containsItem = parsed.Items.Any(i => + i.Contains(displayName, StringComparison.OrdinalIgnoreCase) + || i.Contains(historyItem.ItemTitle, StringComparison.OrdinalIgnoreCase)); + + return containsItem + ? new SearchEventResponse + { + Id = e.Id, + Timestamp = e.Timestamp, + InstanceName = parsed.InstanceName, + InstanceType = e.InstanceType?.ToString(), + ItemCount = parsed.ItemCount, + Items = parsed.Items, + SearchType = parsed.SearchType, + SearchStatus = e.SearchStatus, + CompletedAt = e.CompletedAt, + GrabbedItems = parsed.GrabbedItems, + CycleRunId = e.CycleRunId, + IsDryRun = e.IsDryRun, + } + : null; + }) + .Where(e => e is not null) + .ToList(); + + return Ok(new { Entries = entries }); + } + private static (string InstanceName, int ItemCount, List Items, SeekerSearchType SearchType, object? GrabbedItems) ParseEventData(string? data) { if (string.IsNullOrWhiteSpace(data)) diff --git a/code/frontend/src/app/core/api/search-stats.api.ts b/code/frontend/src/app/core/api/search-stats.api.ts index 960b2701..bd7afcb4 100644 --- a/code/frontend/src/app/core/api/search-stats.api.ts +++ b/code/frontend/src/app/core/api/search-stats.api.ts @@ -18,6 +18,13 @@ export class SearchStatsApi { return this.http.get>('/api/seeker/search-stats/history', { params }); } + getItemDetail(instanceId: string, itemId: number, seasonNumber = 0): Observable<{ entries: SearchEvent[] }> { + return this.http.get<{ entries: SearchEvent[] }>( + `/api/seeker/search-stats/history/${instanceId}/${itemId}/detail`, + { params: { seasonNumber } }, + ); + } + getEvents(page = 1, pageSize = 50, instanceId?: string, cycleRunId?: string): Observable> { const params: Record = { page, pageSize }; if (instanceId) params['instanceId'] = instanceId; 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 index c0adf59f..635416ab 100644 --- a/code/frontend/src/app/features/search-stats/search-stats.component.html +++ b/code/frontend/src/app/features/search-stats/search-stats.component.html @@ -190,15 +190,21 @@
@for (item of items(); track item.id) { -
-
+
+
{{ itemDisplayName(item) }} @if (item.seasonNumber > 0) { S{{ item.seasonNumber }} } - + {{ item.instanceType }} @@ -208,7 +214,53 @@ ({{ 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.cycleRunId) { + {{ event.cycleRunId.substring(0, 8) }} + } + {{ event.timestamp | date:'yyyy-MM-dd HH:mm' }} +
+ @if (event.grabbedItems && event.grabbedItems.length > 0) { +
+ + + Grabbed: {{ formatGrabbedItems(event.grabbedItems) }} + +
+ } + } +
+ } +
+ }
} @empty { (null); + readonly detailEntries = signal([]); + readonly detailLoading = signal(false); + constructor() { effect(() => { this.hub.searchStatsVersion(); // subscribe to changes @@ -144,6 +149,30 @@ export class SearchStatsComponent implements OnInit { 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'); + }, + }); + } + searchTypeSeverity(type: SeekerSearchType): 'info' | 'warning' { return type === SeekerSearchType.Replacement ? 'warning' : 'info'; } @@ -232,6 +261,8 @@ 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) => {