task input section added
This commit is contained in:
parent
4cedab52bc
commit
1fef10ab17
|
|
@ -4,7 +4,8 @@
|
|||
class="w-[320px] shrink-0"
|
||||
[executions]="executions()"
|
||||
[selectedExecutionId]="selectedExecutionId()"
|
||||
(executionSelected)="selectExecution($event)">
|
||||
(executionSelected)="selectExecution($event)"
|
||||
(executionDeleteRequested)="removeExecution($event)">
|
||||
</app-tasks-executions-list>
|
||||
|
||||
<main class="flex flex-col w-full bg-gray-100 overflow-hidden rounded-md">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Observable } from 'rxjs';
|
|||
export abstract class TaskExecutionsCallServiceBase {
|
||||
abstract retrieveAllTaskExecutions(): Observable<TaskExecution[]>;
|
||||
abstract createTaskExecution(flowId: string): Observable<TaskExecution>;
|
||||
abstract deleteTaskExecution(executionId: string): Observable<void>;
|
||||
abstract startTaskExecution(executionId: string): Observable<TaskExecution>;
|
||||
abstract prepareStringInput(
|
||||
executionId: string,
|
||||
|
|
|
|||
|
|
@ -470,6 +470,14 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
|
|||
return of(execution);
|
||||
}
|
||||
|
||||
override deleteTaskExecution(executionId: string): Observable<void> {
|
||||
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<TaskExecution> {
|
||||
const execution = this.findExecution(executionId);
|
||||
execution.context.status = 'RUNNING';
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
return this.http.post<TaskExecution>(`${environment.apiUrl}/executions`, flowId);
|
||||
}
|
||||
|
||||
override deleteTaskExecution(executionId: string): Observable<void> {
|
||||
return this.http.delete<void>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}`);
|
||||
}
|
||||
|
||||
override startTaskExecution(executionId: string): Observable<TaskExecution> {
|
||||
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof setInterval> | null = null;
|
||||
private _taskExecutions = signal<TaskExecution[]>([]);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@
|
|||
}"
|
||||
[emit]="emit">
|
||||
</div>
|
||||
@if (executionInputTooltip(input.key); as inputTooltip) {
|
||||
@if (showInputTooltip() && executionInputTooltip(input.key); as inputTooltip) {
|
||||
<span class="llm-io-tooltip llm-io-tooltip-input">{{ inputTooltip }}</span>
|
||||
}
|
||||
</span>
|
||||
|
|
@ -89,7 +89,9 @@
|
|||
aria-label="Show input value">
|
||||
<i class="bi bi-box-arrow-in-right"></i>
|
||||
</button>
|
||||
@if (showInputTooltip()) {
|
||||
<span class="llm-io-tooltip llm-io-tooltip-input">{{ inputValueTooltip(input.key) }}</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
<span class="llm-pill llm-pill-input">
|
||||
|
|
@ -120,7 +122,7 @@
|
|||
}"
|
||||
[emit]="emit">
|
||||
</div>
|
||||
@if (executionOutputTooltip(output.key); as outputTooltip) {
|
||||
@if (showOutputTooltip() && executionOutputTooltip(output.key); as outputTooltip) {
|
||||
<span class="llm-io-tooltip llm-io-tooltip-output">{{ outputTooltip }}</span>
|
||||
}
|
||||
</span>
|
||||
|
|
@ -132,7 +134,9 @@
|
|||
aria-label="Show output result">
|
||||
<i class="bi bi-box-arrow-right"></i>
|
||||
</button>
|
||||
@if (showOutputTooltip()) {
|
||||
<span class="llm-io-tooltip llm-io-tooltip-output">{{ outputValueTooltip(output.key) }}</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
<div class="p-3 space-y-3">
|
||||
@if (!editableInputs().length) {
|
||||
<div class="text-xs text-slate-500">No manual inputs required.</div>
|
||||
} @else {
|
||||
@for (executionInput of editableInputs(); track executionInput.key) {
|
||||
<div class="rounded-md border border-slate-200 bg-slate-50 p-2">
|
||||
<div class="text-xs font-semibold text-slate-700 mb-1">{{ executionInput.label }}</div>
|
||||
<div class="text-[10px] text-slate-500 mb-2">Type: {{ executionInput.type }}</div>
|
||||
|
||||
@if (isFileInput(executionInput)) {
|
||||
<input
|
||||
type="file"
|
||||
class="form-control form-control-sm"
|
||||
[disabled]="readOnly()"
|
||||
(change)="onFileInputChange(executionInput, $event)" />
|
||||
} @else {
|
||||
<textarea
|
||||
class="form-control form-control-sm"
|
||||
rows="4"
|
||||
[readonly]="readOnly()"
|
||||
[ngModel]="executionInput.value"
|
||||
(ngModelChange)="onTextInputChange(executionInput, $event)">
|
||||
</textarea>
|
||||
}
|
||||
|
||||
@if (isInputSaving(executionInput.key)) {
|
||||
<div class="mt-2 text-[11px] text-blue-600">Saving...</div>
|
||||
}
|
||||
@if (inputSavingError(executionInput.key); as errorMessage) {
|
||||
<div class="mt-2 text-[11px] text-red-600">{{ errorMessage }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
|
@ -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<EditableExecutionInput[]>([]);
|
||||
readonly savingInputs = input<Record<string, boolean>>({});
|
||||
readonly savingErrors = input<Record<string, string>>({});
|
||||
readonly readOnly = input<boolean>(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;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,20 @@
|
|||
@if (execution()) {
|
||||
<div class="h-full bg-white border border-slate-200 rounded-md flex flex-col overflow-hidden">
|
||||
<div class="px-5 pt-2 border-b border-slate-200 bg-slate-50">
|
||||
<h2 class="text-base font-semibold text-slate-900">{{ execution()!.name }}</h2>
|
||||
<p class="text-sm text-slate-500">Execution ID: {{ execution()!.id }}</p>
|
||||
<div class="flex items-stretch justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-900">{{ execution()!.name }}</h2>
|
||||
<p class="text-sm text-slate-500">Execution ID: {{ execution()!.id }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="self-stretch h-full w-10 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-600 hover:bg-emerald-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
aria-label="Start execution"
|
||||
[disabled]="!canStartExecution() || startInProgress()"
|
||||
(click)="startExecution()">
|
||||
<i class="bi bi-play-fill text-lg leading-none"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-1 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-slate-200">
|
||||
|
|
@ -51,22 +63,16 @@
|
|||
</button>
|
||||
|
||||
@if (contextAsideOpen()) {
|
||||
<aside class="w-90 border border-slate-200 rounded-md bg-white min-h-0 overflow-auto">
|
||||
<div class="px-3 py-2 text-xs font-semibold text-slate-600 border-b border-slate-200 bg-slate-50">Execution Context</div>
|
||||
<div class="p-3 space-y-3">
|
||||
<div>
|
||||
<div class="context-title">Result</div>
|
||||
<pre class="context-json">{{ execution()!.context.result | json }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="context-title">Waiting Steps</div>
|
||||
<pre class="context-json">{{ execution()!.context.waitingSteps | json }}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="context-title">Execution Result</div>
|
||||
<pre class="context-json">{{ execution()!.context.executionResult | json }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="w-[360px] border border-slate-200 rounded-md bg-white min-h-0 overflow-auto">
|
||||
<div class="px-3 py-2 text-xs font-semibold text-slate-600 border-b border-slate-200 bg-slate-50">Execution Inputs</div>
|
||||
<app-task-execution-inputs-panel
|
||||
[editableInputs]="editableInputs()"
|
||||
[savingInputs]="savingInputs()"
|
||||
[savingErrors]="savingErrors()"
|
||||
[readOnly]="inputsReadOnly()"
|
||||
(textInputChange)="onTextInputChange($event.input, $event.value)"
|
||||
(fileInputChange)="onFileInputChange($event.input, $event.file)">
|
||||
</app-task-execution-inputs-panel>
|
||||
</aside>
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, ReturnType<typeof setTimeout>>();
|
||||
readonly execution = input<TaskExecution | null>(null);
|
||||
readonly contextAsideOpen = signal(true);
|
||||
readonly startInProgress = signal(false);
|
||||
readonly savingInputs = signal<Record<string, boolean>>({});
|
||||
readonly savingErrors = signal<Record<string, string>>({});
|
||||
readonly pendingTextInputs = signal<Record<string, string>>({});
|
||||
|
||||
readonly stepsArray = computed(() =>
|
||||
Object.values(this.execution()?.context.steps ?? {})
|
||||
);
|
||||
|
||||
readonly executionFlowData = computed<FlowData>(() => {
|
||||
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<EditableExecutionInput[]>(() => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,8 +49,7 @@
|
|||
<div class="text-xs text-slate-500 px-2 py-4 text-center">No executions available</div>
|
||||
} @else {
|
||||
@for (execution of filteredExecutions(); track execution.id) {
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
class="w-full text-left p-3 rounded-md border transition-all duration-150 hover:-translate-y-0.5 hover:border-blue-300 hover:bg-blue-100 hover:shadow-md"
|
||||
[class.border-blue-300]="execution.id === selectedExecutionId()"
|
||||
[class.bg-blue-50]="execution.id === selectedExecutionId()"
|
||||
|
|
@ -67,11 +66,22 @@
|
|||
{{ execution.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-[11px] text-slate-500 flex justify-between gap-2">
|
||||
<div class="mt-2 text-[11px] text-slate-500 flex justify-between items-center gap-2">
|
||||
<span>{{ execution.startedAt }}</span>
|
||||
<span>{{ execution.duration || '-' }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ execution.duration || '-' }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 rounded enabled:hover:bg-red-100 enabled:text-red-600 disabled:text-red-200"
|
||||
title="Delete"
|
||||
aria-label="Delete execution"
|
||||
[disabled]="isDeleteDisabled(execution.status)"
|
||||
(click)="requestDeleteExecution(execution.id, $event)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export class TasksExecutionsListComponent {
|
|||
readonly executions = input<TaskExecutionListItem[]>([]);
|
||||
readonly selectedExecutionId = input<string | null>(null);
|
||||
readonly executionSelected = output<string>();
|
||||
readonly executionDeleteRequested = output<string>();
|
||||
readonly searchTerm = model<string>('');
|
||||
readonly filter = signal<TaskExecutionFilter>('all');
|
||||
readonly orderBy = signal<string | null>('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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue