diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..eb83f6c
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,5 @@
+{
+ "chat.tools.terminal.autoApprove": {
+ "npx ng": true
+ }
+}
\ No newline at end of file
diff --git a/docker/40-runtime-config.sh b/docker/40-runtime-config.sh
index 9e579f8..6bbc4bb 100644
--- a/docker/40-runtime-config.sh
+++ b/docker/40-runtime-config.sh
@@ -3,6 +3,10 @@ set -eu
cat >/usr/share/nginx/html/runtime-config.js <
+
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 7996abf..2f2a6fa 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -4,12 +4,6 @@ import { Signup } from '@pages/auth/signup/signup';
import { AppLayout } from '@layouts/app-layout/app-layout';
import { authGuard } from '@guards/auth-guard';
import { adminGuard } from '@guards/admin-guard';
-import { AdminLayout } from '@layouts/admin-layout/admin-layout';
-import { FlowEditor } from '@layouts/flow-editor/flow-editor';
-import { TasksExecutor } from '@layouts/tasks-executor/tasks-executor';
-import { AdminCreateUserPage } from '@pages/admin/admin-create-user/admin-create-user';
-import { AdminStatsPage } from '@pages/admin/admin-stats/admin-stats';
-import { AdminUsersListPage } from '@pages/admin/admin-users-list/admin-users-list';
export const routes: Routes = [
{
@@ -23,15 +17,15 @@ export const routes: Routes = [
},
{
path: 'editor',
- component: FlowEditor
+ loadComponent: () => import('@layouts/flow-editor/flow-editor').then(m => m.FlowEditor)
},
{
path: 'tasks',
- component: TasksExecutor
+ loadComponent: () => import('@layouts/tasks-executor/tasks-executor').then(m => m.TasksExecutor)
},
{
path: 'admin',
- component: AdminLayout,
+ loadComponent: () => import('@layouts/admin-layout/admin-layout').then(m => m.AdminLayout),
canActivate: [adminGuard],
children: [
{
@@ -41,15 +35,15 @@ export const routes: Routes = [
},
{
path: 'users',
- component: AdminUsersListPage
+ loadComponent: () => import('@pages/admin/admin-users-list/admin-users-list').then(m => m.AdminUsersListPage)
},
{
path: 'create-user',
- component: AdminCreateUserPage
+ loadComponent: () => import('@pages/admin/admin-create-user/admin-create-user').then(m => m.AdminCreateUserPage)
},
{
path: 'stats',
- component: AdminStatsPage
+ loadComponent: () => import('@pages/admin/admin-stats/admin-stats').then(m => m.AdminStatsPage)
}
]
}
diff --git a/src/app/app.ts b/src/app/app.ts
index 83c9d14..2c8834e 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -1,13 +1,14 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ConfirmDialogHostComponent } from '@shared/confirm-dialog/confirm-dialog';
+import { GlobalNotificationComponent } from '@shared/global-notification/global-notification';
import { HumanInteractionDialogHostComponent } from '@shared/human-interaction-dialog/human-interaction-dialog';
import { NodeSettingsDialogHostComponent } from '@shared/node-settings-dialog/node-settings-dialog';
import { SubflowPreviewDialogHostComponent } from '@shared/subflow-preview-dialog/subflow-preview-dialog';
@Component({
selector: 'app-root',
- imports: [RouterOutlet, ConfirmDialogHostComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent],
+ imports: [RouterOutlet, ConfirmDialogHostComponent, GlobalNotificationComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent, SubflowPreviewDialogHostComponent],
templateUrl: './app.html',
styleUrl: './app.css',
changeDetection: ChangeDetectionStrategy.OnPush
diff --git a/src/app/guards/admin-guard.spec.ts b/src/app/guards/admin-guard.spec.ts
new file mode 100644
index 0000000..508ca94
--- /dev/null
+++ b/src/app/guards/admin-guard.spec.ts
@@ -0,0 +1,41 @@
+import { TestBed } from '@angular/core/testing';
+import { CanActivateFn, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
+import { Authorization } from '@services/authorization/authorization';
+import { adminGuard } from './admin-guard';
+
+describe('adminGuard', () => {
+ let authorizationSpy: jasmine.SpyObj;
+ let routerSpy: jasmine.SpyObj;
+
+ const executeGuard: CanActivateFn = (...guardParameters) =>
+ TestBed.runInInjectionContext(() => adminGuard(...guardParameters));
+
+ const dummyRoute = {} as ActivatedRouteSnapshot;
+ const dummyState = {} as RouterStateSnapshot;
+
+ beforeEach(() => {
+ authorizationSpy = jasmine.createSpyObj('Authorization', ['isAdmin']);
+ routerSpy = jasmine.createSpyObj('Router', ['navigate']);
+
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: Authorization, useValue: authorizationSpy },
+ { provide: Router, useValue: routerSpy },
+ ],
+ });
+ });
+
+ it('should allow access when user is admin', () => {
+ authorizationSpy.isAdmin.and.returnValue(true);
+ const result = executeGuard(dummyRoute, dummyState);
+ expect(result).toBeTrue();
+ expect(routerSpy.navigate).not.toHaveBeenCalled();
+ });
+
+ it('should deny access and redirect to / when user is not admin', () => {
+ authorizationSpy.isAdmin.and.returnValue(false);
+ const result = executeGuard(dummyRoute, dummyState);
+ expect(result).toBeFalse();
+ expect(routerSpy.navigate).toHaveBeenCalledWith(['/']);
+ });
+});
diff --git a/src/app/guards/admin-guard.ts b/src/app/guards/admin-guard.ts
index a3fcead..9939006 100644
--- a/src/app/guards/admin-guard.ts
+++ b/src/app/guards/admin-guard.ts
@@ -2,7 +2,7 @@ import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Authorization } from '@services/authorization/authorization';
-export const adminGuard: CanActivateFn = () => {
+export const adminGuard: CanActivateFn = (_route, state) => {
const authorization = inject(Authorization);
const router = inject(Router);
@@ -10,6 +10,7 @@ export const adminGuard: CanActivateFn = () => {
return true;
}
+ console.warn(`[adminGuard] Access denied to ${state.url} — user is not admin, redirecting to /`);
router.navigate(['/']);
return false;
};
diff --git a/src/app/guards/auth-guard.spec.ts b/src/app/guards/auth-guard.spec.ts
index 0ffeaf0..68250cd 100644
--- a/src/app/guards/auth-guard.spec.ts
+++ b/src/app/guards/auth-guard.spec.ts
@@ -1,17 +1,41 @@
import { TestBed } from '@angular/core/testing';
-import { CanActivateFn } from '@angular/router';
-
+import { CanActivateFn, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
+import { Authorization } from '@services/authorization/authorization';
import { authGuard } from './auth-guard';
describe('authGuard', () => {
- const executeGuard: CanActivateFn = (...guardParameters) =>
- TestBed.runInInjectionContext(() => authGuard(...guardParameters));
+ let authorizationSpy: jasmine.SpyObj;
+ let routerSpy: jasmine.SpyObj;
+
+ const executeGuard: CanActivateFn = (...guardParameters) =>
+ TestBed.runInInjectionContext(() => authGuard(...guardParameters));
+
+ const dummyRoute = {} as ActivatedRouteSnapshot;
+ const dummyState = {} as RouterStateSnapshot;
beforeEach(() => {
- TestBed.configureTestingModule({});
+ authorizationSpy = jasmine.createSpyObj('Authorization', ['isLoggedIn']);
+ routerSpy = jasmine.createSpyObj('Router', ['navigate']);
+
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: Authorization, useValue: authorizationSpy },
+ { provide: Router, useValue: routerSpy },
+ ],
+ });
});
- it('should be created', () => {
- expect(executeGuard).toBeTruthy();
+ it('should allow access when logged in', () => {
+ authorizationSpy.isLoggedIn.and.returnValue(true);
+ const result = executeGuard(dummyRoute, dummyState);
+ expect(result).toBeTrue();
+ expect(routerSpy.navigate).not.toHaveBeenCalled();
+ });
+
+ it('should deny access and redirect to /login when not logged in', () => {
+ authorizationSpy.isLoggedIn.and.returnValue(false);
+ const result = executeGuard(dummyRoute, dummyState);
+ expect(result).toBeFalse();
+ expect(routerSpy.navigate).toHaveBeenCalledWith(['/login']);
});
});
diff --git a/src/app/guards/auth-guard.ts b/src/app/guards/auth-guard.ts
index 0b3e470..b2aae9d 100644
--- a/src/app/guards/auth-guard.ts
+++ b/src/app/guards/auth-guard.ts
@@ -2,13 +2,14 @@ import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { Authorization } from '@services/authorization/authorization';
-export const authGuard: CanActivateFn = (_route, _state) => {
+export const authGuard: CanActivateFn = (_route, state) => {
const authService = inject(Authorization);
const router = inject(Router);
if (authService.isLoggedIn()) {
- return true; // Allow access
+ return true;
} else {
- router.navigate(['/login']); // Redirect to login
- return false; // Deny access
+ console.warn(`[authGuard] Access denied to ${state.url} — user not logged in, redirecting to /login`);
+ router.navigate(['/login']);
+ return false;
}
};
diff --git a/src/app/interceptors/auth-token.interceptor.ts b/src/app/interceptors/auth-token.interceptor.ts
index 621741d..e20293d 100644
--- a/src/app/interceptors/auth-token.interceptor.ts
+++ b/src/app/interceptors/auth-token.interceptor.ts
@@ -1,17 +1,16 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
+import { NotificationService } from '@services/notifications/notification';
import { catchError, throwError } from 'rxjs';
-let lastSessionExpiredNotificationAt = 0;
-let lastServiceErrorNotificationAt = 0;
-
export const withCredentialsInterceptor: HttpInterceptorFn = (req, next) => {
return next(req.clone({ withCredentials: true }));
};
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
const router = inject(Router);
+ const notification = inject(NotificationService);
const requestPath = req.url.split('?')[0];
const isAuthEndpoint =
requestPath.endsWith('/auth/login') ||
@@ -24,7 +23,7 @@ export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
error instanceof HttpErrorResponse &&
error.status === 401
) {
- notifySessionExpired();
+ notification.show('Session expired. Redirecting to login.', 'warning');
redirectToLogin(router);
}
@@ -32,7 +31,7 @@ export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
error instanceof HttpErrorResponse &&
(error.status === 0 || error.status >= 500)
) {
- notifyServiceContactError();
+ notification.show('Error contacting service, please retry later.', 'error');
}
return throwError(() => error);
@@ -40,20 +39,6 @@ export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
);
};
-function notifySessionExpired() {
- const now = Date.now();
- if (now - lastSessionExpiredNotificationAt < 1200) return;
- lastSessionExpiredNotificationAt = now;
- console.warn('Session expired. Redirecting to login.');
-}
-
-function notifyServiceContactError() {
- const now = Date.now();
- if (now - lastServiceErrorNotificationAt < 1200) return;
- lastServiceErrorNotificationAt = now;
- console.error('Error contacting service, please retry later.');
-}
-
function redirectToLogin(router: Router) {
if (router.url === '/login') return;
void router.navigateByUrl('/login');
diff --git a/src/app/models/nodes.ts b/src/app/models/nodes.ts
index f1c5a25..234c57c 100644
--- a/src/app/models/nodes.ts
+++ b/src/app/models/nodes.ts
@@ -7,7 +7,15 @@ export type HFNodeData = FlowNode & {
assignSelectedBlocksToContainer?: (blockIds?: string[]) => Promise;
assignImportedSubflow?: (subFlow: FlowData, validationUrl?: string | null) => Promise;
clearContainerSubflow?: () => Promise;
- [key: string]: unknown;
+ cloneNode?: () => Promise;
+ __readonly?: boolean;
+ __needsServerCreate?: boolean;
+ __createdOnServer?: boolean;
+ __isCreatingOnServer?: boolean;
+ __updateBlockError?: string | null;
+ __containerValidationErrors?: unknown[];
+ __containerAssignmentError?: string | null;
+ __containerAssigning?: boolean;
};
export type HFNode = ClassicPreset.Node & {
diff --git a/src/app/pages/auth/signup/signup.ts b/src/app/pages/auth/signup/signup.ts
index d00fcf5..90e3db7 100644
--- a/src/app/pages/auth/signup/signup.ts
+++ b/src/app/pages/auth/signup/signup.ts
@@ -22,6 +22,10 @@ function hasValidPasswordComplexity(value: string): boolean {
declare global {
interface Window {
__runtimeConfig?: {
+ apiUrl?: string;
+ assistantEnabled?: boolean;
+ tourModeAlwaysOn?: boolean;
+ turnstileEnabled?: boolean;
turnstileSiteKey?: string;
};
turnstile?: {
diff --git a/src/app/services/flows/flows.spec.ts b/src/app/services/flows/flows.spec.ts
index 531ba95..299ae54 100644
--- a/src/app/services/flows/flows.spec.ts
+++ b/src/app/services/flows/flows.spec.ts
@@ -1,16 +1,141 @@
import { TestBed } from '@angular/core/testing';
-
import { FlowsService } from './flows';
+import { FlowsCallServiceBase } from './flows-call.base';
+import { Flow, FlowData, FlowValidationError } from '@models/flow';
+import { of, throwError } from 'rxjs';
+
+function makeFlow(id = 'f1'): Flow {
+ const data: FlowData = { blocks: [], containers: [], connections: [], dependencies: [] };
+ return {
+ id,
+ name: 'Flow ' + id,
+ visibility: 'PRIVATE',
+ data,
+ author: 'user',
+ createdAt: new Date(),
+ status: 'DRAFT',
+ updatedAt: new Date(),
+ };
+}
describe('FlowsService', () => {
let service: FlowsService;
+ let callServiceSpy: jasmine.SpyObj;
beforeEach(() => {
+ callServiceSpy = jasmine.createSpyObj('FlowsCallServiceBase', [
+ 'retrieveAllFlows',
+ 'updateFlow',
+ 'createFlow',
+ 'deleteFlow',
+ 'updatePublished',
+ 'finalizeFlow',
+ 'getFlowValidation',
+ 'getFlowById',
+ 'createNewFlow',
+ ]);
+
TestBed.configureTestingModule({});
service = TestBed.inject(FlowsService);
+ service.flowsCallService = callServiceSpy;
});
it('should be created', () => {
expect(service).toBeTruthy();
});
+
+ describe('getAllFlows', () => {
+ it('should load flows on first call', async () => {
+ const flows = [makeFlow('f1'), makeFlow('f2')];
+ callServiceSpy.retrieveAllFlows.and.returnValue(of(flows));
+
+ const result = await service.getAllFlows();
+ expect(result()).toEqual(flows);
+ expect(callServiceSpy.retrieveAllFlows).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not reload on subsequent calls', async () => {
+ callServiceSpy.retrieveAllFlows.and.returnValue(of([makeFlow()]));
+
+ await service.getAllFlows();
+ await service.getAllFlows();
+ expect(callServiceSpy.retrieveAllFlows).toHaveBeenCalledTimes(1);
+ });
+
+ it('should retry after failure', async () => {
+ callServiceSpy.retrieveAllFlows.and.returnValue(throwError(() => new Error('fail')));
+ try { await service.getAllFlows(); } catch {}
+
+ const flows = [makeFlow()];
+ callServiceSpy.retrieveAllFlows.and.returnValue(of(flows));
+ const result = await service.getAllFlows();
+ expect(result()).toEqual(flows);
+ });
+ });
+
+ describe('refresh', () => {
+ it('should update flows signal', async () => {
+ const flows = [makeFlow('r1')];
+ callServiceSpy.retrieveAllFlows.and.returnValue(of(flows));
+ await service.refresh();
+ expect(service.flows()).toEqual(flows);
+ });
+
+ it('should clear loadingPromise after error', async () => {
+ callServiceSpy.retrieveAllFlows.and.returnValue(throwError(() => new Error('fail')));
+ try { await service.refresh(); } catch {}
+
+ callServiceSpy.retrieveAllFlows.and.returnValue(of([]));
+ await service.refresh();
+ expect(service.flows()).toEqual([]);
+ });
+ });
+
+ describe('updateFlow', () => {
+ it('should update the flow in the signal', async () => {
+ callServiceSpy.retrieveAllFlows.and.returnValue(of([makeFlow('f1')]));
+ await service.refresh();
+
+ const updated = makeFlow('f1');
+ updated.name = 'Updated';
+ callServiceSpy.updateFlow.and.returnValue(of(updated));
+
+ await new Promise((resolve) => {
+ service.updateFlow(updated).subscribe(() => resolve());
+ });
+
+ expect(service.flows()[0].name).toBe('Updated');
+ });
+ });
+
+ describe('createFlow', () => {
+ it('should add a new flow to the signal', async () => {
+ callServiceSpy.retrieveAllFlows.and.returnValue(of([]));
+ await service.refresh();
+
+ const created = makeFlow('new1');
+ callServiceSpy.createFlow.and.returnValue(of(created));
+
+ await new Promise((resolve) => {
+ service.createFlow({ name: 'New', description: '', data: created.data, status: 'DRAFT' }).subscribe(() => resolve());
+ });
+
+ expect(service.flows().length).toBe(1);
+ expect(service.flows()[0].id).toBe('new1');
+ });
+ });
+
+ describe('deleteFlow', () => {
+ it('should trigger a refresh after deletion', async () => {
+ callServiceSpy.retrieveAllFlows.and.returnValue(of([makeFlow('f1')]));
+ await service.refresh();
+
+ callServiceSpy.deleteFlow.and.returnValue(of(void 0));
+ callServiceSpy.retrieveAllFlows.and.returnValue(of([]));
+
+ await new Promise((resolve) => {
+ service.deleteFlow('f1').subscribe(() => resolve());
+ });
+ });
+ });
});
diff --git a/src/app/services/flows/flows.ts b/src/app/services/flows/flows.ts
index 5b9934f..11851ff 100644
--- a/src/app/services/flows/flows.ts
+++ b/src/app/services/flows/flows.ts
@@ -24,7 +24,11 @@ export class FlowsService {
async getAllFlows() {
if (this.toInit) {
this.toInit = false;
- await this.refresh();
+ try {
+ await this.refresh();
+ } catch {
+ this.toInit = true;
+ }
}
return this.flows;
@@ -40,6 +44,7 @@ export class FlowsService {
this._flows.set(flows);
})
.catch((err) => {
+ this.loadingPromise = null;
console.error('Retrieve flows failed', err);
throw err;
})
diff --git a/src/app/services/notifications/notification.spec.ts b/src/app/services/notifications/notification.spec.ts
new file mode 100644
index 0000000..ebe21dd
--- /dev/null
+++ b/src/app/services/notifications/notification.spec.ts
@@ -0,0 +1,53 @@
+import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { NotificationService } from './notification';
+
+describe('NotificationService', () => {
+ let service: NotificationService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(NotificationService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should have no current notification initially', () => {
+ expect(service.current()).toBeNull();
+ });
+
+ it('should show a notification', () => {
+ service.show('Test message', 'success');
+ expect(service.current()).toBeTruthy();
+ expect(service.current()!.message).toBe('Test message');
+ expect(service.current()!.type).toBe('success');
+ });
+
+ it('should auto-dismiss after duration', fakeAsync(() => {
+ service.show('Auto dismiss', 'info', 2000);
+ expect(service.current()).toBeTruthy();
+ tick(2000);
+ expect(service.current()).toBeNull();
+ }));
+
+ it('should dismiss manually', () => {
+ service.show('Manual dismiss', 'error', 0);
+ expect(service.current()).toBeTruthy();
+ service.dismiss();
+ expect(service.current()).toBeNull();
+ });
+
+ it('should throttle same-type notifications', () => {
+ service.show('First', 'error');
+ const first = service.current();
+ service.show('Second', 'error');
+ expect(service.current()).toBe(first);
+ });
+
+ it('should not throttle different-type notifications', () => {
+ service.show('Error', 'error');
+ service.show('Success', 'success');
+ expect(service.current()!.message).toBe('Success');
+ });
+});
diff --git a/src/app/services/notifications/notification.ts b/src/app/services/notifications/notification.ts
new file mode 100644
index 0000000..0f42afb
--- /dev/null
+++ b/src/app/services/notifications/notification.ts
@@ -0,0 +1,47 @@
+import { Injectable, signal } from '@angular/core';
+
+export type NotificationType = 'success' | 'error' | 'warning' | 'info';
+
+export type Notification = {
+ message: string;
+ type: NotificationType;
+ timestamp: number;
+};
+
+@Injectable({ providedIn: 'root' })
+export class NotificationService {
+ private static readonly THROTTLE_MS = 1200;
+
+ private _current = signal(null);
+ readonly current = this._current.asReadonly();
+
+ private dismissTimer: ReturnType | null = null;
+ private lastNotificationAt = 0;
+
+ show(message: string, type: NotificationType = 'info', durationMs = 4000) {
+ const now = Date.now();
+ if (now - this.lastNotificationAt < NotificationService.THROTTLE_MS && type === this._current()?.type) {
+ return;
+ }
+ this.lastNotificationAt = now;
+
+ this.clearDismissTimer();
+ this._current.set({ message, type, timestamp: now });
+
+ if (durationMs > 0) {
+ this.dismissTimer = setTimeout(() => this.dismiss(), durationMs);
+ }
+ }
+
+ dismiss() {
+ this.clearDismissTimer();
+ this._current.set(null);
+ }
+
+ private clearDismissTimer() {
+ if (this.dismissTimer) {
+ clearTimeout(this.dismissTimer);
+ this.dismissTimer = null;
+ }
+ }
+}
diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts
index 606e108..9a22578 100644
--- a/src/app/services/task-executions/task-executions.ts
+++ b/src/app/services/task-executions/task-executions.ts
@@ -1,4 +1,4 @@
-import { Injectable, signal } from '@angular/core';
+import { DestroyRef, inject, Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { LLMDescriptor } from '@models/flow';
import { ExecutionEventLogEntry, getExecutionStatusGroup, TaskExecution } from '@models/task-execution';
@@ -10,6 +10,7 @@ import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
})
export class TaskExecutionsService {
private static readonly POLL_INTERVAL_MS = 5000;
+ private destroyRef = inject(DestroyRef);
taskExecutionsCallService: TaskExecutionsCallServiceBase = new environment.taskExecutionsCallService();
private initialized = false;
private refreshInFlight = false;
@@ -23,6 +24,7 @@ export class TaskExecutionsService {
init() {
if (this.initialized) return;
this.initialized = true;
+ this.destroyRef.onDestroy(() => this.stopPolling());
this.refresh();
}
diff --git a/src/app/shared/blocks-list/blocks-list.html b/src/app/shared/blocks-list/blocks-list.html
index f4be6d9..6f73608 100644
--- a/src/app/shared/blocks-list/blocks-list.html
+++ b/src/app/shared/blocks-list/blocks-list.html
@@ -8,7 +8,7 @@
Search blocks
-
+
diff --git a/src/app/shared/blocks-list/blocks-list.ts b/src/app/shared/blocks-list/blocks-list.ts
index 35338a6..5de3bda 100644
--- a/src/app/shared/blocks-list/blocks-list.ts
+++ b/src/app/shared/blocks-list/blocks-list.ts
@@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, computed, inject, model, signal, Si
import { BlockType } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
-import { FormsModule } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -14,7 +13,7 @@ import { BLOCK_TYPE_DRAG_MIME } from './block-drag';
@Component({
selector: 'app-blocks-list',
- imports: [FormsModule, MatCardModule, MatChipsModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
+ imports: [MatCardModule, MatChipsModule, MatFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, MatTooltipModule],
templateUrl: './blocks-list.html',
styleUrl: './blocks-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
diff --git a/src/app/shared/flows-list/flows-list.html b/src/app/shared/flows-list/flows-list.html
index 19f7b08..45af005 100644
--- a/src/app/shared/flows-list/flows-list.html
+++ b/src/app/shared/flows-list/flows-list.html
@@ -8,7 +8,7 @@
Search flows
-
+
diff --git a/src/app/shared/flows-list/flows-list.ts b/src/app/shared/flows-list/flows-list.ts
index b9e913c..9099b9c 100644
--- a/src/app/shared/flows-list/flows-list.ts
+++ b/src/app/shared/flows-list/flows-list.ts
@@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, computed, effect, inject, model, si
import { Flow, FlowVisibility } from '@models/flow';
import { FlowsService } from '@services/flows/flows';
import { FlowItem } from './flow-item/flow-item';
-import { FormsModule } from '@angular/forms';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -17,7 +16,7 @@ type FlowFilter = FlowVisibility | 'FINALIZED' | 'all';
@Component({
selector: 'app-flows-list',
- imports: [FlowItem, FormsModule, Ordering, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatProgressSpinnerModule],
+ imports: [FlowItem, Ordering, MatButtonToggleModule, MatCardModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatProgressSpinnerModule],
templateUrl: './flows-list.html',
styleUrl: './flows-list.css',
changeDetection: ChangeDetectionStrategy.OnPush
diff --git a/src/app/shared/global-notification/global-notification.ts b/src/app/shared/global-notification/global-notification.ts
new file mode 100644
index 0000000..63d9344
--- /dev/null
+++ b/src/app/shared/global-notification/global-notification.ts
@@ -0,0 +1,41 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { NotificationService } from '@services/notifications/notification';
+
+@Component({
+ selector: 'app-global-notification',
+ template: `
+ @if (notification.current(); as n) {
+
+ {{ n.message }}
+
+ }
+ `,
+ styles: `
+ .global-notification {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 12px 24px;
+ border-radius: 8px;
+ color: white;
+ font-size: 14px;
+ z-index: 10000;
+ cursor: pointer;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ animation: slideUp 0.3s ease-out;
+ }
+ .global-notification-error { background-color: #d32f2f; }
+ .global-notification-success { background-color: #388e3c; }
+ .global-notification-warning { background-color: #f57c00; }
+ .global-notification-info { background-color: #1976d2; }
+ @keyframes slideUp {
+ from { opacity: 0; transform: translateX(-50%) translateY(16px); }
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
+ }
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class GlobalNotificationComponent {
+ protected notification = inject(NotificationService);
+}
diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts
new file mode 100644
index 0000000..2bdde40
--- /dev/null
+++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts
@@ -0,0 +1,263 @@
+import {
+ areFlowValueKindsCompatible,
+ FlowBlock,
+ FlowBlockConnection,
+ FlowContainer,
+ FlowData,
+ FlowNode,
+ FlowNodeDependency,
+ normalizeFlowPortValueKinds,
+} from '@models/flow';
+import {
+ ExecutionEventLogEntry,
+ getExecutionStatusGroup,
+ getTaskExecutionStepNode,
+ TaskExecution,
+ TaskExecutionStep,
+} from '@models/task-execution';
+
+export type ExecutionOutputEntry = {
+ key: string;
+ nodeTitle: string;
+ outputName: string;
+ value: string;
+ preview: string;
+ isLong: boolean;
+ itemLabel: string | null;
+};
+
+export type ExecutionOutputGroup = {
+ nodeTitle: string;
+ outputs: ExecutionOutputEntry[];
+};
+
+export type ExecutionLogEntryView = ExecutionEventLogEntry & {
+ messageText: string;
+ levelText: string;
+};
+
+const OUTPUT_PREVIEW_LIMIT = 80;
+
+export function stepTitle(step: TaskExecutionStep | null | undefined): string {
+ return getTaskExecutionStepNode(step)?.name?.trim() || step?.id || 'Step';
+}
+
+export function stepNodeId(step: TaskExecutionStep | null | undefined): string {
+ return getTaskExecutionStepNode(step)?.id || step?.id || '';
+}
+
+export function stringifyOutputValue(value: unknown): string {
+ if (value == null) return '';
+ if (typeof value === 'string') return value;
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+}
+
+export function formatDuration(startTime: number, endTime: number): string {
+ const diffMs = Math.max(0, endTime - startTime);
+ const totalSeconds = Math.floor(diffMs / 1000);
+ const totalMinutes = Math.floor(totalSeconds / 60);
+ const totalHours = Math.floor(totalMinutes / 60);
+ const totalDays = Math.floor(totalHours / 24);
+
+ if (totalSeconds < 60) return `${totalSeconds} sec`;
+ if (totalMinutes < 60) {
+ const seconds = totalSeconds % 60;
+ return seconds > 0 ? `${totalMinutes} min ${seconds} sec` : `${totalMinutes} min`;
+ }
+ if (totalHours < 24) {
+ const minutes = totalMinutes % 60;
+ return minutes > 0 ? `${totalHours} h ${minutes} min` : `${totalHours} h`;
+ }
+ const hours = totalHours % 24;
+ return hours > 0 ? `${totalDays} gg ${hours} h` : `${totalDays} gg`;
+}
+
+export function fallbackExecutionLogMessage(entry: ExecutionEventLogEntry): string {
+ const type = String(entry.type ?? '').trim();
+ if (type) {
+ return type.replaceAll('_', ' ').toLowerCase().replace(/^\w/, (letter) => letter.toUpperCase());
+ }
+ return 'Execution event';
+}
+
+export function logLevelClass(level: string | null | undefined): string {
+ const normalized = String(level ?? '').toUpperCase();
+ if (normalized === 'ERROR') return 'execution-log-level-error';
+ if (normalized === 'WARN' || normalized === 'WARNING') return 'execution-log-level-warn';
+ return 'execution-log-level-info';
+}
+
+export function logTypeIcon(type: string | null | undefined): string {
+ const normalized = String(type ?? '').toUpperCase();
+ if (normalized.includes('FAILED') || normalized.includes('ERROR')) return 'error';
+ if (normalized.includes('WAITING') || normalized.includes('PAUSED')) return 'pause_circle';
+ if (normalized.includes('COMPLETED') || normalized.includes('SUCCESS')) return 'check_circle';
+ if (normalized.includes('HTTP')) return 'language';
+ if (normalized.includes('LLM')) return 'smart_toy';
+ if (normalized.includes('MCP_SESSION')) return 'hub';
+ return 'schedule';
+}
+
+export function formatExecutionOutputLabel(
+ key: string,
+ steps: Record
+): { nodeTitle: string; outputName: string } {
+ const separatorIndex = key.indexOf(':');
+ if (separatorIndex < 0) {
+ return { nodeTitle: 'Execution output', outputName: key };
+ }
+ const nodeId = key.slice(0, separatorIndex);
+ const outputName = key.slice(separatorIndex + 1);
+ const step = steps[nodeId];
+ const nodeName = stepTitle(step).trim();
+ return {
+ nodeTitle: nodeName || key,
+ outputName: outputName || 'Execution output',
+ };
+}
+
+export function buildExecutionOutputs(execution: TaskExecution | null): ExecutionOutputEntry[] {
+ if (!execution) return [];
+ const steps = execution.context.steps ?? {};
+ const resultMap = execution.context.result ?? {};
+
+ return Object.entries(resultMap)
+ .flatMap(([key, rawValue]) => {
+ const label = formatExecutionOutputLabel(key, steps);
+ const values = Array.isArray(rawValue) ? rawValue : [rawValue];
+ return values.map((item, index) => {
+ const value = stringifyOutputValue(item);
+ const isLong = value.length > OUTPUT_PREVIEW_LIMIT;
+ const isArrayItem = Array.isArray(rawValue);
+ return {
+ key: isArrayItem ? `${key}:${index}` : key,
+ nodeTitle: label.nodeTitle,
+ outputName: label.outputName,
+ value,
+ preview: isLong ? `${value.slice(0, OUTPUT_PREVIEW_LIMIT)}...` : value,
+ isLong,
+ itemLabel: isArrayItem ? `Item ${index + 1}` : null,
+ };
+ });
+ })
+ .sort((a, b) => a.nodeTitle.localeCompare(b.nodeTitle) || a.outputName.localeCompare(b.outputName));
+}
+
+export function buildExecutionOutputGroups(outputs: ExecutionOutputEntry[]): ExecutionOutputGroup[] {
+ const groups = new Map();
+ for (const output of outputs) {
+ if (!groups.has(output.nodeTitle)) groups.set(output.nodeTitle, []);
+ groups.get(output.nodeTitle)!.push(output);
+ }
+ return Array.from(groups.entries())
+ .map(([nodeTitle, outs]) => ({ nodeTitle, outputs: outs }))
+ .sort((a, b) => a.nodeTitle.localeCompare(b.nodeTitle));
+}
+
+export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): ExecutionLogEntryView[] {
+ return [...logs]
+ .sort((a, b) => a.timestamp - b.timestamp)
+ .map((entry) => ({
+ ...entry,
+ messageText: String(entry.message ?? '').trim() || fallbackExecutionLogMessage(entry),
+ levelText: String(entry.level ?? 'INFO').toUpperCase(),
+ }));
+}
+
+export function isInputSet(value: unknown, multiple = false): boolean {
+ if (multiple) {
+ if (!Array.isArray(value)) return false;
+ return value.some((item) => (typeof item === 'string' ? item.trim().length > 0 : item != null));
+ }
+ if (value == null) return false;
+ if (typeof value === 'string') return value.trim().length > 0;
+ return true;
+}
+
+export function normalizeEditableInputValue(value: unknown, multiple: boolean): string | string[] {
+ if (multiple) {
+ if (Array.isArray(value)) return value.map((item) => stringifyEditableInputItem(item));
+ if (value == null) return [''];
+ return [stringifyEditableInputItem(value)];
+ }
+ if (value == null) return '';
+ return stringifyEditableInputItem(value);
+}
+
+export function stringifyEditableInputItem(value: unknown): string {
+ if (value == null) return '';
+ if (typeof value === 'string') return value;
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return String(value);
+ }
+}
+
+export function getExecutionInputValues(
+ step: TaskExecutionStep,
+ contextInputs: Record
+): Record {
+ const result: Record = {};
+ for (const input of step.inputs ?? []) {
+ const inputName = input.descriptor?.name;
+ if (!inputName) continue;
+ const key = `${step.id}:${inputName}`;
+ if (Object.prototype.hasOwnProperty.call(contextInputs, key)) {
+ result[inputName] = contextInputs[key];
+ continue;
+ }
+ if (input.set || input.registered || input.value != null) {
+ result[inputName] = input.value;
+ }
+ }
+ return result;
+}
+
+export function getExecutionOutputValues(
+ step: TaskExecutionStep,
+ contextResults: Record
+): Record {
+ const result: Record = {};
+ for (const output of step.outputs ?? []) {
+ const outputName = output.descriptor?.name;
+ if (!outputName) continue;
+ const key = `${step.id}:${outputName}`;
+ if (Object.prototype.hasOwnProperty.call(contextResults, key)) {
+ result[outputName] = contextResults[key];
+ }
+ }
+ return result;
+}
+
+export function getConnectedInputs(step: TaskExecutionStep): string[] {
+ return (step.inputs ?? [])
+ .filter((input) => input.registered)
+ .map((input) => input.descriptor?.name)
+ .filter((name): name is string => typeof name === 'string' && name.length > 0);
+}
+
+export function getConnectedOutputs(step: TaskExecutionStep): string[] {
+ return (step.outputs ?? [])
+ .filter((output) => output.connected)
+ .map((output) => output.descriptor?.name)
+ .filter((name): name is string => typeof name === 'string' && name.length > 0);
+}
+
+export function getExecutionErrors(stepId: string, contextErrors: Record): string[] {
+ const raw = contextErrors[stepId];
+ if (typeof raw === 'string' && raw.trim().length > 0) return [raw];
+ if (Array.isArray(raw)) {
+ return raw.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
+ }
+ return [];
+}
+
+export function getExecutionWarnings(stepId: string, contextWarnings: Record): string[] {
+ const raw = contextWarnings[stepId];
+ return raw && raw.trim().length > 0 ? [raw] : [];
+}
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 ee0d5f0..cd24f8d 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
@@ -36,26 +36,30 @@ import { FieldRetriever } from '@services/retriever/field-retriever';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { ContainersService } from '@services/containers/containers';
import { firstValueFrom } from 'rxjs';
-
-type ExecutionOutputEntry = {
- key: string;
- nodeTitle: string;
- outputName: string;
- value: string;
- preview: string;
- isLong: boolean;
- itemLabel: string | null;
-};
-
-type ExecutionOutputGroup = {
- nodeTitle: string;
- outputs: ExecutionOutputEntry[];
-};
-
-type ExecutionLogEntryView = ExecutionEventLogEntry & {
- messageText: string;
- levelText: string;
-};
+import {
+ ExecutionOutputEntry,
+ ExecutionOutputGroup,
+ ExecutionLogEntryView,
+ stepTitle,
+ stepNodeId,
+ stringifyOutputValue,
+ formatDuration,
+ fallbackExecutionLogMessage,
+ logLevelClass as _logLevelClass,
+ logTypeIcon as _logTypeIcon,
+ formatExecutionOutputLabel,
+ buildExecutionOutputs,
+ buildExecutionOutputGroups,
+ buildVisibleExecutionLogs,
+ isInputSet,
+ normalizeEditableInputValue,
+ getExecutionInputValues,
+ getExecutionOutputValues,
+ getConnectedInputs,
+ getConnectedOutputs,
+ getExecutionErrors,
+ getExecutionWarnings,
+} from './execution-viewer.utils';
@Component({
selector: 'app-task-execution-viewer',
@@ -66,7 +70,6 @@ type ExecutionLogEntryView = ExecutionEventLogEntry & {
})
export class TaskExecutionViewerComponent implements OnDestroy {
private static readonly EVENTS_POLL_INTERVAL_MS = 5000;
- private static readonly OUTPUT_PREVIEW_LIMIT = 80;
private taskExecutionsService = inject(TaskExecutionsService);
private humanInteractionDialog = inject(HumanInteractionDialogService);
private settingsDialog = inject(NodeSettingsDialogService);
@@ -267,14 +270,14 @@ export class TaskExecutionViewerComponent implements OnDestroy {
__stepStatus: step.status,
__executionStatusGroup: executionStatusGroup,
__isWaitingStep: waitingSteps.includes(step.id),
- __executionInputs: this.getExecutionInputValues(step, contextInputs),
- __connectedInputs: this.getConnectedInputs(step),
- __executionOutputs: this.getExecutionOutputValues(step, contextResults),
- __connectedOutputs: this.getConnectedOutputs(step),
+ __executionInputs: getExecutionInputValues(step, contextInputs),
+ __connectedInputs: getConnectedInputs(step),
+ __executionOutputs: getExecutionOutputValues(step, contextResults),
+ __connectedOutputs: getConnectedOutputs(step),
__hasDependencyInputConnection: this.hasIncomingDependency(step.id),
__hasDependantOutputConnection: this.hasOutgoingDependency(step.id),
- __executionErrors: this.getExecutionErrors(step.id, contextErrors),
- __executionWarnings: this.getExecutionWarnings(step.id, contextWarnings),
+ __executionErrors: getExecutionErrors(step.id, contextErrors),
+ __executionWarnings: getExecutionWarnings(step.id, contextWarnings),
__stepResultData: step.result ?? null,
__executionPartialResult: this.execution()?.context.partialResult ?? null
},
@@ -305,7 +308,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly formattedDuration = computed(() => {
const context = this.execution()?.context;
if (!context?.startTime || !context?.endTime) return '-';
- return this.formatDuration(context.startTime, context.endTime);
+ return formatDuration(context.startTime, context.endTime);
});
readonly executionOutputTabEnabled = computed(() => {
@@ -313,50 +316,13 @@ export class TaskExecutionViewerComponent implements OnDestroy {
return status === 'SUCCESS' || status === 'COMPLETED';
});
- readonly executionOutputs = computed(() => {
- const steps = this.execution()?.context.steps ?? {};
- const resultMap = this.execution()?.context.result ?? {};
+ readonly executionOutputs = computed(() =>
+ buildExecutionOutputs(this.execution())
+ );
- return Object.entries(resultMap)
- .flatMap(([key, rawValue]) => {
- const label = this.formatExecutionOutputLabel(key, steps);
- const values = Array.isArray(rawValue) ? rawValue : [rawValue];
-
- return values.map((item, index) => {
- const value = this.stringifyOutputValue(item);
- const isLong = value.length > TaskExecutionViewerComponent.OUTPUT_PREVIEW_LIMIT;
- const isArrayItem = Array.isArray(rawValue);
- return {
- key: isArrayItem ? `${key}:${index}` : key,
- nodeTitle: label.nodeTitle,
- outputName: label.outputName,
- value,
- preview: isLong ? `${value.slice(0, TaskExecutionViewerComponent.OUTPUT_PREVIEW_LIMIT)}...` : value,
- isLong,
- itemLabel: isArrayItem ? `Item ${index + 1}` : null
- };
- });
- })
- .sort((a, b) => a.nodeTitle.localeCompare(b.nodeTitle) || a.outputName.localeCompare(b.outputName));
- });
-
- readonly executionOutputGroups = computed(() => {
- const groups = new Map();
-
- for (const output of this.executionOutputs()) {
- if (!groups.has(output.nodeTitle)) {
- groups.set(output.nodeTitle, []);
- }
- groups.get(output.nodeTitle)!.push(output);
- }
-
- return Array.from(groups.entries())
- .map(([nodeTitle, outputs]) => ({
- nodeTitle,
- outputs
- }))
- .sort((a, b) => a.nodeTitle.localeCompare(b.nodeTitle));
- });
+ readonly executionOutputGroups = computed(() =>
+ buildExecutionOutputGroups(this.executionOutputs())
+ );
readonly inputsReadOnly = computed(() => {
const status = this.execution()?.context.status;
@@ -364,13 +330,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
});
readonly visibleExecutionLogs = computed(() =>
- [...this.executionLogs()]
- .sort((a, b) => a.timestamp - b.timestamp)
- .map((entry) => ({
- ...entry,
- messageText: String(entry.message ?? '').trim() || this.fallbackExecutionLogMessage(entry),
- levelText: String(entry.level ?? 'INFO').toUpperCase()
- }))
+ buildVisibleExecutionLogs(this.executionLogs())
);
readonly canCancelExecution = computed(() => {
@@ -403,7 +363,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const value = Object.prototype.hasOwnProperty.call(globalInputs, inputName)
? globalInputs[inputName]
: descriptor?.value;
- if (!this.isInputSet(value, Boolean(descriptor?.multiple))) return false;
+ if (!isInputSet(value, Boolean(descriptor?.multiple))) return false;
}
for (const step of Object.values(execution.context.steps ?? {})) {
@@ -418,7 +378,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
? execution.context.inputs[key]
: input.value;
- if (!this.isInputSet(value, Boolean(input.descriptor?.multiple))) return false;
+ if (!isInputSet(value, Boolean(input.descriptor?.multiple))) return false;
}
}
@@ -470,7 +430,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
subtitle: inputName,
type: String(descriptor?.kind ?? 'TEXT').toUpperCase(),
multiple: Boolean(descriptor?.multiple),
- value: pendingValue ?? this.normalizeEditableInputValue(rawValue, Boolean(descriptor?.multiple))
+ value: pendingValue ?? normalizeEditableInputValue(rawValue, Boolean(descriptor?.multiple))
});
}
@@ -492,11 +452,11 @@ export class TaskExecutionViewerComponent implements OnDestroy {
scope: 'node',
nodeId: step.id,
inputName,
- title: this.stepTitle(step),
+ title: stepTitle(step),
subtitle: inputName,
type: String(input.descriptor?.type ?? 'TEXT').toUpperCase(),
multiple: Boolean(input.descriptor?.multiple),
- value: pendingValue ?? this.normalizeEditableInputValue(rawValue, Boolean(input.descriptor?.multiple))
+ value: pendingValue ?? normalizeEditableInputValue(rawValue, Boolean(input.descriptor?.multiple))
});
}
}
@@ -713,7 +673,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
private sendPreparedTextInput(input: EditableExecutionInput, executionId: string) {
if (this.inputsReadOnly() || this.execution()?.id !== executionId) return;
- const value = this.pendingTextInputs()[input.key] ?? this.normalizeEditableInputValue(input.value, input.multiple);
+ const value = this.pendingTextInputs()[input.key] ?? normalizeEditableInputValue(input.value, input.multiple);
this.setInputSaving(input.key, true);
const normalizedValues = (Array.isArray(value) ? value : [String(value)])
.map((item) => item.trim())
@@ -881,86 +841,12 @@ export class TaskExecutionViewerComponent implements OnDestroy {
}));
}
- private stringifyOutputValue(value: unknown): string {
- if (value == null) return '';
- if (typeof value === 'string') return value;
-
- try {
- return JSON.stringify(value, null, 2);
- } catch {
- return String(value);
- }
- }
-
- private fallbackExecutionLogMessage(entry: ExecutionEventLogEntry): string {
- const type = String(entry.type ?? '').trim();
- if (type) {
- return type.replaceAll('_', ' ').toLowerCase().replace(/^\w/, (letter) => letter.toUpperCase());
- }
- return 'Execution event';
- }
-
logLevelClass(level: string | null | undefined): string {
- const normalized = String(level ?? '').toUpperCase();
- if (normalized === 'ERROR') return 'execution-log-level-error';
- if (normalized === 'WARN' || normalized === 'WARNING') return 'execution-log-level-warn';
- return 'execution-log-level-info';
+ return _logLevelClass(level);
}
logTypeIcon(type: string | null | undefined): string {
- const normalized = String(type ?? '').toUpperCase();
- if (normalized.includes('FAILED') || normalized.includes('ERROR')) return 'error';
- if (normalized.includes('WAITING') || normalized.includes('PAUSED')) return 'pause_circle';
- if (normalized.includes('COMPLETED') || normalized.includes('SUCCESS')) return 'check_circle';
- if (normalized.includes('HTTP')) return 'language';
- if (normalized.includes('LLM')) return 'smart_toy';
- if (normalized.includes('MCP_SESSION')) return 'hub';
- return 'schedule';
- }
-
- private formatExecutionOutputLabel(
- key: string,
- steps: Record
- ): { nodeTitle: string; outputName: string } {
- const separatorIndex = key.indexOf(':');
- if (separatorIndex < 0) {
- return { nodeTitle: 'Execution output', outputName: key };
- }
-
- const nodeId = key.slice(0, separatorIndex);
- const outputName = key.slice(separatorIndex + 1);
- const step = steps[nodeId];
- const nodeName = this.stepTitle(step).trim();
-
- return {
- nodeTitle: nodeName || key,
- outputName: outputName || 'Execution output'
- };
- }
-
- private formatDuration(startTime: number, endTime: number): string {
- const diffMs = Math.max(0, endTime - startTime);
- const totalSeconds = Math.floor(diffMs / 1000);
- const totalMinutes = Math.floor(totalSeconds / 60);
- const totalHours = Math.floor(totalMinutes / 60);
- const totalDays = Math.floor(totalHours / 24);
-
- if (totalSeconds < 60) {
- return `${totalSeconds} sec`;
- }
-
- if (totalMinutes < 60) {
- const seconds = totalSeconds % 60;
- return seconds > 0 ? `${totalMinutes} min ${seconds} sec` : `${totalMinutes} min`;
- }
-
- if (totalHours < 24) {
- const minutes = totalMinutes % 60;
- return minutes > 0 ? `${totalHours} h ${minutes} min` : `${totalHours} h`;
- }
-
- const hours = totalHours % 24;
- return hours > 0 ? `${totalDays} gg ${hours} h` : `${totalDays} gg`;
+ return _logTypeIcon(type);
}
private inferConnections(steps: TaskExecutionStep[]) {
@@ -998,9 +884,9 @@ export class TaskExecutionViewerComponent implements OnDestroy {
connections.push({
id,
- sourceId: this.stepNodeId(source.sourceStep),
+ sourceId: stepNodeId(source.sourceStep),
sourceName: source.sourceOutputName,
- targetId: this.stepNodeId(targetStep),
+ targetId: stepNodeId(targetStep),
targetName: input.descriptor.name
});
}
@@ -1054,30 +940,6 @@ export class TaskExecutionViewerComponent implements OnDestroy {
return [...candidates].sort((left, right) => left.sourceIndex - right.sourceIndex)[0];
}
- private getExecutionInputValues(
- step: TaskExecutionStep,
- contextInputs: Record
- ): Record {
- const result: Record = {};
-
- for (const input of step.inputs ?? []) {
- const inputName = input.descriptor?.name;
- if (!inputName) continue;
-
- const key = `${step.id}:${inputName}`;
- if (Object.prototype.hasOwnProperty.call(contextInputs, key)) {
- result[inputName] = contextInputs[key];
- continue;
- }
-
- if (input.set || input.registered || input.value != null) {
- result[inputName] = input.value;
- }
- }
-
- return result;
- }
-
private toDialogHistory(rawHistory: unknown): HumanInteractionChatMessage[] {
if (!Array.isArray(rawHistory)) return [];
@@ -1117,99 +979,4 @@ export class TaskExecutionViewerComponent implements OnDestroy {
};
}
- private getConnectedInputs(step: TaskExecutionStep): string[] {
- return (step.inputs ?? [])
- .filter((input) => input.registered)
- .map((input) => input.descriptor?.name)
- .filter((name): name is string => typeof name === 'string' && name.length > 0);
- }
-
- private getExecutionOutputValues(
- step: TaskExecutionStep,
- contextResults: Record
- ): Record {
- const result: Record = {};
-
- for (const output of step.outputs ?? []) {
- const outputName = output.descriptor?.name;
- if (!outputName) continue;
-
- const key = `${step.id}:${outputName}`;
- if (Object.prototype.hasOwnProperty.call(contextResults, key)) {
- result[outputName] = contextResults[key];
- }
- }
-
- return result;
- }
-
- private getConnectedOutputs(step: TaskExecutionStep): string[] {
- return (step.outputs ?? [])
- .filter((output) => output.connected)
- .map((output) => output.descriptor?.name)
- .filter((name): name is string => typeof name === 'string' && name.length > 0);
- }
-
- private getExecutionErrors(
- stepId: string,
- contextErrors: Record
- ): string[] {
- const raw = contextErrors[stepId];
- if (typeof raw === 'string' && raw.trim().length > 0) return [raw];
- if (Array.isArray(raw)) {
- return raw.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
- }
- return [];
- }
-
- private getExecutionWarnings(
- stepId: string,
- contextWarnings: Record
- ): string[] {
- const raw = contextWarnings[stepId];
- return raw && raw.trim().length > 0 ? [raw] : [];
- }
-
- private isInputSet(value: unknown, multiple = false): boolean {
- if (multiple) {
- if (!Array.isArray(value)) return false;
- return value.some((item) => typeof item === 'string' ? item.trim().length > 0 : item != null);
- }
- if (value == null) return false;
- if (typeof value === 'string') return value.trim().length > 0;
- return true;
- }
-
- private normalizeEditableInputValue(value: unknown, multiple: boolean): string | string[] {
- if (multiple) {
- if (Array.isArray(value)) {
- return value.map((item) => this.stringifyEditableInputItem(item));
- }
- if (value == null) {
- return [''];
- }
- return [this.stringifyEditableInputItem(value)];
- }
-
- if (value == null) return '';
- return this.stringifyEditableInputItem(value);
- }
-
- private stringifyEditableInputItem(value: unknown): string {
- if (value == null) return '';
- if (typeof value === 'string') return value;
- try {
- return JSON.stringify(value);
- } catch {
- return String(value);
- }
- }
-
- private stepTitle(step: TaskExecutionStep | null | undefined): string {
- return getTaskExecutionStepNode(step)?.name?.trim() || step?.id || 'Step';
- }
-
- private stepNodeId(step: TaskExecutionStep | null | undefined): string {
- return getTaskExecutionStepNode(step)?.id || step?.id || '';
- }
}
diff --git a/src/app/stores/flow-editor.spec.ts b/src/app/stores/flow-editor.spec.ts
index 6bf825d..7e5bc40 100644
--- a/src/app/stores/flow-editor.spec.ts
+++ b/src/app/stores/flow-editor.spec.ts
@@ -1,16 +1,174 @@
import { TestBed } from '@angular/core/testing';
-
import { EditorStateHolder } from './flow-editor';
+import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
+import { Authorization } from '@services/authorization/authorization';
+import { FlowsService } from '@services/flows/flows';
+import { Flow, FlowData } from '@models/flow';
+
+function makeFlow(overrides?: Partial): Flow {
+ const data: FlowData = { blocks: [], containers: [], connections: [], dependencies: [] };
+ return {
+ id: 'flow-1',
+ name: 'Test Flow',
+ visibility: 'PRIVATE',
+ data,
+ author: 'testuser',
+ createdAt: new Date(),
+ status: 'DRAFT',
+ updatedAt: new Date(),
+ ...overrides,
+ };
+}
describe('EditorStateHolder', () => {
let service: EditorStateHolder;
+ let confirmSpy: jasmine.SpyObj;
+ let authSpy: jasmine.SpyObj;
+ let flowsServiceSpy: jasmine.SpyObj;
beforeEach(() => {
- TestBed.configureTestingModule({});
+ confirmSpy = jasmine.createSpyObj('ConfirmDialogService', ['open']);
+ authSpy = jasmine.createSpyObj('Authorization', ['loggedInUser']);
+ authSpy.loggedInUser = jasmine.createSpy().and.returnValue({ username: 'testuser', email: null, role: 'USER' }) as any;
+ flowsServiceSpy = jasmine.createSpyObj('FlowsService', ['getFlowValidation']);
+
+ TestBed.configureTestingModule({
+ providers: [
+ EditorStateHolder,
+ { provide: ConfirmDialogService, useValue: confirmSpy },
+ { provide: Authorization, useValue: authSpy },
+ ],
+ });
service = TestBed.inject(EditorStateHolder);
+ service.flowsService = flowsServiceSpy as any;
});
it('should be created', () => {
expect(service).toBeTruthy();
});
+
+ it('should have no flow initially', () => {
+ expect(service.hasFlow()).toBeFalse();
+ expect(service.currentFlow()).toBeNull();
+ });
+
+ describe('openDocument', () => {
+ it('should set the current flow', async () => {
+ const flow = makeFlow();
+ flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
+ await service.openDocument(flow);
+ expect(service.currentFlow()).toEqual(flow);
+ expect(service.hasFlow()).toBeTrue();
+ expect(service.isDirty()).toBeFalse();
+ });
+
+ it('should prompt confirmation when dirty', async () => {
+ const flow1 = makeFlow({ id: 'f1' });
+ const flow2 = makeFlow({ id: 'f2' });
+ flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
+ await service.openDocument(flow1);
+ service.updateData({ blocks: [{ id: 'b1', name: 'B1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock' }], containers: [], connections: [], dependencies: [] });
+ expect(service.isDirty()).toBeTrue();
+
+ confirmSpy.open.and.returnValue(Promise.resolve(false));
+ const result = await service.openDocument(flow2);
+ expect(result).toBeFalse();
+ expect(service.currentFlow()!.id).toBe('f1');
+ });
+
+ it('should skip dirty check when option is set', async () => {
+ const flow1 = makeFlow({ id: 'f1' });
+ const flow2 = makeFlow({ id: 'f2' });
+ flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
+ await service.openDocument(flow1);
+ service.updateData({ blocks: [{ id: 'b1', name: 'B1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock' }], containers: [], connections: [], dependencies: [] });
+
+ const result = await service.openDocument(flow2, { skipDirtyCheck: true });
+ expect(result).toBeTrue();
+ expect(service.currentFlow()!.id).toBe('f2');
+ });
+ });
+
+ describe('closeDocument', () => {
+ it('should clear the current flow', async () => {
+ flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
+ await service.openDocument(makeFlow());
+ service.closeDocument();
+ expect(service.currentFlow()).toBeNull();
+ expect(service.hasFlow()).toBeFalse();
+ expect(service.isDirty()).toBeFalse();
+ });
+ });
+
+ describe('updateData', () => {
+ it('should mark editor as dirty', async () => {
+ flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
+ await service.openDocument(makeFlow());
+ const newData: FlowData = { blocks: [{ id: 'b1', name: 'Block1', inputs: [], outputs: [], specificConfiguration: {}, typeName: 'LLMBlock' }], containers: [], connections: [], dependencies: [] };
+ service.updateData(newData);
+ expect(service.isDirty()).toBeTrue();
+ });
+
+ it('should not mark dirty if data unchanged', async () => {
+ const flow = makeFlow();
+ flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
+ await service.openDocument(flow);
+ service.updateData({ ...flow.data });
+ expect(service.isDirty()).toBeFalse();
+ });
+ });
+
+ describe('isCurrentFlowReadOnly', () => {
+ it('should return true for finalized flow', async () => {
+ flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
+ await service.openDocument(makeFlow({ finalized: true }));
+ expect(service.isCurrentFlowReadOnly()).toBeTrue();
+ });
+
+ it('should return true for public flow by another author', async () => {
+ flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
+ await service.openDocument(makeFlow({ visibility: 'PUBLIC', author: 'otheruser' }));
+ expect(service.isCurrentFlowReadOnly()).toBeTrue();
+ });
+
+ it('should return false for own private flow', async () => {
+ flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
+ await service.openDocument(makeFlow({ visibility: 'PRIVATE', author: 'testuser' }));
+ expect(service.isCurrentFlowReadOnly()).toBeFalse();
+ });
+ });
+
+ describe('block selection', () => {
+ it('should set and clear selected blocks', () => {
+ service.setSelectedBlocks(['b1', 'b2']);
+ expect(service.selectedBlockIds()).toEqual(['b1', 'b2']);
+ expect(service.isBlockSelected('b1')).toBeTrue();
+ expect(service.isBlockSelected('b3')).toBeFalse();
+
+ service.clearBlockSelection();
+ expect(service.selectedBlockIds()).toEqual([]);
+ });
+
+ it('should deduplicate block ids', () => {
+ service.setSelectedBlocks(['b1', 'b1', 'b2']);
+ expect(service.selectedBlockIds()).toEqual(['b1', 'b2']);
+ });
+ });
+
+ describe('updateFlowTitle', () => {
+ it('should update the title and mark dirty', async () => {
+ flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
+ await service.openDocument(makeFlow({ name: 'Old Title' }));
+ service.updateFlowTitle('New Title');
+ expect(service.currentFlow()!.name).toBe('New Title');
+ expect(service.isDirty()).toBeTrue();
+ });
+
+ it('should not mark dirty if title unchanged', async () => {
+ flowsServiceSpy.getFlowValidation.and.returnValue({ subscribe: () => {} } as any);
+ await service.openDocument(makeFlow({ name: 'Same' }));
+ service.updateFlowTitle('Same');
+ expect(service.isDirty()).toBeFalse();
+ });
+ });
});
diff --git a/src/app/stores/list-state.spec.ts b/src/app/stores/list-state.spec.ts
index 63e7e93..59534db 100644
--- a/src/app/stores/list-state.spec.ts
+++ b/src/app/stores/list-state.spec.ts
@@ -1,16 +1,56 @@
import { TestBed } from '@angular/core/testing';
-
import { ListState } from './list-state';
describe('ListState', () => {
let service: ListState;
beforeEach(() => {
- TestBed.configureTestingModule({});
+ TestBed.configureTestingModule({ providers: [ListState] });
service = TestBed.inject(ListState);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
+
+ it('should return null for non-existent key', () => {
+ expect(service.get('unknown')).toBeNull();
+ });
+
+ it('should create and retrieve a list view', () => {
+ service.create('test');
+ const view = service.get('test');
+ expect(view).toBeTruthy();
+ expect(view!.order.orderBy).toBeNull();
+ expect(view!.order.orderDir).toBe('asc');
+ });
+
+ it('should apply default order options on create', () => {
+ service.create('sorted', {
+ defaultOrder: { orderBy: 'name', orderDir: 'desc' },
+ });
+ const view = service.get('sorted');
+ expect(view!.order.orderBy).toBe('name');
+ expect(view!.order.orderDir).toBe('desc');
+ });
+
+ it('should apply default filter on create', () => {
+ service.create('filtered', { defaultFilter: 'search term' });
+ const view = service.get('filtered');
+ expect(view!.filter).toBe('search term');
+ });
+
+ it('should not overwrite existing list view on duplicate create', () => {
+ service.create('dup');
+ const original = service.get('dup');
+ original!.filter = 'modified';
+ service.create('dup');
+ expect(service.get('dup')!.filter).toBe('modified');
+ });
+
+ it('should use "default" key when kind is undefined', () => {
+ service.create(undefined);
+ expect(service.get(undefined)).toBeTruthy();
+ expect(service.get('default')).toBeTruthy();
+ });
});
diff --git a/src/app/utilities/form-utility.ts b/src/app/utilities/form-utility.ts
index 567ac0c..833588a 100644
--- a/src/app/utilities/form-utility.ts
+++ b/src/app/utilities/form-utility.ts
@@ -1,6 +1,14 @@
-import { FieldState } from "@angular/forms/signals";
+import { FieldState } from "@angular/forms/signals";
+/**
+ * Utility class for Angular signal-based form validation.
+ * Extend or inject in components that need field-level validation checks.
+ */
export class FormUtility {
+ /**
+ * Returns `true` when the field has been touched, modified, and is currently invalid.
+ * Use this to conditionally show validation messages in templates.
+ */
isInvalid(input: FieldState) {
if (input.touched() && input.dirty() && input.invalid()) {
return true;
diff --git a/src/app/utilities/list-state-holder.ts b/src/app/utilities/list-state-holder.ts
index 3ea8330..932f299 100644
--- a/src/app/utilities/list-state-holder.ts
+++ b/src/app/utilities/list-state-holder.ts
@@ -1,17 +1,39 @@
import { inject, Signal } from "@angular/core";
import { ListState } from "@stores/list-state";
+/** Persisted sort state for a list view. */
export class OrderViewState {
orderBy: string | null = null;
orderDir: 'asc' | 'desc' = 'asc';
}
+/**
+ * View-level state for a named list: current filter text, reactive item signal,
+ * and sort configuration. Stored in the global {@link ListState} store and
+ * reused when the user navigates back to the same list.
+ */
export class ListView {
filter?: string;
list?: Signal;
order: OrderViewState = new OrderViewState();
}
+/**
+ * Base class for components that display a filterable/sortable list.
+ *
+ * On construction it looks up (or creates) a {@link ListView} entry in the
+ * application-wide {@link ListState} store, keyed by `listName`. This ensures
+ * filter text, sort column, and sort direction survive navigation.
+ *
+ * ### Usage
+ * ```ts
+ * class MyListPage extends ListStateViewHolder- {
+ * constructor() { super('my-items', { defaultOrder: { orderBy: 'name', orderDir: 'asc' } }); }
+ * }
+ * ```
+ *
+ * Access the reactive state via `this.view`.
+ */
export class ListStateViewHolder {
private state: ListView = new ListView();
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index 9cdf58e..3ea9700 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -7,11 +7,11 @@ import { FieldRetrieverCallService } from "@services/retriever/field-retriever-c
import { TaskExecutionsCallService } from "@services/task-executions/task-executions-call";
export const environment = {
- apiUrl: '/api',
+ apiUrl: window.__runtimeConfig?.apiUrl || '/api',
production: true,
- assistantEnabled: true,
- tourModeAlwaysOn: false,
- turnstileEnabled: true,
+ assistantEnabled: window.__runtimeConfig?.assistantEnabled ?? true,
+ tourModeAlwaysOn: window.__runtimeConfig?.tourModeAlwaysOn ?? false,
+ turnstileEnabled: window.__runtimeConfig?.turnstileEnabled ?? true,
authorizationCallService: AuthorizationCallService,
assistantCallService: AssistantCallService,
flowsCallService: FlowsCallService,
diff --git a/src/runtime-config.d.ts b/src/runtime-config.d.ts
index fd6f499..047c15d 100644
--- a/src/runtime-config.d.ts
+++ b/src/runtime-config.d.ts
@@ -1,6 +1,10 @@
declare global {
interface Window {
__runtimeConfig?: {
+ apiUrl?: string;
+ assistantEnabled?: boolean;
+ tourModeAlwaysOn?: boolean;
+ turnstileEnabled?: boolean;
turnstileSiteKey?: string;
};
}
diff --git a/src/styles.css b/src/styles.css
index 55944a1..a9faffc 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -5,23 +5,35 @@
@import 'tailwindcss';
@theme {
+ /* ────────────────────────────────────────────────────────────────
+ * Design Tokens
+ * ────────────────────────────────────────────────────────────────
+ * Primary palette: blue scale (Tailwind Blue)
+ * 50–950 shades used for buttons, links, focus rings, and accents.
+ * 600 = default action colour | 700 = hover | 300 = focus ring (dark)
+ *
+ * Fonts: Inter as the single sans-serif family for body & headings.
+ *
+ * Dark mode: toggled via .dark class on or .
+ * ──────────────────────────────────────────────────────────────── */
+
/* Dark mode */
--dark-mode-selector: .dark;
- /* Estensioni */
+ /* Primary colour scale (blue) — used by .button, .link, focus rings */
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-200: #bfdbfe;
--color-primary-300: #93c5fd;
--color-primary-400: #60a5fa;
--color-primary-500: #3b82f6;
- --color-primary-600: #2563eb;
- --color-primary-700: #1d4ed8;
+ --color-primary-600: #2563eb; /* default action */
+ --color-primary-700: #1d4ed8; /* hover */
--color-primary-800: #1e40af;
--color-primary-900: #1e3a8a;
--color-primary-950: #172554;
- /* Font families */
+ /* Font families — single stack for body & UI */
--font-body: Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
@@ -31,13 +43,33 @@
'Noto Color Emoji';
}
+/* ── Semantic surface colours (hard-coded) ──────────────────────
+ * #0f172a slate-900 — body text, modal text
+ * #1e293b slate-800 — param text
+ * #475569 slate-600 — labels, ghost button text
+ * #64748b slate-500 — param keys, muted text
+ * #cbd5e1 slate-300 — borders, toggle track
+ * #dbe2ea — card borders (custom)
+ * #dbe7f5 — array block border (custom)
+ * #2563eb blue-600 — primary action (buttons, toggles)
+ * #1d4ed8 blue-700 — param view hover
+ * #1e40af blue-800 — subflow label
+ * #dc2626 red-600 — error alert bg
+ * #b91c1c red-700 — array remove button
+ * #eab308 yellow-500 — warning alert bg
+ * ─────────────────────────────────────────────────────────────── */
+
body {
@apply bg-gray-50 dark:bg-gray-900;
color: #0f172a;
font-family: var(--font-body);
}
-.mat-mdc-form-field {
+/* ── Global Material overrides ────────────────────────────────── */
+
+/* Default form fields to full width — uses :where() for zero specificity
+ so any component-level rule can override without !important. */
+:where(.mat-mdc-form-field) {
width: 100%;
}
@@ -64,6 +96,8 @@ body {
font-feature-settings: 'liga';
}
+/* ── Utility classes ──────────────────────────────────────────── */
+
.container-centered {
@apply flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0;
}
@@ -94,6 +128,8 @@ body {
@apply bg-primary-500 opacity-40;
}
+/* ── LLM / Node shared styles (used by generic-node, container-node, task-step-node) ── */
+
.llm-modal-backdrop {
position: fixed;
inset: 0;