From 1ac518282bba71d188a5f63b9f704cc1962c1219 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 7 Apr 2026 15:34:02 +0300 Subject: [PATCH] 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) --- .../deadletter/deadletter-queue.component.ts | 208 +++++++++--------- .../scripts/scripts-list.component.ts | 3 + .../directives/glossary-tooltip.directive.ts | 8 +- 3 files changed, 116 insertions(+), 103 deletions(-) diff --git a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-queue.component.ts b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-queue.component.ts index d26a804e3..9a46b0db0 100644 --- a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-queue.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-queue.component.ts @@ -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: `
@@ -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([]); + + readonly errorMultiOptions = computed(() => + this.errorCodes.map(c => ({ id: c, label: this.getErrorLabel(c), checked: !this.filter.errorCode || this.filter.errorCode === c })) + ); + + readonly statusMultiOptions = computed(() => + this.statusList.map(s => ({ id: s, label: s, checked: !this.filter.state || this.filter.state === s })) + ); + + readonly jobTypeMultiOptions = computed(() => { + 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: () => { diff --git a/src/Web/StellaOps.Web/src/app/features/scripts/scripts-list.component.ts b/src/Web/StellaOps.Web/src/app/features/scripts/scripts-list.component.ts index 4f0628154..f194e54c7 100644 --- a/src/Web/StellaOps.Web/src/app/features/scripts/scripts-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/scripts/scripts-list.component.ts @@ -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; } diff --git a/src/Web/StellaOps.Web/src/app/shared/directives/glossary-tooltip.directive.ts b/src/Web/StellaOps.Web/src/app/shared/directives/glossary-tooltip.directive.ts index bef64771b..4e4374f6f 100644 --- a/src/Web/StellaOps.Web/src/app/shared/directives/glossary-tooltip.directive.ts +++ b/src/Web/StellaOps.Web/src/app/shared/directives/glossary-tooltip.directive.ts @@ -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); }