Improve execution group management
This commit is contained in:
parent
def50c229b
commit
1270bf7ea7
|
|
@ -13,6 +13,7 @@
|
|||
[selectedExecutionId]="selectedExecutionId()"
|
||||
(executionSelected)="selectExecution($event)"
|
||||
(executionDeleteRequested)="removeExecution($event)"
|
||||
(executionGroupDeleteRequested)="removeExecutionGroup($event)"
|
||||
(executionRerunRequested)="rerunExecution($event)"
|
||||
(compareRequested)="openComparison($event)">
|
||||
</app-tasks-executions-list>
|
||||
|
|
|
|||
|
|
@ -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([]))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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')"></i>
|
||||
<i
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
@switch (open) {
|
||||
@case ('flows') {
|
||||
<div data-tour="editor-sidebar-flows" class="h-full min-h-0 flex flex-col">
|
||||
<app-group-holder title="Flows" icon="bi-lightning-charge-fill">
|
||||
<app-group-holder headerTitle="Flows" icon="bi-lightning-charge-fill">
|
||||
<span header-button class="editor-sidebar-new-flow-header-action">
|
||||
<input
|
||||
#importInput
|
||||
|
|
@ -114,14 +114,14 @@
|
|||
}
|
||||
@case ('blocks') {
|
||||
<div data-tour="editor-sidebar-blocks" class="h-full min-h-0 flex flex-col">
|
||||
<app-group-holder title="Blocks" icon="bi-boxes">
|
||||
<app-group-holder headerTitle="Blocks" icon="bi-boxes">
|
||||
<app-blocks-list></app-blocks-list>
|
||||
</app-group-holder>
|
||||
</div>
|
||||
}
|
||||
@case ('containers') {
|
||||
<div data-tour="editor-sidebar-containers" class="h-full min-h-0 flex flex-col">
|
||||
<app-group-holder title="Containers" icon="bi-box-seam">
|
||||
<app-group-holder headerTitle="Containers" icon="bi-box-seam">
|
||||
<app-containers-list></app-containers-list>
|
||||
</app-group-holder>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export abstract class TaskExecutionsCallServiceBase {
|
|||
abstract listBiasImpactReports(executionId: string): Observable<BiasImpactReport[]>;
|
||||
abstract getBiasImpactReport(reportId: string): Observable<BiasImpactReport>;
|
||||
abstract deleteTaskExecution(executionId: string): Observable<void>;
|
||||
abstract deleteTaskExecutionGroup(groupId: string): Observable<void>;
|
||||
abstract startTaskExecution(executionId: string): Observable<TaskExecution>;
|
||||
abstract simulateTaskExecution(executionId: string, simulator: LLMDescriptor, credentialId?: string): Observable<TaskExecution>;
|
||||
abstract cancelTaskExecution(executionId: string): Observable<TaskExecution>;
|
||||
|
|
|
|||
|
|
@ -788,6 +788,13 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
|
|||
return of(void 0);
|
||||
}
|
||||
|
||||
override deleteTaskExecutionGroup(groupId: string): Observable<void> {
|
||||
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<TaskExecution> {
|
||||
const execution = this.findExecution(executionId);
|
||||
execution.interactionSimulationEnabled = false;
|
||||
|
|
|
|||
|
|
@ -128,6 +128,10 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
return this.http.delete<void>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}`);
|
||||
}
|
||||
|
||||
override deleteTaskExecutionGroup(groupId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${environment.apiUrl}/executions/groups/${encodeURIComponent(groupId)}`);
|
||||
}
|
||||
|
||||
override startTaskExecution(executionId: string): Observable<TaskExecution> {
|
||||
return this.http.put<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null).pipe(
|
||||
map((raw) => this.mapExecution(raw))
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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'"></mat-icon>
|
||||
<span class="flow-item-title">{{ flow().name }}</span>
|
||||
<span class="flow-item-title" [appTruncatedTooltip]="flow().name">
|
||||
{{ flow().name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flow-item-sub">
|
||||
<span class="flow-item-author">{{ flow().author }}</span>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@
|
|||
class="flows-list-group-chevron"
|
||||
[fontIcon]="expanded() ? 'expand_less' : 'expand_more'"></mat-icon>
|
||||
<div class="flows-list-group-main">
|
||||
<div class="flows-list-group-title">{{ title() }}</div>
|
||||
<div class="flows-list-group-title" [appTruncatedTooltip]="title()">
|
||||
{{ title() }}
|
||||
</div>
|
||||
@if (project()?.description) {
|
||||
<div class="flows-list-group-subtitle">{{ project()?.description }}</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="bi {{ icon }} text-lg"></i>
|
||||
<span class="truncate">{{ title }}</span>
|
||||
<span class="truncate">{{ headerTitle }}</span>
|
||||
</div>
|
||||
<span class="group-holder-header-action">
|
||||
<ng-content select="[header-button]"></ng-content>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
|||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class GroupHolder {
|
||||
@Input() title = '';
|
||||
@Input() headerTitle = '';
|
||||
@Input() icon = '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -51,24 +51,51 @@
|
|||
@for (group of filteredGroups(); track group.id) {
|
||||
<mat-list-item class="tasks-list-list-item">
|
||||
<mat-card class="tasks-list-group" [class.tasks-list-group-expanded]="isGroupExpanded(group.id)">
|
||||
<button type="button" class="tasks-list-group-header" (click)="toggleGroup(group.id, $event)">
|
||||
<mat-icon class="tasks-list-group-chevron" [fontIcon]="isGroupExpanded(group.id) ? 'expand_less' : 'expand_more'"></mat-icon>
|
||||
<div class="tasks-list-group-main">
|
||||
<div class="tasks-list-group-title">{{ group.name }}</div>
|
||||
<div class="tasks-list-group-subtitle">
|
||||
@if (group.projectName) {
|
||||
<span class="tasks-list-project-chip">{{ group.projectName }}</span>
|
||||
}
|
||||
<div class="tasks-list-group-header-row">
|
||||
<button type="button" class="tasks-list-group-header" (click)="toggleGroup(group.id, $event)">
|
||||
<mat-icon class="tasks-list-group-chevron" [fontIcon]="isGroupExpanded(group.id) ? 'expand_less' : 'expand_more'"></mat-icon>
|
||||
<div class="tasks-list-group-main">
|
||||
<div class="tasks-list-group-title" [appTruncatedTooltip]="group.name">
|
||||
{{ group.name }}
|
||||
</div>
|
||||
<div class="tasks-list-group-subtitle">
|
||||
@if (group.projectName) {
|
||||
<span class="tasks-list-project-chip">{{ group.projectName }}</span>
|
||||
}
|
||||
<span>Latest {{ group.lastExecutionTimeLabel }} · {{ runNumberLabel(group.latestRunNumber) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tasks-list-group-summary">
|
||||
<span class="tasks-list-count">{{ runCountLabel(group.executionCount) }}</span>
|
||||
<span class="tasks-list-status" [ngClass]="statusBadgeClass(group.latestStatus)">
|
||||
{{ group.latestStatus }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="tasks-list-group-summary">
|
||||
<span class="tasks-list-count">{{ runCountLabel(group.executionCount) }}</span>
|
||||
<span class="tasks-list-status" [ngClass]="statusBadgeClass(group.latestStatus)">
|
||||
{{ group.latestStatus }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@if (!isGroupExpanded(group.id)) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="tasks-list-open-latest"
|
||||
matTooltip="Open latest execution"
|
||||
aria-label="Open latest execution"
|
||||
(click)="selectExecution(group.latestExecutionId)">
|
||||
<mat-icon fontIcon="arrow_forward"></mat-icon>
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="tasks-list-delete-group"
|
||||
[disabled]="isGroupDeleteDisabled(group)"
|
||||
[matTooltip]="isGroupDeleteDisabled(group) ? 'Stop running executions before deleting this group' : 'Delete every execution in this group'"
|
||||
aria-label="Delete execution group"
|
||||
(click)="requestDeleteExecutionGroup(group, $event)">
|
||||
<mat-icon fontIcon="delete"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (isGroupExpanded(group.id)) {
|
||||
<div class="tasks-list-group-meta">
|
||||
<span>Latest {{ group.lastExecutionTimeLabel }}</span>
|
||||
<span>Run {{ runNumberLabel(group.latestRunNumber) }}</span>
|
||||
|
|
@ -94,7 +121,6 @@
|
|||
</div>
|
||||
}
|
||||
|
||||
@if (isGroupExpanded(group.id)) {
|
||||
<div class="tasks-list-executions">
|
||||
@for (execution of group.executions; track execution.id) {
|
||||
<div
|
||||
|
|
@ -119,9 +145,33 @@
|
|||
{{ kindLabel(execution) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="tasks-list-status" [ngClass]="statusBadgeClass(execution.status)">
|
||||
{{ execution.status }}
|
||||
</span>
|
||||
<div class="tasks-list-execution-trailing">
|
||||
<span class="tasks-list-status" [ngClass]="statusBadgeClass(execution.status)">
|
||||
{{ execution.status }}
|
||||
</span>
|
||||
<div class="tasks-list-execution-actions">
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="tasks-list-execution-action tasks-list-execution-rerun"
|
||||
matTooltip="Rerun execution"
|
||||
aria-label="Rerun execution"
|
||||
[disabled]="!canRerun(execution.status)"
|
||||
(click)="requestRerunExecution(execution.id, $event)">
|
||||
<mat-icon fontIcon="replay"></mat-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="tasks-list-execution-action tasks-list-execution-delete"
|
||||
matTooltip="Delete execution"
|
||||
aria-label="Delete execution"
|
||||
[disabled]="isDeleteDisabled(execution.status)"
|
||||
(click)="requestDeleteExecution(execution.id, $event)">
|
||||
<mat-icon fontIcon="delete"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tasks-list-execution-details">
|
||||
<span>{{ execution.startedAt }}</span>
|
||||
|
|
@ -134,36 +184,9 @@
|
|||
<span class="tasks-list-simulated-badge">Simulated</span>
|
||||
}
|
||||
</div>
|
||||
<div class="tasks-list-execution-actions">
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="!h-8 !w-8 enabled:!text-emerald-700 disabled:!text-slate-300"
|
||||
matTooltip="Rerun execution"
|
||||
aria-label="Rerun execution"
|
||||
[disabled]="!canRerun(execution.status)"
|
||||
(click)="requestRerunExecution(execution.id, $event)">
|
||||
<mat-icon fontIcon="replay"></mat-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="!h-8 !w-8 enabled:!text-red-600 disabled:!text-red-200"
|
||||
matTooltip="Delete execution"
|
||||
aria-label="Delete execution"
|
||||
[disabled]="isDeleteDisabled(execution.status)"
|
||||
(click)="requestDeleteExecution(execution.id, $event)">
|
||||
<mat-icon fontIcon="delete"></mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<button type="button" class="tasks-list-latest" (click)="selectExecution(group.latestExecutionId)">
|
||||
<span>Open latest execution</span>
|
||||
<mat-icon fontIcon="arrow_forward"></mat-icon>
|
||||
</button>
|
||||
}
|
||||
</mat-card>
|
||||
</mat-list-item>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
readonly executionSelected = output<string>();
|
||||
readonly executionDeleteRequested = output<string>();
|
||||
readonly executionGroupDeleteRequested = output<TaskExecutionGroupListItem>();
|
||||
readonly executionRerunRequested = output<string>();
|
||||
/** 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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - 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<HTMLElement>);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue