mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-03-24 17:13:41 -04:00
added item expanding for search stats
This commit is contained in:
@@ -271,6 +271,88 @@ 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,
|
||||
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<string> Items, SeekerSearchType SearchType, object? GrabbedItems) ParseEventData(string? data)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(data))
|
||||
|
||||
@@ -18,6 +18,13 @@ export class SearchStatsApi {
|
||||
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, cycleRunId?: string): Observable<PaginatedResult<SearchEvent>> {
|
||||
const params: Record<string, string | number> = { page, pageSize };
|
||||
if (instanceId) params['instanceId'] = instanceId;
|
||||
|
||||
@@ -190,15 +190,21 @@
|
||||
<app-card [noPadding]="true">
|
||||
<div class="list">
|
||||
@for (item of items(); track item.id) {
|
||||
<div class="list-row">
|
||||
<div class="list-row__main">
|
||||
<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>
|
||||
</span>
|
||||
<app-badge [severity]="instanceTypeSeverity(item.instanceType)" size="sm">
|
||||
{{ item.instanceType }}
|
||||
</app-badge>
|
||||
@@ -208,7 +214,53 @@
|
||||
<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.cycleRunId) {
|
||||
<span class="list-row__cycle">{{ event.cycleRunId.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
|
||||
|
||||
@@ -280,6 +280,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -315,4 +380,8 @@
|
||||
order: 4;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.expand-events__row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,11 @@ export class SearchStatsComponent implements OnInit {
|
||||
|
||||
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
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user