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:
parent
7028ac0c53
commit
de6e86d53a
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"npx ng": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(['/']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 & {
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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] : [];
|
||||
}
|
||||
|
|
@ -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 || '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
declare global {
|
||||
interface Window {
|
||||
__runtimeConfig?: {
|
||||
apiUrl?: string;
|
||||
assistantEnabled?: boolean;
|
||||
tourModeAlwaysOn?: boolean;
|
||||
turnstileEnabled?: boolean;
|
||||
turnstileSiteKey?: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue