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:
master
2026-04-07 15:34:02 +03:00
parent c778e74e22
commit 1ac518282b
3 changed files with 116 additions and 103 deletions

View File

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

View File

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

View File

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