refactor: code quality improvements (tasks 1-15)

- Fix memory leak in task-executions polling (DestroyRef)
- Fix race condition in flows service (error recovery)
- Add centralized NotificationService + GlobalNotificationComponent
- Convert main routes to lazy loading (loadComponent)
- Remove static state from auth interceptor
- Write real unit tests (guards, stores, services)
- Strengthen HFNodeData types (explicit properties)
- Extract pure functions from TaskExecutionViewer into utils
- Add console.warn logging to guards on access denied
- Expand runtime config (apiUrl, assistantEnabled, tourModeAlwaysOn, turnstileEnabled)
- Document FormUtility and ListStateViewHolder base classes
- Remove unused lucide-angular dependency
- Replace ngModel with signal bindings in flows-list/blocks-list
- Use :where() for global mat-form-field override
- Document design system tokens in styles.css
This commit is contained in:
Lucio Lelii 2026-04-09 14:53:02 +02:00
parent 7028ac0c53
commit de6e86d53a
33 changed files with 987 additions and 364 deletions

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"chat.tools.terminal.autoApprove": {
"npx ng": true
}
}

View File

@ -3,6 +3,10 @@ set -eu
cat >/usr/share/nginx/html/runtime-config.js <<EOF
window.__runtimeConfig = {
apiUrl: "${API_URL:-}",
assistantEnabled: ${ASSISTANT_ENABLED:-true},
tourModeAlwaysOn: ${TOUR_MODE_ALWAYS_ON:-false},
turnstileEnabled: ${TURNSTILE_ENABLED:-true},
turnstileSiteKey: "${TURNSTILE_SITE_KEY:-}"
};
EOF

14
package-lock.json generated
View File

@ -24,7 +24,6 @@
"bootstrap-icons": "^1.13.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-angular": "^0.561.0",
"rete": "^2.0.6",
"rete-angular-plugin": "^2.6.0",
"rete-area-plugin": "^2.1.5",
@ -6988,19 +6987,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-angular": {
"version": "0.561.0",
"resolved": "https://registry.npmjs.org/lucide-angular/-/lucide-angular-0.561.0.tgz",
"integrity": "sha512-/DVDEVDJM08h8bGarLH65+4jfLcjaGhHcG1TdDXvo0hX1CXczG0NzEIEs6CJdddfEdO+wzv3KLGpAz1QPw7sSg==",
"license": "ISC",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "13.x - 21.x",
"@angular/core": "13.x - 21.x"
}
},
"node_modules/magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",

View File

@ -39,7 +39,6 @@
"bootstrap-icons": "^1.13.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-angular": "^0.561.0",
"rete": "^2.0.6",
"rete-angular-plugin": "^2.6.0",
"rete-area-plugin": "^2.1.5",

View File

@ -1,4 +1,5 @@
<router-outlet />
<app-global-notification />
<app-confirm-dialog-host></app-confirm-dialog-host>
<app-human-interaction-dialog-host></app-human-interaction-dialog-host>
<app-node-settings-dialog-host></app-node-settings-dialog-host>

View File

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

View File

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

View File

@ -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<Authorization>;
let routerSpy: jasmine.SpyObj<Router>;
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(['/']);
});
});

View File

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

View File

@ -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<Authorization>;
let routerSpy: jasmine.SpyObj<Router>;
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']);
});
});

View File

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

View File

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

View File

@ -7,7 +7,15 @@ export type HFNodeData = FlowNode & {
assignSelectedBlocksToContainer?: (blockIds?: string[]) => Promise<void>;
assignImportedSubflow?: (subFlow: FlowData, validationUrl?: string | null) => Promise<void>;
clearContainerSubflow?: () => Promise<void>;
[key: string]: unknown;
cloneNode?: () => Promise<void>;
__readonly?: boolean;
__needsServerCreate?: boolean;
__createdOnServer?: boolean;
__isCreatingOnServer?: boolean;
__updateBlockError?: string | null;
__containerValidationErrors?: unknown[];
__containerAssignmentError?: string | null;
__containerAssigning?: boolean;
};
export type HFNode = ClassicPreset.Node & {

View File

@ -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?: {

View File

@ -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<FlowsCallServiceBase>;
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<void>((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<void>((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<void>((resolve) => {
service.deleteFlow('f1').subscribe(() => resolve());
});
});
});
});

View File

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

View File

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

View File

@ -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<Notification | null>(null);
readonly current = this._current.asReadonly();
private dismissTimer: ReturnType<typeof setTimeout> | 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;
}
}
}

View File

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

View File

@ -8,7 +8,7 @@
<mat-form-field appearance="outline">
<mat-label>Search blocks</mat-label>
<mat-icon matPrefix fontIcon="search"></mat-icon>
<input matInput placeholder="Search blocks..." [(ngModel)]="searchTerm" />
<input matInput placeholder="Search blocks..." #searchInput [value]="searchTerm()" (input)="searchTerm.set(searchInput.value)" />
</mat-form-field>
</div>

View File

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

View File

@ -8,7 +8,7 @@
<mat-form-field appearance="outline">
<mat-label>Search flows</mat-label>
<mat-icon matPrefix fontIcon="search"></mat-icon>
<input matInput placeholder="Search flows..." [(ngModel)]="searchTerm" />
<input matInput placeholder="Search flows..." #searchInput [value]="searchTerm()" (input)="searchTerm.set(searchInput.value)" />
</mat-form-field>
</div>

View File

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

View File

@ -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) {
<div class="global-notification" [class]="'global-notification-' + n.type" (click)="notification.dismiss()">
{{ n.message }}
</div>
}
`,
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);
}

View File

@ -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<string, TaskExecutionStep>
): { 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<string, ExecutionOutputEntry[]>();
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<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
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, unknown>): 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, string>): string[] {
const raw = contextWarnings[stepId];
return raw && raw.trim().length > 0 ? [raw] : [];
}

View File

@ -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<ExecutionOutputEntry[]>(() => {
const steps = this.execution()?.context.steps ?? {};
const resultMap = this.execution()?.context.result ?? {};
readonly executionOutputs = computed<ExecutionOutputEntry[]>(() =>
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<ExecutionOutputGroup[]>(() => {
const groups = new Map<string, ExecutionOutputEntry[]>();
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<ExecutionOutputGroup[]>(() =>
buildExecutionOutputGroups(this.executionOutputs())
);
readonly inputsReadOnly = computed(() => {
const status = this.execution()?.context.status;
@ -364,13 +330,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
});
readonly visibleExecutionLogs = computed<ExecutionLogEntryView[]>(() =>
[...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<string, TaskExecutionStep>
): { 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<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
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<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
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, unknown>
): 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, string>
): 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 || '';
}
}

View File

@ -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>): 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<ConfirmDialogService>;
let authSpy: jasmine.SpyObj<Authorization>;
let flowsServiceSpy: jasmine.SpyObj<FlowsService>;
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();
});
});
});

View File

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

View File

@ -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<any>) {
if (input.touched() && input.dirty() && input.invalid()) {
return true;

View File

@ -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<T> {
filter?: string;
list?: Signal<T[]>;
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<Item> {
* constructor() { super('my-items', { defaultOrder: { orderBy: 'name', orderDir: 'asc' } }); }
* }
* ```
*
* Access the reactive state via `this.view`.
*/
export class ListStateViewHolder<T> {
private state: ListView<T> = new ListView<T>();

View File

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

View File

@ -1,6 +1,10 @@
declare global {
interface Window {
__runtimeConfig?: {
apiUrl?: string;
assistantEnabled?: boolean;
tourModeAlwaysOn?: boolean;
turnstileEnabled?: boolean;
turnstileSiteKey?: string;
};
}

View File

@ -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 <html> or <body>.
* ──────────────────────────────────────────────────────────────── */
/* 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;