diff --git a/src/app/layouts/tasks-executor/tasks-executor.html b/src/app/layouts/tasks-executor/tasks-executor.html index 1dd5df5..1dcdfca 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.html +++ b/src/app/layouts/tasks-executor/tasks-executor.html @@ -4,7 +4,8 @@ class="w-[320px] shrink-0" [executions]="executions()" [selectedExecutionId]="selectedExecutionId()" - (executionSelected)="selectExecution($event)"> + (executionSelected)="selectExecution($event)" + (executionDeleteRequested)="removeExecution($event)">
diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts index 2d697ef..2b60b39 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.ts @@ -5,6 +5,7 @@ import { TasksExecutionsListComponent } from '@shared/tasks-executions-list/tasks-executions-list'; import { TaskExecutionViewerComponent } from '@shared/task-execution-viewer/task-execution-viewer'; +import { ConfirmDialogService } from '@services/dialogs/confirm-dialog'; import { TaskExecutionsService } from '@services/task-executions/task-executions'; @Component({ @@ -15,6 +16,7 @@ import { TaskExecutionsService } from '@services/task-executions/task-executions }) export class TasksExecutor { private taskExecutionsService = inject(TaskExecutionsService); + private confirm = inject(ConfirmDialogService); readonly executionDetails = this.taskExecutionsService.taskExecutions; @@ -52,6 +54,20 @@ export class TasksExecutor { this.selectedExecutionId.set(id); } + async removeExecution(id: string) { + const confirmed = await this.confirm.open('Are you sure you want to delete this execution?'); + if (!confirmed) return; + + this.taskExecutionsService.deleteExecution(id).subscribe({ + next: () => { + if (this.selectedExecutionId() === id) { + this.selectedExecutionId.set(null); + } + }, + error: (err) => console.error('Error deleting execution:', err) + }); + } + private formatDateTime(timestamp: number): string { const date = new Date(timestamp); const yyyy = date.getFullYear(); 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 2b120b2..447b196 100644 --- a/src/app/services/task-executions/task-executions-call.base.ts +++ b/src/app/services/task-executions/task-executions-call.base.ts @@ -4,6 +4,7 @@ import { Observable } from 'rxjs'; export abstract class TaskExecutionsCallServiceBase { abstract retrieveAllTaskExecutions(): Observable; abstract createTaskExecution(flowId: string): Observable; + abstract deleteTaskExecution(executionId: string): Observable; abstract startTaskExecution(executionId: string): Observable; abstract prepareStringInput( executionId: string, 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 b7db2c8..33a4947 100644 --- a/src/app/services/task-executions/task-executions-call.fake.ts +++ b/src/app/services/task-executions/task-executions-call.fake.ts @@ -470,6 +470,14 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase return of(execution); } + override deleteTaskExecution(executionId: string): Observable { + const index = this.data.findIndex((item) => item.id === executionId); + if (index >= 0) { + this.data.splice(index, 1); + } + return of(void 0); + } + override startTaskExecution(executionId: string): Observable { const execution = this.findExecution(executionId); execution.context.status = 'RUNNING'; diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts index 7dec288..b12871c 100644 --- a/src/app/services/task-executions/task-executions-call.ts +++ b/src/app/services/task-executions/task-executions-call.ts @@ -16,6 +16,10 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { return this.http.post(`${environment.apiUrl}/executions`, flowId); } + override deleteTaskExecution(executionId: string): Observable { + return this.http.delete(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}`); + } + override startTaskExecution(executionId: string): Observable { return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null); } diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts index 84224da..1bb2a56 100644 --- a/src/app/services/task-executions/task-executions.ts +++ b/src/app/services/task-executions/task-executions.ts @@ -1,15 +1,18 @@ import { Injectable, signal } from '@angular/core'; import { environment } from '@environment'; -import { TaskExecution } from '@models/task-execution'; -import { catchError, tap, throwError } from 'rxjs'; +import { getExecutionStatusGroup, TaskExecution } from '@models/task-execution'; +import { catchError, finalize, tap, throwError } from 'rxjs'; import { TaskExecutionsCallServiceBase } from './task-executions-call.base'; @Injectable({ providedIn: 'root', }) export class TaskExecutionsService { + private static readonly POLL_INTERVAL_MS = 5000; taskExecutionsCallService: TaskExecutionsCallServiceBase = new environment.taskExecutionsCallService(); private initialized = false; + private refreshInFlight = false; + private pollTimer: ReturnType | null = null; private _taskExecutions = signal([]); taskExecutions = this._taskExecutions.asReadonly(); @@ -21,8 +24,16 @@ export class TaskExecutionsService { } refresh() { - this.taskExecutionsCallService.retrieveAllTaskExecutions().subscribe((taskExecutions) => { - this._taskExecutions.set(taskExecutions); + if (this.refreshInFlight) return; + this.refreshInFlight = true; + + this.taskExecutionsCallService.retrieveAllTaskExecutions().pipe( + finalize(() => { + this.refreshInFlight = false; + }) + ).subscribe((taskExecutions) => { + this._taskExecutions.set([...taskExecutions]); + this.updatePollingState(taskExecutions); }); } @@ -36,6 +47,16 @@ export class TaskExecutionsService { ); } + deleteExecution(executionId: string) { + return this.taskExecutionsCallService.deleteTaskExecution(executionId).pipe( + tap(() => this.refresh()), + catchError((err) => { + console.error('Delete execution failed', err); + return throwError(() => err); + }) + ); + } + startExecution(executionId: string) { return this.taskExecutionsCallService.startTaskExecution(executionId).pipe( tap(() => this.refresh()), @@ -65,4 +86,30 @@ export class TaskExecutionsService { }) ); } + + private updatePollingState(taskExecutions: TaskExecution[]) { + const shouldPoll = taskExecutions.some((execution) => + getExecutionStatusGroup(execution.context.status) === 'RUNNING' + ); + + if (shouldPoll) { + this.startPolling(); + return; + } + + this.stopPolling(); + } + + private startPolling() { + if (this.pollTimer) return; + this.pollTimer = setInterval(() => { + this.refresh(); + }, TaskExecutionsService.POLL_INTERVAL_MS); + } + + private stopPolling() { + if (!this.pollTimer) return; + clearInterval(this.pollTimer); + this.pollTimer = null; + } } diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index f4d174b..bc5d0d2 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -77,7 +77,7 @@ }" [emit]="emit"> - @if (executionInputTooltip(input.key); as inputTooltip) { + @if (showInputTooltip() && executionInputTooltip(input.key); as inputTooltip) { {{ inputTooltip }} } @@ -89,7 +89,9 @@ aria-label="Show input value"> + @if (showInputTooltip()) { {{ inputValueTooltip(input.key) }} + } } @@ -120,7 +122,7 @@ }" [emit]="emit"> - @if (executionOutputTooltip(output.key); as outputTooltip) { + @if (showOutputTooltip() && executionOutputTooltip(output.key); as outputTooltip) { {{ outputTooltip }} } @@ -132,7 +134,9 @@ aria-label="Show output result"> + @if (showOutputTooltip()) { {{ outputValueTooltip(output.key) }} + } } diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index cad0899..fd8bb9d 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -189,6 +189,11 @@ export class TaskStepNodeComponent { return this.executionInputTooltip(inputName) ?? 'not ready yet'; } + showInputTooltip(): boolean { + const statusGroup = this.blockConfiguration?.['__executionStatusGroup']; + return statusGroup !== 'INIT'; + } + executionOutputTooltip(outputName: string): string | null { const values = this.blockConfiguration?.['__executionOutputs'] as Record | undefined; if (!values || !Object.prototype.hasOwnProperty.call(values, outputName)) return null; @@ -214,6 +219,10 @@ export class TaskStepNodeComponent { return this.executionOutputTooltip(outputName) ?? 'No output result'; } + showOutputTooltip(): boolean { + return this.showInputTooltip(); + } + executionErrors(): string[] { return this.getExecutionMessages('__executionErrors'); } diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index 4451aef..a1e2f47 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -46,7 +46,7 @@ export class ReteEditor implements OnChanges, OnDestroy { ngOnChanges(changes: SimpleChanges): void { if (!this.viewReady) return; - if (changes['flowId']) { + if (changes['flowId'] || (this.readonly() && changes['flowData'])) { void this.reloadEditor(); } } diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html new file mode 100644 index 0000000..d9507c6 --- /dev/null +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html @@ -0,0 +1,35 @@ +
+ @if (!editableInputs().length) { +
No manual inputs required.
+ } @else { + @for (executionInput of editableInputs(); track executionInput.key) { +
+
{{ executionInput.label }}
+
Type: {{ executionInput.type }}
+ + @if (isFileInput(executionInput)) { + + } @else { + + } + + @if (isInputSaving(executionInput.key)) { +
Saving...
+ } + @if (inputSavingError(executionInput.key); as errorMessage) { +
{{ errorMessage }}
+ } +
+ } + } +
diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts new file mode 100644 index 0000000..c2ec5b7 --- /dev/null +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts @@ -0,0 +1,52 @@ +import { CommonModule } from '@angular/common'; +import { Component, input, output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +export type EditableExecutionInput = { + key: string; + nodeId: string; + inputName: string; + label: string; + type: string; + value: string; +}; + +@Component({ + selector: 'app-task-execution-inputs-panel', + imports: [CommonModule, FormsModule], + templateUrl: './task-execution-inputs-panel.html' +}) +export class TaskExecutionInputsPanelComponent { + readonly editableInputs = input([]); + readonly savingInputs = input>({}); + readonly savingErrors = input>({}); + readonly readOnly = input(false); + + readonly textInputChange = output<{ input: EditableExecutionInput; value: string }>(); + readonly fileInputChange = output<{ input: EditableExecutionInput; file: File }>(); + + isFileInput(input: EditableExecutionInput): boolean { + return input.type.includes('FILE') || input.type.includes('BINARY'); + } + + onTextInputChange(input: EditableExecutionInput, value: string) { + if (this.readOnly()) return; + this.textInputChange.emit({ input, value }); + } + + onFileInputChange(input: EditableExecutionInput, event: Event) { + if (this.readOnly()) return; + const target = event.target as HTMLInputElement | null; + const file = target?.files?.[0]; + if (!file) return; + this.fileInputChange.emit({ input, file }); + } + + isInputSaving(key: string): boolean { + return this.savingInputs()[key] === true; + } + + inputSavingError(key: string): string | null { + return this.savingErrors()[key] ?? null; + } +} diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index 24e68aa..fd77e84 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -2,8 +2,20 @@ @if (execution()) {
-

{{ execution()!.name }}

-

Execution ID: {{ execution()!.id }}

+
+
+

{{ execution()!.name }}

+

Execution ID: {{ execution()!.id }}

+
+ +
@@ -51,22 +63,16 @@ @if (contextAsideOpen()) { -
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 86982c3..855a388 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -1,24 +1,37 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, input, signal } from '@angular/core'; +import { Component, computed, inject, input, OnDestroy, signal } from '@angular/core'; import { FlowData } from '@models/flow'; -import { TaskExecution, TaskExecutionStep } from '@models/task-execution'; +import { getExecutionStatusGroup, TaskExecution, TaskExecutionStep } from '@models/task-execution'; +import { + EditableExecutionInput, + TaskExecutionInputsPanelComponent +} from '@shared/task-execution-inputs-panel/task-execution-inputs-panel'; import { ReteEditor } from '@shared/rete-editor/rete-editor'; +import { TaskExecutionsService } from '@services/task-executions/task-executions'; @Component({ selector: 'app-task-execution-viewer', - imports: [CommonModule, ReteEditor], + imports: [CommonModule, ReteEditor, TaskExecutionInputsPanelComponent], templateUrl: './task-execution-viewer.html', styleUrl: './task-execution-viewer.css', }) -export class TaskExecutionViewerComponent { +export class TaskExecutionViewerComponent implements OnDestroy { + private static readonly TEXT_INPUT_DEBOUNCE_MS = 1200; + private taskExecutionsService = inject(TaskExecutionsService); + private readonly textInputDebounceTimers = new Map>(); readonly execution = input(null); readonly contextAsideOpen = signal(true); + readonly startInProgress = signal(false); + readonly savingInputs = signal>({}); + readonly savingErrors = signal>({}); + readonly pendingTextInputs = signal>({}); readonly stepsArray = computed(() => Object.values(this.execution()?.context.steps ?? {}) ); readonly executionFlowData = computed(() => { + const executionStatusGroup = getExecutionStatusGroup(this.execution()?.context.status); const contextInputs = this.execution()?.context.inputs ?? {}; const contextResults = { ...(this.execution()?.context.result ?? {}), @@ -33,6 +46,7 @@ export class TaskExecutionViewerComponent { specificConfiguration: { ...(step.block.specificConfiguration ?? {}), __stepStatus: step.status, + __executionStatusGroup: executionStatusGroup, __isWaitingStep: waitingSteps.includes(step.id), __executionInputs: this.getExecutionInputValues(step, contextInputs), __connectedInputs: this.getConnectedInputs(step), @@ -57,10 +71,174 @@ export class TaskExecutionViewerComponent { return Math.max(0, context.endTime - context.startTime); }); + readonly inputsReadOnly = computed(() => { + const status = this.execution()?.context.status; + return getExecutionStatusGroup(status) !== 'INIT'; + }); + + readonly canStartExecution = computed(() => { + const execution = this.execution(); + if (!execution) return false; + + const statusGroup = getExecutionStatusGroup(execution.context.status); + if (statusGroup !== 'INIT') return false; + + const status = String(execution.context.status ?? '').toUpperCase(); + if (status !== 'CREATED' && status !== 'READY') return false; + + for (const step of Object.values(execution.context.steps ?? {})) { + for (const input of step.inputs ?? []) { + if (input.registered) continue; + + const inputName = input.descriptor?.name; + if (!inputName) continue; + + const key = `${step.id}:${inputName}`; + const value = Object.prototype.hasOwnProperty.call(execution.context.inputs ?? {}, key) + ? execution.context.inputs[key] + : input.value; + + if (!this.isInputSet(value)) return false; + } + } + + return true; + }); + + readonly editableInputs = computed(() => { + const execution = this.execution(); + if (!execution) return []; + + const entries: EditableExecutionInput[] = []; + const contextInputs = execution.context.inputs ?? {}; + + for (const step of Object.values(execution.context.steps ?? {})) { + for (const input of step.inputs ?? []) { + if (input.registered) continue; + + const inputName = input.descriptor?.name; + if (!inputName) continue; + + const key = `${step.id}:${inputName}`; + const rawValue = Object.prototype.hasOwnProperty.call(contextInputs, key) + ? contextInputs[key] + : input.value; + const pendingValue = this.pendingTextInputs()[key]; + + entries.push({ + key, + nodeId: step.id, + inputName, + label: `${step.block.name}.${inputName}`, + type: String(input.descriptor?.type ?? 'TEXT').toUpperCase(), + value: pendingValue ?? (rawValue == null ? '' : String(rawValue)) + }); + } + } + + return entries.sort((a, b) => a.label.localeCompare(b.label)); + }); + toggleContextAside() { this.contextAsideOpen.update((open) => !open); } + startExecution() { + const executionId = this.execution()?.id; + if (!executionId || !this.canStartExecution() || this.startInProgress()) return; + + this.startInProgress.set(true); + this.taskExecutionsService.startExecution(executionId).subscribe({ + next: () => this.startInProgress.set(false), + error: () => this.startInProgress.set(false) + }); + } + + onTextInputChange(input: EditableExecutionInput, value: string) { + if (this.inputsReadOnly()) return; + const executionId = this.execution()?.id; + if (!executionId) return; + + this.pendingTextInputs.update((current) => ({ ...current, [input.key]: value })); + + const timerKey = `${executionId}:${input.key}`; + this.clearDebounceTimer(timerKey); + const timer = setTimeout(() => { + this.textInputDebounceTimers.delete(timerKey); + this.sendPreparedTextInput(input, executionId); + }, TaskExecutionViewerComponent.TEXT_INPUT_DEBOUNCE_MS); + this.textInputDebounceTimers.set(timerKey, timer); + } + + onFileInputChange(input: EditableExecutionInput, file: File) { + if (this.inputsReadOnly()) return; + const executionId = this.execution()?.id; + if (!executionId) return; + + this.setInputSaving(input.key, true); + this.taskExecutionsService.prepareFileInput(executionId, input.nodeId, input.inputName, file).subscribe({ + next: () => this.clearInputSaving(input.key), + error: () => this.setInputError(input.key, 'Failed to upload file') + }); + } + + private setInputSaving(key: string, saving: boolean) { + this.savingInputs.update((current) => ({ ...current, [key]: saving })); + if (saving) { + this.savingErrors.update((current) => { + const next = { ...current }; + delete next[key]; + return next; + }); + } + } + + private clearInputSaving(key: string) { + this.savingInputs.update((current) => ({ ...current, [key]: false })); + this.savingErrors.update((current) => { + const next = { ...current }; + delete next[key]; + return next; + }); + } + + private setInputError(key: string, message: string) { + this.savingInputs.update((current) => ({ ...current, [key]: false })); + this.savingErrors.update((current) => ({ ...current, [key]: message })); + } + + ngOnDestroy() { + for (const timer of this.textInputDebounceTimers.values()) { + clearTimeout(timer); + } + this.textInputDebounceTimers.clear(); + } + + private sendPreparedTextInput(input: EditableExecutionInput, executionId: string) { + if (this.inputsReadOnly() || this.execution()?.id !== executionId) return; + + const value = this.pendingTextInputs()[input.key] ?? ''; + this.setInputSaving(input.key, true); + this.taskExecutionsService.prepareStringInput(executionId, input.nodeId, input.inputName, value).subscribe({ + next: () => { + this.pendingTextInputs.update((current) => { + const next = { ...current }; + delete next[input.key]; + return next; + }); + this.clearInputSaving(input.key); + }, + error: () => this.setInputError(input.key, 'Failed to update input') + }); + } + + private clearDebounceTimer(timerKey: string) { + const timer = this.textInputDebounceTimers.get(timerKey); + if (!timer) return; + clearTimeout(timer); + this.textInputDebounceTimers.delete(timerKey); + } + private inferConnections(steps: TaskExecutionStep[]) { const connections: FlowData['connections'] = []; @@ -174,4 +352,10 @@ export class TaskExecutionViewerComponent { const raw = contextWarnings[stepId]; return raw && raw.trim().length > 0 ? [raw] : []; } + + private isInputSet(value: unknown): boolean { + if (value == null) return false; + if (typeof value === 'string') return value.trim().length > 0; + return true; + } } 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 be11af9..609f94f 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.html +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.html @@ -49,8 +49,7 @@
No executions available
} @else { @for (execution of filteredExecutions(); track execution.id) { -
-
+
{{ execution.startedAt }} - {{ execution.duration || '-' }} +
+ {{ execution.duration || '-' }} + +
- +
} } 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 19ec8ab..538f02d 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.ts +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.ts @@ -26,6 +26,7 @@ export class TasksExecutionsListComponent { readonly executions = input([]); readonly selectedExecutionId = input(null); readonly executionSelected = output(); + readonly executionDeleteRequested = output(); readonly searchTerm = model(''); readonly filter = signal('all'); readonly orderBy = signal('startedAt'); @@ -83,6 +84,11 @@ export class TasksExecutionsListComponent { this.executionSelected.emit(executionId); } + requestDeleteExecution(executionId: string, event?: Event) { + event?.stopPropagation(); + this.executionDeleteRequested.emit(executionId); + } + onOrderChanged(event: OrderEvent) { this.orderBy.set(event.orderBy); this.orderDir.set(event.orderDir); @@ -100,6 +106,10 @@ export class TasksExecutionsListComponent { return 'bg-slate-100 text-slate-700 border-slate-200'; } + isDeleteDisabled(status: TaskExecutionStatus): boolean { + return getExecutionStatusGroup(status) === 'RUNNING'; + } + private matchesFilter(status: TaskExecutionStatus, filter: TaskExecutionFilter): boolean { if (filter === 'all') return true; return getExecutionStatusGroup(status) === filter;