task input section added

This commit is contained in:
Lucio Lelii 2026-03-06 09:41:30 +01:00
parent 4cedab52bc
commit 1fef10ab17
15 changed files with 422 additions and 35 deletions

View File

@ -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">

View File

@ -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();

View File

@ -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,

View File

@ -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';

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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');
}

View File

@ -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();
}
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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>

View File

@ -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;