added item expanding for search stats

This commit is contained in:
Flaminel
2026-03-24 10:28:19 +02:00
parent 8ad0fa3b3d
commit fb6e4ea662
5 changed files with 244 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {