fix(ui): deadletter filter bar refactor, scripts search input, glossary tooltip
Replace deadletter grid filters with a compact inline filter bar using StellaFilterMulti chips. Add missing CSS for scripts search input. Fix glossary tooltip positioning. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,10 +13,11 @@ import {
|
||||
type ReplayResponse,
|
||||
} from '../../core/api/deadletter.models';
|
||||
import { OPERATIONS_PATHS, deadLetterEntryPath } from '../platform/ops/operations-paths';
|
||||
import { StellaFilterMultiComponent, type FilterMultiOption } from '../../shared/components/stella-filter-multi/stella-filter-multi.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-deadletter-queue',
|
||||
imports: [RouterModule, FormsModule],
|
||||
imports: [RouterModule, FormsModule, StellaFilterMultiComponent],
|
||||
template: `
|
||||
<div class="queue-page">
|
||||
<header class="page-header">
|
||||
@@ -45,82 +46,45 @@ import { OPERATIONS_PATHS, deadLetterEntryPath } from '../platform/ops/operation
|
||||
}
|
||||
|
||||
<!-- Filters -->
|
||||
<section class="filters-section">
|
||||
<div class="filters-grid">
|
||||
<div class="filter-group">
|
||||
<label>Error Type</label>
|
||||
<select [(ngModel)]="filter.errorCode" (ngModelChange)="applyFilters()">
|
||||
<option [ngValue]="undefined">All</option>
|
||||
@for (code of errorCodes; track code) {
|
||||
<option [value]="code">
|
||||
{{ getErrorLabel(code) }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Status</label>
|
||||
<select [(ngModel)]="filter.state" (ngModelChange)="applyFilters()">
|
||||
<option [ngValue]="undefined">All</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="retrying">Retrying</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="replayed">Replayed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Tenant</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tenant ID or name..."
|
||||
[(ngModel)]="filter.tenantId"
|
||||
(ngModelChange)="onFilterChange()"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Job Type</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Job type..."
|
||||
[(ngModel)]="filter.jobType"
|
||||
(ngModelChange)="onFilterChange()"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Search</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Entry ID or Job ID..."
|
||||
[(ngModel)]="filter.search"
|
||||
(ngModelChange)="onFilterChange()"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Date From</label>
|
||||
<input type="date" [(ngModel)]="filter.dateFrom" (ngModelChange)="applyFilters()" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Date To</label>
|
||||
<input type="date" [(ngModel)]="filter.dateTo" (ngModelChange)="applyFilters()" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Older Than</label>
|
||||
<select [(ngModel)]="filter.olderThanHours" (ngModelChange)="applyFilters()">
|
||||
<option [ngValue]="undefined">Any age</option>
|
||||
<option [value]="1">1 hour</option>
|
||||
<option [value]="6">6 hours</option>
|
||||
<option [value]="24">24 hours</option>
|
||||
<option [value]="72">3 days</option>
|
||||
<option [value]="168">7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<div class="filter-bar__search">
|
||||
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input
|
||||
type="text"
|
||||
class="filter-bar__input"
|
||||
placeholder="Entry ID or Job ID..."
|
||||
[(ngModel)]="filter.search"
|
||||
(ngModelChange)="onFilterChange()"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-bar__clear"
|
||||
[class.filter-bar__clear--visible]="!!filter.search"
|
||||
(click)="filter.search = ''; applyFilters()"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<button class="btn btn-secondary" (click)="clearFilters()">Clear All</button>
|
||||
<span class="result-count">{{ totalEntries() }} entries</span>
|
||||
</div>
|
||||
</section>
|
||||
<stella-filter-multi
|
||||
label="Error"
|
||||
[options]="errorMultiOptions()"
|
||||
(optionsChange)="onErrorFilterChange($event)"
|
||||
/>
|
||||
<stella-filter-multi
|
||||
label="Status"
|
||||
[options]="statusMultiOptions()"
|
||||
(optionsChange)="onStatusFilterChange($event)"
|
||||
/>
|
||||
@if (jobTypeMultiOptions().length > 0) {
|
||||
<stella-filter-multi
|
||||
label="Job Type"
|
||||
[options]="jobTypeMultiOptions()"
|
||||
(optionsChange)="onJobTypeFilterChange($event)"
|
||||
/>
|
||||
}
|
||||
<span class="filter-bar__count">{{ totalEntries() }} entries</span>
|
||||
</div>
|
||||
|
||||
<!-- Queue Table -->
|
||||
<section class="table-section">
|
||||
@@ -318,36 +282,41 @@ import { OPERATIONS_PATHS, deadLetterEntryPath } from '../platform/ops/operation
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
|
||||
/* Filters */
|
||||
.filters-section {
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
font-size: 0.875rem;
|
||||
.filter-bar__search { position: relative; flex: 1; min-width: 200px; }
|
||||
.filter-bar__search-icon {
|
||||
position: absolute; left: 0.75rem; top: 50%;
|
||||
transform: translateY(-50%); color: var(--color-text-secondary);
|
||||
}
|
||||
.filter-bar__input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 2rem 0.5rem 2.25rem;
|
||||
}
|
||||
.filter-bar__clear {
|
||||
position: absolute; right: 0.5rem; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: none; background: none; cursor: pointer;
|
||||
color: var(--color-text-muted); padding: 0.15rem;
|
||||
border-radius: 50%; display: flex; align-items: center;
|
||||
opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.filter-bar__clear--visible { opacity: 1; pointer-events: auto; }
|
||||
.filter-bar__clear:hover { color: var(--color-text-primary); background: var(--color-surface-tertiary); }
|
||||
.filter-bar__count {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
@@ -508,6 +477,44 @@ export class DeadLetterQueueComponent implements OnInit, OnDestroy {
|
||||
'DLQ_VALIDATION', 'DLQ_POLICY', 'DLQ_AUTH', 'DLQ_CONFLICT', 'DLQ_UNKNOWN'
|
||||
];
|
||||
|
||||
private readonly statusList = ['pending', 'retrying', 'resolved', 'replayed', 'failed'];
|
||||
private readonly knownJobTypes = signal<string[]>([]);
|
||||
|
||||
readonly errorMultiOptions = computed<FilterMultiOption[]>(() =>
|
||||
this.errorCodes.map(c => ({ id: c, label: this.getErrorLabel(c), checked: !this.filter.errorCode || this.filter.errorCode === c }))
|
||||
);
|
||||
|
||||
readonly statusMultiOptions = computed<FilterMultiOption[]>(() =>
|
||||
this.statusList.map(s => ({ id: s, label: s, checked: !this.filter.state || this.filter.state === s }))
|
||||
);
|
||||
|
||||
readonly jobTypeMultiOptions = computed<FilterMultiOption[]>(() => {
|
||||
const types = this.knownJobTypes();
|
||||
return types.map(t => ({ id: t, label: t, checked: !this.filter.jobType || this.filter.jobType === t }));
|
||||
});
|
||||
|
||||
onErrorFilterChange(opts: FilterMultiOption[]): void {
|
||||
const checked = opts.filter(o => o.checked);
|
||||
this.filter.errorCode = checked.length === this.errorCodes.length || checked.length === 0
|
||||
? undefined : checked[0].id as ErrorCode;
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
onStatusFilterChange(opts: FilterMultiOption[]): void {
|
||||
const checked = opts.filter(o => o.checked);
|
||||
this.filter.state = checked.length === this.statusList.length || checked.length === 0
|
||||
? undefined : checked[0].id as DeadLetterFilter['state'];
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
onJobTypeFilterChange(opts: FilterMultiOption[]): void {
|
||||
const checked = opts.filter(o => o.checked);
|
||||
const types = this.knownJobTypes();
|
||||
this.filter.jobType = checked.length === types.length || checked.length === 0
|
||||
? undefined : checked[0].id;
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
readonly allSelected = computed(() =>
|
||||
this.entries().length > 0 && this.selectedIds().length === this.entries().length
|
||||
);
|
||||
@@ -566,6 +573,9 @@ export class DeadLetterQueueComponent implements OnInit, OnDestroy {
|
||||
this.entries.set(filtered);
|
||||
this.totalEntries.set(this.usesClientSideFiltering() ? filtered.length : response.total);
|
||||
this.nextCursor = response.cursor;
|
||||
// Populate known job types for multi-select filter
|
||||
const types = Array.from(new Set(response.items.map(e => e.jobType).filter(Boolean))).sort();
|
||||
if (types.length > 0) { this.knownJobTypes.set(types); }
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
|
||||
@@ -144,6 +144,9 @@ import { DateFormatService } from '../../core/i18n/date-format.service';
|
||||
|
||||
.filter-search { position: relative; flex: 0 1 240px; min-width: 160px; }
|
||||
.filter-search__icon { position: absolute; left: 0.5rem; top: 50%; transform: translateY(-50%); color: var(--color-text-muted); pointer-events: none; }
|
||||
.filter-search__input { width: 100%; height: 28px; padding: 0 0.5rem 0 1.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-surface-primary); color: var(--color-text-primary); font-size: 0.75rem; font-family: inherit; outline: none; }
|
||||
.filter-search__input::placeholder { color: var(--color-text-muted); }
|
||||
.filter-search__input:focus { border-color: var(--color-brand-primary); }
|
||||
|
||||
.table-wrap { overflow-x: auto; }
|
||||
|
||||
|
||||
@@ -451,12 +451,12 @@ export class GlossaryTooltipDirective implements AfterViewInit, OnDestroy {
|
||||
styleEl.textContent = `
|
||||
.glossary-term {
|
||||
cursor: help;
|
||||
text-decoration: underline dotted color-mix(in srgb, var(--color-brand-primary, #2563eb) 70%, transparent);
|
||||
text-decoration: underline dotted color-mix(in srgb, var(--color-brand-primary, #F5A623) 70%, transparent);
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
|
||||
.glossary-term--inline {
|
||||
color: var(--color-brand-primary, #2563eb);
|
||||
color: var(--color-brand-primary, #F5A623);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -467,9 +467,9 @@ export class GlossaryTooltipDirective implements AfterViewInit, OnDestroy {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand-primary, #2563eb) 22%, var(--color-border-primary, #d0d7de));
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand-primary, #F5A623) 22%, var(--color-border-primary, #d0d7de));
|
||||
border-radius: 0.85rem;
|
||||
background: color-mix(in srgb, var(--color-surface-elevated, #ffffff) 96%, var(--color-brand-primary, #2563eb) 4%);
|
||||
background: color-mix(in srgb, var(--color-surface-elevated, #ffffff) 96%, var(--color-brand-primary, #F5A623) 4%);
|
||||
color: var(--color-text-primary, #0f172a);
|
||||
box-shadow: 0 16px 42px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user