Add flow execution status UX and remove sink usage

This commit is contained in:
Lucio Lelii 2026-03-09 15:33:29 +01:00
parent 376234539e
commit a82afa3999
17 changed files with 130 additions and 25 deletions

View File

@ -1,6 +1,7 @@
export type FlowVisibility = 'PUBLIC' | 'PRIVATE';
export type FlowStatus = 'DRAFT' | 'EXECUTABLE';
export type Flow = {
id: string;
@ -10,6 +11,7 @@ export type Flow = {
author: string;
description?: string;
createdAt: Date;
status: FlowStatus;
updatedAt: Date;
published?: boolean;
finalized?: boolean;
@ -37,7 +39,6 @@ export type BlockType = {
export type FlowBlock = {
id: string;
sink: boolean;
name: string;
position?: { x: number, y: number };
inputs: FlowPort[];

View File

@ -155,7 +155,6 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
const io = this.defaultIOForBlockType(typeName);
const block: FlowBlock = {
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,
sink: typeName === "HumanInteractionBlock",
name: typeName,
position: undefined,
inputs: io.inputs,
@ -172,7 +171,6 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
const io = this.defaultIOForBlockType(typeName);
const block: FlowBlock = {
id: blockId,
sink: typeName === "HumanInteractionBlock",
name: configuration?.name ?? typeName,
position: configuration?.position,
inputs: configuration?.inputs ?? io.inputs,

View File

@ -107,7 +107,6 @@ export class BlocksCallService extends BlocksCallServiceBase {
return {
id: String(value["id"] ?? crypto.randomUUID()),
sink: typeof value["sink"] === "boolean" ? value["sink"] : typeName === "HumanInteractionBlock",
name: String(value["name"] ?? specificConfiguration["name"] ?? typeName),
position: this.toPosition(value["position"]),
inputs: this.toPorts(value["inputs"], io.inputs),

View File

@ -1,4 +1,4 @@
import { Injectable, signal } from '@angular/core';
import { computed, Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { BlockType, BlockTypeName, FlowBlock } from '@models/flow';
import { BlocksCallServiceBase } from './block-call.base';
@ -14,8 +14,10 @@ export class BlocksService {
private loadingPromise: Promise<void> | null = null;
private readonly emptyBlockCache = new Map<string, FlowBlock>();
private readonly pendingEmptyBlockRequests = new Map<string, Observable<FlowBlock>>();
private readonly pendingServerSyncCount = signal(0);
private _blockTypes = signal<BlockType[]>([]);
readonly hasPendingServerSync = computed(() => this.pendingServerSyncCount() > 0);
async getAllBlocksTypes() {
if (this.toInit) {
@ -92,7 +94,11 @@ export class BlocksService {
}
updateBlock(blockId: string, configuration: any) {
this.pendingServerSyncCount.update((count) => count + 1);
return this.blocksCallService.updateBlock(blockId, configuration).pipe(
finalize(() => {
this.pendingServerSyncCount.update((count) => Math.max(0, count - 1));
}),
catchError((err) => {
console.error('Update block failed', err);
return throwError(() => err);

View File

@ -1,4 +1,4 @@
import { Flow, FlowData, FlowVisibility } from '@models/flow';
import { Flow, FlowData, FlowStatus, FlowVisibility } from '@models/flow';
function parseDate(value: unknown, fallback: Date): Date {
if (typeof value !== 'string' || !value) return fallback;
@ -16,6 +16,8 @@ export function flowFromApi(raw: unknown): Flow {
? value['published']
: ((value['visibility'] as FlowVisibility | undefined) === 'PUBLIC');
const visibility: FlowVisibility = published ? 'PUBLIC' : 'PRIVATE';
const rawStatus = typeof value['status'] === 'string' ? value['status'].toUpperCase() : null;
const status: FlowStatus = rawStatus === 'EXECUTABLE' ? 'EXECUTABLE' : 'DRAFT';
return {
id: String(value['id'] ?? crypto.randomUUID()),
@ -23,6 +25,7 @@ export function flowFromApi(raw: unknown): Flow {
description: typeof value['description'] === 'string' ? value['description'] : undefined,
author: String(value['author'] ?? value['owner'] ?? 'unknown'),
createdAt,
status,
updatedAt,
visibility,
published,
@ -34,10 +37,11 @@ export function flowFromApi(raw: unknown): Flow {
};
}
export function toFlowCreateRequest(name: string, description?: string, flow?: FlowData) {
export function toFlowCreateRequest(name: string, description?: string, flow?: FlowData, status: FlowStatus = 'DRAFT') {
return {
name,
description: description ?? '',
status,
flow: flow ?? {
blocks: [],
connections: []

View File

@ -17,8 +17,8 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
authorizationService = inject(Authorization);
private data: Record<string, Flow> = {
'1': { id: '1', name: 'A Flow', data: { blocks: [], connections: [] }, visibility: 'PUBLIC', author: 'Alice', createdAt: new Date("December 17, 2023 03:24:00"), updatedAt: new Date("January 7, 2026 12:24:00") },
'2': { id: '2', name: 'Test Flow', data: { blocks: [], connections: [] }, visibility: 'PRIVATE', author: 'Bob', createdAt: new Date("April 25, 2025 12:24:00"), updatedAt: new Date("April 27, 2025 18:42:00") },
'1': { id: '1', name: 'A Flow', data: { blocks: [], connections: [] }, visibility: 'PUBLIC', author: 'Alice', createdAt: new Date("December 17, 2023 03:24:00"), status: 'EXECUTABLE', updatedAt: new Date("January 7, 2026 12:24:00") },
'2': { id: '2', name: 'Test Flow', data: { blocks: [], connections: [] }, visibility: 'PRIVATE', author: 'Bob', createdAt: new Date("April 25, 2025 12:24:00"), status: 'DRAFT', updatedAt: new Date("April 27, 2025 18:42:00") },
'testFlow': flowFromApi(testDataFlow)
}
@ -33,7 +33,7 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
override createNewFlow(name?: string): Observable<Flow> {
const newId = (Object.keys(this.data).length + 1).toString();
this.data[newId] = { id: newId, name: name || `New Flow`, data: { blocks: [], connections: [] }, visibility: 'PRIVATE', author: this.authorizationService.loggedInUser()!.username, createdAt: new Date(), updatedAt: new Date() };
this.data[newId] = { id: newId, name: name || `New Flow`, data: { blocks: [], connections: [] }, visibility: 'PRIVATE', author: this.authorizationService.loggedInUser()!.username, createdAt: new Date(), status: 'DRAFT', updatedAt: new Date() };
return of(this.data[newId]);
}
@ -46,6 +46,7 @@ const testDataFlow ={
"id": "testFlow",
"name" : "Test Flow",
"owner": "lucio",
"status": "DRAFT",
"published": false,
"finalized": false,
"description" : "This is a test flow",

View File

@ -18,7 +18,7 @@ export class FlowsCallService extends FlowsCallServiceBase {
override createNewFlow(name?: string): Observable<Flow> {
return this.http
.post<unknown>(`${environment.apiUrl}/flows`, toFlowCreateRequest(name ?? 'New Flow'))
.post<unknown>(`${environment.apiUrl}/flows`, toFlowCreateRequest(name ?? 'New Flow', undefined, undefined, 'DRAFT'))
.pipe(map((raw) => flowFromApi(raw)));
}
@ -38,7 +38,7 @@ export class FlowsCallService extends FlowsCallServiceBase {
return this.http
.put<unknown>(
`${environment.apiUrl}/flows/${encodedId}`,
toFlowCreateRequest(flow.name, flow.description, flow.data)
toFlowCreateRequest(flow.name, flow.description, flow.data, flow.status)
)
.pipe(map((raw) => flowFromApi(raw)));
}

View File

@ -34,7 +34,18 @@ export class FlowsService {
updateFlow(flow: Flow) {
return this.flowsCallService.updateFlow(flow).pipe(
tap(() => this.refresh()),
tap((updatedFlow) => {
this._flows.update((flows) => {
const index = flows.findIndex((current) => current.id === updatedFlow.id);
if (index < 0) {
return [updatedFlow, ...flows];
}
const next = [...flows];
next[index] = updatedFlow;
return next;
});
}),
catchError(err => {
console.error('Update flow failed', err);
return throwError(() => err);

View File

@ -26,7 +26,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
block: {
id: 'b2540579-ca7b-4beb-8ed3-65136e7f03d6',
position: { x: 120, y: 160 },
sink: false,
name: 'first',
inputs: [{ name: 'name', type: 'TEXT', multiple: false }],
outputs: [{ name: 'response', type: 'TEXT', multiple: false }],
@ -61,7 +60,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
block: {
id: '5ceb9b7b-88a0-41bb-afef-76fcb1f57918',
position: { x: 500, y: 160 },
sink: true,
name: 'second',
inputs: [{ name: 'year', type: 'TEXT', multiple: false }],
outputs: [{ name: 'response', type: 'TEXT', multiple: false }],
@ -119,7 +117,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
block: {
id: '95ebb03f-80e0-412d-87ee-2d4b7ddef240',
position: { x: 120, y: 140 },
sink: false,
name: 'first',
inputs: [{ name: 'name', type: 'TEXT', multiple: false }],
outputs: [{ name: 'response', type: 'TEXT', multiple: false }],
@ -165,7 +162,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
block: {
id: '68c5949c-1c74-400e-a1aa-b5f7739e5bb2',
position: { x: 500, y: 140 },
sink: true,
name: 'interactive',
inputs: [{ name: 'input', type: 'TEXT', multiple: false }],
outputs: [{ name: 'output', type: 'TEXT', multiple: false }],
@ -235,7 +231,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
block: {
id: 'ab7e0b08-c653-4d11-b808-e0e51c89d989',
position: { x: 500, y: 140 },
sink: true,
name: 'interactive',
inputs: [{ name: 'input', type: 'TEXT', multiple: false }],
outputs: [{ name: 'output', type: 'TEXT', multiple: false }],
@ -281,7 +276,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
block: {
id: 'f91ec0f7-03e8-4208-89ac-bd9db46dca8c',
position: { x: 120, y: 140 },
sink: false,
name: 'first',
inputs: [{ name: 'name', type: 'TEXT', multiple: false }],
outputs: [{ name: 'response', type: 'TEXT', multiple: false }],
@ -347,7 +341,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
block: {
id: '82844256-d9c1-4f81-a415-49b18c371a13',
position: { x: 500, y: 140 },
sink: true,
name: 'interactive',
inputs: [{ name: 'input', type: 'TEXT', multiple: false }],
outputs: [{ name: 'output', type: 'TEXT', multiple: false }],
@ -394,7 +387,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
block: {
id: 'f80bce81-f1e4-4e03-9982-d35a042b1276',
position: { x: 120, y: 140 },
sink: false,
name: 'first',
inputs: [{ name: 'name', type: 'TEXT', multiple: false }],
outputs: [{ name: 'response', type: 'TEXT', multiple: false }],

View File

@ -33,6 +33,12 @@
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.2), 0 10px 24px rgba(15, 23, 42, 0.12);
}
.llm-node-running {
border: 2px solid #facc15;
box-shadow: 0 0 0 0 rgba(250, 204, 21, 0.45), 0 10px 24px rgba(15, 23, 42, 0.12);
animation: llmRunningBorderPulse 0.95s ease-in-out infinite;
}
.llm-node-attention {
border: 2px solid #f97316;
animation: llmAttentionBorderPulse 1.2s ease-in-out infinite;
@ -142,6 +148,17 @@
transform: translateY(0);
}
@keyframes llmRunningBorderPulse {
0%, 100% {
border-color: #fde047;
box-shadow: 0 0 0 0 rgba(250, 204, 21, 0.12), 0 10px 24px rgba(15, 23, 42, 0.12);
}
50% {
border-color: #eab308;
box-shadow: 0 0 0 4px rgba(250, 204, 21, 0.32), 0 10px 24px rgba(15, 23, 42, 0.18);
}
}
.llm-error-title,
.llm-warning-list-title {
font-size: 11px;

View File

@ -1,5 +1,6 @@
<div class="llm-node"
[class.llm-node--human]="isHumanNode()"
[class.llm-node-running]="isRunning() && !hasExecutionErrors()"
[class.llm-node-completed]="isCompleted() && !needsAttention() && !hasExecutionErrors() && !hasExecutionWarnings()"
[class.llm-node-attention]="needsAttention()"
[class.llm-node-error]="hasExecutionErrors()"

View File

@ -247,6 +247,10 @@ export class TaskStepNodeComponent {
return this.stepStatus() === 'COMPLETED';
}
isRunning(): boolean {
return this.stepStatus() === 'RUNNING';
}
stepStatus(): string {
const status = this.blockConfiguration?.['__stepStatus'];
return typeof status === 'string' ? status.toUpperCase() : '';

View File

@ -2,6 +2,26 @@
width: min(100%, 48vw);
}
.title-toolbar-spinner {
width: 12px;
height: 12px;
border-radius: 999px;
border: 2px solid rgba(5, 150, 105, 0.2);
border-top-color: #059669;
animation: title-toolbar-spin 0.75s linear infinite;
}
.title-toolbar-spinner--blue {
border-color: rgba(37, 99, 235, 0.2);
border-top-color: #2563eb;
}
@keyframes title-toolbar-spin {
to {
transform: rotate(360deg);
}
}
.save-snackbar {
position: fixed;
right: 24px;

View File

@ -21,6 +21,22 @@
</h2>
@if (notSaved()) {
<span class="inline-flex shrink-0 items-center gap-x-1.5 rounded-full border border-yellow-500 px-3 py-1.5 text-xs font-medium text-yellow-500">not saved</span>
} @else if (blockSyncInProgress()) {
<span class="inline-flex shrink-0 items-center gap-2 rounded-full border border-blue-500 px-3 py-1.5 text-xs font-medium text-blue-600">
<span class="title-toolbar-spinner title-toolbar-spinner--blue" aria-hidden="true"></span>
<span>Updating blocks...</span>
</span>
} @else if (canExecute()) {
<button
type="button"
class="inline-flex shrink-0 items-center gap-2 rounded-full border border-emerald-500 bg-emerald-50 px-3 py-1.5 text-xs font-medium text-emerald-700 transition enabled:hover:bg-emerald-100 disabled:cursor-default disabled:opacity-60"
[disabled]="executeLoading()"
(click)="execute()">
@if (executeLoading()) {
<span class="title-toolbar-spinner" aria-hidden="true"></span>
}
<span>Execute</span>
</button>
}
</div>
@ -32,7 +48,7 @@
flex items-center gap-1
enabled:hover:bg-green-200 enabled:text-green-600 disabled:text-green-300
p-1"
title="Save" [disabled]="!notSaved()" (click)="save()">
title="Save" [disabled]="!canSave()" (click)="save()">
<i class="bi bi-save"></i>
</button>

View File

@ -1,5 +1,8 @@
import { CommonModule } from '@angular/common';
import { Component, computed, ElementRef, inject, signal, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import { BlocksService } from '@services/blocks/blocks';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { take } from 'rxjs';
import { EditorStateHolder } from '@stores/flow-editor';
@ -15,6 +18,9 @@ export class TitleToolbar {
@ViewChild('titleInput') myInputRef!: ElementRef;
editorState: EditorStateHolder = inject(EditorStateHolder);
private router = inject(Router);
private blocksService = inject(BlocksService);
private taskExecutionsService = inject(TaskExecutionsService);
flow = computed(() => this.editorState.currentFlow());
title = computed(() => {
const flow = this.flow();
@ -22,6 +28,13 @@ export class TitleToolbar {
});
notSaved = computed(() => this.editorState.isDirty());
blockSyncInProgress = this.blocksService.hasPendingServerSync;
canSave = computed(() => this.notSaved() && !this.blockSyncInProgress());
canExecute = computed(() => {
const flow = this.flow();
return !!flow && !this.notSaved() && !this.blockSyncInProgress() && flow.status === 'EXECUTABLE';
});
executeLoading = signal(false);
snackbarMessage = signal<string | null>(null);
snackbarType = signal<'success' | 'error'>('success');
@ -38,7 +51,7 @@ export class TitleToolbar {
}
save() {
if (!this.notSaved()) return;
if (!this.canSave()) return;
this.editorState.save().pipe(
take(1)
).subscribe({
@ -61,6 +74,26 @@ export class TitleToolbar {
this.editorState.redo();
}
execute() {
const flow = this.flow();
if (!flow || !this.canExecute() || this.executeLoading()) return;
this.executeLoading.set(true);
this.taskExecutionsService.createExecution(flow.id).pipe(
take(1)
).subscribe({
next: () => {
this.executeLoading.set(false);
this.router.navigate(['/tasks']);
},
error: (err) => {
this.executeLoading.set(false);
console.error('Create execution failed', err);
this.showSnackbar('Errore durante la creazione dell\'esecuzione', 'error');
}
});
}
private showSnackbar(message: string, type: 'success' | 'error') {
this.snackbarMessage.set(message);
this.snackbarType.set(type);

View File

@ -81,7 +81,10 @@ export class EditorStateHolder {
save() {
return this.flowsService.updateFlow(this.currentFlow()!).pipe(
tap(() => this.markSaved())
tap((savedFlow) => {
this.currentFlow.set(savedFlow);
this.markSaved();
})
)
}

View File

@ -99,7 +99,6 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
return {
id: blockId,
sink: blockData?.sink ?? false,
name: blockData?.name ?? node.label,
position: blockData?.position,
inputs,