Add flow execution status UX and remove sink usage
This commit is contained in:
parent
376234539e
commit
a82afa3999
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()"
|
||||
|
|
|
|||
|
|
@ -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() : '';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue