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;