diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index 45444fda8439b..5e828a702249a 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -41,7 +41,7 @@ jobs: needs: install-and-build strategy: matrix: - node-version: [18.x, 20.x, 22.x] + node-version: [18.x, 20.x, 22.4] with: ref: ${{ inputs.branch }} nodeVersion: ${{ matrix.node-version }} diff --git a/packages/@n8n/config/src/configs/event-bus.ts b/packages/@n8n/config/src/configs/event-bus.ts new file mode 100644 index 0000000000000..ed1226fa923ac --- /dev/null +++ b/packages/@n8n/config/src/configs/event-bus.ts @@ -0,0 +1,31 @@ +import { Config, Env, Nested } from '../decorators'; + +@Config +class LogWriterConfig { + /** Number of event log files to keep */ + @Env('N8N_EVENTBUS_LOGWRITER_KEEPLOGCOUNT') + readonly keepLogCount: number = 3; + + /** Max size (in KB) of an event log file before a new one is started */ + @Env('N8N_EVENTBUS_LOGWRITER_MAXFILESIZEINKB') + readonly maxFileSizeInKB: number = 10240; // 10 MB + + /** Basename of event log file */ + @Env('N8N_EVENTBUS_LOGWRITER_LOGBASENAME') + readonly logBaseName: string = 'n8nEventLog'; +} + +@Config +export class EventBusConfig { + /** How often (in ms) to check for unsent event messages. Can in rare cases cause a message to be sent twice. `0` to disable */ + @Env('N8N_EVENTBUS_CHECKUNSENTINTERVAL') + readonly checkUnsentInterval: number = 0; + + /** Endpoint to retrieve n8n version information from */ + @Nested + readonly logWriter: LogWriterConfig; + + /** Whether to recover execution details after a crash or only mark status executions as crashed. */ + @Env('N8N_EVENTBUS_RECOVERY_MODE') + readonly crashRecoveryMode: 'simple' | 'extensive' = 'extensive'; +} diff --git a/packages/@n8n/config/src/configs/templates.ts b/packages/@n8n/config/src/configs/templates.ts new file mode 100644 index 0000000000000..3e10c892b3469 --- /dev/null +++ b/packages/@n8n/config/src/configs/templates.ts @@ -0,0 +1,12 @@ +import { Config, Env } from '../decorators'; + +@Config +export class TemplatesConfig { + /** Whether to load workflow templates. */ + @Env('N8N_TEMPLATES_ENABLED') + readonly enabled: boolean = true; + + /** Host to retrieve workflow templates from endpoints. */ + @Env('N8N_TEMPLATES_HOST') + readonly host: string = 'https://api.n8n.io/api/'; +} diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index d2c8fd3234475..11440c70bfaa4 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -5,6 +5,8 @@ import { EmailConfig } from './configs/email'; import { VersionNotificationsConfig } from './configs/version-notifications'; import { PublicApiConfig } from './configs/public-api'; import { ExternalSecretsConfig } from './configs/external-secrets'; +import { TemplatesConfig } from './configs/templates'; +import { EventBusConfig } from './configs/event-bus'; @Config class UserManagementConfig { @@ -31,4 +33,10 @@ export class GlobalConfig { @Nested externalSecrets: ExternalSecretsConfig; + + @Nested + templates: TemplatesConfig; + + @Nested + eventBus: EventBusConfig; } diff --git a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts index 22e8ff4aa740a..6a433eaac31a0 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts @@ -34,6 +34,7 @@ export class TextClassifier implements INodeType { displayName: 'Text Classifier', name: 'textClassifier', icon: 'fa:tags', + iconColor: 'black', group: ['transform'], version: 1, description: 'Classify your text into distinct categories', diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index e18c98f67e1d1..b9faa73335fc5 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -10,7 +10,7 @@ "build": "tsc -p tsconfig.build.json && pnpm n8n-copy-icons && pnpm build:metadata", "build:metadata": "pnpm n8n-generate-known && pnpm n8n-generate-ui-types", "format": "prettier nodes credentials --write", - "lint": "eslint nodes credentials", + "lint": "eslint nodes credentials --quiet", "lintfix": "eslint nodes credentials --fix", "watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\" --onSuccess \"pnpm n8n-generate-ui-types\"", "test": "jest", diff --git a/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts b/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts index e6384b6728f7c..03436f3d7acaf 100644 --- a/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts +++ b/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts @@ -13,7 +13,7 @@ import { Logger } from '@/Logger'; import { jsonParse, type IDataObject, ApplicationError } from 'n8n-workflow'; import { EXTERNAL_SECRETS_INITIAL_BACKOFF, EXTERNAL_SECRETS_MAX_BACKOFF } from './constants'; import { License } from '@/License'; -import { InternalHooks } from '@/InternalHooks'; +import { EventService } from '@/eventbus/event.service'; import { updateIntervalTime } from './externalSecretsHelper.ee'; import { ExternalSecretsProviders } from './ExternalSecretsProviders.ee'; import { OrchestrationService } from '@/services/orchestration.service'; @@ -38,6 +38,7 @@ export class ExternalSecretsManager { private readonly license: License, private readonly secretsProviders: ExternalSecretsProviders, private readonly cipher: Cipher, + private readonly eventService: EventService, ) {} async init(): Promise { @@ -308,12 +309,12 @@ export class ExternalSecretsManager { try { testResult = await this.getProvider(vaultType)?.test(); } catch {} - void Container.get(InternalHooks).onExternalSecretsProviderSettingsSaved({ - user_id: userId, - vault_type: vaultType, - is_new: isNew, - is_valid: testResult?.[0] ?? false, - error_message: testResult?.[1], + this.eventService.emit('external-secrets-provider-settings-saved', { + userId, + vaultType, + isNew, + isValid: testResult?.[0] ?? false, + errorMessage: testResult?.[1], }); } diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 8647ecf978099..454f4be002176 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -26,7 +26,7 @@ import type { IExecutionTrackProperties, } from '@/Interfaces'; import { License } from '@/License'; -import { EventsService } from '@/services/events.service'; +import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { NodeTypes } from '@/NodeTypes'; import { Telemetry } from '@/telemetry'; import type { Project } from '@db/entities/Project'; @@ -42,18 +42,18 @@ export class InternalHooks { private readonly nodeTypes: NodeTypes, private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly workflowRepository: WorkflowRepository, - eventsService: EventsService, + workflowStatisticsService: WorkflowStatisticsService, private readonly instanceSettings: InstanceSettings, private readonly license: License, private readonly projectRelationRepository: ProjectRelationRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly _eventBus: MessageEventBus, // needed until we decouple telemetry ) { - eventsService.on( + workflowStatisticsService.on( 'telemetry.onFirstProductionWorkflowSuccess', async (metrics) => await this.onFirstProductionWorkflowSuccess(metrics), ); - eventsService.on( + workflowStatisticsService.on( 'telemetry.onFirstWorkflowDataLoad', async (metrics) => await this.onFirstWorkflowDataLoad(metrics), ); @@ -756,32 +756,4 @@ export class InternalHooks { }): Promise { return await this.telemetry.track('Workflow first data fetched', data); } - - /** - * License - */ - async onLicenseRenewAttempt(data: { success: boolean }): Promise { - await this.telemetry.track('Instance attempted to refresh license', data); - } - - /** - * Audit - */ - async onAuditGeneratedViaCli() { - return await this.telemetry.track('Instance generated security audit via CLI command'); - } - - async onVariableCreated(createData: { variable_type: string }): Promise { - return await this.telemetry.track('User created variable', createData); - } - - async onExternalSecretsProviderSettingsSaved(saveData: { - user_id?: string | undefined; - vault_type: string; - is_valid: boolean; - is_new: boolean; - error_message?: string | undefined; - }): Promise { - return await this.telemetry.track('User updated external secrets settings', saveData); - } } diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts index adb7d370e6861..524dc608dbef1 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts @@ -18,7 +18,7 @@ import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { InternalHooks } from '@/InternalHooks'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; export async function getCredentials(credentialId: string): Promise { return await Container.get(CredentialsRepository).findOneBy({ id: credentialId }); @@ -60,7 +60,7 @@ export async function saveCredential( credential_id: credential.id, public_api: true, }); - Container.get(EventRelay).emit('credentials-created', { + Container.get(EventService).emit('credentials-created', { user, credentialName: credential.name, credentialType: credential.type, @@ -102,7 +102,7 @@ export async function removeCredential( credential_type: credentials.type, credential_id: credentials.id, }); - Container.get(EventRelay).emit('credentials-deleted', { + Container.get(EventService).emit('credentials-deleted', { user, credentialName: credentials.name, credentialType: credentials.type, diff --git a/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts b/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts index c1113a5e706ed..f54e8bd95d8e2 100644 --- a/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts @@ -10,7 +10,7 @@ import { getTrackingInformationFromPullResult, isSourceControlLicensed, } from '@/environments/sourceControl/sourceControlHelper.ee'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; export = { pull: [ @@ -39,7 +39,7 @@ export = { }); if (result.statusCode === 200) { - Container.get(EventRelay).emit('source-control-user-pulled-api', { + Container.get(EventService).emit('source-control-user-pulled-api', { ...getTrackingInformationFromPullResult(result.statusResult), forced: req.body.force ?? false, }); diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index aaf965d75198c..943d53764daf3 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -32,7 +32,7 @@ import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflo import { TagRepository } from '@/databases/repositories/tag.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; export = { createWorkflow: [ @@ -59,7 +59,7 @@ export = { await Container.get(ExternalHooks).run('workflow.afterCreate', [createdWorkflow]); void Container.get(InternalHooks).onWorkflowCreated(req.user, createdWorkflow, project, true); - Container.get(EventRelay).emit('workflow-created', { + Container.get(EventService).emit('workflow-created', { workflow: createdWorkflow, user: req.user, }); @@ -240,7 +240,7 @@ export = { await Container.get(ExternalHooks).run('workflow.afterUpdate', [updateData]); void Container.get(InternalHooks).onWorkflowSaved(req.user, updateData, true); - Container.get(EventRelay).emit('workflow-saved', { + Container.get(EventService).emit('workflow-saved', { user: req.user, workflowId: updateData.id, workflowName: updateData.name, diff --git a/packages/cli/src/UserManagement/email/UserManagementMailer.ts b/packages/cli/src/UserManagement/email/UserManagementMailer.ts index 4563b8a69186c..531ce24cd67c4 100644 --- a/packages/cli/src/UserManagement/email/UserManagementMailer.ts +++ b/packages/cli/src/UserManagement/email/UserManagementMailer.ts @@ -16,7 +16,7 @@ import { toError } from '@/utils'; import type { InviteEmailData, PasswordResetData, SendEmailResult } from './Interfaces'; import { NodeMailer } from './NodeMailer'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; type Template = HandlebarsTemplateDelegate; type TemplateName = 'invite' | 'passwordReset' | 'workflowShared' | 'credentialsShared'; @@ -125,7 +125,7 @@ export class UserManagementMailer { message_type: 'Workflow shared', public_api: false, }); - Container.get(EventRelay).emit('email-failed', { + Container.get(EventService).emit('email-failed', { user: sharer, messageType: 'Workflow shared', }); @@ -184,7 +184,7 @@ export class UserManagementMailer { message_type: 'Credentials shared', public_api: false, }); - Container.get(EventRelay).emit('email-failed', { + Container.get(EventService).emit('email-failed', { user: sharer, messageType: 'Credentials shared', }); diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 6f2d01cdda577..95e8825e1a840 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -56,7 +56,7 @@ import * as WorkflowHelpers from '@/WorkflowHelpers'; import { WorkflowRunner } from '@/WorkflowRunner'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { ActiveExecutions } from '@/ActiveExecutions'; -import { EventsService } from '@/services/events.service'; +import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { OwnershipService } from './services/ownership.service'; import { parseBody } from './middlewares'; import { Logger } from './Logger'; @@ -360,7 +360,11 @@ export async function executeWebhook( NodeExecuteFunctions, executionMode, ); - Container.get(EventsService).emit('nodeFetchedData', workflow.id, workflowStartNode); + Container.get(WorkflowStatisticsService).emit( + 'nodeFetchedData', + workflow.id, + workflowStartNode, + ); } catch (err) { // Send error response to webhook caller const errorMessage = 'Workflow Webhook Error: Workflow could not be started!'; diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 62bcc5a67f53e..d68cff676c978 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -54,7 +54,7 @@ import { findSubworkflowStart, isWorkflowIdValid } from '@/utils'; import { PermissionChecker } from './UserManagement/PermissionChecker'; import { InternalHooks } from '@/InternalHooks'; import { ExecutionRepository } from '@db/repositories/execution.repository'; -import { EventsService } from '@/services/events.service'; +import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { SecretsHelper } from './SecretsHelpers'; import { OwnershipService } from './services/ownership.service'; import { @@ -71,7 +71,7 @@ import { WorkflowRepository } from './databases/repositories/workflow.repository import { UrlService } from './services/url.service'; import { WorkflowExecutionService } from './workflows/workflowExecution.service'; import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; -import { EventRelay } from './eventbus/event-relay.service'; +import { EventService } from './eventbus/event.service'; const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); @@ -393,21 +393,21 @@ export function hookFunctionsPreExecute(): IWorkflowExecuteHooks { */ function hookFunctionsSave(): IWorkflowExecuteHooks { const logger = Container.get(Logger); - const eventsService = Container.get(EventsService); - const eventRelay = Container.get(EventRelay); + const workflowStatisticsService = Container.get(WorkflowStatisticsService); + const eventService = Container.get(EventService); return { nodeExecuteBefore: [ async function (this: WorkflowHooks, nodeName: string): Promise { const { executionId, workflowData: workflow } = this; - eventRelay.emit('node-pre-execute', { executionId, workflow, nodeName }); + eventService.emit('node-pre-execute', { executionId, workflow, nodeName }); }, ], nodeExecuteAfter: [ async function (this: WorkflowHooks, nodeName: string): Promise { const { executionId, workflowData: workflow } = this; - eventRelay.emit('node-post-execute', { executionId, workflow, nodeName }); + eventService.emit('node-post-execute', { executionId, workflow, nodeName }); }, ], workflowExecuteBefore: [], @@ -524,13 +524,17 @@ function hookFunctionsSave(): IWorkflowExecuteHooks { ); } } finally { - eventsService.emit('workflowExecutionCompleted', this.workflowData, fullRunData); + workflowStatisticsService.emit( + 'workflowExecutionCompleted', + this.workflowData, + fullRunData, + ); } }, ], nodeFetchedData: [ async (workflowId: string, node: INode) => { - eventsService.emit('nodeFetchedData', workflowId, node); + workflowStatisticsService.emit('nodeFetchedData', workflowId, node); }, ], }; @@ -545,28 +549,28 @@ function hookFunctionsSave(): IWorkflowExecuteHooks { function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { const logger = Container.get(Logger); const internalHooks = Container.get(InternalHooks); - const eventsService = Container.get(EventsService); - const eventRelay = Container.get(EventRelay); + const workflowStatisticsService = Container.get(WorkflowStatisticsService); + const eventService = Container.get(EventService); return { nodeExecuteBefore: [ async function (this: WorkflowHooks, nodeName: string): Promise { const { executionId, workflowData: workflow } = this; - eventRelay.emit('node-pre-execute', { executionId, workflow, nodeName }); + eventService.emit('node-pre-execute', { executionId, workflow, nodeName }); }, ], nodeExecuteAfter: [ async function (this: WorkflowHooks, nodeName: string): Promise { const { executionId, workflowData: workflow } = this; - eventRelay.emit('node-post-execute', { executionId, workflow, nodeName }); + eventService.emit('node-post-execute', { executionId, workflow, nodeName }); }, ], workflowExecuteBefore: [ async function (): Promise { const { executionId, workflowData } = this; - eventRelay.emit('workflow-pre-execute', { executionId, data: workflowData }); + eventService.emit('workflow-pre-execute', { executionId, data: workflowData }); }, ], workflowExecuteAfter: [ @@ -631,14 +635,18 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { this.retryOf, ); } finally { - eventsService.emit('workflowExecutionCompleted', this.workflowData, fullRunData); + workflowStatisticsService.emit( + 'workflowExecutionCompleted', + this.workflowData, + fullRunData, + ); } }, async function (this: WorkflowHooks, runData: IRun): Promise { const { executionId, workflowData: workflow } = this; void internalHooks.onWorkflowPostExecute(executionId, workflow, runData); - eventRelay.emit('workflow-post-execute', { + eventService.emit('workflow-post-execute', { workflowId: workflow.id, workflowName: workflow.name, executionId, @@ -667,7 +675,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { ], nodeFetchedData: [ async (workflowId: string, node: INode) => { - eventsService.emit('nodeFetchedData', workflowId, node); + workflowStatisticsService.emit('nodeFetchedData', workflowId, node); }, ], }; @@ -785,7 +793,7 @@ async function executeWorkflow( const nodeTypes = Container.get(NodeTypes); const activeExecutions = Container.get(ActiveExecutions); - const eventRelay = Container.get(EventRelay); + const eventService = Container.get(EventService); const workflowData = options.loadedWorkflowData ?? @@ -813,7 +821,7 @@ async function executeWorkflow( executionId = options.parentExecutionId ?? (await activeExecutions.add(runData)); } - Container.get(EventRelay).emit('workflow-pre-execute', { executionId, data: runData }); + Container.get(EventService).emit('workflow-pre-execute', { executionId, data: runData }); let data; try { @@ -926,7 +934,7 @@ async function executeWorkflow( await externalHooks.run('workflow.postExecute', [data, workflowData, executionId]); void internalHooks.onWorkflowPostExecute(executionId, workflowData, data, additionalData.userId); - eventRelay.emit('workflow-post-execute', { + eventService.emit('workflow-post-execute', { workflowId: workflowData.id, workflowName: workflowData.name, executionId, diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 402da960f033d..f8faf55fbe49d 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -37,7 +37,7 @@ import { PermissionChecker } from '@/UserManagement/PermissionChecker'; import { InternalHooks } from '@/InternalHooks'; import { Logger } from '@/Logger'; import { WorkflowStaticDataService } from '@/workflows/workflowStaticData.service'; -import { EventRelay } from './eventbus/event-relay.service'; +import { EventService } from './eventbus/event.service'; @Service() export class WorkflowRunner { @@ -53,7 +53,7 @@ export class WorkflowRunner { private readonly workflowStaticDataService: WorkflowStaticDataService, private readonly nodeTypes: NodeTypes, private readonly permissionChecker: PermissionChecker, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) { if (this.executionsMode === 'queue') { this.jobQueue = Container.get(Queue); @@ -147,7 +147,7 @@ export class WorkflowRunner { await this.enqueueExecution(executionId, data, loadStaticData, realtime); } else { await this.runMainProcess(executionId, data, loadStaticData, restartExecutionId); - this.eventRelay.emit('workflow-pre-execute', { executionId, data }); + this.eventService.emit('workflow-pre-execute', { executionId, data }); } // only run these when not in queue mode or when the execution is manual, @@ -166,7 +166,7 @@ export class WorkflowRunner { executionData, data.userId, ); - this.eventRelay.emit('workflow-post-execute', { + this.eventService.emit('workflow-post-execute', { workflowId: data.workflowData.id, workflowName: data.workflowData.name, executionId, diff --git a/packages/cli/src/auth/methods/ldap.ts b/packages/cli/src/auth/methods/ldap.ts index 5bc86fec28a52..09e8f38c87a03 100644 --- a/packages/cli/src/auth/methods/ldap.ts +++ b/packages/cli/src/auth/methods/ldap.ts @@ -12,7 +12,7 @@ import { updateLdapUserOnLocalDb, } from '@/Ldap/helpers.ee'; import type { User } from '@db/entities/User'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; export const handleLdapLogin = async ( loginId: string, @@ -55,7 +55,7 @@ export const handleLdapLogin = async ( user_type: 'ldap', was_disabled_ldap_user: false, }); - Container.get(EventRelay).emit('user-signed-up', { user }); + Container.get(EventService).emit('user-signed-up', { user }); return user; } } else { diff --git a/packages/cli/src/commands/audit.ts b/packages/cli/src/commands/audit.ts index 0fe1d1f9bdd0b..4f6b356028644 100644 --- a/packages/cli/src/commands/audit.ts +++ b/packages/cli/src/commands/audit.ts @@ -7,7 +7,6 @@ import { RISK_CATEGORIES } from '@/security-audit/constants'; import config from '@/config'; import type { Risk } from '@/security-audit/types'; import { BaseCommand } from './BaseCommand'; -import { InternalHooks } from '@/InternalHooks'; export class SecurityAudit extends BaseCommand { static description = 'Generate a security audit report for this n8n instance'; @@ -61,8 +60,6 @@ export class SecurityAudit extends BaseCommand { } else { process.stdout.write(JSON.stringify(result, null, 2)); } - - void Container.get(InternalHooks).onAuditGeneratedViaCli(); } async catch(error: Error) { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 12114acd73574..2c1dbd162a068 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -33,7 +33,7 @@ import { ExecutionService } from '@/executions/execution.service'; import { OwnershipService } from '@/services/ownership.service'; import { WorkflowRunner } from '@/WorkflowRunner'; import { ExecutionRecoveryService } from '@/executions/execution-recovery.service'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires const open = require('open'); @@ -378,7 +378,7 @@ export class Start extends BaseCommand { projectId: project.id, }; - Container.get(EventRelay).emit('execution-started-during-bootup', { + Container.get(EventService).emit('execution-started-during-bootup', { executionId: execution.id, }); diff --git a/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts b/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts index 817c3c71abb05..c694ab29406a3 100644 --- a/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts +++ b/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts @@ -12,13 +12,13 @@ import type { WorkflowExecuteMode as ExecutionMode } from 'n8n-workflow'; import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; import type { IExecutingWorkflowData } from '@/Interfaces'; import type { Telemetry } from '@/telemetry'; -import type { EventRelay } from '@/eventbus/event-relay.service'; +import type { EventService } from '@/eventbus/event.service'; describe('ConcurrencyControlService', () => { const logger = mock(); const executionRepository = mock(); const telemetry = mock(); - const eventRelay = mock(); + const eventService = mock(); afterEach(() => { config.set('executions.concurrency.productionLimit', -1); @@ -41,7 +41,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); /** @@ -63,7 +63,7 @@ describe('ConcurrencyControlService', () => { /** * Act */ - new ConcurrencyControlService(logger, executionRepository, telemetry, eventRelay); + new ConcurrencyControlService(logger, executionRepository, telemetry, eventService); } catch (error) { /** * Assert @@ -85,7 +85,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); /** @@ -108,7 +108,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); /** @@ -132,7 +132,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); /** @@ -161,7 +161,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); const enqueueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'enqueue'); @@ -187,7 +187,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); const enqueueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'enqueue'); @@ -216,7 +216,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); const dequeueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'dequeue'); @@ -242,7 +242,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); const dequeueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'dequeue'); @@ -271,7 +271,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); const removeSpy = jest.spyOn(ConcurrencyQueue.prototype, 'remove'); @@ -299,7 +299,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); const removeSpy = jest.spyOn(ConcurrencyQueue.prototype, 'remove'); @@ -327,7 +327,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); jest @@ -371,7 +371,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); const enqueueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'enqueue'); @@ -399,7 +399,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); const dequeueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'dequeue'); @@ -426,7 +426,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); const removeSpy = jest.spyOn(ConcurrencyQueue.prototype, 'remove'); @@ -461,7 +461,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); /** @@ -491,7 +491,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); /** @@ -523,7 +523,7 @@ describe('ConcurrencyControlService', () => { logger, executionRepository, telemetry, - eventRelay, + eventService, ); /** diff --git a/packages/cli/src/concurrency/concurrency-control.service.ts b/packages/cli/src/concurrency/concurrency-control.service.ts index a71f22b423515..cf249c51722eb 100644 --- a/packages/cli/src/concurrency/concurrency-control.service.ts +++ b/packages/cli/src/concurrency/concurrency-control.service.ts @@ -8,7 +8,7 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito import type { WorkflowExecuteMode as ExecutionMode } from 'n8n-workflow'; import type { IExecutingWorkflowData } from '@/Interfaces'; import { Telemetry } from '@/telemetry'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; export const CLOUD_TEMP_PRODUCTION_LIMIT = 999; export const CLOUD_TEMP_REPORTABLE_THRESHOLDS = [5, 10, 20, 50, 100, 200]; @@ -29,7 +29,7 @@ export class ConcurrencyControlService { private readonly logger: Logger, private readonly executionRepository: ExecutionRepository, private readonly telemetry: Telemetry, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) { this.productionLimit = config.getEnv('executions.concurrency.productionLimit'); @@ -63,7 +63,7 @@ export class ConcurrencyControlService { this.productionQueue.on('execution-throttled', ({ executionId }: { executionId: string }) => { this.log('Execution throttled', { executionId }); - this.eventRelay.emit('execution-throttled', { executionId }); + this.eventService.emit('execution-throttled', { executionId }); }); this.productionQueue.on('execution-released', async (executionId: string) => { diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 3079db91a69bb..d99b51e190ab2 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -679,21 +679,6 @@ export const schema = { }, }, - templates: { - enabled: { - doc: 'Whether templates feature is enabled to load workflow templates.', - format: Boolean, - default: true, - env: 'N8N_TEMPLATES_ENABLED', - }, - host: { - doc: 'Endpoint host to retrieve workflow templates from endpoints.', - format: String, - default: 'https://api.n8n.io/api/', - env: 'N8N_TEMPLATES_HOST', - }, - }, - push: { backend: { format: ['sse', 'websocket'] as const, @@ -930,41 +915,6 @@ export const schema = { doc: 'Hide or show the usage page', }, - eventBus: { - checkUnsentInterval: { - doc: 'How often (in ms) to check for unsent event messages. Can in rare cases cause a message to be sent twice. 0=disabled', - format: Number, - default: 0, - env: 'N8N_EVENTBUS_CHECKUNSENTINTERVAL', - }, - logWriter: { - keepLogCount: { - doc: 'How many event log files to keep.', - format: Number, - default: 3, - env: 'N8N_EVENTBUS_LOGWRITER_KEEPLOGCOUNT', - }, - maxFileSizeInKB: { - doc: 'Maximum size of an event log file before a new one is started.', - format: Number, - default: 10240, // 10MB - env: 'N8N_EVENTBUS_LOGWRITER_MAXFILESIZEINKB', - }, - logBaseName: { - doc: 'Basename of the event log file.', - format: String, - default: 'n8nEventLog', - env: 'N8N_EVENTBUS_LOGWRITER_LOGBASENAME', - }, - }, - crashRecoveryMode: { - doc: 'Should n8n try to recover execution details after a crash, or just mark pending executions as crashed', - format: ['simple', 'extensive'] as const, - default: 'extensive', - env: 'N8N_EVENTBUS_RECOVERY_MODE', - }, - }, - redis: { prefix: { doc: 'Prefix for all n8n related keys', diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 7cfa7a7f43236..7994221d9db6c 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -24,7 +24,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ApplicationError } from 'n8n-workflow'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; @RestController() export class AuthController { @@ -36,7 +36,7 @@ export class AuthController { private readonly userService: UserService, private readonly license: License, private readonly userRepository: UserRepository, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, private readonly postHog?: PostHogClient, ) {} @@ -93,14 +93,14 @@ export class AuthController { this.authService.issueCookie(res, user, req.browserId); - this.eventRelay.emit('user-logged-in', { + this.eventService.emit('user-logged-in', { user, authenticationMethod: usedAuthenticationMethod, }); return await this.userService.toPublic(user, { posthog: this.postHog, withScopes: true }); } - this.eventRelay.emit('user-login-failed', { + this.eventService.emit('user-login-failed', { authenticationMethod: usedAuthenticationMethod, userEmail: email, reason: 'wrong credentials', @@ -180,7 +180,7 @@ export class AuthController { } void this.internalHooks.onUserInviteEmailClick({ inviter, invitee }); - this.eventRelay.emit('user-invite-email-click', { inviter, invitee }); + this.eventService.emit('user-invite-email-click', { inviter, invitee }); const { firstName, lastName } = inviter; return { inviter: { firstName, lastName } }; diff --git a/packages/cli/src/controllers/communityPackages.controller.ts b/packages/cli/src/controllers/communityPackages.controller.ts index 11914e5d7a5cd..e8469127dbf9b 100644 --- a/packages/cli/src/controllers/communityPackages.controller.ts +++ b/packages/cli/src/controllers/communityPackages.controller.ts @@ -14,7 +14,7 @@ import { Push } from '@/push'; import { CommunityPackagesService } from '@/services/communityPackages.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; const { PACKAGE_NOT_INSTALLED, @@ -39,7 +39,7 @@ export class CommunityPackagesController { private readonly push: Push, private readonly internalHooks: InternalHooks, private readonly communityPackagesService: CommunityPackagesService, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) {} // TODO: move this into a new decorator `@IfConfig('executions.mode', 'queue')` @@ -116,7 +116,7 @@ export class CommunityPackagesController { package_version: parsed.version, failure_reason: errorMessage, }); - this.eventRelay.emit('community-package-installed', { + this.eventService.emit('community-package-installed', { user: req.user, inputString: name, packageName: parsed.packageName, @@ -154,7 +154,7 @@ export class CommunityPackagesController { package_author: installedPackage.authorName, package_author_email: installedPackage.authorEmail, }); - this.eventRelay.emit('community-package-installed', { + this.eventService.emit('community-package-installed', { user: req.user, inputString: name, packageName: parsed.packageName, @@ -253,7 +253,7 @@ export class CommunityPackagesController { package_author: installedPackage.authorName, package_author_email: installedPackage.authorEmail, }); - this.eventRelay.emit('community-package-deleted', { + this.eventService.emit('community-package-deleted', { user: req.user, packageName: name, packageVersion: installedPackage.installedVersion, @@ -309,7 +309,7 @@ export class CommunityPackagesController { package_author: newInstalledPackage.authorName, package_author_email: newInstalledPackage.authorEmail, }); - this.eventRelay.emit('community-package-updated', { + this.eventService.emit('community-package-updated', { user: req.user, packageName: name, packageVersionCurrent: previouslyInstalledPackage.installedVersion, diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index 9f1013b29cd0f..d89d077ae61b0 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -18,7 +18,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { InternalHooks } from '@/InternalHooks'; import { ExternalHooks } from '@/ExternalHooks'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; @RestController('/invitations') export class InvitationController { @@ -32,7 +32,7 @@ export class InvitationController { private readonly passwordUtility: PasswordUtility, private readonly userRepository: UserRepository, private readonly postHog: PostHogClient, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) {} /** @@ -172,7 +172,7 @@ export class InvitationController { user_type: 'email', was_disabled_ldap_user: false, }); - this.eventRelay.emit('user-signed-up', { user: updatedUser }); + this.eventService.emit('user-signed-up', { user: updatedUser }); const publicInvitee = await this.userService.toPublic(invitee); diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index 57eafe4a75f49..bc7ebd0609d77 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -23,7 +23,7 @@ import { InternalHooks } from '@/InternalHooks'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { UserRepository } from '@/databases/repositories/user.repository'; import { isApiEnabled } from '@/PublicApi'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; export const isApiEnabledMiddleware: RequestHandler = (_, res, next) => { if (isApiEnabled()) { @@ -43,7 +43,7 @@ export class MeController { private readonly userService: UserService, private readonly passwordUtility: PasswordUtility, private readonly userRepository: UserRepository, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) {} /** @@ -100,7 +100,7 @@ export class MeController { const fieldsChanged = Object.keys(payload); void this.internalHooks.onUserUpdate({ user, fields_changed: fieldsChanged }); - this.eventRelay.emit('user-updated', { user, fieldsChanged }); + this.eventService.emit('user-updated', { user, fieldsChanged }); const publicUser = await this.userService.toPublic(user); @@ -150,7 +150,7 @@ export class MeController { this.authService.issueCookie(res, updatedUser, req.browserId); void this.internalHooks.onUserUpdate({ user: updatedUser, fields_changed: ['password'] }); - this.eventRelay.emit('user-updated', { user: updatedUser, fieldsChanged: ['password'] }); + this.eventService.emit('user-updated', { user: updatedUser, fieldsChanged: ['password'] }); await this.externalHooks.run('user.password.update', [updatedUser.email, updatedUser.password]); @@ -199,7 +199,7 @@ export class MeController { await this.userService.update(req.user.id, { apiKey }); void this.internalHooks.onApiKeyCreated({ user: req.user, public_api: false }); - this.eventRelay.emit('api-key-created', { user: req.user }); + this.eventService.emit('api-key-created', { user: req.user }); return { apiKey }; } @@ -220,7 +220,7 @@ export class MeController { await this.userService.update(req.user.id, { apiKey: null }); void this.internalHooks.onApiKeyDeleted({ user: req.user, public_api: false }); - this.eventRelay.emit('api-key-deleted', { user: req.user }); + this.eventService.emit('api-key-deleted', { user: req.user }); return { success: true }; } diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index aa053d0f525b8..c548e607fd9d4 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -21,7 +21,7 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; @RestController() export class PasswordResetController { @@ -37,7 +37,7 @@ export class PasswordResetController { private readonly license: License, private readonly passwordUtility: PasswordUtility, private readonly userRepository: UserRepository, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) {} /** @@ -125,7 +125,7 @@ export class PasswordResetController { message_type: 'Reset password', public_api: false, }); - this.eventRelay.emit('email-failed', { user, messageType: 'Reset password' }); + this.eventService.emit('email-failed', { user, messageType: 'Reset password' }); if (error instanceof Error) { throw new InternalServerError(`Please contact your administrator: ${error.message}`); } @@ -139,7 +139,7 @@ export class PasswordResetController { }); void this.internalHooks.onUserPasswordResetRequestClick({ user }); - this.eventRelay.emit('user-password-reset-request-click', { user }); + this.eventService.emit('user-password-reset-request-click', { user }); } /** @@ -172,7 +172,7 @@ export class PasswordResetController { this.logger.info('Reset-password token resolved successfully', { userId: user.id }); void this.internalHooks.onUserPasswordResetEmailClick({ user }); - this.eventRelay.emit('user-password-reset-email-click', { user }); + this.eventService.emit('user-password-reset-email-click', { user }); } /** @@ -216,7 +216,7 @@ export class PasswordResetController { this.authService.issueCookie(res, user, req.browserId); void this.internalHooks.onUserUpdate({ user, fields_changed: ['password'] }); - this.eventRelay.emit('user-updated', { user, fieldsChanged: ['password'] }); + this.eventService.emit('user-updated', { user, fieldsChanged: ['password'] }); // if this user used to be an LDAP users const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap'); @@ -225,7 +225,7 @@ export class PasswordResetController { user_type: 'email', was_disabled_ldap_user: true, }); - this.eventRelay.emit('user-signed-up', { user }); + this.eventService.emit('user-signed-up', { user }); } await this.externalHooks.run('user.password.update', [user.email, passwordHash]); diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index 2b9c7dc0033f2..3347e97cb7f07 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -23,7 +23,7 @@ import { ProjectRepository } from '@/databases/repositories/project.repository'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, Not } from '@n8n/typeorm'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; @RestController('/projects') export class ProjectController { @@ -31,7 +31,7 @@ export class ProjectController { private readonly projectsService: ProjectService, private readonly roleService: RoleService, private readonly projectRepository: ProjectRepository, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) {} @Get('/') @@ -52,7 +52,7 @@ export class ProjectController { try { const project = await this.projectsService.createTeamProject(req.body.name, req.user); - this.eventRelay.emit('team-project-created', { + this.eventService.emit('team-project-created', { userId: req.user.id, role: req.user.role, }); @@ -195,7 +195,7 @@ export class ProjectController { throw e; } - this.eventRelay.emit('team-project-updated', { + this.eventService.emit('team-project-updated', { userId: req.user.id, role: req.user.role, members: req.body.relations, @@ -211,7 +211,7 @@ export class ProjectController { migrateToProject: req.query.transferId, }); - this.eventRelay.emit('team-project-deleted', { + this.eventService.emit('team-project-deleted', { userId: req.user.id, role: req.user.role, projectId: req.params.projectId, diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 76206330dbf82..0fd8481ed8813 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -28,7 +28,7 @@ import { Project } from '@/databases/entities/Project'; import { WorkflowService } from '@/workflows/workflow.service'; import { CredentialsService } from '@/credentials/credentials.service'; import { ProjectService } from '@/services/project.service'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; @RestController('/users') export class UsersController { @@ -45,7 +45,7 @@ export class UsersController { private readonly workflowService: WorkflowService, private readonly credentialsService: CredentialsService, private readonly projectService: ProjectService, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) {} static ERROR_MESSAGES = { @@ -258,7 +258,7 @@ export class UsersController { telemetryData, publicApi: false, }); - this.eventRelay.emit('user-deleted', { user: req.user }); + this.eventService.emit('user-deleted', { user: req.user }); await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]); diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 481840cc33a6d..ec592519bb5e0 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -31,7 +31,7 @@ import { SharedCredentialsRepository } from '@/databases/repositories/sharedCred import { SharedCredentials } from '@/databases/entities/SharedCredentials'; import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; import { z } from 'zod'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; @RestController('/credentials') export class CredentialsController { @@ -46,7 +46,7 @@ export class CredentialsController { private readonly userManagementMailer: UserManagementMailer, private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly projectRelationRepository: ProjectRelationRepository, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) {} @Get('/', { middlewares: listQueryMiddleware }) @@ -169,7 +169,7 @@ export class CredentialsController { credential_id: credential.id, public_api: false, }); - this.eventRelay.emit('credentials-created', { + this.eventService.emit('credentials-created', { user: req.user, credentialName: newCredential.name, credentialType: credential.type, @@ -229,7 +229,7 @@ export class CredentialsController { credential_type: credential.type, credential_id: credential.id, }); - this.eventRelay.emit('credentials-updated', { + this.eventService.emit('credentials-updated', { user: req.user, credentialName: credential.name, credentialType: credential.type, @@ -270,7 +270,7 @@ export class CredentialsController { credential_type: credential.type, credential_id: credential.id, }); - this.eventRelay.emit('credentials-deleted', { + this.eventService.emit('credentials-deleted', { user: req.user, credentialName: credential.name, credentialType: credential.type, @@ -344,7 +344,7 @@ export class CredentialsController { user_ids_sharees_added: newShareeIds, sharees_removed: amountRemoved, }); - this.eventRelay.emit('credentials-shared', { + this.eventService.emit('credentials-shared', { user: req.user, credentialName: credential.name, credentialType: credential.type, diff --git a/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts b/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts index e7e32d3547744..0a1db892f60c9 100644 --- a/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts @@ -12,7 +12,7 @@ import type { SourceControlPreferences } from './types/sourceControlPreferences' import type { SourceControlledFile } from './types/sourceControlledFile'; import { SOURCE_CONTROL_DEFAULT_BRANCH } from './constants'; import type { ImportResult } from './types/importResult'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; import { getRepoType } from './sourceControlHelper.ee'; import { SourceControlGetStatus } from './types/sourceControlGetStatus'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -22,7 +22,7 @@ export class SourceControlController { constructor( private readonly sourceControlService: SourceControlService, private readonly sourceControlPreferencesService: SourceControlPreferencesService, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) {} @Get('/preferences', { middlewares: [sourceControlLicensedMiddleware], skipAuth: true }) @@ -83,7 +83,7 @@ export class SourceControlController { const resultingPreferences = this.sourceControlPreferencesService.getPreferences(); // #region Tracking Information // located in controller so as to not call this multiple times when updating preferences - this.eventRelay.emit('source-control-settings-updated', { + this.eventService.emit('source-control-settings-updated', { branchName: resultingPreferences.branchName, connected: resultingPreferences.connected, readOnlyInstance: resultingPreferences.branchReadOnly, @@ -128,7 +128,7 @@ export class SourceControlController { } await this.sourceControlService.init(); const resultingPreferences = this.sourceControlPreferencesService.getPreferences(); - this.eventRelay.emit('source-control-settings-updated', { + this.eventService.emit('source-control-settings-updated', { branchName: resultingPreferences.branchName, connected: resultingPreferences.connected, readOnlyInstance: resultingPreferences.branchReadOnly, diff --git a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts index 8a0d06ad05aa3..ac226a1b2eacc 100644 --- a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts @@ -30,7 +30,7 @@ import type { TagEntity } from '@db/entities/TagEntity'; import type { Variables } from '@db/entities/Variables'; import type { SourceControlWorkflowVersionId } from './types/sourceControlWorkflowVersionId'; import type { ExportableCredential } from './types/exportableCredential'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; import { TagRepository } from '@db/repositories/tag.repository'; import { Logger } from '@/Logger'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -52,7 +52,7 @@ export class SourceControlService { private sourceControlExportService: SourceControlExportService, private sourceControlImportService: SourceControlImportService, private tagRepository: TagRepository, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) { const { gitFolder, sshFolder, sshKeyName } = sourceControlPreferencesService; this.gitFolder = gitFolder; @@ -292,7 +292,7 @@ export class SourceControlService { }); // #region Tracking Information - this.eventRelay.emit( + this.eventService.emit( 'source-control-user-finished-push-ui', getTrackingInformationFromPostPushResult(statusResult), ); @@ -370,7 +370,7 @@ export class SourceControlService { } // #region Tracking Information - this.eventRelay.emit( + this.eventService.emit( 'source-control-user-finished-pull-ui', getTrackingInformationFromPullResult(statusResult), ); @@ -424,12 +424,12 @@ export class SourceControlService { // #region Tracking Information if (options.direction === 'push') { - this.eventRelay.emit( + this.eventService.emit( 'source-control-user-started-push-ui', getTrackingInformationFromPrePushResult(sourceControlledFiles), ); } else if (options.direction === 'pull') { - this.eventRelay.emit( + this.eventService.emit( 'source-control-user-started-pull-ui', getTrackingInformationFromPullResult(sourceControlledFiles), ); diff --git a/packages/cli/src/environments/variables/variables.service.ee.ts b/packages/cli/src/environments/variables/variables.service.ee.ts index 80a4846252eb8..2db892bca86e7 100644 --- a/packages/cli/src/environments/variables/variables.service.ee.ts +++ b/packages/cli/src/environments/variables/variables.service.ee.ts @@ -1,18 +1,19 @@ import { Container, Service } from 'typedi'; import type { Variables } from '@db/entities/Variables'; -import { InternalHooks } from '@/InternalHooks'; import { generateNanoId } from '@db/utils/generators'; import { canCreateNewVariable } from './environmentHelpers'; import { CacheService } from '@/services/cache/cache.service'; import { VariablesRepository } from '@db/repositories/variables.repository'; import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error'; import { VariableValidationError } from '@/errors/variable-validation.error'; +import { EventService } from '@/eventbus/event.service'; @Service() export class VariablesService { constructor( protected cacheService: CacheService, protected variablesRepository: VariablesRepository, + private readonly eventService: EventService, ) {} async getAllCached(): Promise { @@ -70,7 +71,7 @@ export class VariablesService { } this.validateVariable(variable); - void Container.get(InternalHooks).onVariableCreated({ variable_type: variable.type }); + this.eventService.emit('variable-created', { variableType: variable.type }); const saveResult = await this.variablesRepository.save( { ...variable, diff --git a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts index 46a353e0c70d9..758eeb5ae590f 100644 --- a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts +++ b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts @@ -37,6 +37,7 @@ import { import { License } from '@/License'; import type { EventMessageExecutionOptions } from '../EventMessageClasses/EventMessageExecution'; import { EventMessageExecution } from '../EventMessageClasses/EventMessageExecution'; +import { GlobalConfig } from '@n8n/config'; export type EventMessageReturnMode = 'sent' | 'unsent' | 'all' | 'unfinished'; @@ -70,6 +71,7 @@ export class MessageEventBus extends EventEmitter { private readonly orchestrationService: OrchestrationService, private readonly recoveryService: ExecutionRecoveryService, private readonly license: License, + private readonly globalConfig: GlobalConfig, ) { super(); } @@ -109,7 +111,7 @@ export class MessageEventBus extends EventEmitter { if (options?.workerId) { // only add 'worker' to log file name since the ID changes on every start and we // would not be able to recover the log files from the previous run not knowing it - const logBaseName = config.getEnv('eventBus.logWriter.logBaseName') + '-worker'; + const logBaseName = this.globalConfig.eventBus.logWriter.logBaseName + '-worker'; this.logWriter = await MessageEventBusLogWriter.getInstance({ logBaseName, }); @@ -168,7 +170,7 @@ export class MessageEventBus extends EventEmitter { } } const recoveryAlreadyAttempted = this.logWriter?.isRecoveryProcessRunning(); - if (recoveryAlreadyAttempted || config.getEnv('eventBus.crashRecoveryMode') === 'simple') { + if (recoveryAlreadyAttempted || this.globalConfig.eventBus.crashRecoveryMode === 'simple') { await this.executionRepository.markAsCrashed(unfinishedExecutionIds); // if we end up here, it means that the previous recovery process did not finish // a possible reason would be that recreating the workflow data itself caused e.g an OOM error @@ -188,13 +190,13 @@ export class MessageEventBus extends EventEmitter { } } // if configured, run this test every n ms - if (config.getEnv('eventBus.checkUnsentInterval') > 0) { + if (this.globalConfig.eventBus.checkUnsentInterval > 0) { if (this.pushIntervalTimer) { clearInterval(this.pushIntervalTimer); } this.pushIntervalTimer = setInterval(async () => { await this.trySendingUnsent(); - }, config.getEnv('eventBus.checkUnsentInterval')); + }, this.globalConfig.eventBus.checkUnsentInterval); } this.logger.debug('MessageEventBus initialized'); diff --git a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts index 87a4326d2619f..08899c0e0902d 100644 --- a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts +++ b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts @@ -7,7 +7,6 @@ import { Worker } from 'worker_threads'; import { createReadStream, existsSync, rmSync } from 'fs'; import readline from 'readline'; import remove from 'lodash/remove'; -import config from '@/config'; import type { EventMessageGenericOptions } from '../EventMessageClasses/EventMessageGeneric'; import { EventMessageGeneric } from '../EventMessageClasses/EventMessageGeneric'; import type { AbstractEventMessageOptions } from '../EventMessageClasses/AbstractEventMessageOptions'; @@ -29,6 +28,7 @@ import { once as eventOnce } from 'events'; import { inTest } from '@/constants'; import { Logger } from '@/Logger'; import Container from 'typedi'; +import { GlobalConfig } from '@n8n/config'; interface MessageEventBusLogWriterConstructorOptions { logBaseName?: string; @@ -59,10 +59,13 @@ export class MessageEventBusLogWriter { private readonly logger: Logger; + private readonly globalConfig: GlobalConfig; + private _worker: Worker | undefined; constructor() { this.logger = Container.get(Logger); + this.globalConfig = Container.get(GlobalConfig); } public get worker(): Worker | undefined { @@ -83,12 +86,13 @@ export class MessageEventBusLogWriter { MessageEventBusLogWriter.options = { logFullBasePath: path.join( options?.logBasePath ?? Container.get(InstanceSettings).n8nFolder, - options?.logBaseName ?? config.getEnv('eventBus.logWriter.logBaseName'), + options?.logBaseName ?? Container.get(GlobalConfig).eventBus.logWriter.logBaseName, ), keepNumberOfFiles: - options?.keepNumberOfFiles ?? config.getEnv('eventBus.logWriter.keepLogCount'), + options?.keepNumberOfFiles ?? Container.get(GlobalConfig).eventBus.logWriter.keepLogCount, maxFileSizeInKB: - options?.maxFileSizeInKB ?? config.getEnv('eventBus.logWriter.maxFileSizeInKB'), + options?.maxFileSizeInKB ?? + Container.get(GlobalConfig).eventBus.logWriter.maxFileSizeInKB, }; await MessageEventBusLogWriter.instance.startThread(); } @@ -181,7 +185,7 @@ export class MessageEventBusLogWriter { sentMessages: [], unfinishedExecutions: {}, }; - const configLogCount = config.get('eventBus.logWriter.keepLogCount'); + const configLogCount = this.globalConfig.eventBus.logWriter.keepLogCount; const logCount = logHistory ? Math.min(configLogCount, logHistory) : configLogCount; for (let i = logCount; i >= 0; i--) { const logFileName = this.getLogFileName(i); @@ -282,7 +286,7 @@ export class MessageEventBusLogWriter { logHistory?: number, ): Promise { const result: EventMessageTypes[] = []; - const configLogCount = config.get('eventBus.logWriter.keepLogCount'); + const configLogCount = this.globalConfig.eventBus.logWriter.keepLogCount; const logCount = logHistory ? Math.min(configLogCount, logHistory) : configLogCount; for (let i = 0; i < logCount; i++) { const logFileName = this.getLogFileName(i); diff --git a/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts b/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts index 84408e56a711c..80392206079b7 100644 --- a/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts +++ b/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts @@ -2,12 +2,12 @@ import { mock } from 'jest-mock-extended'; import { AuditEventRelay } from '../audit-event-relay.service'; import type { MessageEventBus } from '../MessageEventBus/MessageEventBus'; import type { Event } from '../event.types'; -import type { EventRelay } from '../event-relay.service'; +import type { EventService } from '../event.service'; describe('AuditorService', () => { const eventBus = mock(); - const eventRelay = mock(); - const auditor = new AuditEventRelay(eventRelay, eventBus); + const eventService = mock(); + const auditor = new AuditEventRelay(eventService, eventBus); afterEach(() => { jest.clearAllMocks(); diff --git a/packages/cli/src/eventbus/audit-event-relay.service.ts b/packages/cli/src/eventbus/audit-event-relay.service.ts index fde3abf31a2fc..09a283d5442b3 100644 --- a/packages/cli/src/eventbus/audit-event-relay.service.ts +++ b/packages/cli/src/eventbus/audit-event-relay.service.ts @@ -1,14 +1,14 @@ import { Service } from 'typedi'; import { MessageEventBus } from './MessageEventBus/MessageEventBus'; import { Redactable } from '@/decorators/Redactable'; -import { EventRelay } from './event-relay.service'; +import { EventService } from './event.service'; import type { Event } from './event.types'; import type { IWorkflowBase } from 'n8n-workflow'; @Service() export class AuditEventRelay { constructor( - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, private readonly eventBus: MessageEventBus, ) {} @@ -17,42 +17,46 @@ export class AuditEventRelay { } private setupHandlers() { - this.eventRelay.on('workflow-created', (event) => this.workflowCreated(event)); - this.eventRelay.on('workflow-deleted', (event) => this.workflowDeleted(event)); - this.eventRelay.on('workflow-saved', (event) => this.workflowSaved(event)); - this.eventRelay.on('workflow-pre-execute', (event) => this.workflowPreExecute(event)); - this.eventRelay.on('workflow-post-execute', (event) => this.workflowPostExecute(event)); - this.eventRelay.on('node-pre-execute', (event) => this.nodePreExecute(event)); - this.eventRelay.on('node-post-execute', (event) => this.nodePostExecute(event)); - this.eventRelay.on('user-deleted', (event) => this.userDeleted(event)); - this.eventRelay.on('user-invited', (event) => this.userInvited(event)); - this.eventRelay.on('user-reinvited', (event) => this.userReinvited(event)); - this.eventRelay.on('user-updated', (event) => this.userUpdated(event)); - this.eventRelay.on('user-signed-up', (event) => this.userSignedUp(event)); - this.eventRelay.on('user-logged-in', (event) => this.userLoggedIn(event)); - this.eventRelay.on('user-login-failed', (event) => this.userLoginFailed(event)); - this.eventRelay.on('user-invite-email-click', (event) => this.userInviteEmailClick(event)); - this.eventRelay.on('user-password-reset-email-click', (event) => + this.eventService.on('workflow-created', (event) => this.workflowCreated(event)); + this.eventService.on('workflow-deleted', (event) => this.workflowDeleted(event)); + this.eventService.on('workflow-saved', (event) => this.workflowSaved(event)); + this.eventService.on('workflow-pre-execute', (event) => this.workflowPreExecute(event)); + this.eventService.on('workflow-post-execute', (event) => this.workflowPostExecute(event)); + this.eventService.on('node-pre-execute', (event) => this.nodePreExecute(event)); + this.eventService.on('node-post-execute', (event) => this.nodePostExecute(event)); + this.eventService.on('user-deleted', (event) => this.userDeleted(event)); + this.eventService.on('user-invited', (event) => this.userInvited(event)); + this.eventService.on('user-reinvited', (event) => this.userReinvited(event)); + this.eventService.on('user-updated', (event) => this.userUpdated(event)); + this.eventService.on('user-signed-up', (event) => this.userSignedUp(event)); + this.eventService.on('user-logged-in', (event) => this.userLoggedIn(event)); + this.eventService.on('user-login-failed', (event) => this.userLoginFailed(event)); + this.eventService.on('user-invite-email-click', (event) => this.userInviteEmailClick(event)); + this.eventService.on('user-password-reset-email-click', (event) => this.userPasswordResetEmailClick(event), ); - this.eventRelay.on('user-password-reset-request-click', (event) => + this.eventService.on('user-password-reset-request-click', (event) => this.userPasswordResetRequestClick(event), ); - this.eventRelay.on('api-key-created', (event) => this.apiKeyCreated(event)); - this.eventRelay.on('api-key-deleted', (event) => this.apiKeyDeleted(event)); - this.eventRelay.on('email-failed', (event) => this.emailFailed(event)); - this.eventRelay.on('credentials-created', (event) => this.credentialsCreated(event)); - this.eventRelay.on('credentials-deleted', (event) => this.credentialsDeleted(event)); - this.eventRelay.on('credentials-shared', (event) => this.credentialsShared(event)); - this.eventRelay.on('credentials-updated', (event) => this.credentialsUpdated(event)); - this.eventRelay.on('credentials-deleted', (event) => this.credentialsDeleted(event)); - this.eventRelay.on('community-package-installed', (event) => + this.eventService.on('api-key-created', (event) => this.apiKeyCreated(event)); + this.eventService.on('api-key-deleted', (event) => this.apiKeyDeleted(event)); + this.eventService.on('email-failed', (event) => this.emailFailed(event)); + this.eventService.on('credentials-created', (event) => this.credentialsCreated(event)); + this.eventService.on('credentials-deleted', (event) => this.credentialsDeleted(event)); + this.eventService.on('credentials-shared', (event) => this.credentialsShared(event)); + this.eventService.on('credentials-updated', (event) => this.credentialsUpdated(event)); + this.eventService.on('credentials-deleted', (event) => this.credentialsDeleted(event)); + this.eventService.on('community-package-installed', (event) => this.communityPackageInstalled(event), ); - this.eventRelay.on('community-package-updated', (event) => this.communityPackageUpdated(event)); - this.eventRelay.on('community-package-deleted', (event) => this.communityPackageDeleted(event)); - this.eventRelay.on('execution-throttled', (event) => this.executionThrottled(event)); - this.eventRelay.on('execution-started-during-bootup', (event) => + this.eventService.on('community-package-updated', (event) => + this.communityPackageUpdated(event), + ); + this.eventService.on('community-package-deleted', (event) => + this.communityPackageDeleted(event), + ); + this.eventService.on('execution-throttled', (event) => this.executionThrottled(event)); + this.eventService.on('execution-started-during-bootup', (event) => this.executionStartedDuringBootup(event), ); } diff --git a/packages/cli/src/eventbus/event-relay.service.ts b/packages/cli/src/eventbus/event.service.ts similarity index 88% rename from packages/cli/src/eventbus/event-relay.service.ts rename to packages/cli/src/eventbus/event.service.ts index 8f6bb4c5c1420..0506305df8c99 100644 --- a/packages/cli/src/eventbus/event-relay.service.ts +++ b/packages/cli/src/eventbus/event.service.ts @@ -3,7 +3,7 @@ import { Service } from 'typedi'; import type { Event } from './event.types'; @Service() -export class EventRelay extends EventEmitter { +export class EventService extends EventEmitter { emit(eventName: K, arg: Event[K]) { super.emit(eventName, arg); return true; diff --git a/packages/cli/src/eventbus/event.types.ts b/packages/cli/src/eventbus/event.types.ts index a93b4c1204a8e..3c74fe7ee52d2 100644 --- a/packages/cli/src/eventbus/event.types.ts +++ b/packages/cli/src/eventbus/event.types.ts @@ -12,7 +12,7 @@ export type UserLike = { }; /** - * Events sent at services and forwarded by relays, e.g. `AuditEventRelay` and `TelemetryEventRelay`. + * Events sent by `EventService` and forwarded by relays, e.g. `AuditEventRelay` and `TelemetryEventRelay`. */ export type Event = { 'workflow-created': { @@ -252,4 +252,20 @@ export type Event = { credsPushed: number; variablesPushed: number; }; + + 'license-renewal-attempted': { + success: boolean; + }; + + 'variable-created': { + variableType: string; + }; + + 'external-secrets-provider-settings-saved': { + userId?: string; + vaultType: string; + isValid: boolean; + isNew: boolean; + errorMessage?: string; + }; }; diff --git a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts index fb8bdd2e929f7..e863d46163ef5 100644 --- a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts @@ -21,7 +21,7 @@ import { WorkflowCrashedError } from '@/errors/workflow-crashed.error'; import { EventMessageNode } from '@/eventbus/EventMessageClasses/EventMessageNode'; import { EventMessageWorkflow } from '@/eventbus/EventMessageClasses/EventMessageWorkflow'; -import type { EventRelay } from '@/eventbus/event-relay.service'; +import type { EventService } from '@/eventbus/event.service'; import type { EventMessageTypes as EventMessage } from '@/eventbus/EventMessageClasses'; import type { Logger } from '@/Logger'; @@ -193,7 +193,7 @@ describe('ExecutionRecoveryService', () => { push, executionRepository, orchestrationService, - mock(), + mock(), ); }); diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index 5abfe2c2e0509..974f56df6ca28 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -16,7 +16,7 @@ import config from '@/config'; import { OnShutdown } from '@/decorators/OnShutdown'; import type { QueueRecoverySettings } from './execution.types'; import { OrchestrationService } from '@/services/orchestration.service'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; /** * Service for recovering key properties in executions. @@ -28,7 +28,7 @@ export class ExecutionRecoveryService { private readonly push: Push, private readonly executionRepository: ExecutionRepository, private readonly orchestrationService: OrchestrationService, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) {} /** @@ -286,7 +286,7 @@ export class ExecutionRecoveryService { status: execution.status, }); - this.eventRelay.emit('workflow-post-execute', { + this.eventService.emit('workflow-post-execute', { workflowId: execution.workflowData.id, workflowName: execution.workflowData.name, executionId: execution.id, diff --git a/packages/cli/src/license/license.service.ts b/packages/cli/src/license/license.service.ts index 543b953737e43..01d2a73c48941 100644 --- a/packages/cli/src/license/license.service.ts +++ b/packages/cli/src/license/license.service.ts @@ -3,7 +3,7 @@ import axios from 'axios'; import { Logger } from '@/Logger'; import { License } from '@/License'; -import { InternalHooks } from '@/InternalHooks'; +import { EventService } from '@/eventbus/event.service'; import type { User } from '@db/entities/User'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -26,9 +26,9 @@ export class LicenseService { constructor( private readonly logger: Logger, private readonly license: License, - private readonly internalHooks: InternalHooks, private readonly workflowRepository: WorkflowRepository, private readonly urlService: UrlService, + private readonly eventService: EventService, ) {} async getLicenseData() { @@ -78,13 +78,12 @@ export class LicenseService { await this.license.renew(); } catch (e) { const message = this.mapErrorMessage(e as LicenseError, 'renew'); - // not awaiting so as not to make the endpoint hang - void this.internalHooks.onLicenseRenewAttempt({ success: false }); + + this.eventService.emit('license-renewal-attempted', { success: false }); throw new BadRequestError(message); } - // not awaiting so as not to make the endpoint hang - void this.internalHooks.onLicenseRenewAttempt({ success: true }); + this.eventService.emit('license-renewal-attempted', { success: true }); } private mapErrorMessage(error: LicenseError, action: 'activate' | 'renew') { diff --git a/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts b/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts index 3c90d154cb637..1c1f2f8b01020 100644 --- a/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts +++ b/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts @@ -106,7 +106,16 @@ describe('PrometheusMetricsService', () => { }); expect(app.use).toHaveBeenCalledWith( - ['/rest/', '/webhook/', '/webhook-waiting/', '/form-waiting/', '/webhook-test/', '/api/'], + [ + '/rest/', + '/api/', + '/webhook/', + '/webhook-waiting/', + '/webhook-test/', + '/form/', + '/form-waiting/', + '/form-test/', + ], expect.any(Function), ); }); diff --git a/packages/cli/src/metrics/prometheus-metrics.service.ts b/packages/cli/src/metrics/prometheus-metrics.service.ts index 2c3e66b048ec3..1c22238f4c8a7 100644 --- a/packages/cli/src/metrics/prometheus-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus-metrics.service.ts @@ -47,7 +47,7 @@ export class PrometheusMetricsService { this.initN8nVersionMetric(); this.initCacheMetrics(); this.initEventBusMetrics(); - this.initApiMetrics(app); + this.initRouteMetrics(app); this.mountMetricsEndpoint(app); } @@ -95,9 +95,9 @@ export class PrometheusMetricsService { } /** - * Set up metrics for API endpoints with `express-prom-bundle` + * Set up metrics for server routes with `express-prom-bundle` */ - private initApiMetrics(app: express.Application) { + private initRouteMetrics(app: express.Application) { if (!this.includes.metrics.api) return; const metricsMiddleware = promBundle({ @@ -109,7 +109,16 @@ export class PrometheusMetricsService { }); app.use( - ['/rest/', '/webhook/', '/webhook-waiting/', '/form-waiting/', '/webhook-test/', '/api/'], + [ + '/rest/', + '/api/', + '/webhook/', + '/webhook-waiting/', + '/webhook-test/', + '/form/', + '/form-waiting/', + '/form-test/', + ], metricsMiddleware, ); } diff --git a/packages/cli/src/security-audit/risk-reporters/InstanceRiskReporter.ts b/packages/cli/src/security-audit/risk-reporters/InstanceRiskReporter.ts index eaedf36c3950a..04242ba260264 100644 --- a/packages/cli/src/security-audit/risk-reporters/InstanceRiskReporter.ts +++ b/packages/cli/src/security-audit/risk-reporters/InstanceRiskReporter.ts @@ -90,7 +90,7 @@ export class InstanceRiskReporter implements RiskReporter { settings.features = { communityPackagesEnabled: config.getEnv('nodes.communityPackages.enabled'), versionNotificationsEnabled: this.globalConfig.versionNotifications.enabled, - templatesEnabled: config.getEnv('templates.enabled'), + templatesEnabled: this.globalConfig.templates.enabled, publicApiEnabled: isApiEnabled(), }; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index d959254369ba1..dbf90e45de84a 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -161,8 +161,8 @@ export class FrontendService { logLevel: config.getEnv('logs.level'), hiringBannerEnabled: config.getEnv('hiringBanner.enabled'), templates: { - enabled: config.getEnv('templates.enabled'), - host: config.getEnv('templates.host'), + enabled: this.globalConfig.templates.enabled, + host: this.globalConfig.templates.host, }, executionMode: config.getEnv('executions.mode'), pushBackend: config.getEnv('push.backend'), diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 18b45d8b6e4df..cc50e09f74607 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -12,7 +12,7 @@ import { InternalHooks } from '@/InternalHooks'; import { UrlService } from '@/services/url.service'; import type { UserRequest } from '@/requests'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; @Service() export class UserService { @@ -21,7 +21,7 @@ export class UserService { private readonly userRepository: UserRepository, private readonly mailer: UserManagementMailer, private readonly urlService: UrlService, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) {} async update(userId: string, data: Partial) { @@ -158,7 +158,7 @@ export class UserService { email_sent: result.emailSent, invitee_role: role, // same role for all invited users }); - this.eventRelay.emit('user-invited', { + this.eventService.emit('user-invited', { user: owner, targetUserId: Object.values(toInviteUsers), }); @@ -169,7 +169,7 @@ export class UserService { message_type: 'New user invite', public_api: false, }); - this.eventRelay.emit('email-failed', { user: owner, messageType: 'New user invite' }); + this.eventService.emit('email-failed', { user: owner, messageType: 'New user invite' }); this.logger.error('Failed to send email', { userId: owner.id, inviteAcceptUrl, diff --git a/packages/cli/src/services/events.service.ts b/packages/cli/src/services/workflow-statistics.service.ts similarity index 97% rename from packages/cli/src/services/events.service.ts rename to packages/cli/src/services/workflow-statistics.service.ts index 7f5ad4c121b28..516732add87b4 100644 --- a/packages/cli/src/services/events.service.ts +++ b/packages/cli/src/services/workflow-statistics.service.ts @@ -8,7 +8,7 @@ import { Logger } from '@/Logger'; import { OwnershipService } from './ownership.service'; @Service() -export class EventsService extends EventEmitter { +export class WorkflowStatisticsService extends EventEmitter { constructor( private readonly logger: Logger, private readonly repository: WorkflowStatisticsRepository, @@ -112,7 +112,7 @@ export class EventsService extends EventEmitter { } } -export declare interface EventsService { +export declare interface WorkflowStatisticsService { on( event: 'nodeFetchedData', listener: (workflowId: string | undefined | null, node: INode) => void, diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts index f08189ffafb74..344bd34e92b72 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -27,7 +27,7 @@ import { import { SamlService } from '../saml.service.ee'; import { SamlConfiguration } from '../types/requests'; import { getInitSSOFormView } from '../views/initSsoPost'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; @RestController('/sso/saml') export class SamlController { @@ -35,7 +35,7 @@ export class SamlController { private readonly authService: AuthService, private readonly samlService: SamlService, private readonly urlService: UrlService, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) {} @Get('/metadata', { skipAuth: true }) @@ -126,7 +126,7 @@ export class SamlController { } } if (loginResult.authenticatedUser) { - this.eventRelay.emit('user-logged-in', { + this.eventService.emit('user-logged-in', { user: loginResult.authenticatedUser, authenticationMethod: 'saml', }); @@ -144,7 +144,7 @@ export class SamlController { return res.status(202).send(loginResult.attributes); } } - this.eventRelay.emit('user-login-failed', { + this.eventService.emit('user-login-failed', { userEmail: loginResult.attributes.email ?? 'unknown', authenticationMethod: 'saml', }); @@ -153,7 +153,7 @@ export class SamlController { if (isConnectionTestRequest(req)) { return res.send(getSamlConnectionTestFailedView((error as Error).message)); } - this.eventRelay.emit('user-login-failed', { + this.eventService.emit('user-login-failed', { userEmail: 'unknown', authenticationMethod: 'saml', }); diff --git a/packages/cli/src/telemetry/telemetry-event-relay.service.ts b/packages/cli/src/telemetry/telemetry-event-relay.service.ts index 982376226ec73..d3313d18c36a3 100644 --- a/packages/cli/src/telemetry/telemetry-event-relay.service.ts +++ b/packages/cli/src/telemetry/telemetry-event-relay.service.ts @@ -1,5 +1,5 @@ import { Service } from 'typedi'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; import type { Event } from '@/eventbus/event.types'; import { Telemetry } from '.'; import config from '@/config'; @@ -7,7 +7,7 @@ import config from '@/config'; @Service() export class TelemetryEventRelay { constructor( - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, private readonly telemetry: Telemetry, ) {} @@ -20,27 +20,36 @@ export class TelemetryEventRelay { } private setupHandlers() { - this.eventRelay.on('team-project-updated', (event) => this.teamProjectUpdated(event)); - this.eventRelay.on('team-project-deleted', (event) => this.teamProjectDeleted(event)); - this.eventRelay.on('team-project-created', (event) => this.teamProjectCreated(event)); - this.eventRelay.on('source-control-settings-updated', (event) => + this.eventService.on('team-project-updated', (event) => this.teamProjectUpdated(event)); + this.eventService.on('team-project-deleted', (event) => this.teamProjectDeleted(event)); + this.eventService.on('team-project-created', (event) => this.teamProjectCreated(event)); + this.eventService.on('source-control-settings-updated', (event) => this.sourceControlSettingsUpdated(event), ); - this.eventRelay.on('source-control-user-started-pull-ui', (event) => + this.eventService.on('source-control-user-started-pull-ui', (event) => this.sourceControlUserStartedPullUi(event), ); - this.eventRelay.on('source-control-user-finished-pull-ui', (event) => + this.eventService.on('source-control-user-finished-pull-ui', (event) => this.sourceControlUserFinishedPullUi(event), ); - this.eventRelay.on('source-control-user-pulled-api', (event) => + this.eventService.on('source-control-user-pulled-api', (event) => this.sourceControlUserPulledApi(event), ); - this.eventRelay.on('source-control-user-started-push-ui', (event) => + this.eventService.on('source-control-user-started-push-ui', (event) => this.sourceControlUserStartedPushUi(event), ); - this.eventRelay.on('source-control-user-finished-push-ui', (event) => + this.eventService.on('source-control-user-finished-push-ui', (event) => this.sourceControlUserFinishedPushUi(event), ); + this.eventService.on('license-renewal-attempted', (event) => { + this.licenseRenewalAttempted(event); + }); + this.eventService.on('variable-created', (event) => { + this.variableCreated(event); + }); + this.eventService.on('external-secrets-provider-settings-saved', (event) => { + this.externalSecretsProviderSettingsSaved(event); + }); } private teamProjectUpdated({ userId, role, members, projectId }: Event['team-project-updated']) { @@ -153,4 +162,32 @@ export class TelemetryEventRelay { variables_pushed: variablesPushed, }); } + + private licenseRenewalAttempted({ success }: Event['license-renewal-attempted']) { + void this.telemetry.track('Instance attempted to refresh license', { + success, + }); + } + + private variableCreated({ variableType }: Event['variable-created']) { + void this.telemetry.track('User created variable', { + variable_type: variableType, + }); + } + + private externalSecretsProviderSettingsSaved({ + userId, + vaultType, + isValid, + isNew, + errorMessage, + }: Event['external-secrets-provider-settings-saved']) { + void this.telemetry.track('User updated external secrets settings', { + user_id: userId, + vault_type: vaultType, + is_valid: isValid, + is_new: isNew, + error_message: errorMessage, + }); + } } diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index e7bb8292baba0..cf742acecef2e 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -34,7 +34,7 @@ import type { EntityManager } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; @Service() export class WorkflowService { @@ -54,7 +54,7 @@ export class WorkflowService { private readonly workflowSharingService: WorkflowSharingService, private readonly projectService: ProjectService, private readonly executionRepository: ExecutionRepository, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) {} async getMany(user: User, options?: ListQuery.Options, includeScopes?: boolean) { @@ -220,7 +220,7 @@ export class WorkflowService { await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]); void Container.get(InternalHooks).onWorkflowSaved(user, updatedWorkflow, false); - this.eventRelay.emit('workflow-saved', { + this.eventService.emit('workflow-saved', { user, workflowId: updatedWorkflow.id, workflowName: updatedWorkflow.name, @@ -283,7 +283,7 @@ export class WorkflowService { await this.binaryDataService.deleteMany(idsForDeletion); void Container.get(InternalHooks).onWorkflowDeleted(user, workflowId, false); - this.eventRelay.emit('workflow-deleted', { user, workflowId }); + this.eventService.emit('workflow-deleted', { user, workflowId }); await this.externalHooks.run('workflow.afterDelete', [workflowId]); return workflow; diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 9042c5ee130b5..f236f6dbaae78 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -42,7 +42,7 @@ import { In, type FindOptionsRelations } from '@n8n/typeorm'; import type { Project } from '@/databases/entities/Project'; import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; import { z } from 'zod'; -import { EventRelay } from '@/eventbus/event-relay.service'; +import { EventService } from '@/eventbus/event.service'; @RestController('/workflows') export class WorkflowsController { @@ -66,7 +66,7 @@ export class WorkflowsController { private readonly projectRepository: ProjectRepository, private readonly projectService: ProjectService, private readonly projectRelationRepository: ProjectRelationRepository, - private readonly eventRelay: EventRelay, + private readonly eventService: EventService, ) {} @Post('/') @@ -178,7 +178,7 @@ export class WorkflowsController { await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); void this.internalHooks.onWorkflowCreated(req.user, newWorkflow, project!, false); - this.eventRelay.emit('workflow-created', { user: req.user, workflow: newWorkflow }); + this.eventService.emit('workflow-created', { user: req.user, workflow: newWorkflow }); const scopes = await this.workflowService.getWorkflowScopes(req.user, savedWorkflow.id); diff --git a/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts b/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts index 1cf03848c5866..7a49e61fa1b01 100644 --- a/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts +++ b/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts @@ -21,6 +21,7 @@ import { TestFailProvider, } from '../../shared/ExternalSecrets/utils'; import type { SuperAgentTest } from '../shared/types'; +import type { EventService } from '@/eventbus/event.service'; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; @@ -49,6 +50,8 @@ async function getExternalSecretsSettings(): Promise(); + const resetManager = async () => { Container.get(ExternalSecretsManager).shutdown(); Container.set( @@ -59,6 +62,7 @@ const resetManager = async () => { Container.get(License), mockProvidersInstance, Container.get(Cipher), + eventService, ), ); diff --git a/packages/cli/test/integration/eventbus.ee.test.ts b/packages/cli/test/integration/eventbus.ee.test.ts index b19f024f52080..a79c1388b39ee 100644 --- a/packages/cli/test/integration/eventbus.ee.test.ts +++ b/packages/cli/test/integration/eventbus.ee.test.ts @@ -1,5 +1,4 @@ import { Container } from 'typedi'; -import config from '@/config'; import axios from 'axios'; import syslog from 'syslog-client'; import { v4 as uuid } from 'uuid'; @@ -92,9 +91,6 @@ beforeAll(async () => { mockedSyslog.createClient.mockImplementation(() => new syslog.Client()); - config.set('eventBus.logWriter.logBaseName', 'n8n-test-logwriter'); - config.set('eventBus.logWriter.keepLogCount', 1); - eventBus = Container.get(MessageEventBus); await eventBus.initialize(); }); diff --git a/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts b/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts index b77b33bd024b1..cf72688d24397 100644 --- a/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts +++ b/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts @@ -49,7 +49,14 @@ describe('External Secrets Manager', () => { }); license.isExternalSecretsEnabled.mockReturnValue(true); settingsRepo.getEncryptedSecretsProviderSettings.mockResolvedValue(settings); - manager = new ExternalSecretsManager(mock(), settingsRepo, license, providersMock, cipher); + manager = new ExternalSecretsManager( + mock(), + settingsRepo, + license, + providersMock, + cipher, + mock(), + ); }); afterEach(() => { diff --git a/packages/cli/test/unit/license/license.service.test.ts b/packages/cli/test/unit/license/license.service.test.ts index 7ac75ba5b3623..e28895025ffbe 100644 --- a/packages/cli/test/unit/license/license.service.test.ts +++ b/packages/cli/test/unit/license/license.service.test.ts @@ -1,6 +1,6 @@ import { LicenseErrors, LicenseService } from '@/license/license.service'; import type { License } from '@/License'; -import type { InternalHooks } from '@/InternalHooks'; +import type { EventService } from '@/eventbus/event.service'; import type { WorkflowRepository } from '@db/repositories/workflow.repository'; import type { TEntitlement } from '@n8n_io/license-sdk'; import { mock } from 'jest-mock-extended'; @@ -8,10 +8,16 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; describe('LicenseService', () => { const license = mock(); - const internalHooks = mock(); const workflowRepository = mock(); const entitlement = mock({ productId: '123' }); - const licenseService = new LicenseService(mock(), license, internalHooks, workflowRepository); + const eventService = mock(); + const licenseService = new LicenseService( + mock(), + license, + workflowRepository, + mock(), + eventService, + ); license.getMainPlan.mockReturnValue(entitlement); license.getTriggerLimit.mockReturnValue(400); @@ -61,7 +67,9 @@ describe('LicenseService', () => { license.renew.mockResolvedValueOnce(); await licenseService.renewLicense(); - expect(internalHooks.onLicenseRenewAttempt).toHaveBeenCalledWith({ success: true }); + expect(eventService.emit).toHaveBeenCalledWith('license-renewal-attempted', { + success: true, + }); }); test('on failure', async () => { @@ -70,7 +78,9 @@ describe('LicenseService', () => { new BadRequestError('Activation key has expired'), ); - expect(internalHooks.onLicenseRenewAttempt).toHaveBeenCalledWith({ success: false }); + expect(eventService.emit).toHaveBeenCalledWith('license-renewal-attempted', { + success: false, + }); }); }); }); diff --git a/packages/cli/test/unit/services/events.service.test.ts b/packages/cli/test/unit/services/workflow-statistics.service.test.ts similarity index 88% rename from packages/cli/test/unit/services/events.service.test.ts rename to packages/cli/test/unit/services/workflow-statistics.service.test.ts index 0fbab36623c4e..da1338f8eb764 100644 --- a/packages/cli/test/unit/services/events.service.test.ts +++ b/packages/cli/test/unit/services/workflow-statistics.service.test.ts @@ -14,13 +14,13 @@ import config from '@/config'; import type { User } from '@db/entities/User'; import type { WorkflowStatistics } from '@db/entities/WorkflowStatistics'; import { WorkflowStatisticsRepository } from '@db/repositories/workflowStatistics.repository'; -import { EventsService } from '@/services/events.service'; +import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { UserService } from '@/services/user.service'; import { OwnershipService } from '@/services/ownership.service'; import { mockInstance } from '../../shared/mocking'; import type { Project } from '@/databases/entities/Project'; -describe('EventsService', () => { +describe('WorkflowStatisticsService', () => { const fakeUser = mock({ id: 'abcde-fghij' }); const fakeProject = mock({ id: '12345-67890', type: 'personal' }); const ownershipService = mockInstance(OwnershipService); @@ -44,7 +44,7 @@ describe('EventsService', () => { mocked(ownershipService.getProjectOwnerCached).mockResolvedValue(fakeUser); const updateSettingsMock = jest.spyOn(userService, 'updateSettings').mockImplementation(); - const eventsService = new EventsService( + const workflowStatisticsService = new WorkflowStatisticsService( mock(), new WorkflowStatisticsRepository(dataSource, globalConfig), ownershipService, @@ -52,8 +52,11 @@ describe('EventsService', () => { const onFirstProductionWorkflowSuccess = jest.fn(); const onFirstWorkflowDataLoad = jest.fn(); - eventsService.on('telemetry.onFirstProductionWorkflowSuccess', onFirstProductionWorkflowSuccess); - eventsService.on('telemetry.onFirstWorkflowDataLoad', onFirstWorkflowDataLoad); + workflowStatisticsService.on( + 'telemetry.onFirstProductionWorkflowSuccess', + onFirstProductionWorkflowSuccess, + ); + workflowStatisticsService.on('telemetry.onFirstWorkflowDataLoad', onFirstWorkflowDataLoad); beforeEach(() => { jest.clearAllMocks(); @@ -91,7 +94,7 @@ describe('EventsService', () => { }; mockDBCall(); - await eventsService.workflowExecutionCompleted(workflow, runData); + await workflowStatisticsService.workflowExecutionCompleted(workflow, runData); expect(updateSettingsMock).toHaveBeenCalledTimes(1); expect(onFirstProductionWorkflowSuccess).toBeCalledTimes(1); expect(onFirstProductionWorkflowSuccess).toHaveBeenNthCalledWith(1, { @@ -119,7 +122,7 @@ describe('EventsService', () => { mode: 'internal' as WorkflowExecuteMode, startedAt: new Date(), }; - await eventsService.workflowExecutionCompleted(workflow, runData); + await workflowStatisticsService.workflowExecutionCompleted(workflow, runData); expect(onFirstProductionWorkflowSuccess).toBeCalledTimes(0); }); @@ -142,7 +145,7 @@ describe('EventsService', () => { startedAt: new Date(), }; mockDBCall(2); - await eventsService.workflowExecutionCompleted(workflow, runData); + await workflowStatisticsService.workflowExecutionCompleted(workflow, runData); expect(onFirstProductionWorkflowSuccess).toBeCalledTimes(0); }); }); @@ -159,7 +162,7 @@ describe('EventsService', () => { position: [0, 0] as [number, number], parameters: {}, }; - await eventsService.nodeFetchedData(workflowId, node); + await workflowStatisticsService.nodeFetchedData(workflowId, node); expect(onFirstWorkflowDataLoad).toBeCalledTimes(1); expect(onFirstWorkflowDataLoad).toHaveBeenNthCalledWith(1, { user_id: fakeUser.id, @@ -187,7 +190,7 @@ describe('EventsService', () => { }, }, }; - await eventsService.nodeFetchedData(workflowId, node); + await workflowStatisticsService.nodeFetchedData(workflowId, node); expect(onFirstWorkflowDataLoad).toBeCalledTimes(1); expect(onFirstWorkflowDataLoad).toHaveBeenNthCalledWith(1, { user_id: fakeUser.id, @@ -212,7 +215,7 @@ describe('EventsService', () => { position: [0, 0] as [number, number], parameters: {}, }; - await eventsService.nodeFetchedData(workflowId, node); + await workflowStatisticsService.nodeFetchedData(workflowId, node); expect(onFirstWorkflowDataLoad).toBeCalledTimes(0); }); }); diff --git a/packages/editor-ui/src/__tests__/defaults.ts b/packages/editor-ui/src/__tests__/defaults.ts index 2a1d029155ae9..b6c22cbf7e0e1 100644 --- a/packages/editor-ui/src/__tests__/defaults.ts +++ b/packages/editor-ui/src/__tests__/defaults.ts @@ -23,7 +23,7 @@ export const defaultSettings: IN8nUISettings = { logStreaming: false, debugInEditor: false, advancedExecutionFilters: false, - variables: true, + variables: false, sourceControl: false, auditLogs: false, showNonProdBanner: false, diff --git a/packages/editor-ui/src/components/AssignmentCollection/__tests__/Assignment.test.ts b/packages/editor-ui/src/components/AssignmentCollection/__tests__/Assignment.test.ts index 6d2f438afe855..ba5617ec8584c 100644 --- a/packages/editor-ui/src/components/AssignmentCollection/__tests__/Assignment.test.ts +++ b/packages/editor-ui/src/components/AssignmentCollection/__tests__/Assignment.test.ts @@ -2,9 +2,14 @@ import { createComponentRenderer } from '@/__tests__/render'; import { createTestingPinia } from '@pinia/testing'; import userEvent from '@testing-library/user-event'; import Assignment from '../Assignment.vue'; +import { defaultSettings } from '@/__tests__/defaults'; +import { STORES } from '@/constants'; +import { merge } from 'lodash-es'; const DEFAULT_SETUP = { - pinia: createTestingPinia(), + pinia: createTestingPinia({ + initialState: { [STORES.SETTINGS]: { settings: merge({}, defaultSettings) } }, + }), props: { path: 'parameters.fields.0', modelValue: { diff --git a/packages/editor-ui/src/components/AssignmentCollection/__tests__/AssignmentCollection.test.ts b/packages/editor-ui/src/components/AssignmentCollection/__tests__/AssignmentCollection.test.ts index 3cb22cebbff0f..d066e6b218f9b 100644 --- a/packages/editor-ui/src/components/AssignmentCollection/__tests__/AssignmentCollection.test.ts +++ b/packages/editor-ui/src/components/AssignmentCollection/__tests__/AssignmentCollection.test.ts @@ -5,10 +5,16 @@ import userEvent from '@testing-library/user-event'; import { fireEvent, within } from '@testing-library/vue'; import * as workflowHelpers from '@/composables/useWorkflowHelpers'; import AssignmentCollection from '../AssignmentCollection.vue'; -import { createPinia, setActivePinia } from 'pinia'; +import { STORES } from '@/constants'; +import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; const DEFAULT_SETUP = { - pinia: createTestingPinia(), + pinia: createTestingPinia({ + initialState: { + [STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE, + }, + stubActions: false, + }), props: { path: 'parameters.fields', node: { @@ -97,10 +103,7 @@ describe('AssignmentCollection.vue', () => { }); it('can add assignments by drag and drop (and infer type)', async () => { - const pinia = createPinia(); - setActivePinia(pinia); - - const { getByTestId, findAllByTestId } = renderComponent({ pinia }); + const { getByTestId, findAllByTestId } = renderComponent(); const dropArea = getByTestId('drop-area'); await dropAssignment({ key: 'boolKey', value: true, dropArea }); diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index 3421a28fe618c..df3e4f1339841 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -775,7 +775,7 @@ async function saveCredential(): Promise { }; if ( - settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing) && + settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing] && credentialData.value.sharedWithProjects ) { credentialDetails.sharedWithProjects = credentialData.value @@ -812,7 +812,7 @@ async function saveCredential(): Promise { type: 'success', }); } else { - if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) { + if (settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]) { credentialDetails.sharedWithProjects = credentialData.value .sharedWithProjects as ProjectSharingData[]; } diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue index 87f4e09f70cb0..9fcbb1d369bf0 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue @@ -155,7 +155,7 @@ export default defineComponent({ ]; }, isSharingEnabled(): boolean { - return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing); + return this.settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]; }, credentialOwnerName(): string { return this.credentialsStore.getCredentialOwnerNameById(`${this.credentialId}`); diff --git a/packages/editor-ui/src/components/EnterpriseEdition.ee.vue b/packages/editor-ui/src/components/EnterpriseEdition.ee.vue index 9077f0137e036..a2dec568889cc 100644 --- a/packages/editor-ui/src/components/EnterpriseEdition.ee.vue +++ b/packages/editor-ui/src/components/EnterpriseEdition.ee.vue @@ -23,7 +23,7 @@ export default defineComponent({ ...mapStores(useSettingsStore), canAccess(): boolean { return this.features.reduce((acc: boolean, feature) => { - return acc && !!this.settingsStore.isEnterpriseFeatureEnabled(feature); + return acc && !!this.settingsStore.isEnterpriseFeatureEnabled[feature]; }, true); }, }, diff --git a/packages/editor-ui/src/components/InviteUsersModal.vue b/packages/editor-ui/src/components/InviteUsersModal.vue index 8f245b9797a2a..dab656c95364f 100644 --- a/packages/editor-ui/src/components/InviteUsersModal.vue +++ b/packages/editor-ui/src/components/InviteUsersModal.vue @@ -190,9 +190,9 @@ export default defineComponent({ : []; }, isAdvancedPermissionsEnabled(): boolean { - return this.settingsStore.isEnterpriseFeatureEnabled( - EnterpriseEditionFeature.AdvancedPermissions, - ); + return this.settingsStore.isEnterpriseFeatureEnabled[ + EnterpriseEditionFeature.AdvancedPermissions + ]; }, }, methods: { diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue index 47e122ad791e3..f00691539c102 100644 --- a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -7,6 +7,7 @@ import { MODAL_CONFIRM, PLACEHOLDER_EMPTY_WORKFLOW_ID, SOURCE_CONTROL_PUSH_MODAL_KEY, + VALID_WORKFLOW_IMPORT_URL_REGEX, VIEWS, WORKFLOW_MENU_ACTIONS, WORKFLOW_SETTINGS_MODAL_KEY, @@ -204,7 +205,7 @@ const workflowMenuItems = computed(() => { }); const isWorkflowHistoryFeatureEnabled = computed(() => { - return settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.WorkflowHistory); + return settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.WorkflowHistory]; }); const workflowHistoryRoute = computed<{ name: string; params: { workflowId: string } }>(() => { @@ -450,7 +451,7 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise { const usedCredentials = workflowsStore.usedCredentials; const foreignCredentialsArray: string[] = []; - if (credentials && settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) { + if (credentials && settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]) { Object.values(credentials).forEach((credential) => { if ( credential.id && diff --git a/packages/editor-ui/src/components/ParameterInputHint.vue b/packages/editor-ui/src/components/ParameterInputHint.vue index 0acafdd721dc1..8a88ba4793a87 100644 --- a/packages/editor-ui/src/components/ParameterInputHint.vue +++ b/packages/editor-ui/src/components/ParameterInputHint.vue @@ -7,7 +7,7 @@ [$style.highlight]: highlight, }" > - +
{ + test('should resolve expression', async () => { + const { getByTestId } = renderComponent(ParameterInputWrapper, { + pinia: createTestingPinia({ + initialState: { + ndv: { + activeNodeName: 'testNode', + input: { nodeName: 'inputNode' }, + }, + }, + }), + props: { + parameter: { + name: 'test', + type: 'string', + }, + path: 'params.test', + modelValue: '={{ $secrets.infisical.password }}', + isForCredential: true, + }, + global: { + mocks: { + $workflowHelpers: { + resolveExpression: vi.fn(() => 'topSecret'), + }, + $ndvStore: { + activeNode: vi.fn(() => ({ test: 'test' })), + }, + }, + }, + }); + + expect(getByTestId('parameter-input-hint')).toHaveTextContent('[ERROR: ]'); + }); +}); diff --git a/packages/editor-ui/src/components/ParameterInputWrapper.vue b/packages/editor-ui/src/components/ParameterInputWrapper.vue index aebdcf229918d..7165bccf13c29 100644 --- a/packages/editor-ui/src/components/ParameterInputWrapper.vue +++ b/packages/editor-ui/src/components/ParameterInputWrapper.vue @@ -177,6 +177,8 @@ const evaluatedExpression = computed>(() => { }; } + if (props.isForCredential) opts.additionalKeys = resolvedAdditionalExpressionData.value; + return { ok: true, result: workflowHelpers.resolveExpression(value, undefined, opts) }; } catch (error) { return { ok: false, error }; diff --git a/packages/editor-ui/src/components/VariablesRow.vue b/packages/editor-ui/src/components/VariablesRow.vue index 1660f3b70dc73..f970bd91ae625 100644 --- a/packages/editor-ui/src/components/VariablesRow.vue +++ b/packages/editor-ui/src/components/VariablesRow.vue @@ -52,8 +52,8 @@ const valueInputRef = ref(); const usage = ref(`$vars.${props.data.name}`); -const isFeatureEnabled = computed(() => - settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Variables), +const isFeatureEnabled = computed( + () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables], ); onMounted(() => { diff --git a/packages/editor-ui/src/components/WorkflowSettings.vue b/packages/editor-ui/src/components/WorkflowSettings.vue index a916199f36a30..42eac67db091c 100644 --- a/packages/editor-ui/src/components/WorkflowSettings.vue +++ b/packages/editor-ui/src/components/WorkflowSettings.vue @@ -478,7 +478,7 @@ export default defineComponent({ return this.usersStore.currentUser; }, isSharingEnabled(): boolean { - return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing); + return this.settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]; }, workflowOwnerName(): string { const fallback = this.$locale.baseText( diff --git a/packages/editor-ui/src/components/WorkflowShareModal.ee.vue b/packages/editor-ui/src/components/WorkflowShareModal.ee.vue index d8c478e997c65..886ac5017644b 100644 --- a/packages/editor-ui/src/components/WorkflowShareModal.ee.vue +++ b/packages/editor-ui/src/components/WorkflowShareModal.ee.vue @@ -195,7 +195,7 @@ export default defineComponent({ useRolesStore, ), isSharingEnabled(): boolean { - return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing); + return this.settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]; }, modalTitle(): string { if (this.isHomeTeamProject) { diff --git a/packages/editor-ui/src/components/canvas/Canvas.vue b/packages/editor-ui/src/components/canvas/Canvas.vue index e0fa85ff77ade..cc3f8490c9529 100644 --- a/packages/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/editor-ui/src/components/canvas/Canvas.vue @@ -15,6 +15,7 @@ import { useKeybindings } from '@/composables/useKeybindings'; import ContextMenu from '@/components/ContextMenu/ContextMenu.vue'; import type { NodeCreatorOpenSource } from '@/Interface'; import type { PinDataSource } from '@/composables/usePinnedData'; +import { isPresent } from '@/utils/typesUtils'; const $style = useCssModule(); @@ -75,6 +76,7 @@ const { project, nodes: graphNodes, onPaneReady, + findNode, } = useVueFlow({ id: props.id, deleteKeyCode: null }); useKeybindings({ @@ -131,11 +133,20 @@ function onSetNodeActive(id: string) { emit('update:node:active', id); } +function clearSelectedNodes() { + removeSelectedNodes(selectedNodes.value); +} + function onSelectNode() { if (!lastSelectedNode.value) return; emit('update:node:selected', lastSelectedNode.value.id); } +function onSelectNodes(ids: string[]) { + clearSelectedNodes(); + addSelectedNodes(ids.map(findNode).filter(isPresent)); +} + function onToggleNodeEnabled(id: string) { emit('update:node:enabled', id); } @@ -288,7 +299,7 @@ function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) { case 'select_all': return addSelectedNodes(graphNodes.value); case 'deselect_all': - return removeSelectedNodes(selectedNodes.value); + return clearSelectedNodes(); case 'duplicate': return emit('duplicate:nodes', nodeIds); case 'toggle_pin': @@ -310,10 +321,12 @@ function onContextMenuAction(action: ContextMenuAction, nodeIds: string[]) { onMounted(() => { props.eventBus.on('fitView', onFitView); + props.eventBus.on('selectNodes', onSelectNodes); }); onUnmounted(() => { props.eventBus.off('fitView', onFitView); + props.eventBus.off('selectNodes', onSelectNodes); }); onPaneReady(async () => { diff --git a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue index 27d9bceb1c905..eb88dfb3a5b06 100644 --- a/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue +++ b/packages/editor-ui/src/components/canvas/elements/handles/render-types/parts/CanvasHandlePlus.vue @@ -3,7 +3,7 @@ import { computed, useCssModule } from 'vue'; const props = withDefaults( defineProps<{ - position: 'top' | 'right' | 'bottom' | 'left'; + position?: 'top' | 'right' | 'bottom' | 'left'; }>(), { position: 'right', diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeStickyNote.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeStickyNote.vue index d740f17e7611a..d290dbe26be70 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeStickyNote.vue +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeStickyNote.vue @@ -7,6 +7,10 @@ import { NodeResizer } from '@vue-flow/node-resizer'; import type { OnResize } from '@vue-flow/node-resizer/dist/types'; import type { XYPosition } from '@vue-flow/core'; +defineOptions({ + inheritAttrs: false, +}); + const emit = defineEmits<{ update: [parameters: Record]; move: [position: XYPosition]; @@ -62,6 +66,7 @@ function onDoubleClick(event: MouseEvent) { @resize="onResize" /> - settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.AdvancedExecutionFilters), +const isAdvancedExecutionFilterEnabled = computed( + () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters], ); const showTags = computed(() => false); diff --git a/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap b/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap new file mode 100644 index 0000000000000..875db0009307d --- /dev/null +++ b/packages/editor-ui/src/composables/__tests__/__snapshots__/useCanvasOperations.spec.ts.snap @@ -0,0 +1,71 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`useCanvasOperations > copyNodes > should copy nodes 1`] = ` +[ + [ + "{ + "nodes": [ + { + "parameters": {}, + "id": "1", + "name": "Node 1", + "type": "type", + "position": [ + 40, + 40 + ], + "typeVersion": 1 + }, + { + "parameters": {}, + "id": "2", + "name": "Node 2", + "type": "type", + "position": [ + 40, + 40 + ], + "typeVersion": 1 + } + ], + "connections": {}, + "pinData": {} +}", + ], +] +`; + +exports[`useCanvasOperations > cutNodes > should copy and delete nodes 1`] = ` +[ + [ + "{ + "nodes": [ + { + "parameters": {}, + "id": "1", + "name": "Node 1", + "type": "type", + "position": [ + 40, + 40 + ], + "typeVersion": 1 + }, + { + "parameters": {}, + "id": "2", + "name": "Node 2", + "type": "type", + "position": [ + 40, + 40 + ], + "typeVersion": 1 + } + ], + "connections": {}, + "pinData": {} +}", + ], +] +`; diff --git a/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts b/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts index cb57fe3c1741c..4efaf81615b93 100644 --- a/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts +++ b/packages/editor-ui/src/composables/__tests__/useCanvasOperations.spec.ts @@ -22,16 +22,22 @@ import { mock } from 'vitest-mock-extended'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useCredentialsStore } from '@/stores/credentials.store'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; +import { telemetry } from '@/plugins/telemetry'; +import { useClipboard } from '@/composables/useClipboard'; -vi.mock('vue-router', async () => { - const actual = await import('vue-router'); - +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal<{}>(); return { ...actual, useRouter: () => ({}), }; }); +vi.mock('@/composables/useClipboard', async () => { + const copySpy = vi.fn(); + return { useClipboard: vi.fn(() => ({ copy: copySpy })) }; +}); + describe('useCanvasOperations', () => { let workflowsStore: ReturnType; let uiStore: ReturnType; @@ -71,6 +77,7 @@ describe('useCanvasOperations', () => { await workflowHelpers.initState(workflow); canvasOperations = useCanvasOperations({ router, lastClickPosition }); + vi.clearAllMocks(); }); describe('addNode', () => { @@ -167,6 +174,20 @@ describe('useCanvasOperations', () => { }); expect(result.credentials).toBeUndefined(); }); + + it('should open NDV when specified', async () => { + nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]); + + await canvasOperations.addNode( + { + type: 'type', + name: 'Test Name', + }, + { openNDV: true }, + ); + + expect(ndvStore.activeNodeName).toBe('Test Name'); + }); }); describe('updateNodePosition', () => { @@ -890,4 +911,71 @@ describe('useCanvasOperations', () => { expect(addConnectionSpy).toHaveBeenCalledWith({ connection }); }); }); + + describe('duplicateNodes', () => { + it('should duplicate nodes', async () => { + nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]); + const telemetrySpy = vi.spyOn(telemetry, 'track'); + + const nodes = buildImportNodes(); + workflowsStore.setNodes(nodes); + + const duplicatedNodeIds = await canvasOperations.duplicateNodes(['1', '2']); + expect(duplicatedNodeIds.length).toBe(2); + expect(duplicatedNodeIds).not.toContain('1'); + expect(duplicatedNodeIds).not.toContain('2'); + expect(workflowsStore.workflow.nodes.length).toEqual(4); + expect(telemetrySpy).toHaveBeenCalledWith( + 'User duplicated nodes', + expect.objectContaining({ node_graph_string: expect.any(String), workflow_id: 'test' }), + ); + }); + }); + + describe('copyNodes', () => { + it('should copy nodes', async () => { + nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]); + const telemetrySpy = vi.spyOn(telemetry, 'track'); + const nodes = buildImportNodes(); + workflowsStore.setNodes(nodes); + + await canvasOperations.copyNodes(['1', '2']); + expect(useClipboard().copy).toHaveBeenCalledTimes(1); + expect(vi.mocked(useClipboard().copy).mock.calls).toMatchSnapshot(); + expect(telemetrySpy).toHaveBeenCalledWith( + 'User copied nodes', + expect.objectContaining({ node_types: ['type', 'type'], workflow_id: 'test' }), + ); + }); + }); + + describe('cutNodes', () => { + it('should copy and delete nodes', async () => { + nodeTypesStore.setNodeTypes([mockNodeTypeDescription({ name: 'type' })]); + const telemetrySpy = vi.spyOn(telemetry, 'track'); + const nodes = buildImportNodes(); + workflowsStore.setNodes(nodes); + + await canvasOperations.cutNodes(['1', '2']); + expect(useClipboard().copy).toHaveBeenCalledTimes(1); + expect(vi.mocked(useClipboard().copy).mock.calls).toMatchSnapshot(); + expect(telemetrySpy).toHaveBeenCalledWith( + 'User copied nodes', + expect.objectContaining({ node_types: ['type', 'type'], workflow_id: 'test' }), + ); + expect(workflowsStore.getNodes().length).toBe(0); + }); + }); }); + +function buildImportNodes() { + return [ + mockNode({ id: '1', name: 'Node 1', type: 'type' }), + mockNode({ id: '2', name: 'Node 2', type: 'type' }), + ].map((node) => { + // Setting position in mockNode will wrap it in a Proxy + // This causes deepCopy to remove position -> set position after instead + node.position = [40, 40]; + return node; + }); +} diff --git a/packages/editor-ui/src/composables/useCanvasOperations.ts b/packages/editor-ui/src/composables/useCanvasOperations.ts index be1e021c8e588..f45b575997d74 100644 --- a/packages/editor-ui/src/composables/useCanvasOperations.ts +++ b/packages/editor-ui/src/composables/useCanvasOperations.ts @@ -7,6 +7,8 @@ import type { AddedNodesAndConnections, INodeUi, ITag, + IUsedCredential, + IWorkflowData, IWorkflowDataUpdate, IWorkflowDb, XYPosition, @@ -20,6 +22,7 @@ import { useTelemetry } from '@/composables/useTelemetry'; import { useToast } from '@/composables/useToast'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { + EnterpriseEditionFeature, FORM_TRIGGER_NODE_TYPE, QUICKSTART_NOTE_NAME, STICKY_NODE_TYPE, @@ -56,7 +59,6 @@ import { } from '@/utils/canvasUtilsV2'; import * as NodeViewUtils from '@/utils/nodeViewUtils'; import { isValidNodeConnectionType } from '@/utils/typeGuards'; -import { isPresent } from '@/utils/typesUtils'; import type { Connection } from '@vue-flow/core'; import type { ConnectionTypes, @@ -64,20 +66,24 @@ import type { IConnections, INode, INodeConnections, + INodeCredentials, INodeInputConfiguration, INodeOutputConfiguration, INodeTypeDescription, INodeTypeNameVersion, + IPinData, ITelemetryTrackProperties, IWorkflowBase, NodeParameterValueType, Workflow, } from 'n8n-workflow'; -import { NodeConnectionType, NodeHelpers, TelemetryHelpers } from 'n8n-workflow'; +import { deepCopy, NodeConnectionType, NodeHelpers, TelemetryHelpers } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import type { Ref } from 'vue'; -import { computed } from 'vue'; +import { computed, nextTick } from 'vue'; import type { useRouter } from 'vue-router'; +import { useClipboard } from '@/composables/useClipboard'; +import { isPresent } from '../utils/typesUtils'; type AddNodeData = Partial & { type: string; @@ -116,6 +122,7 @@ export function useCanvasOperations({ const nodeHelpers = useNodeHelpers(); const telemetry = useTelemetry(); const externalHooks = useExternalHooks(); + const clipboard = useClipboard(); const editableWorkflow = computed(() => workflowsStore.workflow); const editableWorkflowObject = computed(() => workflowsStore.getCurrentWorkflow()); @@ -296,14 +303,14 @@ export function useCanvasOperations({ ids: string[], { trackHistory = true }: { trackHistory?: boolean } = {}, ) { - const nodes = ids.map((id) => workflowsStore.getNodeById(id)).filter(isPresent); + const nodes = workflowsStore.getNodesByIds(ids); nodeHelpers.disableNodes(nodes, trackHistory); } function toggleNodesPinned(ids: string[], source: PinDataSource) { historyStore.startRecordingUndo(); - const nodes = ids.map((id) => workflowsStore.getNodeById(id)).filter(isPresent); + const nodes = workflowsStore.getNodesByIds(ids); const nextStatePinned = nodes.some((node) => !workflowsStore.pinDataByNodeName(node.name)); for (const node of nodes) { @@ -429,6 +436,12 @@ export function useCanvasOperations({ workflowsStore.setNodePristine(nodeData.name, true); uiStore.stateIsDirty = true; + if (options.openNDV) { + void nextTick(() => { + ndvStore.setActiveNodeName(nodeData.name); + }); + } + return nodeData; } @@ -1162,7 +1175,7 @@ export function useCanvasOperations({ // Get only the connections of the nodes that get created const newConnections: IConnections = {}; - const currentConnections = data.connections!; + const currentConnections = data.connections ?? {}; const createNodeNames = createNodes.map((node) => node.name); let sourceNode, type, sourceIndex, connectionIndex, connectionData; for (sourceNode of Object.keys(currentConnections)) { @@ -1265,10 +1278,10 @@ export function useCanvasOperations({ workflowData: IWorkflowDataUpdate, source: string, importTags = true, - ): Promise { + ): Promise { // If it is JSON check if it looks on the first look like data we can use if (!workflowData.hasOwnProperty('nodes') || !workflowData.hasOwnProperty('connections')) { - return; + return {}; } try { @@ -1340,10 +1353,6 @@ export function useCanvasOperations({ }); } - // By default we automatically deselect all the currently - // selected nodes and select the new ones - // this.deselectAllNodes(); - // Fix the node position as it could be totally offscreen // and the pasted nodes would so not be directly visible to // the user @@ -1354,17 +1363,14 @@ export function useCanvasOperations({ await addImportedNodesToWorkflow(workflowData); - // setTimeout(() => { - // (data?.nodes ?? []).forEach((node: INodeUi) => { - // this.nodeSelectedByName(node.name); - // }); - // }); - if (importTags && settingsStore.areTagsEnabled && Array.isArray(workflowData.tags)) { await importWorkflowTags(workflowData); } + + return workflowData; } catch (error) { toast.showError(error, i18n.baseText('nodeView.showError.importWorkflowData.title')); + return {}; } } @@ -1377,9 +1383,9 @@ export function useCanvasOperations({ const creatingTagPromises: Array> = []; for (const tag of notFound) { - const creationPromise = tagsStore.create(tag.name).then((tag: ITag) => { - allTags.push(tag); - return tag; + const creationPromise = tagsStore.create(tag.name).then((newTag: ITag) => { + allTags.push(newTag); + return newTag; }); creatingTagPromises.push(creationPromise); @@ -1388,7 +1394,7 @@ export function useCanvasOperations({ await Promise.all(creatingTagPromises); const tagIds = workflowTags.reduce((accu: string[], imported: ITag) => { - const tag = allTags.find((tag) => tag.name === imported.name); + const tag = allTags.find((t) => t.name === imported.name); if (tag) { accu.push(tag.id); } @@ -1418,6 +1424,121 @@ export function useCanvasOperations({ return workflowData; } + function getNodesToSave(nodes: INode[]): IWorkflowData { + const data = { + nodes: [] as INodeUi[], + connections: {} as IConnections, + pinData: {} as IPinData, + } satisfies IWorkflowData; + + const exportedNodeNames = new Set(); + + for (const node of nodes) { + const nodeSaveData = workflowHelpers.getNodeDataToSave(node); + const pinDataForNode = workflowsStore.pinDataByNodeName(node.name); + + if (pinDataForNode) { + data.pinData[node.name] = pinDataForNode; + } + + if ( + nodeSaveData.credentials && + settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing) + ) { + nodeSaveData.credentials = filterAllowedCredentials( + nodeSaveData.credentials, + workflowsStore.usedCredentials, + ); + } + + data.nodes.push(nodeSaveData); + exportedNodeNames.add(node.name); + } + + data.connections = getConnectionsForNodes(data.nodes, exportedNodeNames); + + workflowHelpers.removeForeignCredentialsFromWorkflow(data, credentialsStore.allCredentials); + + return data; + } + + function filterAllowedCredentials( + credentials: INodeCredentials, + usedCredentials: Record, + ): INodeCredentials { + return Object.fromEntries( + Object.entries(credentials).filter(([, credential]) => { + return ( + credential.id && + (!usedCredentials[credential.id] || usedCredentials[credential.id]?.currentUserHasAccess) + ); + }), + ); + } + + function getConnectionsForNodes( + nodes: INodeUi[], + includeNodeNames: Set, + ): Record { + const connections: Record = {}; + + for (const node of nodes) { + const outgoingConnections = workflowsStore.outgoingConnectionsByNodeName(node.name); + if (!Object.keys(outgoingConnections).length) continue; + + const filteredConnections = filterConnectionsByNodes(outgoingConnections, includeNodeNames); + if (Object.keys(filteredConnections).length) { + connections[node.name] = filteredConnections; + } + } + + return connections; + } + + function filterConnectionsByNodes( + connections: Record, + includeNodeNames: Set, + ): INodeConnections { + const filteredConnections: INodeConnections = {}; + + for (const [type, typeConnections] of Object.entries(connections)) { + const validConnections = typeConnections + .map((sourceConnections) => + sourceConnections.filter((connection) => includeNodeNames.has(connection.node)), + ) + .filter((sourceConnections) => sourceConnections.length > 0); + + if (validConnections.length) { + filteredConnections[type] = validConnections; + } + } + + return filteredConnections; + } + + async function duplicateNodes(ids: string[]) { + const workflowData = deepCopy(getNodesToSave(workflowsStore.getNodesByIds(ids))); + const result = await importWorkflowData(workflowData, 'duplicate', false); + + return result.nodes?.map((node) => node.id).filter(isPresent) ?? []; + } + + async function copyNodes(ids: string[]) { + const workflowData = deepCopy(getNodesToSave(workflowsStore.getNodesByIds(ids))); + + await clipboard.copy(JSON.stringify(workflowData, null, 2)); + + telemetry.track('User copied nodes', { + node_types: workflowData.nodes.map((node) => node.type), + workflow_id: workflowsStore.workflowId, + }); + } + + async function cutNodes(ids: string[]) { + await copyNodes(ids); + deleteNodes(ids); + } + return { editableWorkflow, editableWorkflowObject, @@ -1435,6 +1556,9 @@ export function useCanvasOperations({ revertRenameNode, deleteNode, deleteNodes, + copyNodes, + cutNodes, + duplicateNodes, revertDeleteNode, addConnections, createConnection, diff --git a/packages/editor-ui/src/composables/useDebugInfo.ts b/packages/editor-ui/src/composables/useDebugInfo.ts index 89acc06c61bbd..ed573066a7543 100644 --- a/packages/editor-ui/src/composables/useDebugInfo.ts +++ b/packages/editor-ui/src/composables/useDebugInfo.ts @@ -1,3 +1,4 @@ +import { useRootStore } from '@/stores/root.store'; import { useSettingsStore } from '@/stores/settings.store'; import type { WorkflowSettings } from 'n8n-workflow'; @@ -38,60 +39,62 @@ type DebugInfo = { }; export function useDebugInfo() { - const store = useSettingsStore(); + const settingsStore = useSettingsStore(); + const rootStore = useRootStore(); const coreInfo = () => { return { - n8nVersion: store.versionCli, + n8nVersion: rootStore.versionCli, platform: - store.isDocker && store.deploymentType === 'cloud' + settingsStore.isDocker && settingsStore.deploymentType === 'cloud' ? 'docker (cloud)' - : store.isDocker + : settingsStore.isDocker ? 'docker (self-hosted)' : 'npm', - nodeJsVersion: store.nodeJsVersion, + nodeJsVersion: settingsStore.nodeJsVersion, database: - store.databaseType === 'postgresdb' + settingsStore.databaseType === 'postgresdb' ? 'postgres' - : store.databaseType === 'mysqldb' + : settingsStore.databaseType === 'mysqldb' ? 'mysql' - : store.databaseType, - executionMode: store.isQueueModeEnabled ? 'scaling' : 'regular', - concurrency: store.settings.concurrency, - license: store.isCommunityPlan + : settingsStore.databaseType, + executionMode: settingsStore.isQueueModeEnabled ? 'scaling' : 'regular', + concurrency: settingsStore.settings.concurrency, + license: settingsStore.isCommunityPlan ? 'community' - : store.settings.license.environment === 'production' + : settingsStore.settings.license.environment === 'production' ? 'enterprise (production)' : 'enterprise (sandbox)', - consumerId: store.consumerId, + consumerId: settingsStore.consumerId, } as const; }; const storageInfo = (): DebugInfo['storage'] => { return { - success: store.saveDataSuccessExecution, - error: store.saveDataErrorExecution, - progress: store.saveDataProgressExecution, - manual: store.saveManualExecutions, - binaryMode: store.binaryDataMode === 'default' ? 'memory' : store.binaryDataMode, + success: settingsStore.saveDataSuccessExecution, + error: settingsStore.saveDataErrorExecution, + progress: settingsStore.saveDataProgressExecution, + manual: settingsStore.saveManualExecutions, + binaryMode: + settingsStore.binaryDataMode === 'default' ? 'memory' : settingsStore.binaryDataMode, }; }; const pruningInfo = () => { - if (!store.pruning.isEnabled) return { enabled: false } as const; + if (!settingsStore.pruning.isEnabled) return { enabled: false } as const; return { enabled: true, - maxAge: `${store.pruning.maxAge} hours`, - maxCount: `${store.pruning.maxCount} executions`, + maxAge: `${settingsStore.pruning.maxAge} hours`, + maxCount: `${settingsStore.pruning.maxCount} executions`, } as const; }; const securityInfo = () => { const info: DebugInfo['security'] = {}; - if (!store.security.blockFileAccessToN8nFiles) info.blockFileAccessToN8nFiles = false; - if (!store.security.secureCookie) info.secureCookie = false; + if (!settingsStore.security.blockFileAccessToN8nFiles) info.blockFileAccessToN8nFiles = false; + if (!settingsStore.security.secureCookie) info.secureCookie = false; if (Object.keys(info).length === 0) return; diff --git a/packages/editor-ui/src/composables/useExecutionDebugging.ts b/packages/editor-ui/src/composables/useExecutionDebugging.ts index b137468ad664c..05d7144fef369 100644 --- a/packages/editor-ui/src/composables/useExecutionDebugging.ts +++ b/packages/editor-ui/src/composables/useExecutionDebugging.ts @@ -28,8 +28,8 @@ export const useExecutionDebugging = () => { const settingsStore = useSettingsStore(); const uiStore = useUIStore(); - const isDebugEnabled = computed(() => - settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.DebugInEditor), + const isDebugEnabled = computed( + () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.DebugInEditor], ); const applyExecutionData = async (executionId: string): Promise => { diff --git a/packages/editor-ui/src/composables/useExpressionEditor.ts b/packages/editor-ui/src/composables/useExpressionEditor.ts index da66887319926..416080a8ad189 100644 --- a/packages/editor-ui/src/composables/useExpressionEditor.ts +++ b/packages/editor-ui/src/composables/useExpressionEditor.ts @@ -293,14 +293,13 @@ export const useExpressionEditor = ({ // e.g. credential modal result.resolved = Expression.resolveWithoutWorkflow(resolvable, toValue(additionalData)); } else { - let opts; + let opts: Record = { additionalKeys: toValue(additionalData) }; if (ndvStore.isInputParentOfActiveNode) { opts = { targetItem: target ?? undefined, inputNodeName: ndvStore.ndvInputNodeName, inputRunIndex: ndvStore.ndvInputRunIndex, inputBranchIndex: ndvStore.ndvInputBranchIndex, - additionalKeys: toValue(additionalData), }; } result.resolved = workflowHelpers.resolveExpression('=' + resolvable, undefined, opts); diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 683b4e9cf64cc..ea3e5a9662fce 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -403,6 +403,7 @@ export const MODAL_CLOSE = 'close'; export const VALID_EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; +export const VALID_WORKFLOW_IMPORT_URL_REGEX = /^http[s]?:\/\/.*\.json$/i; export const LOCAL_STORAGE_ACTIVATION_FLAG = 'N8N_HIDE_ACTIVATION_ALERT'; export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG = 'N8N_PIN_DATA_DISCOVERY_NDV'; export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS'; diff --git a/packages/editor-ui/src/stores/__tests__/posthog.test.ts b/packages/editor-ui/src/stores/__tests__/posthog.test.ts index f06e053935853..75f0c40b2906c 100644 --- a/packages/editor-ui/src/stores/__tests__/posthog.test.ts +++ b/packages/editor-ui/src/stores/__tests__/posthog.test.ts @@ -44,7 +44,7 @@ function setCurrentUser() { } function resetStores() { - useSettingsStore().$reset(); + useSettingsStore().reset(); useUsersStore().reset(); } diff --git a/packages/editor-ui/src/stores/__tests__/ui.test.ts b/packages/editor-ui/src/stores/__tests__/ui.test.ts index 5ca85a010bfd8..ace9ab3bca474 100644 --- a/packages/editor-ui/src/stores/__tests__/ui.test.ts +++ b/packages/editor-ui/src/stores/__tests__/ui.test.ts @@ -3,9 +3,9 @@ import { generateUpgradeLinkUrl, useUIStore } from '@/stores/ui.store'; import { useSettingsStore } from '@/stores/settings.store'; import { useUsersStore } from '@/stores/users.store'; import { merge } from 'lodash-es'; -import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { useCloudPlanStore } from '@/stores/cloudPlan.store'; import * as cloudPlanApi from '@/api/cloudPlans'; +import { defaultSettings } from '../../__tests__/defaults'; import { getTrialExpiredUserResponse, getTrialingUserResponse, @@ -34,7 +34,7 @@ function setUser(role: IRole) { function setupOwnerAndCloudDeployment() { setUser(ROLE.Owner); settingsStore.setSettings( - merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, { + merge({}, defaultSettings, { n8nMetadata: { userId: '1', }, @@ -103,7 +103,7 @@ describe('UI store', () => { setUser(role as IRole); settingsStore.setSettings( - merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, { + merge({}, defaultSettings, { deployment: { type, }, @@ -123,7 +123,7 @@ describe('UI store', () => { it('should add non-production license banner to stack based on enterprise settings', () => { settingsStore.setSettings( - merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, { + merge({}, defaultSettings, { enterprise: { showNonProdBanner: true, }, @@ -134,7 +134,7 @@ describe('UI store', () => { it("should add V1 banner to stack if it's not dismissed", () => { settingsStore.setSettings( - merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, { + merge({}, defaultSettings, { versionCli: '1.0.0', }), ); @@ -143,7 +143,7 @@ describe('UI store', () => { it("should not add V1 banner to stack if it's dismissed", () => { settingsStore.setSettings( - merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, { + merge({}, defaultSettings, { versionCli: '1.0.0', banners: { dismissed: ['V1'], diff --git a/packages/editor-ui/src/stores/auditLogs.store.ts b/packages/editor-ui/src/stores/auditLogs.store.ts index 12c42dcfd079b..fc121a00ff905 100644 --- a/packages/editor-ui/src/stores/auditLogs.store.ts +++ b/packages/editor-ui/src/stores/auditLogs.store.ts @@ -6,8 +6,8 @@ import { useSettingsStore } from '@/stores/settings.store'; export const useAuditLogsStore = defineStore('auditLogs', () => { const settingsStore = useSettingsStore(); - const isEnterpriseAuditLogsFeatureEnabled = computed(() => - settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.AuditLogs), + const isEnterpriseAuditLogsFeatureEnabled = computed( + () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AuditLogs], ); return { diff --git a/packages/editor-ui/src/stores/credentials.store.ts b/packages/editor-ui/src/stores/credentials.store.ts index 31fe3901a80fe..e6759466145c9 100644 --- a/packages/editor-ui/src/stores/credentials.store.ts +++ b/packages/editor-ui/src/stores/credentials.store.ts @@ -314,7 +314,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => { projectId, ); - if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) { + if (settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]) { upsertCredential(credential); if (data.sharedWithProjects) { await setCredentialSharedWith({ @@ -389,7 +389,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => { sharedWithProjects: ProjectSharingData[]; credentialId: string; }): Promise => { - if (useSettingsStore().isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) { + if (useSettingsStore().isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]) { await credentialsEeApi.setCredentialSharedWith( useRootStore().restApiContext, payload.credentialId, diff --git a/packages/editor-ui/src/stores/externalSecrets.ee.store.ts b/packages/editor-ui/src/stores/externalSecrets.ee.store.ts index 92071fa30c29d..8dfff7d693162 100644 --- a/packages/editor-ui/src/stores/externalSecrets.ee.store.ts +++ b/packages/editor-ui/src/stores/externalSecrets.ee.store.ts @@ -19,8 +19,8 @@ export const useExternalSecretsStore = defineStore('externalSecrets', () => { connectionState: {} as Record, }); - const isEnterpriseExternalSecretsEnabled = computed(() => - settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.ExternalSecrets), + const isEnterpriseExternalSecretsEnabled = computed( + () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.ExternalSecrets], ); const secrets = computed(() => state.secrets); diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index 53bbab6a08e96..1739af1bcb31e 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -1,27 +1,15 @@ -import { createApiKey, deleteApiKey, getApiKey } from '@/api/api-keys'; -import { - getLdapConfig, - getLdapSynchronizations, - runLdapSync, - testLdapConnection, - updateLdapConfig, -} from '@/api/ldap'; -import { getSettings, submitContactInfo } from '@/api/settings'; +import * as publicApiApi from '@/api/api-keys'; +import * as ldapApi from '@/api/ldap'; +import * as settingsApi from '@/api/settings'; import { testHealthEndpoint } from '@/api/templates'; -import type { - EnterpriseEditionFeatureValue, - ILdapConfig, - IN8nPromptResponse, - ISettingsState, -} from '@/Interface'; +import type { ILdapConfig } from '@/Interface'; import { STORES, INSECURE_CONNECTION_WARNING } from '@/constants'; import { UserManagementAuthenticationMethod } from '@/Interface'; import type { IDataObject, - LogLevel, IN8nUISettings, - ITelemetrySettings, WorkflowSettings, + IUserManagementSettings, } from 'n8n-workflow'; import { ExpressionEvaluatorProxy } from 'n8n-workflow'; import { defineStore } from 'pinia'; @@ -33,382 +21,422 @@ import { makeRestApiRequest } from '@/utils/apiUtils'; import { useTitleChange } from '@/composables/useTitleChange'; import { useToast } from '@/composables/useToast'; import { i18n } from '@/plugins/i18n'; +import { computed, ref } from 'vue'; -export const useSettingsStore = defineStore(STORES.SETTINGS, { - state: (): ISettingsState => ({ - initialized: false, - settings: {} as IN8nUISettings, - userManagement: { - quota: -1, - showSetupOnFirstLoad: false, - smtpSetup: false, - authenticationMethod: UserManagementAuthenticationMethod.Email, - }, - templatesEndpointHealthy: false, - api: { +export const useSettingsStore = defineStore(STORES.SETTINGS, () => { + const initialized = ref(false); + const settings = ref({} as IN8nUISettings); + const userManagement = ref({ + quota: -1, + showSetupOnFirstLoad: false, + smtpSetup: false, + authenticationMethod: UserManagementAuthenticationMethod.Email, + }); + const templatesEndpointHealthy = ref(false); + const api = ref({ + enabled: false, + latestVersion: 0, + path: '/', + swaggerUi: { enabled: false, - latestVersion: 0, - path: '/', - swaggerUi: { + }, + }); + const ldap = ref({ loginLabel: '', loginEnabled: false }); + const saml = ref({ loginLabel: '', loginEnabled: false }); + const mfa = ref({ enabled: false }); + const saveDataErrorExecution = ref('all'); + const saveDataSuccessExecution = ref('all'); + const saveManualExecutions = ref(false); + const saveDataProgressExecution = ref(false); + + const isDocker = computed(() => settings.value?.isDocker ?? false); + + const databaseType = computed(() => settings.value?.databaseType); + + const planName = computed(() => settings.value?.license.planName ?? 'Community'); + + const consumerId = computed(() => settings.value?.license.consumerId); + + const binaryDataMode = computed(() => settings.value?.binaryDataMode); + + const pruning = computed(() => settings.value?.pruning); + + const security = computed(() => ({ + blockFileAccessToN8nFiles: settings.value.security.blockFileAccessToN8nFiles, + secureCookie: settings.value.authCookie.secure, + })); + + const isEnterpriseFeatureEnabled = computed(() => settings.value.enterprise); + + const nodeJsVersion = computed(() => settings.value.nodeJsVersion); + + const concurrency = computed(() => settings.value.concurrency); + + const isPublicApiEnabled = computed(() => api.value.enabled); + + const isSwaggerUIEnabled = computed(() => api.value.swaggerUi.enabled); + + const isPreviewMode = computed(() => settings.value.previewMode); + + const publicApiLatestVersion = computed(() => api.value.latestVersion); + + const publicApiPath = computed(() => api.value.path); + + const isLdapLoginEnabled = computed(() => ldap.value.loginEnabled); + + const ldapLoginLabel = computed(() => ldap.value.loginLabel); + + const isSamlLoginEnabled = computed(() => saml.value.loginEnabled); + + const showSetupPage = computed(() => userManagement.value.showSetupOnFirstLoad); + + const deploymentType = computed(() => settings.value.deployment?.type || 'default'); + + const isDesktopDeployment = computed(() => + settings.value.deployment?.type.startsWith('desktop_'), + ); + + const isCloudDeployment = computed(() => settings.value.deployment?.type === 'cloud'); + + const isSmtpSetup = computed(() => userManagement.value.smtpSetup); + + const isPersonalizationSurveyEnabled = computed( + () => settings.value.telemetry?.enabled && settings.value.personalizationSurveyEnabled, + ); + + const telemetry = computed(() => settings.value.telemetry); + + const logLevel = computed(() => settings.value.logLevel); + + const isTelemetryEnabled = computed( + () => settings.value.telemetry && settings.value.telemetry.enabled, + ); + + const isMfaFeatureEnabled = computed(() => mfa.value.enabled); + + const areTagsEnabled = computed(() => + settings.value.workflowTagsDisabled !== undefined ? !settings.value.workflowTagsDisabled : true, + ); + + const isHiringBannerEnabled = computed(() => settings.value.hiringBannerEnabled); + + const isTemplatesEnabled = computed(() => + Boolean(settings.value.templates && settings.value.templates.enabled), + ); + + const isTemplatesEndpointReachable = computed(() => templatesEndpointHealthy.value); + + const templatesHost = computed(() => settings.value.templates.host); + + const pushBackend = computed(() => settings.value.pushBackend); + + const isCommunityNodesFeatureEnabled = computed(() => settings.value.communityNodesEnabled); + + const isNpmAvailable = computed(() => settings.value.isNpmAvailable); + + const allowedModules = computed(() => settings.value.allowedModules); + + const isQueueModeEnabled = computed(() => settings.value.executionMode === 'queue'); + + const isWorkerViewAvailable = computed(() => !!settings.value.enterprise?.workerView); + + const workflowCallerPolicyDefaultOption = computed( + () => settings.value.workflowCallerPolicyDefaultOption, + ); + + const isDefaultAuthenticationSaml = computed( + () => userManagement.value.authenticationMethod === UserManagementAuthenticationMethod.Saml, + ); + + const permanentlyDismissedBanners = computed(() => settings.value.banners?.dismissed ?? []); + + const isBelowUserQuota = computed( + () => + userManagement.value.quota === -1 || + userManagement.value.quota > useUsersStore().allUsers.length, + ); + + const isCommunityPlan = computed(() => planName.value.toLowerCase() === 'community'); + + const isDevRelease = computed(() => settings.value.releaseChannel === 'dev'); + + const setSettings = (newSettings: IN8nUISettings) => { + settings.value = newSettings; + userManagement.value = newSettings.userManagement; + if (userManagement.value) { + userManagement.value.showSetupOnFirstLoad = + !!settings.value.userManagement.showSetupOnFirstLoad; + } + api.value = settings.value.publicApi; + if (settings.value.sso?.ldap) { + ldap.value.loginEnabled = settings.value.sso.ldap.loginEnabled; + ldap.value.loginLabel = settings.value.sso.ldap.loginLabel; + } + if (settings.value.sso?.saml) { + saml.value.loginEnabled = settings.value.sso.saml.loginEnabled; + saml.value.loginLabel = settings.value.sso.saml.loginLabel; + } + + mfa.value.enabled = settings.value.mfa?.enabled; + + if (settings.value.enterprise?.showNonProdBanner) { + useUIStore().pushBannerToStack('NON_PRODUCTION_LICENSE'); + } + + if (settings.value.versionCli) { + useRootStore().setVersionCli(settings.value.versionCli); + } + + if ( + settings.value.authCookie.secure && + location.protocol === 'http:' && + !['localhost', '127.0.0.1'].includes(location.hostname) + ) { + document.write(INSECURE_CONNECTION_WARNING); + return; + } + + const isV1BannerDismissedPermanently = (settings.value.banners?.dismissed || []).includes('V1'); + if (!isV1BannerDismissedPermanently && settings.value.versionCli.startsWith('1.')) { + useUIStore().pushBannerToStack('V1'); + } + }; + + const setAllowedModules = (allowedModules: IN8nUISettings['allowedModules']) => { + settings.value.allowedModules = allowedModules; + }; + + const setSaveDataErrorExecution = (newValue: WorkflowSettings.SaveDataExecution) => { + saveDataErrorExecution.value = newValue; + }; + + const setSaveDataSuccessExecution = (newValue: WorkflowSettings.SaveDataExecution) => { + saveDataSuccessExecution.value = newValue; + }; + + const setSaveManualExecutions = (newValue: boolean) => { + saveManualExecutions.value = newValue; + }; + + const setSaveDataProgressExecution = (newValue: boolean) => { + saveDataProgressExecution.value = newValue; + }; + + const getSettings = async () => { + const rootStore = useRootStore(); + const fetchedSettings = await settingsApi.getSettings(rootStore.restApiContext); + setSettings(fetchedSettings); + settings.value.communityNodesEnabled = fetchedSettings.communityNodesEnabled; + setAllowedModules(fetchedSettings.allowedModules); + setSaveDataErrorExecution(fetchedSettings.saveDataErrorExecution); + setSaveDataSuccessExecution(fetchedSettings.saveDataSuccessExecution); + setSaveDataProgressExecution(fetchedSettings.saveExecutionProgress); + setSaveManualExecutions(fetchedSettings.saveManualExecutions); + + rootStore.setUrlBaseWebhook(fetchedSettings.urlBaseWebhook); + rootStore.setUrlBaseEditor(fetchedSettings.urlBaseEditor); + rootStore.setEndpointForm(fetchedSettings.endpointForm); + rootStore.setEndpointFormTest(fetchedSettings.endpointFormTest); + rootStore.setEndpointFormWaiting(fetchedSettings.endpointFormWaiting); + rootStore.setEndpointWebhook(fetchedSettings.endpointWebhook); + rootStore.setEndpointWebhookTest(fetchedSettings.endpointWebhookTest); + rootStore.setTimezone(fetchedSettings.timezone); + rootStore.setExecutionTimeout(fetchedSettings.executionTimeout); + rootStore.setMaxExecutionTimeout(fetchedSettings.maxExecutionTimeout); + rootStore.setInstanceId(fetchedSettings.instanceId); + rootStore.setOauthCallbackUrls(fetchedSettings.oauthCallbackUrls); + rootStore.setN8nMetadata(fetchedSettings.n8nMetadata || {}); + rootStore.setDefaultLocale(fetchedSettings.defaultLocale); + rootStore.setIsNpmAvailable(fetchedSettings.isNpmAvailable); + rootStore.setBinaryDataMode(fetchedSettings.binaryDataMode); + useVersionsStore().setVersionNotificationSettings(fetchedSettings.versionNotifications); + }; + + const initialize = async () => { + if (initialized.value) { + return; + } + + const { showToast } = useToast(); + try { + await getSettings(); + + ExpressionEvaluatorProxy.setEvaluator(settings.value.expressions.evaluator); + + // Re-compute title since settings are now available + useTitleChange().titleReset(); + + initialized.value = true; + } catch (e) { + showToast({ + title: i18n.baseText('startupError'), + message: i18n.baseText('startupError.message'), + type: 'error', + duration: 0, + dangerouslyUseHTMLString: true, + }); + + throw e; + } + }; + + const stopShowingSetupPage = () => { + userManagement.value.showSetupOnFirstLoad = false; + }; + + const disableTemplates = () => { + settings.value = { + ...settings.value, + templates: { + ...settings.value.templates, enabled: false, }, - }, - ldap: { - loginLabel: '', - loginEnabled: false, - }, - saml: { - loginLabel: '', - loginEnabled: false, - }, - mfa: { - enabled: false, - }, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - saveManualExecutions: false, - saveDataProgressExecution: false, - }), - getters: { - isDocker(): boolean { - return this.settings.isDocker; - }, - databaseType(): 'sqlite' | 'mariadb' | 'mysqldb' | 'postgresdb' { - return this.settings.databaseType; - }, - planName(): string { - return this.settings.license?.planName ?? 'Community'; - }, - isCommunityPlan(): boolean { - return this.planName.toLowerCase() === 'community'; - }, - consumerId(): string { - return this.settings.license?.consumerId ?? 'unknown'; - }, - binaryDataMode(): 'default' | 'filesystem' | 's3' { - return this.settings.binaryDataMode; - }, - pruning(): { isEnabled: boolean; maxAge: number; maxCount: number } { - return this.settings.pruning; - }, - security(): { - blockFileAccessToN8nFiles: boolean; - secureCookie: boolean; - } { - return { - blockFileAccessToN8nFiles: this.settings.security.blockFileAccessToN8nFiles, - secureCookie: this.settings.authCookie.secure, - }; - }, - isEnterpriseFeatureEnabled() { - return (feature: EnterpriseEditionFeatureValue): boolean => - Boolean(this.settings.enterprise?.[feature]); - }, + }; + }; - versionCli(): string { - return this.settings.versionCli; - }, - nodeJsVersion(): string { - return this.settings.nodeJsVersion; - }, - concurrency(): number { - return this.settings.concurrency; - }, - isPublicApiEnabled(): boolean { - return this.api.enabled; - }, - isSwaggerUIEnabled(): boolean { - return this.api.swaggerUi.enabled; - }, - isPreviewMode(): boolean { - return this.settings.previewMode; - }, - publicApiLatestVersion(): number { - return this.api.latestVersion; - }, - publicApiPath(): string { - return this.api.path; - }, - isLdapLoginEnabled(): boolean { - return this.ldap.loginEnabled; - }, - ldapLoginLabel(): string { - return this.ldap.loginLabel; - }, - isSamlLoginEnabled(): boolean { - return this.saml.loginEnabled; - }, - samlLoginLabel(): string { - return this.saml.loginLabel; - }, - showSetupPage(): boolean { - return this.userManagement.showSetupOnFirstLoad === true; - }, - deploymentType(): string { - return this.settings.deployment?.type || 'default'; - }, - isDesktopDeployment(): boolean { - if (!this.settings.deployment) { - return false; - } - return this.settings.deployment?.type.startsWith('desktop_'); - }, - isCloudDeployment(): boolean { - return this.settings.deployment?.type === 'cloud'; - }, - isSmtpSetup(): boolean { - return this.userManagement.smtpSetup; - }, - isPersonalizationSurveyEnabled(): boolean { - return ( - this.settings.telemetry && - this.settings.telemetry.enabled && - this.settings.personalizationSurveyEnabled + const submitContactInfo = async (email: string) => { + try { + const usersStore = useUsersStore(); + return await settingsApi.submitContactInfo( + settings.value.instanceId, + usersStore.currentUserId || '', + email, ); - }, - telemetry(): ITelemetrySettings { - return this.settings.telemetry; - }, - logLevel(): LogLevel { - return this.settings.logLevel; - }, - isTelemetryEnabled(): boolean { - return this.settings.telemetry && this.settings.telemetry.enabled; - }, - isMfaFeatureEnabled(): boolean { - return this.settings?.mfa?.enabled; - }, - areTagsEnabled(): boolean { - return this.settings.workflowTagsDisabled !== undefined - ? !this.settings.workflowTagsDisabled - : true; - }, - isHiringBannerEnabled(): boolean { - return this.settings.hiringBannerEnabled; - }, - isTemplatesEnabled(): boolean { - return Boolean(this.settings.templates && this.settings.templates.enabled); - }, - isTemplatesEndpointReachable(): boolean { - return this.templatesEndpointHealthy; - }, - templatesHost(): string { - return this.settings.templates.host; - }, - pushBackend(): IN8nUISettings['pushBackend'] { - return this.settings.pushBackend; - }, - isCommunityNodesFeatureEnabled(): boolean { - return this.settings.communityNodesEnabled; - }, - isNpmAvailable(): boolean { - return this.settings.isNpmAvailable; - }, - allowedModules(): { builtIn?: string[]; external?: string[] } { - return this.settings.allowedModules; - }, - isQueueModeEnabled(): boolean { - return this.settings.executionMode === 'queue'; - }, - isWorkerViewAvailable(): boolean { - return !!this.settings.enterprise?.workerView; - }, - workflowCallerPolicyDefaultOption(): WorkflowSettings.CallerPolicy { - return this.settings.workflowCallerPolicyDefaultOption; - }, - isDefaultAuthenticationSaml(): boolean { - return this.userManagement.authenticationMethod === UserManagementAuthenticationMethod.Saml; - }, - permanentlyDismissedBanners(): string[] { - return this.settings.banners?.dismissed ?? []; - }, - isBelowUserQuota(): boolean { - const userStore = useUsersStore(); - return ( - this.userManagement.quota === -1 || this.userManagement.quota > userStore.allUsers.length - ); - }, - isDevRelease(): boolean { - return this.settings.releaseChannel === 'dev'; - }, - }, - actions: { - async initialize() { - if (this.initialized) { - return; - } - - const { showToast } = useToast(); - try { - await this.getSettings(); - - ExpressionEvaluatorProxy.setEvaluator(this.settings.expressions.evaluator); - - // Re-compute title since settings are now available - useTitleChange().titleReset(); - - this.initialized = true; - } catch (e) { - showToast({ - title: i18n.baseText('startupError'), - message: i18n.baseText('startupError.message'), - type: 'error', - duration: 0, - dangerouslyUseHTMLString: true, - }); - - throw e; - } - }, - setSettings(settings: IN8nUISettings): void { - this.settings = settings; - this.userManagement = settings.userManagement; - if (this.userManagement) { - this.userManagement.showSetupOnFirstLoad = !!settings.userManagement.showSetupOnFirstLoad; - } - this.api = settings.publicApi; - if (settings.sso?.ldap) { - this.ldap.loginEnabled = settings.sso.ldap.loginEnabled; - this.ldap.loginLabel = settings.sso.ldap.loginLabel; - } - if (settings.sso?.saml) { - this.saml.loginEnabled = settings.sso.saml.loginEnabled; - this.saml.loginLabel = settings.sso.saml.loginLabel; - } - if (settings.enterprise?.showNonProdBanner) { - useUIStore().pushBannerToStack('NON_PRODUCTION_LICENSE'); - } - if (settings.versionCli) { - useRootStore().setVersionCli(settings.versionCli); - } - - if ( - settings.authCookie.secure && - location.protocol === 'http:' && - !['localhost', '127.0.0.1'].includes(location.hostname) - ) { - document.write(INSECURE_CONNECTION_WARNING); - return; - } - - const isV1BannerDismissedPermanently = (settings.banners?.dismissed || []).includes('V1'); - if (!isV1BannerDismissedPermanently && useRootStore().versionCli.startsWith('1.')) { - useUIStore().pushBannerToStack('V1'); - } - }, - async getSettings(): Promise { - const rootStore = useRootStore(); - const settings = await getSettings(rootStore.restApiContext); - - this.setSettings(settings); - this.settings.communityNodesEnabled = settings.communityNodesEnabled; - this.setAllowedModules(settings.allowedModules); - this.setSaveDataErrorExecution(settings.saveDataErrorExecution); - this.setSaveDataSuccessExecution(settings.saveDataSuccessExecution); - this.setSaveDataProgressExecution(settings.saveExecutionProgress); - this.setSaveManualExecutions(settings.saveManualExecutions); - - rootStore.setUrlBaseWebhook(settings.urlBaseWebhook); - rootStore.setUrlBaseEditor(settings.urlBaseEditor); - rootStore.setEndpointForm(settings.endpointForm); - rootStore.setEndpointFormTest(settings.endpointFormTest); - rootStore.setEndpointFormWaiting(settings.endpointFormWaiting); - rootStore.setEndpointWebhook(settings.endpointWebhook); - rootStore.setEndpointWebhookTest(settings.endpointWebhookTest); - rootStore.setTimezone(settings.timezone); - rootStore.setExecutionTimeout(settings.executionTimeout); - rootStore.setMaxExecutionTimeout(settings.maxExecutionTimeout); - rootStore.setVersionCli(settings.versionCli); - rootStore.setInstanceId(settings.instanceId); - rootStore.setOauthCallbackUrls(settings.oauthCallbackUrls); - rootStore.setN8nMetadata(settings.n8nMetadata || {}); - rootStore.setDefaultLocale(settings.defaultLocale); - rootStore.setIsNpmAvailable(settings.isNpmAvailable); - rootStore.setBinaryDataMode(settings.binaryDataMode); - - useVersionsStore().setVersionNotificationSettings(settings.versionNotifications); - }, - stopShowingSetupPage(): void { - this.userManagement.showSetupOnFirstLoad = false; - }, - disableTemplates(): void { - this.settings = { - ...this.settings, - templates: { - ...this.settings.templates, - enabled: false, - }, - }; - }, - setAllowedModules(allowedModules: { builtIn?: string[]; external?: string[] }): void { - this.settings.allowedModules = allowedModules; - }, - async submitContactInfo(email: string): Promise { - try { - const usersStore = useUsersStore(); - return await submitContactInfo( - this.settings.instanceId, - usersStore.currentUserId || '', - email, - ); - } catch (error) { - return; - } - }, - async testTemplatesEndpoint(): Promise { - const timeout = new Promise((_, reject) => setTimeout(() => reject(), 2000)); - await Promise.race([testHealthEndpoint(this.templatesHost), timeout]); - this.templatesEndpointHealthy = true; - }, - async getApiKey(): Promise { - const rootStore = useRootStore(); - const { apiKey } = await getApiKey(rootStore.restApiContext); - return apiKey; - }, - async createApiKey(): Promise { - const rootStore = useRootStore(); - const { apiKey } = await createApiKey(rootStore.restApiContext); - return apiKey; - }, - async deleteApiKey(): Promise { - const rootStore = useRootStore(); - await deleteApiKey(rootStore.restApiContext); - }, - async getLdapConfig() { - const rootStore = useRootStore(); - return await getLdapConfig(rootStore.restApiContext); - }, - async getLdapSynchronizations(pagination: { page: number }) { - const rootStore = useRootStore(); - return await getLdapSynchronizations(rootStore.restApiContext, pagination); - }, - async testLdapConnection() { - const rootStore = useRootStore(); - return await testLdapConnection(rootStore.restApiContext); - }, - async updateLdapConfig(ldapConfig: ILdapConfig) { - const rootStore = useRootStore(); - return await updateLdapConfig(rootStore.restApiContext, ldapConfig); - }, - async runLdapSync(data: IDataObject) { - const rootStore = useRootStore(); - return await runLdapSync(rootStore.restApiContext, data); - }, - setSaveDataErrorExecution(newValue: WorkflowSettings.SaveDataExecution) { - this.saveDataErrorExecution = newValue; - }, - setSaveDataSuccessExecution(newValue: WorkflowSettings.SaveDataExecution) { - this.saveDataSuccessExecution = newValue; - }, - setSaveManualExecutions(saveManualExecutions: boolean) { - this.saveManualExecutions = saveManualExecutions; - }, - setSaveDataProgressExecution(newValue: boolean) { - this.saveDataProgressExecution = newValue; - }, - async getTimezones(): Promise { - const rootStore = useRootStore(); - return await makeRestApiRequest(rootStore.restApiContext, 'GET', '/options/timezones'); - }, - }, + } catch (error) { + return; + } + }; + + const testTemplatesEndpoint = async () => { + const timeout = new Promise((_, reject) => setTimeout(() => reject(), 2000)); + await Promise.race([testHealthEndpoint(templatesHost.value), timeout]); + templatesEndpointHealthy.value = true; + }; + + const getApiKey = async () => { + const rootStore = useRootStore(); + const { apiKey } = await publicApiApi.getApiKey(rootStore.restApiContext); + return apiKey; + }; + + const createApiKey = async () => { + const rootStore = useRootStore(); + const { apiKey } = await publicApiApi.createApiKey(rootStore.restApiContext); + return apiKey; + }; + + const deleteApiKey = async () => { + const rootStore = useRootStore(); + await publicApiApi.deleteApiKey(rootStore.restApiContext); + }; + + const getLdapConfig = async () => { + const rootStore = useRootStore(); + return await ldapApi.getLdapConfig(rootStore.restApiContext); + }; + + const getLdapSynchronizations = async (pagination: { page: number }) => { + const rootStore = useRootStore(); + return await ldapApi.getLdapSynchronizations(rootStore.restApiContext, pagination); + }; + + const testLdapConnection = async () => { + const rootStore = useRootStore(); + return await ldapApi.testLdapConnection(rootStore.restApiContext); + }; + + const updateLdapConfig = async (ldapConfig: ILdapConfig) => { + const rootStore = useRootStore(); + return await ldapApi.updateLdapConfig(rootStore.restApiContext, ldapConfig); + }; + + const runLdapSync = async (data: IDataObject) => { + const rootStore = useRootStore(); + return await ldapApi.runLdapSync(rootStore.restApiContext, data); + }; + + const getTimezones = async (): Promise => { + const rootStore = useRootStore(); + return await makeRestApiRequest(rootStore.restApiContext, 'GET', '/options/timezones'); + }; + + const reset = () => { + settings.value = {} as IN8nUISettings; + }; + + return { + settings, + userManagement, + templatesEndpointHealthy, + api, + ldap, + saml, + mfa, + isDocker, + isDevRelease, + isEnterpriseFeatureEnabled, + databaseType, + planName, + consumerId, + binaryDataMode, + pruning, + security, + nodeJsVersion, + concurrency, + isPublicApiEnabled, + isSwaggerUIEnabled, + isPreviewMode, + publicApiLatestVersion, + publicApiPath, + isLdapLoginEnabled, + ldapLoginLabel, + isSamlLoginEnabled, + showSetupPage, + deploymentType, + isDesktopDeployment, + isCloudDeployment, + isSmtpSetup, + isPersonalizationSurveyEnabled, + telemetry, + logLevel, + isTelemetryEnabled, + isMfaFeatureEnabled, + areTagsEnabled, + isHiringBannerEnabled, + isTemplatesEnabled, + isTemplatesEndpointReachable, + templatesHost, + pushBackend, + isCommunityNodesFeatureEnabled, + isNpmAvailable, + allowedModules, + isQueueModeEnabled, + isWorkerViewAvailable, + isDefaultAuthenticationSaml, + workflowCallerPolicyDefaultOption, + permanentlyDismissedBanners, + isBelowUserQuota, + saveDataErrorExecution, + saveDataSuccessExecution, + saveManualExecutions, + saveDataProgressExecution, + isCommunityPlan, + reset, + testLdapConnection, + getLdapConfig, + getLdapSynchronizations, + updateLdapConfig, + runLdapSync, + getTimezones, + createApiKey, + getApiKey, + deleteApiKey, + testTemplatesEndpoint, + submitContactInfo, + disableTemplates, + stopShowingSetupPage, + getSettings, + setSettings, + initialize, + }; }); diff --git a/packages/editor-ui/src/stores/sourceControl.store.ts b/packages/editor-ui/src/stores/sourceControl.store.ts index 25c763b586ea8..958558603f438 100644 --- a/packages/editor-ui/src/stores/sourceControl.store.ts +++ b/packages/editor-ui/src/stores/sourceControl.store.ts @@ -11,8 +11,8 @@ export const useSourceControlStore = defineStore('sourceControl', () => { const rootStore = useRootStore(); const settingsStore = useSettingsStore(); - const isEnterpriseSourceControlEnabled = computed(() => - settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.SourceControl), + const isEnterpriseSourceControlEnabled = computed( + () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.SourceControl], ); const sshKeyTypes: SshKeyTypes = ['ed25519', 'rsa']; diff --git a/packages/editor-ui/src/stores/sso.store.ts b/packages/editor-ui/src/stores/sso.store.ts index e445ed56cf1e0..63c71c3899a92 100644 --- a/packages/editor-ui/src/stores/sso.store.ts +++ b/packages/editor-ui/src/stores/sso.store.ts @@ -42,8 +42,8 @@ export const useSSOStore = defineStore('sso', () => { void toggleLoginEnabled(value); }, }); - const isEnterpriseSamlEnabled = computed(() => - settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Saml), + const isEnterpriseSamlEnabled = computed( + () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Saml], ); const isDefaultAuthenticationSaml = computed(() => settingsStore.isDefaultAuthenticationSaml); const showSsoLoginButton = computed( diff --git a/packages/editor-ui/src/stores/templates.store.ts b/packages/editor-ui/src/stores/templates.store.ts index 42c2c48dfbbff..36eceb120a864 100644 --- a/packages/editor-ui/src/stores/templates.store.ts +++ b/packages/editor-ui/src/stores/templates.store.ts @@ -289,8 +289,9 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, { }, async fetchTemplateById(templateId: string): Promise { const settingsStore = useSettingsStore(); + const rootStore = useRootStore(); const apiEndpoint: string = settingsStore.templatesHost; - const versionCli: string = settingsStore.versionCli; + const versionCli: string = rootStore.versionCli; const response = await getTemplateById(apiEndpoint, templateId, { 'n8n-version': versionCli, }); @@ -305,8 +306,9 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, { }, async fetchCollectionById(collectionId: string): Promise { const settingsStore = useSettingsStore(); + const rootStore = useRootStore(); const apiEndpoint: string = settingsStore.templatesHost; - const versionCli: string = settingsStore.versionCli; + const versionCli: string = rootStore.versionCli; const response = await getCollectionById(apiEndpoint, collectionId, { 'n8n-version': versionCli, }); @@ -325,8 +327,9 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, { return cachedCategories; } const settingsStore = useSettingsStore(); + const rootStore = useRootStore(); const apiEndpoint: string = settingsStore.templatesHost; - const versionCli: string = settingsStore.versionCli; + const versionCli: string = rootStore.versionCli; const response = await getCategories(apiEndpoint, { 'n8n-version': versionCli }); const categories = response.categories; @@ -340,8 +343,9 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, { } const settingsStore = useSettingsStore(); + const rootStore = useRootStore(); const apiEndpoint: string = settingsStore.templatesHost; - const versionCli: string = settingsStore.versionCli; + const versionCli: string = rootStore.versionCli; const response = await getCollections(apiEndpoint, query, { 'n8n-version': versionCli }); const collections = response.collections; @@ -361,8 +365,9 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, { } const settingsStore = useSettingsStore(); + const rootStore = useRootStore(); const apiEndpoint: string = settingsStore.templatesHost; - const versionCli: string = settingsStore.versionCli; + const versionCli: string = rootStore.versionCli; const payload = await getWorkflows( apiEndpoint, @@ -402,8 +407,9 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, { }, async getWorkflowTemplate(templateId: string): Promise { const settingsStore = useSettingsStore(); + const rootStore = useRootStore(); const apiEndpoint: string = settingsStore.templatesHost; - const versionCli: string = settingsStore.versionCli; + const versionCli: string = rootStore.versionCli; return await getWorkflowTemplate(apiEndpoint, templateId, { 'n8n-version': versionCli }); }, diff --git a/packages/editor-ui/src/stores/workflows.ee.store.ts b/packages/editor-ui/src/stores/workflows.ee.store.ts index 672a3827db031..ae1c1b3a65374 100644 --- a/packages/editor-ui/src/stores/workflows.ee.store.ts +++ b/packages/editor-ui/src/stores/workflows.ee.store.ts @@ -50,7 +50,7 @@ export const useWorkflowsEEStore = defineStore(STORES.WORKFLOWS_EE, { const rootStore = useRootStore(); const settingsStore = useSettingsStore(); - if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing)) { + if (settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]) { await setWorkflowSharedWith(rootStore.restApiContext, payload.workflowId, { shareWithIds: payload.sharedWithProjects.map((p) => p.id), }); diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index 59ebe10900568..075e55268089c 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -64,7 +64,7 @@ import { useUIStore } from '@/stores/ui.store'; import { dataPinningEventBus } from '@/event-bus'; import { isObject } from '@/utils/objectUtils'; import { getPairedItemsMapping } from '@/utils/pairedItemUtils'; -import { isJsonKeyObject, isEmpty, stringSizeInBytes } from '@/utils/typesUtils'; +import { isJsonKeyObject, isEmpty, stringSizeInBytes, isPresent } from '@/utils/typesUtils'; import { makeRestApiRequest, unflattenExecutionData, ResponseError } from '@/utils/apiUtils'; import { useNDVStore } from '@/stores/ndv.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; @@ -250,6 +250,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { return workflow.value.nodes.find((node) => node.id === nodeId); } + function getNodesByIds(nodeIds: string[]): INodeUi[] { + return nodeIds.map(getNodeById).filter(isPresent); + } + function getParametersLastUpdate(nodeName: string): number | undefined { return nodeMetadata.value[nodeName]?.parametersLastUpdatedAt; } @@ -1584,6 +1588,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { getWorkflowById, getNodeByName, getNodeById, + getNodesByIds, getParametersLastUpdate, isNodePristine, isNodeExecuting, diff --git a/packages/editor-ui/src/utils/rbac/checks/isEnterpriseFeatureEnabled.test.ts b/packages/editor-ui/src/utils/rbac/checks/isEnterpriseFeatureEnabled.test.ts index c765343ed70e3..52f123d0fc6f0 100644 --- a/packages/editor-ui/src/utils/rbac/checks/isEnterpriseFeatureEnabled.test.ts +++ b/packages/editor-ui/src/utils/rbac/checks/isEnterpriseFeatureEnabled.test.ts @@ -1,23 +1,24 @@ import { useSettingsStore } from '@/stores/settings.store'; import { isEnterpriseFeatureEnabled } from '@/utils/rbac/checks/isEnterpriseFeatureEnabled'; import { EnterpriseEditionFeature } from '@/constants'; - -vi.mock('@/stores/settings.store', () => ({ - useSettingsStore: vi.fn(), -})); +import { createPinia, setActivePinia } from 'pinia'; +import { defaultSettings } from '@/__tests__/defaults'; describe('Checks', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + describe('isEnterpriseFeatureEnabled()', () => { it('should return true if no feature is provided', () => { expect(isEnterpriseFeatureEnabled({})).toBe(true); }); it('should return true if feature is enabled', () => { - vi.mocked(useSettingsStore).mockReturnValue({ - isEnterpriseFeatureEnabled: vi - .fn() - .mockImplementation((feature) => feature !== EnterpriseEditionFeature.Variables), - } as unknown as ReturnType); + useSettingsStore().settings.enterprise = { + ...defaultSettings.enterprise, + [EnterpriseEditionFeature.Saml]: true, + }; expect( isEnterpriseFeatureEnabled({ @@ -27,11 +28,11 @@ describe('Checks', () => { }); it('should return true if all features are enabled in allOf mode', () => { - vi.mocked(useSettingsStore).mockReturnValue({ - isEnterpriseFeatureEnabled: vi - .fn() - .mockImplementation((feature) => feature !== EnterpriseEditionFeature.Variables), - } as unknown as ReturnType); + useSettingsStore().settings.enterprise = { + ...defaultSettings.enterprise, + [EnterpriseEditionFeature.Ldap]: true, + [EnterpriseEditionFeature.Saml]: true, + }; expect( isEnterpriseFeatureEnabled({ @@ -42,11 +43,11 @@ describe('Checks', () => { }); it('should return false if any feature is not enabled in allOf mode', () => { - vi.mocked(useSettingsStore).mockReturnValue({ - isEnterpriseFeatureEnabled: vi - .fn() - .mockImplementation((feature) => feature !== EnterpriseEditionFeature.Saml), - } as unknown as ReturnType); + useSettingsStore().settings.enterprise = { + ...defaultSettings.enterprise, + [EnterpriseEditionFeature.Ldap]: true, + [EnterpriseEditionFeature.Saml]: false, + }; expect( isEnterpriseFeatureEnabled({ @@ -57,11 +58,11 @@ describe('Checks', () => { }); it('should return true if any feature is enabled in oneOf mode', () => { - vi.mocked(useSettingsStore).mockReturnValue({ - isEnterpriseFeatureEnabled: vi - .fn() - .mockImplementation((feature) => feature === EnterpriseEditionFeature.Ldap), - } as unknown as ReturnType); + useSettingsStore().settings.enterprise = { + ...defaultSettings.enterprise, + [EnterpriseEditionFeature.Ldap]: true, + [EnterpriseEditionFeature.Saml]: false, + }; expect( isEnterpriseFeatureEnabled({ @@ -72,9 +73,11 @@ describe('Checks', () => { }); it('should return false if no features are enabled in anyOf mode', () => { - vi.mocked(useSettingsStore).mockReturnValue({ - isEnterpriseFeatureEnabled: vi.fn().mockReturnValue(false), - } as unknown as ReturnType); + useSettingsStore().settings.enterprise = { + ...defaultSettings.enterprise, + [EnterpriseEditionFeature.Ldap]: false, + [EnterpriseEditionFeature.Saml]: false, + }; expect( isEnterpriseFeatureEnabled({ diff --git a/packages/editor-ui/src/utils/rbac/checks/isEnterpriseFeatureEnabled.ts b/packages/editor-ui/src/utils/rbac/checks/isEnterpriseFeatureEnabled.ts index 652e7ec0beab8..996a04bcd538f 100644 --- a/packages/editor-ui/src/utils/rbac/checks/isEnterpriseFeatureEnabled.ts +++ b/packages/editor-ui/src/utils/rbac/checks/isEnterpriseFeatureEnabled.ts @@ -12,8 +12,8 @@ export const isEnterpriseFeatureEnabled: RBACPermissionCheck settingsStore.isEnterpriseFeatureEnabled[feature]); } else { - return features.some(settingsStore.isEnterpriseFeatureEnabled); + return features.some((feature) => settingsStore.isEnterpriseFeatureEnabled[feature]); } }; diff --git a/packages/editor-ui/src/utils/rbac/middleware/enterprise.test.ts b/packages/editor-ui/src/utils/rbac/middleware/enterprise.test.ts index ee887efb0424f..f7d556cde740a 100644 --- a/packages/editor-ui/src/utils/rbac/middleware/enterprise.test.ts +++ b/packages/editor-ui/src/utils/rbac/middleware/enterprise.test.ts @@ -3,17 +3,21 @@ import { VIEWS, EnterpriseEditionFeature } from '@/constants'; import { enterpriseMiddleware } from '@/utils/rbac/middleware/enterprise'; import { type RouteLocationNormalized } from 'vue-router'; import type { EnterprisePermissionOptions } from '@/types/rbac'; - -vi.mock('@/stores/settings.store', () => ({ - useSettingsStore: vi.fn(), -})); +import { createPinia, setActivePinia } from 'pinia'; +import { defaultSettings } from '@/__tests__/defaults'; describe('Middleware', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + describe('enterprise', () => { it('should redirect to homepage if none of the required features are enabled in allOf mode', async () => { - vi.mocked(useSettingsStore).mockReturnValue({ - isEnterpriseFeatureEnabled: (_) => false, - } as ReturnType); + useSettingsStore().settings.enterprise = { + ...defaultSettings.enterprise, + [EnterpriseEditionFeature.Ldap]: false, + [EnterpriseEditionFeature.Saml]: false, + }; const nextMock = vi.fn(); const options: EnterprisePermissionOptions = { @@ -32,10 +36,11 @@ describe('Middleware', () => { }); it('should allow navigation if all of the required features are enabled in allOf mode', async () => { - vi.mocked(useSettingsStore).mockReturnValue({ - isEnterpriseFeatureEnabled: (feature) => - [EnterpriseEditionFeature.Saml, EnterpriseEditionFeature.Ldap].includes(feature), - } as ReturnType); + useSettingsStore().settings.enterprise = { + ...defaultSettings.enterprise, + [EnterpriseEditionFeature.Ldap]: true, + [EnterpriseEditionFeature.Saml]: true, + }; const nextMock = vi.fn(); const options: EnterprisePermissionOptions = { @@ -54,9 +59,10 @@ describe('Middleware', () => { }); it('should redirect to homepage if none of the required features are enabled in oneOf mode', async () => { - vi.mocked(useSettingsStore).mockReturnValue({ - isEnterpriseFeatureEnabled: (_) => false, - } as ReturnType); + useSettingsStore().settings.enterprise = { + ...defaultSettings.enterprise, + [EnterpriseEditionFeature.Saml]: false, + }; const nextMock = vi.fn(); const options: EnterprisePermissionOptions = { @@ -75,9 +81,11 @@ describe('Middleware', () => { }); it('should allow navigation if at least one of the required features is enabled in oneOf mode', async () => { - vi.mocked(useSettingsStore).mockReturnValue({ - isEnterpriseFeatureEnabled: (feature) => feature === EnterpriseEditionFeature.Saml, - } as ReturnType); + useSettingsStore().settings.enterprise = { + ...defaultSettings.enterprise, + [EnterpriseEditionFeature.Ldap]: true, + [EnterpriseEditionFeature.Saml]: false, + }; const nextMock = vi.fn(); const options: EnterprisePermissionOptions = { diff --git a/packages/editor-ui/src/views/CredentialsView.vue b/packages/editor-ui/src/views/CredentialsView.vue index 2718e14a64952..fe71fcb8fff72 100644 --- a/packages/editor-ui/src/views/CredentialsView.vue +++ b/packages/editor-ui/src/views/CredentialsView.vue @@ -169,9 +169,8 @@ export default defineComponent({ }, async initialize() { this.loading = true; - const isVarsEnabled = useSettingsStore().isEnterpriseFeatureEnabled( - EnterpriseEditionFeature.Variables, - ); + const isVarsEnabled = + useSettingsStore().isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables]; const loadPromises = [ this.credentialsStore.fetchAllCredentials( diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue index 5aa7069c1b478..3ae627d1dc22d 100644 --- a/packages/editor-ui/src/views/NodeView.v2.vue +++ b/packages/editor-ui/src/views/NodeView.v2.vue @@ -42,12 +42,13 @@ import { PLACEHOLDER_EMPTY_WORKFLOW_ID, START_NODE_TYPE, STICKY_NODE_TYPE, + VALID_WORKFLOW_IMPORT_URL_REGEX, VIEWS, } from '@/constants'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useNodeCreatorStore } from '@/stores/nodeCreator.store'; import { useExternalHooks } from '@/composables/useExternalHooks'; -import { TelemetryHelpers, NodeConnectionType } from 'n8n-workflow'; +import { TelemetryHelpers, NodeConnectionType, jsonParse } from 'n8n-workflow'; import type { IDataObject, ExecutionSummary, IConnection, IWorkflowBase } from 'n8n-workflow'; import { useToast } from '@/composables/useToast'; import { useSettingsStore } from '@/stores/settings.store'; @@ -83,6 +84,7 @@ import { tryToParseNumber } from '@/utils/typesUtils'; import { useTemplatesStore } from '@/stores/templates.store'; import { createEventBus } from 'n8n-design-system'; import type { PinDataSource } from '@/composables/usePinnedData'; +import { useClipboard } from '@/composables/useClipboard'; const NodeCreation = defineAsyncComponent( async () => await import('@/components/Node/NodeCreation.vue'), @@ -141,6 +143,9 @@ const { setNodeParameters, deleteNode, deleteNodes, + copyNodes, + cutNodes, + duplicateNodes, revertDeleteNode, addNodes, createConnection, @@ -156,6 +161,7 @@ const { editableWorkflowObject, } = useCanvasOperations({ router, lastClickPosition }); const { applyExecutionData } = useExecutionDebugging(); +useClipboard({ onPaste: onClipboardPaste }); const isLoading = ref(true); const isBlankRedirect = ref(false); @@ -210,11 +216,11 @@ async function initializeData() { credentialsStore.fetchCredentialTypes(true), ]; - if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Variables)) { + if (settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables]) { promises.push(environmentsStore.fetchAllVariables()); } - if (settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.ExternalSecrets)) { + if (settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.ExternalSecrets]) { promises.push(externalSecretsStore.fetchAllSecrets()); } @@ -475,16 +481,76 @@ function onSetNodeSelected(id?: string) { setNodeSelected(id); } -function onCopyNodes(_ids: string[]) { - // @TODO: implement this +async function onCopyNodes(ids: string[]) { + await copyNodes(ids); + + toast.showMessage({ title: i18n.baseText('generic.copiedToClipboard'), type: 'success' }); +} + +async function onClipboardPaste(plainTextData: string): Promise { + if (getNodeViewTab(route) !== MAIN_HEADER_TABS.WORKFLOW) { + return; + } + + if (!checkIfEditingIsAllowed()) { + return; + } + + let workflowData: IWorkflowDataUpdate | null | undefined = null; + + // Check if it is an URL which could contain workflow data + if (plainTextData.match(VALID_WORKFLOW_IMPORT_URL_REGEX)) { + const importConfirm = await message.confirm( + i18n.baseText('nodeView.confirmMessage.onClipboardPasteEvent.message', { + interpolate: { plainTextData }, + }), + i18n.baseText('nodeView.confirmMessage.onClipboardPasteEvent.headline'), + { + type: 'warning', + confirmButtonText: i18n.baseText( + 'nodeView.confirmMessage.onClipboardPasteEvent.confirmButtonText', + ), + cancelButtonText: i18n.baseText( + 'nodeView.confirmMessage.onClipboardPasteEvent.cancelButtonText', + ), + dangerouslyUseHTMLString: true, + }, + ); + + if (importConfirm !== MODAL_CONFIRM) { + return; + } + + workflowData = await fetchWorkflowDataFromUrl(plainTextData); + } else { + // Pasted data is is possible workflow data + workflowData = jsonParse(plainTextData, { fallbackValue: null }); + } + + if (!workflowData) { + return; + } + + const result = await importWorkflowData(workflowData, 'paste', false); + selectNodes(result.nodes?.map((node) => node.id) ?? []); } -function onCutNodes(_ids: string[]) { - // @TODO: implement this +async function onCutNodes(ids: string[]) { + if (isCanvasReadOnly.value) { + await copyNodes(ids); + } else { + await cutNodes(ids); + } } -function onDuplicateNodes(_ids: string[]) { - // @TODO: implement this +async function onDuplicateNodes(ids: string[]) { + if (!checkIfEditingIsAllowed()) { + return; + } + + const newIds = await duplicateNodes(ids); + + selectNodes(newIds); } function onPinNodes(ids: string[], source: PinDataSource) { @@ -496,7 +562,30 @@ function onPinNodes(ids: string[], source: PinDataSource) { } async function onSaveWorkflow() { - await workflowHelpers.saveCurrentWorkflow(); + const saved = await workflowHelpers.saveCurrentWorkflow(); + if (saved) { + canvasEventBus.emit('saved:workflow'); + } +} + +function addWorkflowSavedEventBindings() { + canvasEventBus.on('saved:workflow', npsSurveyStore.fetchPromptsData); + canvasEventBus.on('saved:workflow', onSaveFromWithinNDV); +} + +function removeWorkflowSavedEventBindings() { + canvasEventBus.off('saved:workflow', npsSurveyStore.fetchPromptsData); + canvasEventBus.off('saved:workflow', onSaveFromWithinNDV); + canvasEventBus.off('saved:workflow', onSaveFromWithinExecutionDebug); +} + +async function onSaveFromWithinNDV() { + if (ndvStore.activeNodeName) { + toast.showMessage({ + title: i18n.baseText('generic.workflowSaved'), + type: 'success', + }); + } } async function onCreateWorkflow() { @@ -643,9 +732,11 @@ async function importWorkflowExact({ workflow: workflowData }: { workflow: IWork } async function onImportWorkflowDataEvent(data: IDataObject) { - await importWorkflowData(data.data as IWorkflowDataUpdate, 'file'); + const workflowData = data.data as IWorkflowDataUpdate; + await importWorkflowData(workflowData, 'file'); fitView(); + selectNodes(workflowData.nodes?.map((node) => node.id) ?? []); } async function onImportWorkflowUrlEvent(data: IDataObject) { @@ -657,6 +748,7 @@ async function onImportWorkflowUrlEvent(data: IDataObject) { await importWorkflowData(workflowData, 'url'); fitView(); + selectNodes(workflowData.nodes?.map((node) => node.id) ?? []); } function addImportEventBindings() { @@ -874,20 +966,6 @@ const chatTriggerNodePinnedData = computed(() => { return workflowsStore.pinDataByNodeName(chatTriggerNode.value.name); }); -/** - * Keyboard - */ - -function addKeyboardEventBindings() { - // document.addEventListener('keydown', this.keyDown); - // document.addEventListener('keyup', this.keyUp); -} - -function removeKeyboardEventBindings() { - // document.removeEventListener('keydown', this.keyDown); - // document.removeEventListener('keyup', this.keyUp); -} - /** * History events */ @@ -963,12 +1041,16 @@ function removePostMessageEventBindings() { window.removeEventListener('message', onPostMessageReceived); } -async function onPostMessageReceived(message: MessageEvent) { - if (!message || typeof message.data !== 'string' || !message.data?.includes?.('"command"')) { +async function onPostMessageReceived(messageEvent: MessageEvent) { + if ( + !messageEvent || + typeof messageEvent.data !== 'string' || + !messageEvent.data?.includes?.('"command"') + ) { return; } try { - const json = JSON.parse(message.data); + const json = JSON.parse(messageEvent.data); if (json && json.command === 'openWorkflow') { try { await importWorkflowExact(json); @@ -1074,13 +1156,25 @@ function checkIfRouteIsAllowed() { async function initializeDebugMode() { if (route.name === VIEWS.EXECUTION_DEBUG) { titleSet(workflowsStore.workflowName, 'DEBUG'); + if (!workflowsStore.isInDebugMode) { await applyExecutionData(route.params.executionId as string); workflowsStore.isInDebugMode = true; } + + canvasEventBus.on('saved:workflow', onSaveFromWithinExecutionDebug); } } +async function onSaveFromWithinExecutionDebug() { + if (route.name !== VIEWS.EXECUTION_DEBUG) return; + + await router.replace({ + name: VIEWS.WORKFLOW, + params: { name: workflowId.value }, + }); +} + /** * Canvas */ @@ -1089,6 +1183,10 @@ function fitView() { setTimeout(() => canvasEventBus.emit('fitView')); } +function selectNodes(ids: string[]) { + setTimeout(() => canvasEventBus.emit('selectNodes', ids)); +} + /** * Mouse events */ @@ -1223,15 +1321,12 @@ onMounted(async () => { addUndoRedoEventBindings(); addPostMessageEventBindings(); - addKeyboardEventBindings(); addSourceControlEventBindings(); addImportEventBindings(); + addWorkflowSavedEventBindings(); registerCustomActions(); - // @TODO Implement this - // this.clipboard.onPaste.value = this.onClipboardPasteEvent; - // @TODO: This currently breaks since front-end hooks are still not updated to work with pinia store void externalHooks.run('nodeView.mount').catch(() => {}); }); @@ -1239,9 +1334,9 @@ onMounted(async () => { onBeforeUnmount(() => { removeUndoRedoEventBindings(); removePostMessageEventBindings(); - removeKeyboardEventBindings(); removeSourceControlEventBindings(); removeImportEventBindings(); + removeWorkflowSavedEventBindings(); }); @@ -1320,10 +1415,10 @@ onBeforeUnmount(() => { @stop-execution="onStopExecution" @switch-selected-node="onSwitchActiveNode" @open-connection-node-creator="onOpenSelectiveNodeCreator" + @save-keyboard-shortcut="onSaveWorkflow" /> diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 1b0a6fec37a38..28f5432869dd3 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -244,6 +244,7 @@ import { DRAG_EVENT_DATA_KEY, UPDATE_WEBHOOK_ID_NODE_TYPES, CANVAS_AUTO_ADD_MANUAL_TRIGGER_EXPERIMENT, + VALID_WORKFLOW_IMPORT_URL_REGEX, } from '@/constants'; import useGlobalLinkActions from '@/composables/useGlobalLinkActions'; @@ -848,10 +849,10 @@ export default defineComponent({ const loadPromises = (() => { if (this.settingsStore.isPreviewMode && this.isDemo) return []; const promises = [this.loadActiveWorkflows(), this.loadCredentialTypes()]; - if (this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Variables)) { + if (this.settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables]) { promises.push(this.loadVariables()); } - if (this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.ExternalSecrets)) { + if (this.settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.ExternalSecrets]) { promises.push(this.loadSecrets()); } return promises; @@ -2039,7 +2040,7 @@ export default defineComponent({ return; } // Check if it is an URL which could contain workflow data - if (plainTextData.match(/^http[s]?:\/\/.*\.json$/i)) { + if (plainTextData.match(VALID_WORKFLOW_IMPORT_URL_REGEX)) { // Pasted data points to a possible workflow JSON file if (!this.editAllowedCheck()) { @@ -4226,7 +4227,7 @@ export default defineComponent({ if ( nodeData.credentials && - this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing) + this.settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing] ) { const usedCredentials = this.workflowsStore.usedCredentials; nodeData.credentials = Object.fromEntries( diff --git a/packages/editor-ui/src/views/SettingsLogStreamingView.vue b/packages/editor-ui/src/views/SettingsLogStreamingView.vue index f0695dd795587..29fb285e4e590 100644 --- a/packages/editor-ui/src/views/SettingsLogStreamingView.vue +++ b/packages/editor-ui/src/views/SettingsLogStreamingView.vue @@ -153,7 +153,7 @@ export default defineComponent({ }, isLicensed(): boolean { if (this.disableLicense) return false; - return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.LogStreaming); + return this.settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.LogStreaming]; }, canManageLogStreaming(): boolean { return hasPermission(['rbac'], { rbac: { scope: 'logStreaming:manage' } }); diff --git a/packages/editor-ui/src/views/SettingsUsersView.vue b/packages/editor-ui/src/views/SettingsUsersView.vue index ed095648964e3..fe45f63c8552f 100644 --- a/packages/editor-ui/src/views/SettingsUsersView.vue +++ b/packages/editor-ui/src/views/SettingsUsersView.vue @@ -76,7 +76,7 @@ const usersListActions = computed((): IUserListAction[] => { ]; }); const isAdvancedPermissionsEnabled = computed((): boolean => { - return settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.AdvancedPermissions); + return settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedPermissions]; }); const userRoles = computed((): Array<{ value: IRole; label: string; disabled?: boolean }> => { diff --git a/packages/editor-ui/src/views/VariablesView.spec.ts b/packages/editor-ui/src/views/VariablesView.spec.ts index 7bccdbd5516a7..9680a716b2d05 100644 --- a/packages/editor-ui/src/views/VariablesView.spec.ts +++ b/packages/editor-ui/src/views/VariablesView.spec.ts @@ -7,7 +7,9 @@ import { useSettingsStore } from '@/stores/settings.store'; import { useUsersStore } from '@/stores/users.store'; import { useRBACStore } from '@/stores/rbac.store'; import { createComponentRenderer } from '@/__tests__/render'; -import { EnterpriseEditionFeature } from '@/constants'; +import { EnterpriseEditionFeature, STORES } from '@/constants'; +import { createTestingPinia } from '@pinia/testing'; +import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; describe('VariablesView', () => { let server: ReturnType; @@ -16,7 +18,13 @@ describe('VariablesView', () => { let usersStore: ReturnType; let rbacStore: ReturnType; - const renderComponent = createComponentRenderer(VariablesView); + const renderComponent = createComponentRenderer(VariablesView, { + pinia: createTestingPinia({ + initialState: { + [STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE, + }, + }), + }); beforeAll(() => { server = setupServer(); @@ -105,6 +113,7 @@ describe('VariablesView', () => { }); it('should render variable entries', async () => { + settingsStore.settings.enterprise[EnterpriseEditionFeature.Variables] = true; server.createList('variable', 3); const wrapper = renderComponent({ pinia }); diff --git a/packages/editor-ui/src/views/VariablesView.vue b/packages/editor-ui/src/views/VariablesView.vue index 9bbfa6d13344f..fffc079f2f0bb 100644 --- a/packages/editor-ui/src/views/VariablesView.vue +++ b/packages/editor-ui/src/views/VariablesView.vue @@ -44,8 +44,8 @@ const permissions = computed( () => getResourcePermissions(usersStore.currentUser?.globalScopes).variable, ); -const isFeatureEnabled = computed(() => - settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Variables), +const isFeatureEnabled = computed( + () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables], ); const variablesToResources = computed((): IResource[] => diff --git a/packages/editor-ui/src/views/WorkflowsView.vue b/packages/editor-ui/src/views/WorkflowsView.vue index 4dbe6335e3062..c15c8671c0acf 100644 --- a/packages/editor-ui/src/views/WorkflowsView.vue +++ b/packages/editor-ui/src/views/WorkflowsView.vue @@ -213,7 +213,7 @@ const WorkflowsView = defineComponent({ return this.workflowsStore.allWorkflows; }, isShareable(): boolean { - return this.settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.Sharing); + return this.settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]; }, statusFilterOptions(): Array<{ label: string; value: string | boolean }> { return [ diff --git a/packages/editor-ui/src/views/__tests__/SettingsUsersView.test.ts b/packages/editor-ui/src/views/__tests__/SettingsUsersView.test.ts index d8b9834b86b7d..d67cd3502af7f 100644 --- a/packages/editor-ui/src/views/__tests__/SettingsUsersView.test.ts +++ b/packages/editor-ui/src/views/__tests__/SettingsUsersView.test.ts @@ -11,8 +11,10 @@ import { useUsersStore } from '@/stores/users.store'; import { createUser } from '@/__tests__/data/users'; import { createProjectListItem } from '@/__tests__/data/projects'; import { useRBACStore } from '@/stores/rbac.store'; -import { DELETE_USER_MODAL_KEY } from '@/constants'; +import { DELETE_USER_MODAL_KEY, EnterpriseEditionFeature } from '@/constants'; import * as usersApi from '@/api/users'; +import { useSettingsStore } from '@/stores/settings.store'; +import { defaultSettings } from '@/__tests__/defaults'; const wrapperComponentWithModal = { components: { SettingsUsersView, ModalRoot, DeleteUserModal }, @@ -47,6 +49,11 @@ describe('SettingsUsersView', () => { usersStore = useUsersStore(); rbacStore = useRBACStore(); + useSettingsStore().settings.enterprise = { + ...defaultSettings.enterprise, + [EnterpriseEditionFeature.AdvancedExecutionFilters]: true, + }; + vi.spyOn(rbacStore, 'hasScope').mockReturnValue(true); vi.spyOn(usersApi, 'getUsers').mockResolvedValue(users); vi.spyOn(usersStore, 'allUsers', 'get').mockReturnValue(users); diff --git a/packages/nodes-base/nodes/RabbitMQ/RabbitMQ.node.ts b/packages/nodes-base/nodes/RabbitMQ/RabbitMQ.node.ts index b40aa981fd0c6..c4f9ace2c6b88 100644 --- a/packages/nodes-base/nodes/RabbitMQ/RabbitMQ.node.ts +++ b/packages/nodes-base/nodes/RabbitMQ/RabbitMQ.node.ts @@ -1,5 +1,6 @@ /* eslint-disable n8n-nodes-base/node-filename-against-convention */ import * as amqplib from 'amqplib'; +import type { Options } from 'amqplib'; import type { IExecuteFunctions, ICredentialsDecrypted, @@ -265,7 +266,8 @@ export class RabbitMQ implements INodeType { displayName: 'Arguments', name: 'arguments', placeholder: 'Add Argument', - description: 'Arguments to add', + description: + 'Arguments to add, See here for valid options', type: 'fixedCollection', typeOptions: { multipleValues: true, @@ -451,7 +453,13 @@ export class RabbitMQ implements INodeType { ); headers = additionalHeaders; } - queuePromises.push(channel.sendToQueue(queue, Buffer.from(message), { headers })); + + queuePromises.push( + channel.sendToQueue(queue, Buffer.from(message), { + headers, + ...(options.arguments ? (options.arguments as Options.Publish) : {}), + }), + ); } // @ts-ignore @@ -519,7 +527,10 @@ export class RabbitMQ implements INodeType { } exchangePromises.push( - channel.publish(exchange, routingKey, Buffer.from(message), { headers }), + channel.publish(exchange, routingKey, Buffer.from(message), { + headers, + ...(options.arguments ? (options.arguments as Options.Publish) : {}), + }), ); } diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 3433e0c45f3f1..966563748f993 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1814,9 +1814,17 @@ export interface INodeOutputConfiguration { export type ExpressionString = `={{${string}}}`; +export type NodeDefaults = Partial<{ + /** + * @deprecated Use {@link INodeTypeBaseDescription.iconColor|iconColor} instead. `iconColor` supports dark mode and uses preset colors from n8n's design system. + */ + color: string; + name: string; +}>; + export interface INodeTypeDescription extends INodeTypeBaseDescription { version: number | number[]; - defaults: INodeParameters; + defaults: NodeDefaults; eventTriggerDescription?: string; activationMessage?: string; inputs: Array | ExpressionString; @@ -2596,7 +2604,7 @@ export type ExpressionEvaluatorType = 'tmpl' | 'tournament'; export type N8nAIProviderType = 'openai' | 'unknown'; export interface IN8nUISettings { - isDocker: boolean; + isDocker?: boolean; databaseType: 'sqlite' | 'mariadb' | 'mysqldb' | 'postgresdb'; endpointForm: string; endpointFormTest: string;