diff --git a/cypress/e2e/17-sharing.cy.ts b/cypress/e2e/17-sharing.cy.ts index 77d9fd92cfc4a..71f41250eca77 100644 --- a/cypress/e2e/17-sharing.cy.ts +++ b/cypress/e2e/17-sharing.cy.ts @@ -98,6 +98,26 @@ describe('Sharing', { disableAutoLogin: true }, () => { ndv.actions.close(); }); + it('should open W1, add node using C2 as U2', () => { + cy.signin(INSTANCE_MEMBERS[0]); + + cy.visit(workflowsPage.url); + workflowsPage.getters.workflowCards().should('have.length', 2); + workflowsPage.getters.workflowCard('Workflow W1').click(); + workflowPage.actions.addNodeToCanvas('Airtable', true, true); + ndv.getters.credentialInput().find('input').should('have.value', 'Credential C2'); + ndv.actions.close(); + workflowPage.actions.saveWorkflowOnButtonClick(); + + workflowPage.actions.openNode('Notion'); + ndv.getters + .credentialInput() + .find('input') + .should('have.value', 'Credential C1') + .should('be.enabled'); + ndv.actions.close(); + }); + it('should not have access to W2, as U3', () => { cy.signin(INSTANCE_MEMBERS[1]); diff --git a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/OutputParserItemList.node.ts b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/OutputParserItemList.node.ts index 57896165c8eb4..24327b2970e2f 100644 --- a/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/OutputParserItemList.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/output_parser/OutputParserItemList/OutputParserItemList.node.ts @@ -55,7 +55,7 @@ export class OutputParserItemList implements INodeType { type: 'number', default: -1, description: - 'Defines many many items should be returned maximally. If set to -1, there is no limit.', + 'Defines how many items should be returned maximally. If set to -1, there is no limit.', }, // For that to be easily possible the metadata would have to be returned and be able to be read. // Would also be possible with a wrapper but that would be even more hacky and the output types diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts index 44e75881e589f..77147abdc2868 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolSerpApi/ToolSerpApi.node.ts @@ -97,7 +97,7 @@ export class ToolSerpApi implements INodeType { type: 'string', default: 'google.com', description: - 'Defines the country to use for search. Head to Google countries page for a full list of supported countries.', + 'Defines the domain to use for search. Head to Google domains page for a full list of supported domains.', }, { displayName: 'Language', diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 413c2e53072e7..fc8b28c0df4fb 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -80,6 +80,12 @@ export abstract class BaseCommand extends Command { ); } + if (config.getEnv('executions.mode') === 'queue' && dbType === 'sqlite') { + this.logger.warn( + 'Queue mode is not officially supported with sqlite. Please switch to PostgreSQL.', + ); + } + if ( process.env.N8N_BINARY_DATA_TTL ?? process.env.N8N_PERSISTED_BINARY_DATA_TTL ?? diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 856b4fa43b39b..07b4ce98fe491 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -13,7 +13,13 @@ import type { INodeTypes, IRun, } from 'n8n-workflow'; -import { Workflow, NodeOperationError, sleep, ApplicationError } from 'n8n-workflow'; +import { + Workflow, + NodeOperationError, + sleep, + ApplicationError, + ErrorReporterProxy as EventReporter, +} from 'n8n-workflow'; import * as Db from '@/Db'; import * as ResponseHelper from '@/ResponseHelper'; @@ -130,7 +136,15 @@ export class Worker extends BaseCommand { { extra: { executionId } }, ); } - const workflowId = fullExecutionData.workflowData.id!; + const workflowId = fullExecutionData.workflowData.id!; // @tech_debt Ensure this is not optional + + if (!workflowId) { + EventReporter.report('Detected ID-less workflow', { + level: 'info', + extra: { execution: fullExecutionData }, + }); + } + this.logger.info( `Start job: ${job.id} (Workflow ID: ${workflowId} | Execution: ${executionId})`, ); @@ -292,7 +306,6 @@ export class Worker extends BaseCommand { this.logger.debug('Queue init complete'); await this.initOrchestration(); this.logger.debug('Orchestration init complete'); - await this.initQueue(); await Container.get(OrchestrationWorkerService).publishToEventLog( new EventMessageGeneric({ diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 7598ff788f922..093430326763d 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -220,7 +220,7 @@ export class ExecutionRepository extends Repository { const { connections, nodes, name } = workflowData ?? {}; await this.executionDataRepository.insert({ executionId, - workflowData: { connections, nodes, name }, + workflowData: { connections, nodes, name, id: workflowData?.id }, data: stringify(data), }); return String(executionId); diff --git a/packages/cli/test/integration/commands/worker.cmd.test.ts b/packages/cli/test/integration/commands/worker.cmd.test.ts index 3092e80f6eac2..ee5ac646d2d02 100644 --- a/packages/cli/test/integration/commands/worker.cmd.test.ts +++ b/packages/cli/test/integration/commands/worker.cmd.test.ts @@ -65,15 +65,15 @@ test('worker initializes all its components', async () => { expect(worker.queueModeId).toBeDefined(); expect(worker.queueModeId).toContain('worker'); expect(worker.queueModeId.length).toBeGreaterThan(15); - expect(worker.initLicense).toHaveBeenCalled(); - expect(worker.initBinaryDataService).toHaveBeenCalled(); - expect(worker.initExternalHooks).toHaveBeenCalled(); - expect(worker.initExternalSecrets).toHaveBeenCalled(); - expect(worker.initEventBus).toHaveBeenCalled(); - expect(worker.initOrchestration).toHaveBeenCalled(); - expect(OrchestrationHandlerWorkerService.prototype.initSubscriber).toHaveBeenCalled(); - expect(OrchestrationWorkerService.prototype.publishToEventLog).toHaveBeenCalled(); - expect(worker.initQueue).toHaveBeenCalled(); + expect(worker.initLicense).toHaveBeenCalledTimes(1); + expect(worker.initBinaryDataService).toHaveBeenCalledTimes(1); + expect(worker.initExternalHooks).toHaveBeenCalledTimes(1); + expect(worker.initExternalSecrets).toHaveBeenCalledTimes(1); + expect(worker.initEventBus).toHaveBeenCalledTimes(1); + expect(worker.initOrchestration).toHaveBeenCalledTimes(1); + expect(OrchestrationHandlerWorkerService.prototype.initSubscriber).toHaveBeenCalledTimes(1); + expect(OrchestrationWorkerService.prototype.publishToEventLog).toHaveBeenCalledTimes(1); + expect(worker.initQueue).toHaveBeenCalledTimes(1); jest.restoreAllMocks(); }); diff --git a/packages/cli/test/integration/database/repositories/execution.repository.test.ts b/packages/cli/test/integration/database/repositories/execution.repository.test.ts index d16645367f66b..62cb57d2b8ac5 100644 --- a/packages/cli/test/integration/database/repositories/execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/execution.repository.test.ts @@ -43,6 +43,7 @@ describe('ExecutionRepository', () => { const executionDataRepo = Container.get(ExecutionDataRepository); const executionData = await executionDataRepo.findOneBy({ executionId }); expect(executionData?.workflowData).toEqual({ + id: workflow.id, connections: workflow.connections, nodes: workflow.nodes, name: workflow.name, diff --git a/packages/editor-ui/src/components/ExecutionsView/ExecutionsSidebar.vue b/packages/editor-ui/src/components/ExecutionsView/ExecutionsSidebar.vue index d8c26cd5dcd85..2f6b482b73d60 100644 --- a/packages/editor-ui/src/components/ExecutionsView/ExecutionsSidebar.vue +++ b/packages/editor-ui/src/components/ExecutionsView/ExecutionsSidebar.vue @@ -120,10 +120,6 @@ export default defineComponent({ this.$router.go(-1); } }, - 'workflowsStore.activeWorkflowExecution'() { - this.checkListSize(); - this.scrollToActiveCard(); - }, }, mounted() { // On larger screens, we need to load more then first page of executions diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index 8fe9b64379e3f..07a0b273e2410 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -637,8 +637,7 @@ export default defineComponent({ // so we only update it when necessary (when node is mounted and when it's opened and closed (isActive)) try { const nodeSubtitle = - this.nodeHelpers.getNodeSubtitle(this.data, this.nodeType, this.getCurrentWorkflow()) || - ''; + this.nodeHelpers.getNodeSubtitle(this.data, this.nodeType, this.workflow) || ''; this.nodeSubtitle = nodeSubtitle.includes(CUSTOM_API_CALL_KEY) ? '' : nodeSubtitle; } catch (e) { diff --git a/packages/editor-ui/src/composables/useNodeHelpers.ts b/packages/editor-ui/src/composables/useNodeHelpers.ts index fda8a2fa35bc1..93b262381f047 100644 --- a/packages/editor-ui/src/composables/useNodeHelpers.ts +++ b/packages/editor-ui/src/composables/useNodeHelpers.ts @@ -420,7 +420,7 @@ export function useNodeHelpers() { .getCredentialsByType(credentialTypeDescription.name) .filter((credential: ICredentialsResponse) => { const permissions = getCredentialPermissions(currentUser, credential); - return permissions.read; + return permissions.use; }); if (userCredentials === null) { diff --git a/packages/editor-ui/src/hooks/cloud.ts b/packages/editor-ui/src/hooks/cloud.ts index f4990cba6338e..4c68bc4d284fd 100644 --- a/packages/editor-ui/src/hooks/cloud.ts +++ b/packages/editor-ui/src/hooks/cloud.ts @@ -268,7 +268,7 @@ export const n8nCloudHooks: PartialDeep = { dialogVisibleChanged: [ (_, meta) => { const segmentStore = useSegment(); - const currentValue = meta.value.slice(1); + const currentValue = meta.value?.slice(1) ?? ''; let isValueDefault = false; switch (typeof meta.parameter.default) { diff --git a/packages/editor-ui/src/permissions.ts b/packages/editor-ui/src/permissions.ts index dfbec10fb4530..639ced565978f 100644 --- a/packages/editor-ui/src/permissions.ts +++ b/packages/editor-ui/src/permissions.ts @@ -101,6 +101,10 @@ export const getCredentialPermissions = (user: IUser | null, credential: ICreden test: (permissions) => hasPermission(['rbac'], { rbac: { scope: 'credential:delete' } }) || !!permissions.isOwner, }, + { + name: 'use', + test: (permissions) => !!permissions.isOwner || !!permissions.isSharee, + }, ]; return parsePermissionsTable(user, table); diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts index 28b27ea3383c0..712add0cd2361 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -23,6 +23,8 @@ export const i18nInstance = createI18n({ }); export class I18nClass { + private baseTextCache = new Map(); + private get i18n() { return i18nInstance.global; } @@ -50,11 +52,25 @@ export class I18nClass { key: BaseTextKey, options?: { adjustToNumber?: number; interpolate?: { [key: string]: string } }, ): string { + // Create a unique cache key + const cacheKey = `${key}-${JSON.stringify(options)}`; + + // Check if the result is already cached + if (this.baseTextCache.has(cacheKey)) { + return this.baseTextCache.get(cacheKey) ?? key; + } + + let result: string; if (options?.adjustToNumber !== undefined) { - return this.i18n.tc(key, options.adjustToNumber, options?.interpolate).toString(); + result = this.i18n.tc(key, options.adjustToNumber, options?.interpolate ?? {}).toString(); + } else { + result = this.i18n.t(key, options?.interpolate ?? {}).toString(); } - return this.i18n.t(key, options?.interpolate).toString(); + // Store the result in the cache + this.baseTextCache.set(cacheKey, result); + + return result; } /** diff --git a/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointRenderer.ts b/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointRenderer.ts index c7102d755219a..54d4d98695598 100644 --- a/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointRenderer.ts +++ b/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointRenderer.ts @@ -70,7 +70,6 @@ export const register = () => { container.appendChild(unconnectedGroup); container.appendChild(defaultGroup); - endpointInstance.setupOverlays(); endpointInstance.setVisible(false); return container; diff --git a/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointType.ts b/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointType.ts index d80697fe8d21a..4bd54ae486b58 100644 --- a/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointType.ts +++ b/packages/editor-ui/src/plugins/jsplumb/N8nAddInputEndpointType.ts @@ -33,11 +33,6 @@ export class N8nAddInputEndpoint extends EndpointRepresentation { this.endpoint.getOverlays()[overlay].setVisible(visible); }); - this.setVisible(visible); - // Re-trigger the success state if label is set if (visible && this.label) { this.setSuccessOutput(this.label); } - this.instance.setSuspendDrawing(false); } setSuccessOutput(label: string) { diff --git a/packages/editor-ui/src/stores/users.store.ts b/packages/editor-ui/src/stores/users.store.ts index 312a5ec0db83e..6d20dda8e4611 100644 --- a/packages/editor-ui/src/stores/users.store.ts +++ b/packages/editor-ui/src/stores/users.store.ts @@ -95,7 +95,7 @@ export const useUsersStore = defineStore(STORES.USERS, { return (resource: ICredentialsResponse): boolean => { const permissions = getCredentialPermissions(this.currentUser, resource); - return permissions.read; + return permissions.use; }; }, }, diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index 5818dd4f5098a..a217641863410 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -894,8 +894,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { }, }; } - - this.workflowExecutionPairedItemMappings = getPairedItemsMapping(this.workflowExecutionData); }, resetAllNodesIssues(): boolean { @@ -1130,7 +1128,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { }; } this.workflowExecutionData.data!.resultData.runData[pushData.nodeName].push(pushData.data); - this.workflowExecutionPairedItemMappings = getPairedItemsMapping(this.workflowExecutionData); }, clearNodeExecutionData(nodeName: string): void { if (!this.workflowExecutionData?.data) { diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 1ab3118ed2352..aa125f592aaac 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -75,6 +75,7 @@ @removeNode="(name) => removeNode(name, true)" :key="`${stickyData.id}_sticky`" :name="stickyData.name" + :workflow="currentWorkflowObject" :isReadOnly="isReadOnlyRoute || readOnlyEnv" :instance="instance" :isActive="!!activeNode && activeNode.name === stickyData.name" @@ -732,6 +733,7 @@ export default defineComponent({ showTriggerMissingTooltip: false, workflowData: null as INewWorkflowData | null, activeConnection: null as null | Connection, + isInsertingNodes: false, isProductionExecutionPreview: false, enterTimer: undefined as undefined | ReturnType, exitTimer: undefined as undefined | ReturnType, @@ -2735,15 +2737,6 @@ export default defineComponent({ }); }, ); - setTimeout(() => { - NodeViewUtils.addConnectionTestData( - info.source, - info.target, - info.connection?.connector?.hasOwnProperty('canvas') - ? (info.connection.connector.canvas as HTMLElement) - : undefined, - ); - }, 0); const endpointArrow = NodeViewUtils.getOverlay( info.connection, @@ -2763,14 +2756,44 @@ export default defineComponent({ if (!this.suspendRecordingDetachedConnections) { this.historyStore.pushCommandToUndo(new AddConnectionCommand(connectionData)); } - this.nodeHelpers.updateNodesInputIssues(); - this.resetEndpointsErrors(); + // When we add multiple nodes, this event could be fired hundreds of times for large workflows. + // And because the updateNodesInputIssues() method is quite expensive, we only call it if not in insert mode + if (!this.isInsertingNodes) { + this.nodeHelpers.updateNodesInputIssues(); + this.resetEndpointsErrors(); + setTimeout(() => { + NodeViewUtils.addConnectionTestData( + info.source, + info.target, + info.connection?.connector?.hasOwnProperty('canvas') + ? (info.connection.connector.canvas as HTMLElement) + : undefined, + ); + }, 0); + } } } catch (e) { console.error(e); } }, + addConectionsTestData() { + this.instance.connections.forEach((connection) => { + NodeViewUtils.addConnectionTestData( + connection.source, + connection.target, + connection?.connector?.hasOwnProperty('canvas') + ? (connection?.connector.canvas as HTMLElement) + : undefined, + ); + }); + }, onDragMove() { + const totalNodes = this.nodes.length; + void this.callDebounced('updateConnectionsOverlays', { + debounceTime: totalNodes > 20 ? 200 : 0, + }); + }, + updateConnectionsOverlays() { this.instance?.connections.forEach((connection) => { NodeViewUtils.showOrHideItemsLabel(connection); NodeViewUtils.showOrHideMidpointArrow(connection); @@ -3915,7 +3938,7 @@ export default defineComponent({ if (!nodes?.length) { return; } - + this.isInsertingNodes = true; // Before proceeding we must check if all nodes contain the `properties` attribute. // Nodes are loaded without this information so we must make sure that all nodes // being added have this information. @@ -3973,60 +3996,64 @@ export default defineComponent({ // check and match credentials, apply new format if old is used this.matchCredentials(node); - this.workflowsStore.addNode(node); if (trackHistory) { this.historyStore.pushCommandToUndo(new AddNodeCommand(node)); } }); - // Wait for the node to be rendered + // Wait for the nodes to be rendered await this.$nextTick(); - // Suspend drawing this.instance?.setSuspendDrawing(true); - // Load the connections - if (connections !== undefined) { - let connectionData; - for (const sourceNode of Object.keys(connections)) { - for (const type of Object.keys(connections[sourceNode])) { - for ( - let sourceIndex = 0; - sourceIndex < connections[sourceNode][type].length; - sourceIndex++ - ) { - const outwardConnections = connections[sourceNode][type][sourceIndex]; - if (!outwardConnections) { - continue; - } + if (connections) { + await this.addConnections(connections); + } + // Add the node issues at the end as the node-connections are required + this.nodeHelpers.refreshNodeIssues(); + this.nodeHelpers.updateNodesInputIssues(); + this.resetEndpointsErrors(); + this.isInsertingNodes = false; + + // Now it can draw again + this.instance?.setSuspendDrawing(false, true); + }, + async addConnections(connections: IConnections) { + const batchedConnectionData: Array<[IConnection, IConnection]> = []; + + for (const sourceNode in connections) { + for (const type in connections[sourceNode]) { + connections[sourceNode][type].forEach((outwardConnections, sourceIndex) => { + if (outwardConnections) { outwardConnections.forEach((targetData) => { - connectionData = [ - { - node: sourceNode, - type, - index: sourceIndex, - }, - { - node: targetData.node, - type: targetData.type, - index: targetData.index, - }, - ] as [IConnection, IConnection]; - - this.__addConnection(connectionData); + batchedConnectionData.push([ + { node: sourceNode, type, index: sourceIndex }, + { node: targetData.node, type: targetData.type, index: targetData.index }, + ]); }); } - } + }); } } - // Add the node issues at the end as the node-connections are required - void this.nodeHelpers.refreshNodeIssues(); + // Process the connections in batches + await this.processConnectionBatch(batchedConnectionData); + setTimeout(this.addConectionsTestData, 0); + }, - // Now it can draw again - this.instance?.setSuspendDrawing(false, true); + async processConnectionBatch(batchedConnectionData: Array<[IConnection, IConnection]>) { + const batchSize = 100; + + for (let i = 0; i < batchedConnectionData.length; i += batchSize) { + const batch = batchedConnectionData.slice(i, i + batchSize); + + batch.forEach((connectionData) => { + this.__addConnection(connectionData); + }); + } }, + async addNodesToWorkflow(data: IWorkflowDataUpdate): Promise { // Because nodes with the same name maybe already exist, it could // be needed that they have to be renamed. Also could it be possible diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 6ec06db20cadd..f09256d8ca1be 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -52,6 +52,7 @@ import { RoutingNode } from './RoutingNode'; import { Expression } from './Expression'; import { NODES_WITH_RENAMABLE_CONTENT } from './Constants'; import { ApplicationError } from './errors/application.error'; +import * as EventReporter from './ErrorReporterProxy'; function dedupe(arr: T[]): T[] { return [...new Set(arr)]; @@ -94,7 +95,14 @@ export class Workflow { settings?: IWorkflowSettings; pinData?: IPinData; }) { - this.id = parameters.id as string; + if (!parameters.id) { + EventReporter.report('Detected ID-less workflow', { + level: 'info', + extra: { parameters }, + }); + } + + this.id = parameters.id as string; // @tech_debt Ensure this is not optional this.name = parameters.name; this.nodeTypes = parameters.nodeTypes; this.pinData = parameters.pinData;