mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-05-09 07:13:59 -04:00
Add sorting and filters for Seeker stats (#576)
This commit is contained in:
@@ -51,6 +51,7 @@ import {
|
||||
tablerChartDots,
|
||||
tablerHistory,
|
||||
tablerGripVertical,
|
||||
tablerFilter,
|
||||
} from '@ng-icons/tabler-icons';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
@@ -112,6 +113,7 @@ export const appConfig: ApplicationConfig = {
|
||||
tablerChartDots,
|
||||
tablerHistory,
|
||||
tablerGripVertical,
|
||||
tablerFilter,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { SortDirection } from '@core/api/search-stats.api';
|
||||
|
||||
export { SortDirection };
|
||||
|
||||
export interface CfScoreStats {
|
||||
totalTracked: number;
|
||||
@@ -58,6 +61,7 @@ export interface CfScoreEntry {
|
||||
isBelowCutoff: boolean;
|
||||
isMonitored: boolean;
|
||||
lastSyncedAt: string;
|
||||
lastUpgradedAt: string | null;
|
||||
}
|
||||
|
||||
export interface CfScoreEntriesResponse {
|
||||
@@ -82,6 +86,60 @@ export interface CfScoreInstance {
|
||||
id: string;
|
||||
name: string;
|
||||
itemType: string;
|
||||
qualityProfiles?: string[];
|
||||
}
|
||||
|
||||
export enum CutoffFilter {
|
||||
All = 'All',
|
||||
Below = 'Below',
|
||||
Met = 'Met',
|
||||
}
|
||||
|
||||
export enum MonitoredFilter {
|
||||
All = 'All',
|
||||
Monitored = 'Monitored',
|
||||
Unmonitored = 'Unmonitored',
|
||||
}
|
||||
|
||||
export enum CfScoresSortBy {
|
||||
Title = 'Title',
|
||||
CurrentScore = 'CurrentScore',
|
||||
CutoffScore = 'CutoffScore',
|
||||
QualityProfile = 'QualityProfile',
|
||||
LastSyncedAt = 'LastSyncedAt',
|
||||
LastUpgradedAt = 'LastUpgradedAt',
|
||||
}
|
||||
|
||||
export enum CfUpgradesSortBy {
|
||||
UpgradedAt = 'UpgradedAt',
|
||||
Title = 'Title',
|
||||
NewScore = 'NewScore',
|
||||
PreviousScore = 'PreviousScore',
|
||||
ScoreDelta = 'ScoreDelta',
|
||||
CutoffScore = 'CutoffScore',
|
||||
}
|
||||
|
||||
export interface CfScoresQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
instanceId?: string;
|
||||
search?: string;
|
||||
sortBy?: CfScoresSortBy;
|
||||
sortDirection?: SortDirection;
|
||||
qualityProfile?: string;
|
||||
itemType?: string;
|
||||
cutoffFilter?: CutoffFilter;
|
||||
monitoredFilter?: MonitoredFilter;
|
||||
}
|
||||
|
||||
export interface CfScoreUpgradesQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
instanceId?: string;
|
||||
days?: number;
|
||||
search?: string;
|
||||
sortBy?: CfUpgradesSortBy;
|
||||
sortDirection?: SortDirection;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -92,20 +150,34 @@ export class CfScoreApi {
|
||||
return this.http.get<CfScoreStats>('/api/seeker/cf-scores/stats');
|
||||
}
|
||||
|
||||
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;
|
||||
getRecentUpgrades(query: CfScoreUpgradesQuery = {}): Observable<CfScoreUpgradesResponse> {
|
||||
let params = new HttpParams()
|
||||
.set('page', String(query.page ?? 1))
|
||||
.set('pageSize', String(query.pageSize ?? 20));
|
||||
|
||||
if (query.instanceId) params = params.set('instanceId', query.instanceId);
|
||||
if (query.days !== undefined) params = params.set('days', String(query.days));
|
||||
if (query.search) params = params.set('search', query.search);
|
||||
if (query.sortBy) params = params.set('sortBy', query.sortBy);
|
||||
if (query.sortDirection) params = params.set('sortDirection', query.sortDirection);
|
||||
|
||||
return this.http.get<CfScoreUpgradesResponse>('/api/seeker/cf-scores/upgrades', { params });
|
||||
}
|
||||
|
||||
getScores(page = 1, pageSize = 50, search?: string, instanceId?: string, sortBy?: string, hideMet?: boolean, hideUnmonitored?: boolean): Observable<CfScoreEntriesResponse> {
|
||||
const params: Record<string, string | number | boolean> = { page, pageSize };
|
||||
if (search) params['search'] = search;
|
||||
if (instanceId) params['instanceId'] = instanceId;
|
||||
if (sortBy) params['sortBy'] = sortBy;
|
||||
if (hideMet) params['hideMet'] = true;
|
||||
if (hideUnmonitored) params['hideUnmonitored'] = true;
|
||||
getScores(query: CfScoresQuery = {}): Observable<CfScoreEntriesResponse> {
|
||||
let params = new HttpParams()
|
||||
.set('page', String(query.page ?? 1))
|
||||
.set('pageSize', String(query.pageSize ?? 50));
|
||||
|
||||
if (query.search) params = params.set('search', query.search);
|
||||
if (query.instanceId) params = params.set('instanceId', query.instanceId);
|
||||
if (query.sortBy) params = params.set('sortBy', query.sortBy);
|
||||
if (query.sortDirection) params = params.set('sortDirection', query.sortDirection);
|
||||
if (query.qualityProfile) params = params.set('qualityProfile', query.qualityProfile);
|
||||
if (query.itemType) params = params.set('itemType', query.itemType);
|
||||
if (query.cutoffFilter && query.cutoffFilter !== CutoffFilter.All) params = params.set('cutoffFilter', query.cutoffFilter);
|
||||
if (query.monitoredFilter && query.monitoredFilter !== MonitoredFilter.All) params = params.set('monitoredFilter', query.monitoredFilter);
|
||||
|
||||
return this.http.get<CfScoreEntriesResponse>('/api/seeker/cf-scores', { params });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { SearchStatsSummary, SearchEvent } from '@core/models/search-stats.models';
|
||||
import { SeekerSearchType, SeekerSearchReason, SearchCommandStatus } from '@core/models/search-stats.models';
|
||||
import type { PaginatedResult } from '@core/models/pagination.model';
|
||||
|
||||
export enum SortDirection {
|
||||
Asc = 'Asc',
|
||||
Desc = 'Desc',
|
||||
}
|
||||
|
||||
export enum SearchEventsSortBy {
|
||||
Timestamp = 'Timestamp',
|
||||
Title = 'Title',
|
||||
Status = 'Status',
|
||||
Type = 'Type',
|
||||
}
|
||||
|
||||
export interface SearchEventsQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
instanceId?: string;
|
||||
cycleId?: string;
|
||||
search?: string;
|
||||
sortBy?: SearchEventsSortBy;
|
||||
sortDirection?: SortDirection;
|
||||
searchStatus?: SearchCommandStatus[];
|
||||
searchType?: SeekerSearchType;
|
||||
searchReason?: SeekerSearchReason;
|
||||
grabbed?: boolean;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SearchStatsApi {
|
||||
private http = inject(HttpClient);
|
||||
@@ -12,11 +39,26 @@ export class SearchStatsApi {
|
||||
return this.http.get<SearchStatsSummary>('/api/seeker/search-stats/summary');
|
||||
}
|
||||
|
||||
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;
|
||||
getEvents(query: SearchEventsQuery = {}): Observable<PaginatedResult<SearchEvent>> {
|
||||
let params = new HttpParams()
|
||||
.set('page', String(query.page ?? 1))
|
||||
.set('pageSize', String(query.pageSize ?? 50));
|
||||
|
||||
if (query.instanceId) params = params.set('instanceId', query.instanceId);
|
||||
if (query.cycleId) params = params.set('cycleId', query.cycleId);
|
||||
if (query.search) params = params.set('search', query.search);
|
||||
if (query.sortBy) params = params.set('sortBy', query.sortBy);
|
||||
if (query.sortDirection) params = params.set('sortDirection', query.sortDirection);
|
||||
if (query.searchType) params = params.set('searchType', query.searchType);
|
||||
if (query.searchReason) params = params.set('searchReason', query.searchReason);
|
||||
if (query.grabbed !== undefined) params = params.set('grabbed', String(query.grabbed));
|
||||
|
||||
if (query.searchStatus?.length) {
|
||||
for (const status of query.searchStatus) {
|
||||
params = params.append('searchStatus', status);
|
||||
}
|
||||
}
|
||||
|
||||
return this.http.get<PaginatedResult<SearchEvent>>('/api/seeker/search-stats/events', { params });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Directive, ElementRef, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Toggles `.is-stuck` on the host element when it becomes pinned to the top
|
||||
* of its nearest scrollable ancestor by `position: sticky; top: 0`.
|
||||
*
|
||||
* Implementation uses IntersectionObserver with a 1px negative top rootMargin:
|
||||
* while the host is at its natural position it's fully inside the (shrunk)
|
||||
* root, so intersectionRatio stays at 1. Once scrolled and stuck at top: 0,
|
||||
* the top 1px of the host falls outside the shrunk root, intersectionRatio
|
||||
* drops below 1, and the class flips on.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[stickyAware]',
|
||||
standalone: true,
|
||||
})
|
||||
export class StickyAwareDirective implements OnInit, OnDestroy {
|
||||
private readonly el: ElementRef<HTMLElement> = inject(ElementRef);
|
||||
private observer?: IntersectionObserver;
|
||||
|
||||
ngOnInit(): void {
|
||||
const root = this.findScrollParent(this.el.nativeElement);
|
||||
this.observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
this.el.nativeElement.classList.toggle('is-stuck', entry.intersectionRatio < 1);
|
||||
},
|
||||
{ root, rootMargin: '-1px 0px 0px 0px', threshold: [1] },
|
||||
);
|
||||
this.observer.observe(this.el.nativeElement);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.observer?.disconnect();
|
||||
}
|
||||
|
||||
private findScrollParent(el: HTMLElement): HTMLElement | null {
|
||||
let parent: HTMLElement | null = el.parentElement;
|
||||
while (parent) {
|
||||
const style = getComputedStyle(parent);
|
||||
if (style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
||||
return parent;
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@ export enum SeekerSearchReason {
|
||||
Replacement = 'Replacement',
|
||||
}
|
||||
|
||||
export enum SearchCommandStatus {
|
||||
Pending = 'Pending',
|
||||
Started = 'Started',
|
||||
Completed = 'Completed',
|
||||
Failed = 'Failed',
|
||||
TimedOut = 'TimedOut',
|
||||
}
|
||||
|
||||
export interface SearchEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use 'page-animations' as *;
|
||||
|
||||
// Support section
|
||||
.support-section {
|
||||
display: grid;
|
||||
@@ -111,6 +113,7 @@
|
||||
|
||||
// Dashboard rows (drag-and-drop)
|
||||
.dashboard-rows {
|
||||
@include page-section-stagger($increment: 80ms);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
@@ -118,12 +121,6 @@
|
||||
|
||||
.dashboard-row {
|
||||
min-width: 0;
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
|
||||
&:nth-child(1) { animation-delay: 0ms; }
|
||||
&:nth-child(2) { animation-delay: 80ms; }
|
||||
&:nth-child(3) { animation-delay: 160ms; }
|
||||
&:nth-child(4) { animation-delay: 240ms; }
|
||||
}
|
||||
|
||||
// Logs + Events side-by-side row
|
||||
|
||||
@@ -95,7 +95,7 @@ export class DashboardComponent implements OnInit {
|
||||
this.cfScoreApi.getStats().subscribe({
|
||||
next: (stats) => this.cfScoreStats.set(stats),
|
||||
});
|
||||
this.cfScoreApi.getRecentUpgrades(1, 5).subscribe({
|
||||
this.cfScoreApi.getRecentUpgrades({ page: 1, pageSize: 5 }).subscribe({
|
||||
next: (res) => this.cfScoreUpgrades.set(res.items),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<div class="page-content">
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar" stickyAware>
|
||||
<div class="toolbar__filters">
|
||||
<app-select
|
||||
placeholder="All Severities"
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
// Staggered page content animations
|
||||
.page-content {
|
||||
@include page-section-stagger;
|
||||
|
||||
> .toolbar {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 0ms;
|
||||
@include sticky-page-header;
|
||||
}
|
||||
> .event-count {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 40ms;
|
||||
}
|
||||
> app-card {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 80ms;
|
||||
}
|
||||
> app-paginator {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 120ms;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { EventsApi } from '@core/api/events.api';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { PaginationService } from '@core/services/pagination.service';
|
||||
import { StickyAwareDirective } from '@core/directives/sticky-aware.directive';
|
||||
import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component';
|
||||
import { AppEvent, EventFilter } from '@core/models/event.models';
|
||||
|
||||
@@ -31,6 +32,7 @@ import { AppEvent, EventFilter } from '@core/models/event.models';
|
||||
PaginatorComponent,
|
||||
EmptyStateComponent,
|
||||
AnimatedCounterComponent,
|
||||
StickyAwareDirective,
|
||||
],
|
||||
templateUrl: './events.component.html',
|
||||
styleUrl: './events.component.scss',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<div class="page-content">
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar" stickyAware>
|
||||
<div class="toolbar__filters">
|
||||
<app-select
|
||||
placeholder="All Levels"
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
// Staggered page content animations
|
||||
.page-content {
|
||||
@include page-section-stagger;
|
||||
|
||||
> .toolbar {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 0ms;
|
||||
@include sticky-page-header;
|
||||
}
|
||||
> .log-count {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 40ms;
|
||||
}
|
||||
> app-card {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 80ms;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PageHeaderComponent } from '@layout/page-header/page-header.component';
|
||||
import { CardComponent, BadgeComponent, ButtonComponent, SelectComponent, InputComponent, EmptyStateComponent, type SelectOption } from '@ui';
|
||||
import { AppHubService } from '@core/realtime/app-hub.service';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { StickyAwareDirective } from '@core/directives/sticky-aware.directive';
|
||||
import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component';
|
||||
import { LogEntry } from '@core/models/signalr.models';
|
||||
|
||||
@@ -33,7 +34,8 @@ const LOG_LEVELS: SelectOption[] = [
|
||||
SelectComponent,
|
||||
InputComponent,
|
||||
EmptyStateComponent,
|
||||
AnimatedCounterComponent
|
||||
AnimatedCounterComponent,
|
||||
StickyAwareDirective,
|
||||
],
|
||||
templateUrl: './logs.component.html',
|
||||
styleUrl: './logs.component.scss',
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar" stickyAware>
|
||||
<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"
|
||||
@@ -14,25 +8,27 @@
|
||||
(entered)="onFilterChange()"
|
||||
/>
|
||||
<app-select
|
||||
label="Sort by"
|
||||
[value]="sortBy()"
|
||||
[options]="sortOptions"
|
||||
(valueChange)="onSortChange($any($event))"
|
||||
(valueChange)="onSortByChange($any($event))"
|
||||
/>
|
||||
<app-toggle
|
||||
label="Hide met"
|
||||
[checked]="hideMet()"
|
||||
(checkedChange)="onHideMetChange($event)"
|
||||
/>
|
||||
<app-toggle
|
||||
label="Hide unmonitored"
|
||||
[checked]="hideUnmonitored()"
|
||||
(checkedChange)="onHideUnmonitoredChange($event)"
|
||||
<app-select
|
||||
label="Sort order"
|
||||
[value]="sortDirection()"
|
||||
[options]="sortOrderOptions"
|
||||
(valueChange)="onSortOrderChange($any($event))"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar__actions">
|
||||
<app-button variant="ghost" size="sm" (clicked)="refresh()">
|
||||
Refresh
|
||||
<app-button variant="secondary" size="sm" (clicked)="openFilters()">
|
||||
<ng-icon name="tablerFilter" />
|
||||
Filters
|
||||
@if (activeFilterCount() > 0) {
|
||||
<app-badge severity="accent" size="sm">{{ activeFilterCount() }}</app-badge>
|
||||
}
|
||||
</app-button>
|
||||
<app-button variant="ghost" size="sm" (clicked)="refresh()">Refresh</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,6 +120,10 @@
|
||||
<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>
|
||||
<div class="score-row__detail">
|
||||
<span class="score-row__detail-label">Last Upgraded</span>
|
||||
<span class="score-row__detail-value">{{ item.lastUpgradedAt ? (item.lastUpgradedAt | date:'yyyy-MM-dd HH:mm:ss') : 'Never' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Score History -->
|
||||
<div class="score-row__detail">
|
||||
@@ -172,3 +172,47 @@
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Filter drawer -->
|
||||
<app-drawer title="Filter quality scores" [(visible)]="drawerOpen">
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Instance</label>
|
||||
<app-select
|
||||
[value]="draft().instanceId"
|
||||
[options]="instanceOptions()"
|
||||
(valueChange)="updateDraft('instanceId', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Quality profile</label>
|
||||
<app-select
|
||||
[value]="draft().qualityProfile"
|
||||
[options]="qualityProfileOptions()"
|
||||
(valueChange)="updateDraft('qualityProfile', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Cutoff status</label>
|
||||
<app-select
|
||||
[value]="draft().cutoffFilter"
|
||||
[options]="cutoffOptions"
|
||||
(valueChange)="updateDraft('cutoffFilter', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Monitored</label>
|
||||
<app-select
|
||||
[value]="draft().monitoredFilter"
|
||||
[options]="monitoredOptions"
|
||||
(valueChange)="updateDraft('monitoredFilter', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div drawer-footer>
|
||||
<app-button variant="ghost" (clicked)="resetFilters()">Reset</app-button>
|
||||
<app-button variant="primary" (clicked)="applyFilters()">Apply</app-button>
|
||||
</div>
|
||||
</app-drawer>
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' 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;
|
||||
}
|
||||
@include page-section-stagger;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@include data-toolbar;
|
||||
@include sticky-page-header;
|
||||
|
||||
&__filters {
|
||||
app-input {
|
||||
@@ -280,3 +264,86 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter drawer
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
}
|
||||
|
||||
// Table cell content
|
||||
.cell-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
|
||||
&__text {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
&__chips {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
|
||||
&--below {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
// Expanded detail
|
||||
.score-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
&__history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,36 @@ import { DatePipe } from '@angular/common';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import {
|
||||
CardComponent, BadgeComponent, ButtonComponent, InputComponent,
|
||||
PaginatorComponent, EmptyStateComponent, SelectComponent, ToggleComponent,
|
||||
TooltipComponent,
|
||||
PaginatorComponent, EmptyStateComponent, SelectComponent,
|
||||
TooltipComponent, DrawerComponent,
|
||||
} from '@ui';
|
||||
import type { SelectOption } from '@ui';
|
||||
import { AnimatedCounterComponent } from '@ui/animated-counter/animated-counter.component';
|
||||
import {
|
||||
CfScoreApi, CfScoreEntry, CfScoreStats, CfScoreHistoryEntry,
|
||||
CfScoreApi, CfScoreEntry, CfScoreStats, CfScoreHistoryEntry, CfScoreInstance,
|
||||
CutoffFilter, MonitoredFilter, CfScoresSortBy, SortDirection,
|
||||
} from '@core/api/cf-score.api';
|
||||
import { AppHubService } from '@core/realtime/app-hub.service';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { PaginationService } from '@core/services/pagination.service';
|
||||
import { StickyAwareDirective } from '@core/directives/sticky-aware.directive';
|
||||
|
||||
const DEFAULT_SORT_BY = CfScoresSortBy.Title;
|
||||
const DEFAULT_SORT_DIRECTION = SortDirection.Asc;
|
||||
|
||||
interface AdvancedFilters {
|
||||
instanceId: string;
|
||||
qualityProfile: string;
|
||||
cutoffFilter: CutoffFilter;
|
||||
monitoredFilter: MonitoredFilter;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: AdvancedFilters = {
|
||||
instanceId: '',
|
||||
qualityProfile: '',
|
||||
cutoffFilter: CutoffFilter.All,
|
||||
monitoredFilter: MonitoredFilter.All,
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-quality-tab',
|
||||
@@ -26,11 +45,12 @@ import { PaginationService } from '@core/services/pagination.service';
|
||||
ButtonComponent,
|
||||
InputComponent,
|
||||
SelectComponent,
|
||||
ToggleComponent,
|
||||
PaginatorComponent,
|
||||
EmptyStateComponent,
|
||||
AnimatedCounterComponent,
|
||||
TooltipComponent,
|
||||
DrawerComponent,
|
||||
StickyAwareDirective,
|
||||
],
|
||||
templateUrl: './quality-tab.component.html',
|
||||
styleUrl: './quality-tab.component.scss',
|
||||
@@ -44,6 +64,7 @@ export class QualityTabComponent implements OnInit {
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly pagination = inject(PaginationService);
|
||||
private initialLoad = true;
|
||||
private latestLoadToken = 0;
|
||||
|
||||
readonly items = signal<CfScoreEntry[]>([]);
|
||||
readonly stats = signal<CfScoreStats | null>(null);
|
||||
@@ -54,16 +75,30 @@ export class QualityTabComponent implements OnInit {
|
||||
readonly pageSize = signal(this.pagination.getPageSize(QualityTabComponent.PAGE_SIZE_KEY, 50));
|
||||
readonly searchQuery = signal('');
|
||||
readonly selectedInstanceId = signal<string>('');
|
||||
readonly instances = signal<CfScoreInstance[]>([]);
|
||||
readonly instanceOptions = signal<SelectOption[]>([]);
|
||||
|
||||
readonly sortBy = signal<string>('title');
|
||||
readonly hideMet = signal(false);
|
||||
readonly hideUnmonitored = signal(false);
|
||||
readonly sortBy = signal<CfScoresSortBy>(DEFAULT_SORT_BY);
|
||||
readonly sortDirection = signal<SortDirection>(DEFAULT_SORT_DIRECTION);
|
||||
|
||||
readonly sortOptions: SelectOption[] = [
|
||||
{ label: 'Title', value: 'title' },
|
||||
{ label: 'Last Synced', value: 'date' },
|
||||
{ label: 'Title', value: CfScoresSortBy.Title },
|
||||
{ label: 'Current Score', value: CfScoresSortBy.CurrentScore },
|
||||
{ label: 'Cutoff', value: CfScoresSortBy.CutoffScore },
|
||||
{ label: 'Quality Profile', value: CfScoresSortBy.QualityProfile },
|
||||
{ label: 'Last Synced', value: CfScoresSortBy.LastSyncedAt },
|
||||
{ label: 'Last Upgraded', value: CfScoresSortBy.LastUpgradedAt },
|
||||
];
|
||||
|
||||
readonly sortOrderOptions: SelectOption[] = [
|
||||
{ label: 'Ascending', value: SortDirection.Asc },
|
||||
{ label: 'Descending', value: SortDirection.Desc },
|
||||
];
|
||||
|
||||
readonly applied = signal<AdvancedFilters>({ ...EMPTY_FILTERS });
|
||||
readonly draft = signal<AdvancedFilters>({ ...EMPTY_FILTERS });
|
||||
readonly drawerOpen = signal(false);
|
||||
|
||||
readonly displayStats = computed(() => {
|
||||
const s = this.stats();
|
||||
if (!s) return null;
|
||||
@@ -78,6 +113,46 @@ export class QualityTabComponent implements OnInit {
|
||||
readonly historyEntries = signal<CfScoreHistoryEntry[]>([]);
|
||||
readonly historyLoading = signal(false);
|
||||
|
||||
readonly cutoffOptions: SelectOption[] = [
|
||||
{ label: 'Any', value: CutoffFilter.All },
|
||||
{ label: 'Below cutoff', value: CutoffFilter.Below },
|
||||
{ label: 'Met cutoff', value: CutoffFilter.Met },
|
||||
];
|
||||
|
||||
readonly monitoredOptions: SelectOption[] = [
|
||||
{ label: 'Any', value: MonitoredFilter.All },
|
||||
{ label: 'Monitored only', value: MonitoredFilter.Monitored },
|
||||
{ label: 'Unmonitored only', value: MonitoredFilter.Unmonitored },
|
||||
];
|
||||
|
||||
readonly qualityProfileOptions = computed<SelectOption[]>(() => {
|
||||
// Narrow to the drafted instance while the drawer is open so the profile
|
||||
// list stays consistent with the instance the user is composing.
|
||||
const instanceId = this.drawerOpen() ? this.draft().instanceId : this.selectedInstanceId();
|
||||
const profiles = new Set<string>();
|
||||
for (const inst of this.instances()) {
|
||||
if (instanceId && inst.id !== instanceId) continue;
|
||||
for (const p of inst.qualityProfiles ?? []) {
|
||||
profiles.add(p);
|
||||
}
|
||||
}
|
||||
const sorted = [...profiles].sort((a, b) => a.localeCompare(b));
|
||||
return [
|
||||
{ label: 'Any', value: '' },
|
||||
...sorted.map(p => ({ label: p, value: p })),
|
||||
];
|
||||
});
|
||||
|
||||
readonly activeFilterCount = computed(() => {
|
||||
const a = this.applied();
|
||||
let n = 0;
|
||||
if (a.instanceId) n++;
|
||||
if (a.qualityProfile) n++;
|
||||
if (a.cutoffFilter !== CutoffFilter.All) n++;
|
||||
if (a.monitoredFilter !== MonitoredFilter.All) n++;
|
||||
return n;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.hub.cfScoresVersion();
|
||||
@@ -100,13 +175,27 @@ export class QualityTabComponent implements OnInit {
|
||||
|
||||
loadScores(): void {
|
||||
this.loading.set(true);
|
||||
this.api.getScores(this.currentPage(), this.pageSize(), this.searchQuery() || undefined, this.selectedInstanceId() || undefined, this.sortBy(), this.hideMet(), this.hideUnmonitored()).subscribe({
|
||||
const loadToken = ++this.latestLoadToken;
|
||||
const a = this.applied();
|
||||
this.api.getScores({
|
||||
page: this.currentPage(),
|
||||
pageSize: this.pageSize(),
|
||||
search: this.searchQuery() || undefined,
|
||||
instanceId: this.selectedInstanceId() || undefined,
|
||||
sortBy: this.sortBy(),
|
||||
sortDirection: this.sortDirection(),
|
||||
qualityProfile: a.qualityProfile || undefined,
|
||||
cutoffFilter: a.cutoffFilter,
|
||||
monitoredFilter: a.monitoredFilter,
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
if (loadToken !== this.latestLoadToken) return;
|
||||
this.items.set(result.items);
|
||||
this.totalRecords.set(result.totalCount);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
if (loadToken !== this.latestLoadToken) return;
|
||||
this.loading.set(false);
|
||||
this.toast.error('Failed to load CF scores');
|
||||
},
|
||||
@@ -116,6 +205,7 @@ export class QualityTabComponent implements OnInit {
|
||||
private loadInstances(): void {
|
||||
this.api.getInstances().subscribe({
|
||||
next: (result) => {
|
||||
this.instances.set(result.instances);
|
||||
this.instanceOptions.set([
|
||||
{ label: 'All Instances', value: '' },
|
||||
...result.instances.map(i => ({
|
||||
@@ -128,10 +218,6 @@ export class QualityTabComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
onInstanceFilterChange(value: string): void {
|
||||
this.applyFilterChange(this.selectedInstanceId, value);
|
||||
}
|
||||
|
||||
private loadStats(): void {
|
||||
this.api.getStats().subscribe({
|
||||
next: (stats) => this.stats.set(stats),
|
||||
@@ -144,20 +230,14 @@ export class QualityTabComponent implements OnInit {
|
||||
this.loadScores();
|
||||
}
|
||||
|
||||
onSortChange(value: string): void {
|
||||
this.applyFilterChange(this.sortBy, value);
|
||||
onSortByChange(value: CfScoresSortBy): void {
|
||||
this.sortBy.set(value);
|
||||
this.currentPage.set(1);
|
||||
this.loadScores();
|
||||
}
|
||||
|
||||
onHideMetChange(value: boolean): void {
|
||||
this.applyFilterChange(this.hideMet, value);
|
||||
}
|
||||
|
||||
onHideUnmonitoredChange(value: boolean): void {
|
||||
this.applyFilterChange(this.hideUnmonitored, value);
|
||||
}
|
||||
|
||||
private applyFilterChange<T>(setter: { set: (v: T) => void }, value: T): void {
|
||||
setter.set(value);
|
||||
onSortOrderChange(value: SortDirection): void {
|
||||
this.sortDirection.set(value);
|
||||
this.currentPage.set(1);
|
||||
this.loadScores();
|
||||
}
|
||||
@@ -174,6 +254,47 @@ export class QualityTabComponent implements OnInit {
|
||||
() => this.loadScores(),
|
||||
);
|
||||
|
||||
openFilters(): void {
|
||||
this.draft.set({ ...this.applied(), instanceId: this.selectedInstanceId() });
|
||||
this.drawerOpen.set(true);
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.draft.set({ ...EMPTY_FILTERS });
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
const draft = { ...this.draft() };
|
||||
// Quality profile options narrow to the chosen instance — clear any stale
|
||||
// selection that no longer belongs to the drafted instance's profiles.
|
||||
if (draft.qualityProfile) {
|
||||
const profiles = this.collectProfilesFor(draft.instanceId);
|
||||
if (!profiles.has(draft.qualityProfile)) {
|
||||
draft.qualityProfile = '';
|
||||
}
|
||||
}
|
||||
this.applied.set(draft);
|
||||
this.selectedInstanceId.set(draft.instanceId);
|
||||
this.drawerOpen.set(false);
|
||||
this.currentPage.set(1);
|
||||
this.loadScores();
|
||||
}
|
||||
|
||||
private collectProfilesFor(instanceId: string): Set<string> {
|
||||
const profiles = new Set<string>();
|
||||
for (const inst of this.instances()) {
|
||||
if (instanceId && inst.id !== instanceId) continue;
|
||||
for (const p of inst.qualityProfiles ?? []) {
|
||||
profiles.add(p);
|
||||
}
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
updateDraft<K extends keyof AdvancedFilters>(key: K, value: AdvancedFilters[K]): void {
|
||||
this.draft.update(d => ({ ...d, [key]: value }));
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.loadScores();
|
||||
this.loadStats();
|
||||
|
||||
@@ -101,30 +101,36 @@
|
||||
}
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar" stickyAware>
|
||||
<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"
|
||||
(entered)="onSearchFilterChange()"
|
||||
/>
|
||||
<app-select
|
||||
label="Sort by"
|
||||
[value]="sortBy()"
|
||||
[options]="sortOptions"
|
||||
(valueChange)="onSortByChange($any($event))"
|
||||
/>
|
||||
<app-select
|
||||
label="Sort order"
|
||||
[value]="sortDirection()"
|
||||
[options]="sortOrderOptions"
|
||||
(valueChange)="onSortOrderChange($any($event))"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar__actions">
|
||||
<app-button variant="ghost" size="sm" (clicked)="refresh()">
|
||||
Refresh
|
||||
<app-button variant="secondary" size="sm" (clicked)="openFilters()">
|
||||
<ng-icon name="tablerFilter" />
|
||||
Filters
|
||||
@if (activeFilterCount() > 0) {
|
||||
<app-badge severity="accent" size="sm">{{ activeFilterCount() }}</app-badge>
|
||||
}
|
||||
</app-button>
|
||||
<app-button variant="ghost" size="sm" (clicked)="refresh()">Refresh</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,3 +196,75 @@
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Filter drawer -->
|
||||
<app-drawer title="Filter searches" [(visible)]="drawerOpen">
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Instance</label>
|
||||
<app-select
|
||||
[value]="draft().instanceId"
|
||||
[options]="instanceOptions()"
|
||||
(valueChange)="updateDraft('instanceId', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Cycle</label>
|
||||
<app-select
|
||||
[value]="draft().cycleFilter"
|
||||
[options]="cycleFilterOptions"
|
||||
[disabled]="!draft().instanceId"
|
||||
(valueChange)="updateDraft('cycleFilter', $any($event))"
|
||||
/>
|
||||
@if (!draft().instanceId) {
|
||||
<span class="filter-group__hint">Select an instance to filter by cycle.</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Status</label>
|
||||
<div class="chip-group">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
[class.chip--active]="isStatusDrafted(opt.value)"
|
||||
[attr.aria-pressed]="isStatusDrafted(opt.value)"
|
||||
(click)="toggleStatus(opt.value)"
|
||||
>{{ opt.label }}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Search type</label>
|
||||
<app-select
|
||||
[value]="draft().searchType"
|
||||
[options]="searchTypeOptions"
|
||||
(valueChange)="updateDraft('searchType', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Search reason</label>
|
||||
<app-select
|
||||
[value]="draft().searchReason"
|
||||
[options]="searchReasonOptions"
|
||||
(valueChange)="updateDraft('searchReason', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Grabbed</label>
|
||||
<app-select
|
||||
[value]="draft().grabbed"
|
||||
[options]="triStateOptions"
|
||||
(valueChange)="updateDraft('grabbed', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div drawer-footer>
|
||||
<app-button variant="ghost" (clicked)="resetFilters()">Reset</app-button>
|
||||
<app-button variant="primary" (clicked)="applyFilters()">Apply</app-button>
|
||||
</div>
|
||||
</app-drawer>
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
// Staggered animations
|
||||
:host {
|
||||
> .stats-bar {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 40ms;
|
||||
}
|
||||
> .instance-cards {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 80ms;
|
||||
}
|
||||
> .toolbar {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 120ms;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
> app-card {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 160ms;
|
||||
}
|
||||
> app-paginator {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 200ms;
|
||||
}
|
||||
@include page-section-stagger;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@include data-toolbar;
|
||||
@include sticky-page-header;
|
||||
|
||||
&__filters {
|
||||
app-input {
|
||||
@@ -215,7 +195,106 @@
|
||||
}
|
||||
}
|
||||
|
||||
// List rows
|
||||
// Filter drawer
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.chip-group {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 4px 10px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) var(--ease-default),
|
||||
border-color var(--duration-fast) var(--ease-default),
|
||||
color var(--duration-fast) var(--ease-default);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-subtle);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
// Expanded row detail
|
||||
|
||||
.event-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
&__chips {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
&__cycle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
background: var(--glass-bg);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
}
|
||||
|
||||
// List rows (legacy styles — kept for potential reuse)
|
||||
|
||||
.list-row {
|
||||
border-bottom: 1px solid var(--divider);
|
||||
|
||||
@@ -4,18 +4,49 @@ import { NgIcon } from '@ng-icons/core';
|
||||
import {
|
||||
CardComponent, BadgeComponent, ButtonComponent, SelectComponent,
|
||||
InputComponent, PaginatorComponent, EmptyStateComponent, TooltipComponent,
|
||||
DrawerComponent,
|
||||
} 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 { SearchStatsApi, SearchEventsSortBy, SortDirection } from '@core/api/search-stats.api';
|
||||
import type { SearchStatsSummary, SearchEvent, InstanceSearchStat } from '@core/models/search-stats.models';
|
||||
import { SeekerSearchType, SeekerSearchReason } from '@core/models/search-stats.models';
|
||||
import { SeekerSearchType, SeekerSearchReason, SearchCommandStatus } from '@core/models/search-stats.models';
|
||||
import { AppHubService } from '@core/realtime/app-hub.service';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { PaginationService } from '@core/services/pagination.service';
|
||||
import { StickyAwareDirective } from '@core/directives/sticky-aware.directive';
|
||||
|
||||
type CycleFilter = 'current' | 'all';
|
||||
type TriState = 'any' | 'true' | 'false';
|
||||
|
||||
const DEFAULT_SORT_BY = SearchEventsSortBy.Timestamp;
|
||||
const DEFAULT_SORT_DIRECTION = SortDirection.Desc;
|
||||
|
||||
interface AdvancedFilters {
|
||||
instanceId: string;
|
||||
cycleFilter: CycleFilter;
|
||||
statuses: SearchCommandStatus[];
|
||||
searchType: SeekerSearchType | '';
|
||||
searchReason: SeekerSearchReason | '';
|
||||
grabbed: TriState;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: AdvancedFilters = {
|
||||
instanceId: '',
|
||||
cycleFilter: 'all',
|
||||
statuses: [],
|
||||
searchType: '',
|
||||
searchReason: '',
|
||||
grabbed: 'any',
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS: ReadonlyArray<{ value: SearchCommandStatus; label: string }> = [
|
||||
{ value: SearchCommandStatus.Started, label: 'Started' },
|
||||
{ value: SearchCommandStatus.Completed, label: 'Completed' },
|
||||
{ value: SearchCommandStatus.Failed, label: 'Failed' },
|
||||
{ value: SearchCommandStatus.TimedOut, label: 'Timed Out' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-searches-tab',
|
||||
@@ -32,6 +63,8 @@ type CycleFilter = 'current' | 'all';
|
||||
EmptyStateComponent,
|
||||
AnimatedCounterComponent,
|
||||
TooltipComponent,
|
||||
DrawerComponent,
|
||||
StickyAwareDirective,
|
||||
],
|
||||
templateUrl: './searches-tab.component.html',
|
||||
styleUrl: './searches-tab.component.scss',
|
||||
@@ -45,6 +78,7 @@ export class SearchesTabComponent implements OnInit {
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly pagination = inject(PaginationService);
|
||||
private initialLoad = true;
|
||||
private latestLoadToken = 0;
|
||||
|
||||
readonly summary = signal<SearchStatsSummary | null>(null);
|
||||
readonly loading = signal(false);
|
||||
@@ -56,25 +90,74 @@ export class SearchesTabComponent implements OnInit {
|
||||
})
|
||||
);
|
||||
|
||||
// Instance filter
|
||||
readonly selectedInstanceId = signal<string>('');
|
||||
readonly instanceOptions = signal<SelectOption[]>([]);
|
||||
|
||||
// Cycle filter
|
||||
readonly cycleFilter = signal<CycleFilter>('current');
|
||||
readonly searchQuery = signal('');
|
||||
|
||||
readonly sortBy = signal<SearchEventsSortBy>(DEFAULT_SORT_BY);
|
||||
readonly sortDirection = signal<SortDirection>(DEFAULT_SORT_DIRECTION);
|
||||
|
||||
// Applied filters drive the query; draft lives inside the open drawer.
|
||||
readonly applied = signal<AdvancedFilters>({ ...EMPTY_FILTERS });
|
||||
readonly draft = signal<AdvancedFilters>({ ...EMPTY_FILTERS });
|
||||
readonly drawerOpen = signal(false);
|
||||
|
||||
readonly events = signal<SearchEvent[]>([]);
|
||||
readonly eventsTotalRecords = signal(0);
|
||||
readonly eventsPage = signal(1);
|
||||
readonly pageSize = signal(this.pagination.getPageSize(SearchesTabComponent.PAGE_SIZE_KEY, 50));
|
||||
|
||||
readonly sortOptions: SelectOption[] = [
|
||||
{ label: 'Timestamp', value: SearchEventsSortBy.Timestamp },
|
||||
{ label: 'Title', value: SearchEventsSortBy.Title },
|
||||
{ label: 'Status', value: SearchEventsSortBy.Status },
|
||||
{ label: 'Type', value: SearchEventsSortBy.Type },
|
||||
];
|
||||
|
||||
readonly sortOrderOptions: SelectOption[] = [
|
||||
{ label: 'Descending', value: SortDirection.Desc },
|
||||
{ label: 'Ascending', value: SortDirection.Asc },
|
||||
];
|
||||
|
||||
readonly cycleFilterOptions: SelectOption[] = [
|
||||
{ label: 'Current Cycle', value: 'current' },
|
||||
{ label: 'All Time', value: 'all' },
|
||||
];
|
||||
|
||||
// Search filter
|
||||
readonly searchQuery = signal('');
|
||||
readonly searchTypeOptions: SelectOption[] = [
|
||||
{ label: 'Any', value: '' },
|
||||
{ label: 'Proactive', value: SeekerSearchType.Proactive },
|
||||
{ label: 'Replacement', value: SeekerSearchType.Replacement },
|
||||
];
|
||||
|
||||
// Events
|
||||
readonly events = signal<SearchEvent[]>([]);
|
||||
readonly eventsTotalRecords = signal(0);
|
||||
readonly eventsPage = signal(1);
|
||||
readonly pageSize = signal(this.pagination.getPageSize(SearchesTabComponent.PAGE_SIZE_KEY, 50));
|
||||
readonly searchReasonOptions: SelectOption[] = [
|
||||
{ label: 'Any', value: '' },
|
||||
{ label: 'Missing', value: SeekerSearchReason.Missing },
|
||||
{ label: 'Cutoff Unmet', value: SeekerSearchReason.QualityCutoffNotMet },
|
||||
{ label: 'CF Below Cutoff', value: SeekerSearchReason.CustomFormatScoreBelowCutoff },
|
||||
{ label: 'Replacement', value: SeekerSearchReason.Replacement },
|
||||
];
|
||||
|
||||
readonly triStateOptions: SelectOption[] = [
|
||||
{ label: 'Any', value: 'any' },
|
||||
{ label: 'Yes', value: 'true' },
|
||||
{ label: 'No', value: 'false' },
|
||||
];
|
||||
|
||||
readonly statusOptions = STATUS_OPTIONS;
|
||||
|
||||
readonly activeFilterCount = computed(() => {
|
||||
const a = this.applied();
|
||||
let n = 0;
|
||||
if (a.instanceId) n++;
|
||||
if (a.cycleFilter !== EMPTY_FILTERS.cycleFilter) n++;
|
||||
if (a.statuses.length) n++;
|
||||
if (a.searchType) n++;
|
||||
if (a.searchReason) n++;
|
||||
if (a.grabbed !== 'any') n++;
|
||||
return n;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -95,21 +178,6 @@ export class SearchesTabComponent implements OnInit {
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
onInstanceFilterChange(value: string): void {
|
||||
this.selectedInstanceId.set(value);
|
||||
if (!value) {
|
||||
this.cycleFilter.set('all');
|
||||
}
|
||||
this.eventsPage.set(1);
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
onCycleFilterChange(value: string): void {
|
||||
this.cycleFilter.set(value as CycleFilter);
|
||||
this.eventsPage.set(1);
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
onSearchFilterChange(): void {
|
||||
this.eventsPage.set(1);
|
||||
this.loadEvents();
|
||||
@@ -120,6 +188,18 @@ export class SearchesTabComponent implements OnInit {
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
onSortByChange(value: SearchEventsSortBy): void {
|
||||
this.sortBy.set(value);
|
||||
this.eventsPage.set(1);
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
onSortOrderChange(value: SortDirection): void {
|
||||
this.sortDirection.set(value);
|
||||
this.eventsPage.set(1);
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
readonly onPageSizeChange = this.pagination.createPageSizeHandler(
|
||||
SearchesTabComponent.PAGE_SIZE_KEY,
|
||||
this.pageSize,
|
||||
@@ -127,6 +207,47 @@ export class SearchesTabComponent implements OnInit {
|
||||
() => this.loadEvents(),
|
||||
);
|
||||
|
||||
openFilters(): void {
|
||||
this.draft.set({ ...this.applied(), instanceId: this.selectedInstanceId() });
|
||||
this.drawerOpen.set(true);
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.draft.set({ ...EMPTY_FILTERS });
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
const draft = { ...this.draft() };
|
||||
this.applied.set(draft);
|
||||
this.selectedInstanceId.set(draft.instanceId);
|
||||
this.drawerOpen.set(false);
|
||||
this.eventsPage.set(1);
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
toggleStatus(value: SearchCommandStatus): void {
|
||||
this.draft.update(d => {
|
||||
const has = d.statuses.includes(value);
|
||||
return { ...d, statuses: has ? d.statuses.filter(s => s !== value) : [...d.statuses, value] };
|
||||
});
|
||||
}
|
||||
|
||||
isStatusDrafted(value: SearchCommandStatus): boolean {
|
||||
return this.draft().statuses.includes(value);
|
||||
}
|
||||
|
||||
updateDraft<K extends keyof AdvancedFilters>(key: K, value: AdvancedFilters[K]): void {
|
||||
this.draft.update(d => {
|
||||
const next = { ...d, [key]: value };
|
||||
// 'Current Cycle' only makes sense against a specific instance — clearing
|
||||
// the instance must fall the cycle filter back to 'All Time'.
|
||||
if (key === 'instanceId' && !value && next.cycleFilter === 'current') {
|
||||
next.cycleFilter = 'all';
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.loadSummary();
|
||||
this.loadEvents();
|
||||
@@ -223,22 +344,40 @@ export class SearchesTabComponent implements OnInit {
|
||||
|
||||
private loadEvents(): void {
|
||||
this.loading.set(true);
|
||||
const loadToken = ++this.latestLoadToken;
|
||||
const instanceId = this.selectedInstanceId() || undefined;
|
||||
const search = this.searchQuery() || undefined;
|
||||
let cycleId: string | undefined;
|
||||
const a = this.applied();
|
||||
|
||||
if (this.cycleFilter() === 'current' && instanceId) {
|
||||
let cycleId: string | undefined;
|
||||
if (a.cycleFilter === 'current' && instanceId) {
|
||||
const instance = this.summary()?.perInstanceStats.find(s => s.instanceId === instanceId);
|
||||
cycleId = instance?.currentCycleId ?? undefined;
|
||||
}
|
||||
|
||||
this.api.getEvents(this.eventsPage(), this.pageSize(), instanceId, cycleId, search).subscribe({
|
||||
const triToBool = (v: TriState): boolean | undefined => v === 'any' ? undefined : v === 'true';
|
||||
|
||||
this.api.getEvents({
|
||||
page: this.eventsPage(),
|
||||
pageSize: this.pageSize(),
|
||||
instanceId,
|
||||
cycleId,
|
||||
search,
|
||||
sortBy: this.sortBy(),
|
||||
sortDirection: this.sortDirection(),
|
||||
searchStatus: a.statuses.length ? a.statuses : undefined,
|
||||
searchType: a.searchType || undefined,
|
||||
searchReason: a.searchReason || undefined,
|
||||
grabbed: triToBool(a.grabbed),
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
if (loadToken !== this.latestLoadToken) return;
|
||||
this.events.set(result.items);
|
||||
this.eventsTotalRecords.set(result.totalCount);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
if (loadToken !== this.latestLoadToken) return;
|
||||
this.loading.set(false);
|
||||
this.toast.error('Failed to load search events');
|
||||
},
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
.page-content {
|
||||
@include page-section-stagger;
|
||||
|
||||
> app-tabs {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 0ms;
|
||||
display: block;
|
||||
@include sticky-page-header;
|
||||
}
|
||||
|
||||
> .tab-content {
|
||||
margin-top: var(--space-6);
|
||||
|
||||
// Chromium retains the compositor layer from slide-up's compositing properties,
|
||||
// which persists as a backdrop root and clips the descendant toolbar's
|
||||
// backdrop-filter. No animated ancestor = no backdrop root = correct frosting.
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar" stickyAware>
|
||||
<div class="toolbar__filters">
|
||||
<app-select
|
||||
[value]="timeRange()"
|
||||
[options]="timeRangeOptions"
|
||||
(valueChange)="onTimeRangeChange($any($event))"
|
||||
<app-input
|
||||
placeholder="Search by title..."
|
||||
type="search"
|
||||
[(value)]="searchQuery"
|
||||
(entered)="onSearchFilterChange()"
|
||||
/>
|
||||
<app-select
|
||||
placeholder="All Instances"
|
||||
[options]="instanceOptions()"
|
||||
[value]="selectedInstanceId()"
|
||||
(valueChange)="onInstanceFilterChange($any($event))"
|
||||
label="Sort by"
|
||||
[value]="sortBy()"
|
||||
[options]="sortOptions"
|
||||
(valueChange)="onSortByChange($any($event))"
|
||||
/>
|
||||
<app-select
|
||||
label="Sort order"
|
||||
[value]="sortDirection()"
|
||||
[options]="sortOrderOptions"
|
||||
(valueChange)="onSortOrderChange($any($event))"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar__actions">
|
||||
<app-button variant="ghost" size="sm" (clicked)="refresh()">
|
||||
Refresh
|
||||
<app-button variant="secondary" size="sm" (clicked)="openFilters()">
|
||||
<ng-icon name="tablerFilter" />
|
||||
Filters
|
||||
@if (activeFilterCount() > 0) {
|
||||
<app-badge severity="accent" size="sm">{{ activeFilterCount() }}</app-badge>
|
||||
}
|
||||
</app-button>
|
||||
<app-button variant="ghost" size="sm" (clicked)="refresh()">Refresh</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,3 +82,29 @@
|
||||
(pageSizeChange)="onPageSizeChange($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Filter drawer -->
|
||||
<app-drawer title="Filter upgrades" [(visible)]="drawerOpen">
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Instance</label>
|
||||
<app-select
|
||||
[value]="draft().instanceId"
|
||||
[options]="instanceOptions()"
|
||||
(valueChange)="updateDraft('instanceId', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="filter-group__label">Time range</label>
|
||||
<app-select
|
||||
[value]="draft().timeRange"
|
||||
[options]="timeRangeOptions"
|
||||
(valueChange)="updateDraft('timeRange', $any($event))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div drawer-footer>
|
||||
<app-button variant="ghost" (clicked)="resetFilters()">Reset</app-button>
|
||||
<app-button variant="primary" (clicked)="applyFilters()">Apply</app-button>
|
||||
</div>
|
||||
</app-drawer>
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' 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;
|
||||
}
|
||||
@include page-section-stagger;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@include data-toolbar;
|
||||
@include sticky-page-header;
|
||||
}
|
||||
|
||||
// Stats bar
|
||||
@@ -158,3 +142,72 @@
|
||||
padding: 0 var(--space-3) var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter drawer
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
}
|
||||
|
||||
// Table cell content
|
||||
.cell-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__text {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.score-value {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
|
||||
&--new { color: var(--color-success); }
|
||||
&--old { color: var(--text-tertiary); }
|
||||
}
|
||||
|
||||
.score-delta {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
// Expanded detail
|
||||
.upgrade-detail {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: var(--space-3);
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, effect, untracked, OnInit } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, untracked, OnInit } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import {
|
||||
CardComponent, BadgeComponent, ButtonComponent, SelectComponent,
|
||||
PaginatorComponent, EmptyStateComponent,
|
||||
InputComponent, PaginatorComponent, EmptyStateComponent,
|
||||
DrawerComponent,
|
||||
} 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 { CfScoreApi, CfScoreUpgrade, CfUpgradesSortBy, SortDirection } from '@core/api/cf-score.api';
|
||||
import { AppHubService } from '@core/realtime/app-hub.service';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { PaginationService } from '@core/services/pagination.service';
|
||||
import { StickyAwareDirective } from '@core/directives/sticky-aware.directive';
|
||||
|
||||
const DEFAULT_SORT_BY = CfUpgradesSortBy.UpgradedAt;
|
||||
const DEFAULT_SORT_DIRECTION = SortDirection.Desc;
|
||||
|
||||
interface AdvancedFilters {
|
||||
instanceId: string;
|
||||
timeRange: string;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: AdvancedFilters = {
|
||||
instanceId: '',
|
||||
timeRange: '30',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-upgrades-tab',
|
||||
@@ -22,9 +37,12 @@ import { PaginationService } from '@core/services/pagination.service';
|
||||
BadgeComponent,
|
||||
ButtonComponent,
|
||||
SelectComponent,
|
||||
InputComponent,
|
||||
PaginatorComponent,
|
||||
EmptyStateComponent,
|
||||
AnimatedCounterComponent,
|
||||
DrawerComponent,
|
||||
StickyAwareDirective,
|
||||
],
|
||||
templateUrl: './upgrades-tab.component.html',
|
||||
styleUrl: './upgrades-tab.component.scss',
|
||||
@@ -38,6 +56,7 @@ export class UpgradesTabComponent implements OnInit {
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly pagination = inject(PaginationService);
|
||||
private initialLoad = true;
|
||||
private latestLoadToken = 0;
|
||||
|
||||
readonly upgrades = signal<CfScoreUpgrade[]>([]);
|
||||
readonly totalRecords = signal(0);
|
||||
@@ -45,10 +64,31 @@ export class UpgradesTabComponent implements OnInit {
|
||||
readonly pageSize = signal(this.pagination.getPageSize(UpgradesTabComponent.PAGE_SIZE_KEY, 50));
|
||||
readonly loading = signal(false);
|
||||
|
||||
readonly timeRange = signal<string>('30');
|
||||
readonly searchQuery = signal('');
|
||||
readonly selectedInstanceId = signal<string>('');
|
||||
readonly instanceOptions = signal<SelectOption[]>([]);
|
||||
|
||||
readonly sortBy = signal<CfUpgradesSortBy>(DEFAULT_SORT_BY);
|
||||
readonly sortDirection = signal<SortDirection>(DEFAULT_SORT_DIRECTION);
|
||||
|
||||
readonly applied = signal<AdvancedFilters>({ ...EMPTY_FILTERS });
|
||||
readonly draft = signal<AdvancedFilters>({ ...EMPTY_FILTERS });
|
||||
readonly drawerOpen = signal(false);
|
||||
|
||||
readonly sortOptions: SelectOption[] = [
|
||||
{ label: 'Upgraded At', value: CfUpgradesSortBy.UpgradedAt },
|
||||
{ label: 'Title', value: CfUpgradesSortBy.Title },
|
||||
{ label: 'New Score', value: CfUpgradesSortBy.NewScore },
|
||||
{ label: 'Previous Score', value: CfUpgradesSortBy.PreviousScore },
|
||||
{ label: 'Score Delta', value: CfUpgradesSortBy.ScoreDelta },
|
||||
{ label: 'Cutoff', value: CfUpgradesSortBy.CutoffScore },
|
||||
];
|
||||
|
||||
readonly sortOrderOptions: SelectOption[] = [
|
||||
{ label: 'Descending', value: SortDirection.Desc },
|
||||
{ label: 'Ascending', value: SortDirection.Asc },
|
||||
];
|
||||
|
||||
readonly timeRangeOptions: SelectOption[] = [
|
||||
{ label: 'Last 7 Days', value: '7' },
|
||||
{ label: 'Last 30 Days', value: '30' },
|
||||
@@ -56,6 +96,14 @@ export class UpgradesTabComponent implements OnInit {
|
||||
{ label: 'All Time', value: '0' },
|
||||
];
|
||||
|
||||
readonly activeFilterCount = computed(() => {
|
||||
const a = this.applied();
|
||||
let n = 0;
|
||||
if (a.instanceId) n++;
|
||||
if (a.timeRange !== EMPTY_FILTERS.timeRange) n++;
|
||||
return n;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
this.hub.cfScoresVersion();
|
||||
@@ -74,14 +122,19 @@ export class UpgradesTabComponent implements OnInit {
|
||||
this.loadUpgrades();
|
||||
}
|
||||
|
||||
onTimeRangeChange(value: string): void {
|
||||
this.timeRange.set(value);
|
||||
onSearchFilterChange(): void {
|
||||
this.currentPage.set(1);
|
||||
this.loadUpgrades();
|
||||
}
|
||||
|
||||
onInstanceFilterChange(value: string): void {
|
||||
this.selectedInstanceId.set(value);
|
||||
onSortByChange(value: CfUpgradesSortBy): void {
|
||||
this.sortBy.set(value);
|
||||
this.currentPage.set(1);
|
||||
this.loadUpgrades();
|
||||
}
|
||||
|
||||
onSortOrderChange(value: SortDirection): void {
|
||||
this.sortDirection.set(value);
|
||||
this.currentPage.set(1);
|
||||
this.loadUpgrades();
|
||||
}
|
||||
@@ -98,6 +151,28 @@ export class UpgradesTabComponent implements OnInit {
|
||||
() => this.loadUpgrades(),
|
||||
);
|
||||
|
||||
openFilters(): void {
|
||||
this.draft.set({ ...this.applied(), instanceId: this.selectedInstanceId() });
|
||||
this.drawerOpen.set(true);
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.draft.set({ ...EMPTY_FILTERS });
|
||||
}
|
||||
|
||||
applyFilters(): void {
|
||||
const draft = { ...this.draft() };
|
||||
this.applied.set(draft);
|
||||
this.selectedInstanceId.set(draft.instanceId);
|
||||
this.drawerOpen.set(false);
|
||||
this.currentPage.set(1);
|
||||
this.loadUpgrades();
|
||||
}
|
||||
|
||||
updateDraft<K extends keyof AdvancedFilters>(key: K, value: AdvancedFilters[K]): void {
|
||||
this.draft.update(d => ({ ...d, [key]: value }));
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.loadUpgrades();
|
||||
}
|
||||
@@ -123,16 +198,28 @@ export class UpgradesTabComponent implements OnInit {
|
||||
|
||||
private loadUpgrades(): void {
|
||||
this.loading.set(true);
|
||||
const days = parseInt(this.timeRange(), 10) || undefined;
|
||||
const loadToken = ++this.latestLoadToken;
|
||||
const a = this.applied();
|
||||
const days = parseInt(a.timeRange, 10);
|
||||
const instanceId = this.selectedInstanceId() || undefined;
|
||||
|
||||
this.api.getRecentUpgrades(this.currentPage(), this.pageSize(), instanceId, days).subscribe({
|
||||
this.api.getRecentUpgrades({
|
||||
page: this.currentPage(),
|
||||
pageSize: this.pageSize(),
|
||||
instanceId,
|
||||
days: Number.isFinite(days) ? days : undefined,
|
||||
search: this.searchQuery() || undefined,
|
||||
sortBy: this.sortBy(),
|
||||
sortDirection: this.sortDirection(),
|
||||
}).subscribe({
|
||||
next: (result) => {
|
||||
if (loadToken !== this.latestLoadToken) return;
|
||||
this.upgrades.set(result.items);
|
||||
this.totalRecords.set(result.totalCount);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
if (loadToken !== this.latestLoadToken) return;
|
||||
this.loading.set(false);
|
||||
this.toast.error('Failed to load upgrades');
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
<div class="page-content">
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="toolbar" stickyAware>
|
||||
<div class="toolbar__filters">
|
||||
<app-select
|
||||
placeholder="All Types"
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
@use 'data-toolbar' as *;
|
||||
@use 'page-animations' as *;
|
||||
|
||||
// Staggered page content animations
|
||||
.page-content {
|
||||
@include page-section-stagger;
|
||||
|
||||
> .toolbar {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 0ms;
|
||||
@include sticky-page-header;
|
||||
}
|
||||
> .strike-count {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 40ms;
|
||||
}
|
||||
> app-card {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 80ms;
|
||||
}
|
||||
> app-paginator {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
animation-delay: 120ms;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { StrikesApi } from '@core/api/strikes.api';
|
||||
import { ToastService } from '@core/services/toast.service';
|
||||
import { ConfirmService } from '@core/services/confirm.service';
|
||||
import { PaginationService } from '@core/services/pagination.service';
|
||||
import { StickyAwareDirective } from '@core/directives/sticky-aware.directive';
|
||||
import { DownloadItemStrikes, StrikeFilter } from '@core/models/strike.models';
|
||||
|
||||
@Component({
|
||||
@@ -28,6 +29,7 @@ import { DownloadItemStrikes, StrikeFilter } from '@core/models/strike.models';
|
||||
PaginatorComponent,
|
||||
EmptyStateComponent,
|
||||
AnimatedCounterComponent,
|
||||
StickyAwareDirective,
|
||||
],
|
||||
templateUrl: './strikes.component.html',
|
||||
styleUrl: './strikes.component.scss',
|
||||
|
||||
27
code/frontend/src/app/ui/drawer/drawer.component.html
Normal file
27
code/frontend/src/app/ui/drawer/drawer.component.html
Normal file
@@ -0,0 +1,27 @@
|
||||
@if (visible()) {
|
||||
<div class="drawer-backdrop" (click)="onBackdropClick()">
|
||||
<aside
|
||||
class="drawer"
|
||||
(click)="$event.stopPropagation()"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-labelledby]="title() ? titleId : null"
|
||||
[attr.aria-label]="title() ? null : 'Drawer'"
|
||||
cdkTrapFocus
|
||||
[cdkTrapFocusAutoCapture]="false"
|
||||
>
|
||||
@if (title()) {
|
||||
<header class="drawer__header">
|
||||
<h2 [id]="titleId" class="drawer__title">{{ title() }}</h2>
|
||||
<button class="drawer__close" type="button" (click)="close()" aria-label="Close">×</button>
|
||||
</header>
|
||||
}
|
||||
<div class="drawer__body">
|
||||
<ng-content />
|
||||
</div>
|
||||
<footer class="drawer__footer">
|
||||
<ng-content select="[drawer-footer]" />
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
}
|
||||
97
code/frontend/src/app/ui/drawer/drawer.component.scss
Normal file
97
code/frontend/src/app/ui/drawer/drawer.component.scss
Normal file
@@ -0,0 +1,97 @@
|
||||
@use 'glass' as *;
|
||||
|
||||
@keyframes drawer-slide-in {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: var(--z-modal);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
animation: fade-in var(--duration-fast) var(--ease-default),
|
||||
backdrop-blur-in var(--duration-normal) var(--ease-default) both;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
@include glass('elevated');
|
||||
|
||||
position: relative;
|
||||
width: 420px;
|
||||
max-width: 100vw;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0;
|
||||
animation: drawer-slide-in var(--duration-normal) var(--ease-default);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-5) var(--space-6);
|
||||
border-bottom: 1px solid var(--divider);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-2xl);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: color var(--duration-fast) var(--ease-default);
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-top: 1px solid var(--divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100vw;
|
||||
|
||||
&__header {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
&__footer {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
81
code/frontend/src/app/ui/drawer/drawer.component.ts
Normal file
81
code/frontend/src/app/ui/drawer/drawer.component.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Component, ChangeDetectionStrategy, input, output, model, HostListener, effect, ElementRef, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import { A11yModule } from '@angular/cdk/a11y';
|
||||
|
||||
@Component({
|
||||
selector: 'app-drawer',
|
||||
standalone: true,
|
||||
imports: [A11yModule],
|
||||
templateUrl: './drawer.component.html',
|
||||
styleUrl: './drawer.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DrawerComponent implements OnInit, OnDestroy {
|
||||
private static nextId = 0;
|
||||
|
||||
private readonly host: ElementRef<HTMLElement> = inject(ElementRef);
|
||||
private previousFocus: HTMLElement | null = null;
|
||||
|
||||
readonly titleId = `drawer-title-${++DrawerComponent.nextId}`;
|
||||
|
||||
title = input<string>();
|
||||
visible = model(false);
|
||||
closeOnBackdrop = input(true);
|
||||
|
||||
closed = output<void>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.visible()) {
|
||||
this.previousFocus = document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
queueMicrotask(() => this.focusFirstControl());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
document.body.appendChild(this.host.nativeElement);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.restoreFocus();
|
||||
this.host.nativeElement.remove();
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey(): void {
|
||||
if (this.visible()) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.visible.set(false);
|
||||
this.restoreFocus();
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
onBackdropClick(): void {
|
||||
if (this.closeOnBackdrop()) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private focusFirstControl(): void {
|
||||
const panel = this.host.nativeElement.querySelector('.drawer__body') as HTMLElement | null;
|
||||
if (!panel) return;
|
||||
const focusable = panel.querySelector(
|
||||
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
|
||||
) as HTMLElement | null;
|
||||
focusable?.focus();
|
||||
}
|
||||
|
||||
private restoreFocus(): void {
|
||||
const target = this.previousFocus;
|
||||
this.previousFocus = null;
|
||||
if (target && document.body.contains(target)) {
|
||||
target.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export { TextareaComponent } from './textarea/textarea.component';
|
||||
export { ChipInputComponent } from './chip-input/chip-input.component';
|
||||
export { AccordionComponent } from './accordion/accordion.component';
|
||||
export { ModalComponent } from './modal/modal.component';
|
||||
export { DrawerComponent } from './drawer/drawer.component';
|
||||
export { PaginatorComponent } from './paginator/paginator.component';
|
||||
export { TabsComponent } from './tabs/tabs.component';
|
||||
export type { Tab } from './tabs/tabs.component';
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<app-select
|
||||
[options]="pageSizeSelectOptions()"
|
||||
[value]="pageSize()"
|
||||
placement="top"
|
||||
(valueChange)="onPageSizeChange($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,11 @@
|
||||
</button>
|
||||
|
||||
@if (isOpen()) {
|
||||
<div class="select-dropdown" role="listbox">
|
||||
<div
|
||||
class="select-dropdown"
|
||||
[class.select-dropdown--top]="placement() === 'top'"
|
||||
role="listbox"
|
||||
>
|
||||
@for (option of options(); track option.value) {
|
||||
<button
|
||||
class="select-option"
|
||||
|
||||
@@ -82,6 +82,11 @@
|
||||
overflow-y: auto;
|
||||
padding: var(--space-1);
|
||||
animation: scale-in var(--duration-fast) var(--ease-default);
|
||||
|
||||
&--top {
|
||||
top: auto;
|
||||
bottom: calc(100% + var(--space-1));
|
||||
}
|
||||
}
|
||||
|
||||
.select-option {
|
||||
|
||||
@@ -27,6 +27,7 @@ export class SelectComponent {
|
||||
error = input<string>();
|
||||
hint = input<string>();
|
||||
helpKey = input<string>();
|
||||
placement = input<'bottom' | 'top'>('bottom');
|
||||
value = model<unknown>(null);
|
||||
|
||||
readonly isOpen = signal(false);
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
|
||||
to { opacity: 1; transform: none; filter: none; }
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
|
||||
@@ -7,11 +7,8 @@
|
||||
// Pins a page header bar to the top of the shell scroll container
|
||||
@mixin sticky-page-header {
|
||||
position: sticky;
|
||||
top: calc(-1 * var(--content-padding, var(--space-6)));
|
||||
top: 0;
|
||||
z-index: var(--z-sticky);
|
||||
background: var(--surface-overlay);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
padding-top: var(--content-padding, var(--space-6));
|
||||
padding-bottom: var(--space-3);
|
||||
padding-left: var(--content-padding, var(--space-6));
|
||||
@@ -19,12 +16,21 @@
|
||||
margin-top: calc(-1 * var(--content-padding, var(--space-6)));
|
||||
margin-left: calc(-1 * var(--content-padding, var(--space-6)));
|
||||
margin-right: calc(-1 * var(--content-padding, var(--space-6)));
|
||||
box-shadow: 0 10px 18px -14px rgba(0, 0, 0, 0.35);
|
||||
transition:
|
||||
backdrop-filter var(--duration-fast) var(--ease-default),
|
||||
-webkit-backdrop-filter var(--duration-fast) var(--ease-default),
|
||||
box-shadow var(--duration-fast) var(--ease-default);
|
||||
|
||||
&.is-stuck {
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
box-shadow: 0 10px 18px -14px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin data-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
26
code/frontend/src/styles/_page-animations.scss
Normal file
26
code/frontend/src/styles/_page-animations.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
// =============================================================================
|
||||
// Page-level animation utilities
|
||||
// Usage: @use 'page-animations' as *;
|
||||
// =============================================================================
|
||||
|
||||
// Staggered entry for direct children of a page container.
|
||||
// Default 40ms increments up to 10 children; pages that need wider spacing can
|
||||
// override $increment (e.g. dashboard uses 80ms).
|
||||
@mixin page-section-stagger($increment: 40ms, $start: 0ms, $count: 10) {
|
||||
> * {
|
||||
animation: slide-up var(--duration-normal) var(--ease-default) both;
|
||||
}
|
||||
|
||||
@for $i from 1 through $count {
|
||||
> :nth-child(#{$i}) {
|
||||
animation-delay: #{$start + ($i - 1) * $increment};
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
> * {
|
||||
animation: none;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,22 +70,35 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-2);
|
||||
padding: var(--space-4);
|
||||
|
||||
// Sticky at bottom of scroll container (shell__content)
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: var(--z-sticky);
|
||||
|
||||
// Transparent — button floats over content without blocking it
|
||||
// Row stays transparent; each action gets its own glass pedestal so the
|
||||
// backdrop hugs the save button instead of spanning the full row.
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
|
||||
// Re-enable pointer events on the button itself
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: calc(-1 * var(--space-10));
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur-sm, 8px));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-sm, 8px));
|
||||
mask-image: radial-gradient(ellipse at center, black 40%, transparent 85%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at center, black 40%, transparent 85%);
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@mixin form-section {
|
||||
|
||||
Reference in New Issue
Block a user