diff --git a/src/app/layouts/tasks-executor/tasks-executor.html b/src/app/layouts/tasks-executor/tasks-executor.html index 600199f..c9c94d2 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.html +++ b/src/app/layouts/tasks-executor/tasks-executor.html @@ -13,6 +13,7 @@ [selectedExecutionId]="selectedExecutionId()" (executionSelected)="selectExecution($event)" (executionDeleteRequested)="removeExecution($event)" + (executionGroupDeleteRequested)="removeExecutionGroup($event)" (executionRerunRequested)="rerunExecution($event)" (compareRequested)="openComparison($event)"> diff --git a/src/app/layouts/tasks-executor/tasks-executor.spec.ts b/src/app/layouts/tasks-executor/tasks-executor.spec.ts index 416e416..abba078 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.spec.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.spec.ts @@ -73,6 +73,7 @@ describe('TasksExecutor', () => { init: vi.fn(), retrieveExecution: vi.fn().mockReturnValue(of(null)), deleteExecution: vi.fn().mockReturnValue(of(null)), + deleteExecutionGroup: vi.fn().mockReturnValue(of(null)), rerunExecution: vi.fn().mockReturnValue(of(null)), retrieveExecutionEvents: vi.fn().mockReturnValue(of([])) } diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts index 4b67bdc..de98c7d 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.ts @@ -331,6 +331,25 @@ export class TasksExecutor { }); } + async removeExecutionGroup(group: TaskExecutionGroupListItem) { + const confirmed = await this.confirm.open( + `Delete all ${group.executionCount} executions in “${group.name}”? This permanently removes this group's history, including child executions and comparison data. This cannot be undone.` + ); + if (!confirmed) return; + + this.taskExecutionsService.deleteExecutionGroup(group.id).subscribe({ + next: () => { + if (group.executions.some((execution) => execution.id === this.selectedExecutionId())) { + this.selectedExecutionId.set(null); + this.requestedExecutionId.set(null); + this.selectedChildExecutionId.set(null); + this.closeComparison(); + } + }, + error: (err) => console.error('Error deleting execution group:', err) + }); + } + rerunExecution(id: string) { this.taskExecutionsService.rerunExecution(id).subscribe({ next: (execution) => this.selectExecution(execution.id), diff --git a/src/app/pages/main/editor-sidebar/editor-sidebar.html b/src/app/pages/main/editor-sidebar/editor-sidebar.html index a0c168a..5fa7d1e 100644 --- a/src/app/pages/main/editor-sidebar/editor-sidebar.html +++ b/src/app/pages/main/editor-sidebar/editor-sidebar.html @@ -30,7 +30,7 @@ 'cursor-none bg-gray-200 text-indigo-500': !flowsDisabled() && open == 'flows', 'text-gray-200': flowsDisabled() }" - [attr.title]="flowsDisabled() ? 'Exit fullscreen to browse other flows' : 'Flows'" + [attr.aria-label]="flowsDisabled() ? 'Exit fullscreen to browse other flows' : 'Flows'" class="bi bi-lightning-charge-fill text-xl p-2" (click)="!flowsDisabled() && open != 'flows' && openSide('flows')"> - + - + } @case ('containers') {
- +
diff --git a/src/app/services/task-executions/task-executions-call.base.ts b/src/app/services/task-executions/task-executions-call.base.ts index 45379c7..9256ddc 100644 --- a/src/app/services/task-executions/task-executions-call.base.ts +++ b/src/app/services/task-executions/task-executions-call.base.ts @@ -40,6 +40,7 @@ export abstract class TaskExecutionsCallServiceBase { abstract listBiasImpactReports(executionId: string): Observable; abstract getBiasImpactReport(reportId: string): Observable; abstract deleteTaskExecution(executionId: string): Observable; + abstract deleteTaskExecutionGroup(groupId: string): Observable; abstract startTaskExecution(executionId: string): Observable; abstract simulateTaskExecution(executionId: string, simulator: LLMDescriptor, credentialId?: string): Observable; abstract cancelTaskExecution(executionId: string): Observable; diff --git a/src/app/services/task-executions/task-executions-call.fake.ts b/src/app/services/task-executions/task-executions-call.fake.ts index 55b9eef..0ae05f8 100644 --- a/src/app/services/task-executions/task-executions-call.fake.ts +++ b/src/app/services/task-executions/task-executions-call.fake.ts @@ -788,6 +788,13 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase return of(void 0); } + override deleteTaskExecutionGroup(groupId: string): Observable { + for (let index = this.data.length - 1; index >= 0; index--) { + if (this.executionSourceFlowId(this.data[index]) === groupId) this.data.splice(index, 1); + } + return of(void 0); + } + override startTaskExecution(executionId: string): Observable { const execution = this.findExecution(executionId); execution.interactionSimulationEnabled = false; diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts index 40b4cfe..b3c86de 100644 --- a/src/app/services/task-executions/task-executions-call.ts +++ b/src/app/services/task-executions/task-executions-call.ts @@ -128,6 +128,10 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { return this.http.delete(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}`); } + override deleteTaskExecutionGroup(groupId: string): Observable { + return this.http.delete(`${environment.apiUrl}/executions/groups/${encodeURIComponent(groupId)}`); + } + override startTaskExecution(executionId: string): Observable { return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null).pipe( map((raw) => this.mapExecution(raw)) diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts index 8eafc23..1f27902 100644 --- a/src/app/services/task-executions/task-executions.ts +++ b/src/app/services/task-executions/task-executions.ts @@ -235,6 +235,13 @@ export class TaskExecutionsService { ); } + deleteExecutionGroup(groupId: string) { + return this.withRefreshAndErrorHandling( + this.taskExecutionsCallService.deleteTaskExecutionGroup(groupId), + 'Delete execution group failed' + ); + } + startExecution(executionId: string) { return this.withRefreshAndErrorHandling( this.taskExecutionsCallService.startTaskExecution(executionId), diff --git a/src/app/shared/flows-list/flow-item/flow-item.html b/src/app/shared/flows-list/flow-item/flow-item.html index 0b2551f..f1491ed 100644 --- a/src/app/shared/flows-list/flow-item/flow-item.html +++ b/src/app/shared/flows-list/flow-item/flow-item.html @@ -17,7 +17,9 @@ [class.flow-item-visibility-public]="flow().visibility !== 'PRIVATE'" [matTooltip]="flow().visibility === 'PRIVATE' ? 'Private flow' : 'Public flow'" [fontIcon]="flow().visibility === 'PRIVATE' ? 'lock' : 'public'"> - {{ flow().name }} + + {{ flow().name }} +
{{ flow().author }} diff --git a/src/app/shared/flows-list/flow-item/flow-item.ts b/src/app/shared/flows-list/flow-item/flow-item.ts index 48dc928..3846f8d 100644 --- a/src/app/shared/flows-list/flow-item/flow-item.ts +++ b/src/app/shared/flows-list/flow-item/flow-item.ts @@ -20,11 +20,12 @@ import { NotificationService } from '@services/notifications/notification'; import { ProjectsService } from '@services/projects/projects'; import { PROJECTS_ENABLED } from '@shared/feature-flags'; import { EditorStateHolder } from '@stores/flow-editor'; +import { TruncatedTooltipDirective } from '@shared/truncated-tooltip/truncated-tooltip'; import { firstValueFrom } from 'rxjs'; @Component({ selector: 'app-flow-item', - imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule, MatMenuModule, MatTooltipModule], + imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule, MatMenuModule, MatTooltipModule, TruncatedTooltipDirective], templateUrl: './flow-item.html', styleUrl: './flow-item.css', changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/app/shared/flows-list/flows-group/flows-group.html b/src/app/shared/flows-list/flows-group/flows-group.html index 47bbdb1..942696c 100644 --- a/src/app/shared/flows-list/flows-group/flows-group.html +++ b/src/app/shared/flows-list/flows-group/flows-group.html @@ -15,7 +15,9 @@ class="flows-list-group-chevron" [fontIcon]="expanded() ? 'expand_less' : 'expand_more'">
-
{{ title() }}
+
+ {{ title() }} +
@if (project()?.description) {
{{ project()?.description }}
} diff --git a/src/app/shared/flows-list/flows-group/flows-group.ts b/src/app/shared/flows-list/flows-group/flows-group.ts index 713da99..0ce2874 100644 --- a/src/app/shared/flows-list/flows-group/flows-group.ts +++ b/src/app/shared/flows-list/flows-group/flows-group.ts @@ -11,6 +11,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { Flow } from '@models/flow'; import { Project } from '@models/project'; import { FlowItem } from '../flow-item/flow-item'; +import { TruncatedTooltipDirective } from '@shared/truncated-tooltip/truncated-tooltip'; /** * One collapsible project section of the flows list. Purely presentational: the list owns the @@ -21,7 +22,7 @@ import { FlowItem } from '../flow-item/flow-item'; */ @Component({ selector: 'app-flows-group', - imports: [FlowItem, MatButtonModule, MatCardModule, MatIconModule, MatMenuModule, MatTooltipModule], + imports: [FlowItem, MatButtonModule, MatCardModule, MatIconModule, MatMenuModule, MatTooltipModule, TruncatedTooltipDirective], templateUrl: './flows-group.html', styleUrl: './flows-group.css', changeDetection: ChangeDetectionStrategy.OnPush @@ -51,4 +52,5 @@ export class FlowsGroup { event.stopPropagation(); this.toggled.emit(); } + } diff --git a/src/app/shared/group-holder/group-holder.html b/src/app/shared/group-holder/group-holder.html index 27ad39b..0dc8c58 100644 --- a/src/app/shared/group-holder/group-holder.html +++ b/src/app/shared/group-holder/group-holder.html @@ -19,7 +19,7 @@
- {{ title }} + {{ headerTitle }}
diff --git a/src/app/shared/group-holder/group-holder.ts b/src/app/shared/group-holder/group-holder.ts index e3b588f..6995ac8 100644 --- a/src/app/shared/group-holder/group-holder.ts +++ b/src/app/shared/group-holder/group-holder.ts @@ -12,6 +12,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; changeDetection: ChangeDetectionStrategy.OnPush }) export class GroupHolder { - @Input() title = ''; + @Input() headerTitle = ''; @Input() icon = ''; } diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.css b/src/app/shared/tasks-executions-list/tasks-executions-list.css index 519dc89..af64998 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.css +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.css @@ -75,7 +75,7 @@ /* Flat, like the flows list: a scrolling history should not be a stack of raised, tinted panels. */ .tasks-list-group { width: 100%; - padding: 8px 10px; + padding: 7px 8px; border: 1px solid #e2e8f0; border-radius: 6px; background: #ffffff; @@ -95,14 +95,21 @@ background: #f8fafc; } +.tasks-list-group-header-row { + display: flex; + align-items: center; + gap: 2px; +} + .tasks-list-group-header { - width: 100%; + flex: 1; + min-width: 0; border: 0; padding: 0; display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; - align-items: start; - gap: 8px; + align-items: center; + gap: 6px; background: transparent; text-align: left; cursor: pointer; @@ -125,7 +132,11 @@ color: #1e293b; } +/* Keep the compact ellipsis at rest, but reveal the real label in its own card on hover. */ .tasks-list-group-subtitle { + display: flex; + align-items: center; + gap: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -137,8 +148,8 @@ display: flex; align-items: center; justify-content: flex-end; - gap: 6px; - flex-wrap: wrap; + gap: 4px; + flex-wrap: nowrap; } .tasks-list-count { @@ -151,7 +162,7 @@ color: #1d4ed8; font-size: 10px; font-weight: 700; - padding: 2px 8px; + padding: 1px 6px; white-space: nowrap; } @@ -173,7 +184,7 @@ display: inline-flex; align-items: center; min-height: 20px; - padding: 3px 9px; + padding: 2px 7px; border-radius: 999px; border-width: 1px; font-size: 10px; @@ -186,26 +197,26 @@ align-items: center; justify-content: space-between; gap: 8px; - margin-top: 10px; + margin-top: 6px; font-size: 11px; color: #64748b; - padding-top: 8px; + padding-top: 6px; border-top: 1px solid rgba(148, 163, 184, 0.18); } .tasks-list-executions { display: flex; flex-direction: column; - gap: 8px; - margin-top: 10px; + gap: 5px; + margin-top: 7px; } .tasks-list-execution { position: relative; border: 1px solid #eef2f6; border-left: 3px solid transparent; - border-radius: 6px; - padding: 7px 8px; + border-radius: 5px; + padding: 5px 7px; background: #ffffff; cursor: pointer; transition: border-color 0.12s ease, background-color 0.12s ease; @@ -229,6 +240,13 @@ gap: 8px; } +.tasks-list-execution-trailing { + display: flex; + align-items: center; + gap: 3px; + flex: 0 0 auto; +} + .tasks-list-execution-title { display: flex; align-items: center; @@ -254,42 +272,71 @@ .tasks-list-execution-details { display: flex; align-items: center; - gap: 8px; - margin-top: 6px; + gap: 6px; + margin-top: 2px; min-width: 0; flex-wrap: wrap; - font-size: 11px; + font-size: 10px; color: #64748b; } .tasks-list-execution-actions { - display: flex; - justify-content: flex-end; - gap: 4px; - margin-top: 6px; -} - -.tasks-list-latest { - width: 100%; - border: 1px solid #dbeafe; - border-radius: 8px; - padding: 8px 10px; display: flex; align-items: center; - justify-content: center; - gap: 6px; - margin-top: 10px; - background: #f8fafc; - color: #2563eb; - font-size: 12px; - font-weight: 700; - cursor: pointer; + gap: 1px; } -.tasks-list-latest:hover { +.tasks-list-execution-action { + width: 26px !important; + height: 26px !important; + padding: 0 !important; +} + +.tasks-list-execution-action .mat-icon { + width: 17px; + height: 17px; + font-size: 17px; + line-height: 17px; +} + +.tasks-list-execution-rerun { + color: #047857; +} + +.tasks-list-execution-rerun:disabled { + color: #cbd5e1; +} + +.tasks-list-execution-delete { + color: #dc2626; +} + +.tasks-list-execution-delete:disabled { + color: #fecaca; +} + +.tasks-list-open-latest { + flex: 0 0 auto; + color: #2563eb; +} + +.tasks-list-open-latest:hover { background: #eff6ff; } +.tasks-list-delete-group { + flex: 0 0 auto; + color: #dc2626; +} + +.tasks-list-delete-group:disabled { + color: #cbd5e1; +} + +.tasks-list-delete-group:hover:not(:disabled) { + background: #fff1f2; +} + /* Clusters the sibling groups produced by one project run. */ .tasks-list-project-chip { display: inline-block; diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.html b/src/app/shared/tasks-executions-list/tasks-executions-list.html index 46512d2..c744b21 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.html +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.html @@ -51,24 +51,51 @@ @for (group of filteredGroups(); track group.id) { -
-
- {{ runCountLabel(group.executionCount) }} - - {{ group.latestStatus }} - -
- +
+ {{ runCountLabel(group.executionCount) }} + + {{ group.latestStatus }} + +
+ + @if (!isGroupExpanded(group.id)) { + + } + +
+ @if (isGroupExpanded(group.id)) {
Latest {{ group.lastExecutionTimeLabel }} Run {{ runNumberLabel(group.latestRunNumber) }} @@ -94,7 +121,6 @@
} - @if (isGroupExpanded(group.id)) {
@for (execution of group.executions; track execution.id) {
- - {{ execution.status }} - +
+ + {{ execution.status }} + +
+ + +
+
{{ execution.startedAt }} @@ -134,36 +184,9 @@ Simulated }
-
- - -
} - } @else { - } diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.spec.ts b/src/app/shared/tasks-executions-list/tasks-executions-list.spec.ts index 3b4600a..946bf01 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.spec.ts +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.spec.ts @@ -100,6 +100,25 @@ describe('TasksExecutionsListComponent comparison picking', () => { expect(component.selectedExecutionId()).toBe('e1'); }); + it('requests clearing every run in one group from its header action', () => { + const deleteRequested = vi.fn(); + const target = group('g1', ['e1', 'e2']); + component.executionGroupDeleteRequested.subscribe(deleteRequested); + + component.requestDeleteExecutionGroup(target); + + expect(deleteRequested).toHaveBeenCalledWith(target); + }); + + it('disables deleting a group while one of its executions is running', () => { + const runningGroup: TaskExecutionGroupListItem = { + ...group('g1', ['e1']), + executions: [{ ...group('g1', ['e1']).executions[0], status: 'RUNNING' }] + }; + + expect(component.isGroupDeleteDisabled(runningGroup)).toBe(true); + }); + it('confines comparison to one group, and clears the picks on leaving', () => { // Runs of different flows share no node ids, so comparing across groups is meaningless. component.toggleCompareMode('g1'); diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.ts b/src/app/shared/tasks-executions-list/tasks-executions-list.ts index 595aa6f..ceae6ec 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.ts +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.ts @@ -16,6 +16,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { getExecutionStatusGroup, TaskExecutionStatus, TaskExecutionStatusGroup } from '@models/task-execution'; import { OrderEvent, OrderField, Ordering, orderDirType } from '@shared/ordering/ordering'; import { OrderViewState } from '@utilities/list-state-holder'; +import { TruncatedTooltipDirective } from '@shared/truncated-tooltip/truncated-tooltip'; export type TaskExecutionFilter = 'all' | TaskExecutionStatusGroup; @@ -67,7 +68,7 @@ export type TaskExecutionGroupListItem = { @Component({ selector: 'app-tasks-executions-list', - imports: [CommonModule, FormsModule, Ordering, MatButtonModule, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatTooltipModule], + imports: [CommonModule, FormsModule, Ordering, MatButtonModule, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatTooltipModule, TruncatedTooltipDirective], templateUrl: './tasks-executions-list.html', styleUrl: './tasks-executions-list.css', changeDetection: ChangeDetectionStrategy.OnPush @@ -77,6 +78,7 @@ export class TasksExecutionsListComponent { readonly selectedExecutionId = input(null); readonly executionSelected = output(); readonly executionDeleteRequested = output(); + readonly executionGroupDeleteRequested = output(); readonly executionRerunRequested = output(); /** Two runs of one group, picked to be compared. */ readonly compareRequested = output<{ leftId: string; rightId: string }>(); @@ -201,6 +203,11 @@ export class TasksExecutionsListComponent { this.executionDeleteRequested.emit(executionId); } + requestDeleteExecutionGroup(group: TaskExecutionGroupListItem, event?: Event) { + event?.stopPropagation(); + this.executionGroupDeleteRequested.emit(group); + } + requestRerunExecution(executionId: string, event?: Event) { event?.stopPropagation(); this.executionRerunRequested.emit(executionId); @@ -246,6 +253,12 @@ export class TasksExecutionsListComponent { return getExecutionStatusGroup(status) === 'RUNNING'; } + isGroupDeleteDisabled(group: TaskExecutionGroupListItem): boolean { + return group.executions.some((execution) => + getExecutionStatusGroup(execution.status) === 'RUNNING' + ); + } + canRerun(status: TaskExecutionStatus): boolean { return getExecutionStatusGroup(status) === 'FINAL'; } diff --git a/src/app/shared/truncated-tooltip/truncated-tooltip.ts b/src/app/shared/truncated-tooltip/truncated-tooltip.ts new file mode 100644 index 0000000..9f21dea --- /dev/null +++ b/src/app/shared/truncated-tooltip/truncated-tooltip.ts @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - ISTI-CNR +// SPDX-License-Identifier: AGPL-3.0-or-later +// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM. + +import { AfterViewInit, Directive, ElementRef, Input, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core'; +import { MatTooltip } from '@angular/material/tooltip'; + +/** + * Shows a title preview only when its host has actually been ellipsized. Measuring after render + * and observing size changes avoids the stale first-row-only result a template expression gives + * while a list is still laying itself out. + */ +@Directive({ + selector: '[appTruncatedTooltip]', + standalone: true, + hostDirectives: [MatTooltip] +}) +export class TruncatedTooltipDirective implements AfterViewInit, OnChanges, OnDestroy { + @Input({ required: true }) appTruncatedTooltip = ''; + + private readonly element = inject(ElementRef); + private readonly tooltip = inject(MatTooltip); + private resizeObserver: ResizeObserver | null = null; + + ngAfterViewInit() { + this.tooltip.position = 'above'; + this.tooltip.tooltipClass = 'title-preview-tooltip'; + this.scheduleRefresh(); + + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(() => this.refresh()); + this.resizeObserver.observe(this.element.nativeElement); + } + } + + ngOnChanges(_changes: SimpleChanges) { + this.tooltip.message = this.appTruncatedTooltip; + this.scheduleRefresh(); + } + + ngOnDestroy() { + this.resizeObserver?.disconnect(); + } + + private scheduleRefresh() { + queueMicrotask(() => this.refresh()); + } + + private refresh() { + const host = this.element.nativeElement; + this.tooltip.message = this.appTruncatedTooltip; + this.tooltip.disabled = host.scrollWidth <= host.clientWidth; + } +} diff --git a/src/styles.css b/src/styles.css index 0a14c0f..9dc75ba 100644 --- a/src/styles.css +++ b/src/styles.css @@ -422,6 +422,22 @@ body.node-focus-modal-open { transform: translateY(0); } +/* Full sidebar titles float above the sidebar like a horizontally expanded card. */ +.cdk-overlay-container:has(.title-preview-tooltip) { + z-index: 2000; +} + +.title-preview-tooltip.mat-mdc-tooltip-surface, +.title-preview-tooltip .mat-mdc-tooltip-surface { + max-width: calc(100vw - 2rem); + white-space: nowrap; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #ffffff; + color: #1e293b; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.2); +} + .llm-error-title, .llm-warning-list-title { font-size: 11px;