From 3a8b013a966784928fb0a7f01b75412f0100f347 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Feb 2025 16:09:36 +0200 Subject: [PATCH 001/106] :rocket: Release 1.80.0 (#13316) Co-authored-by: tomi <10324676+tomi@users.noreply.github.com> --- CHANGELOG.md | 25 ++++++++++++++++++++++ package.json | 2 +- packages/@n8n/nodes-langchain/package.json | 2 +- packages/cli/package.json | 2 +- packages/editor-ui/package.json | 2 +- 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c90cde6a33..b288cc0af7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +# [1.80.0](https://github.com/n8n-io/n8n/compare/n8n@1.79.0...n8n@1.80.0) (2025-02-17) + + +### Bug Fixes + +* **AI Agent Node:** Move model retrieval into try/catch to fix continueOnFail handling ([#13165](https://github.com/n8n-io/n8n/issues/13165)) ([47c5688](https://github.com/n8n-io/n8n/commit/47c5688618001a51c9412c5d07fd25d85b8d1b8d)) +* **Code Tool Node:** Fix Input Schema Parameter not hiding correctly ([#13245](https://github.com/n8n-io/n8n/issues/13245)) ([8e15ebf](https://github.com/n8n-io/n8n/commit/8e15ebf8333d06b5fe4d5bf8ee39f285b31332d7)) +* **core:** Redact credentials ([#13263](https://github.com/n8n-io/n8n/issues/13263)) ([052f177](https://github.com/n8n-io/n8n/commit/052f17744d072cd16ce90ea94fa9873b4ea2ffed)) +* **core:** Reduce risk of race condition during workflow activation loop ([#13186](https://github.com/n8n-io/n8n/issues/13186)) ([64c5b6e](https://github.com/n8n-io/n8n/commit/64c5b6e0604ce9da6b19dd5f04e61e38209b3153)) +* **core:** Run full manual execution when a trigger is executed even if run data exists ([#13194](https://github.com/n8n-io/n8n/issues/13194)) ([66acb1b](https://github.com/n8n-io/n8n/commit/66acb1bcb68926526ed98a5fe5b89bdaa74148d6)) +* Display correct editor URL ([#13251](https://github.com/n8n-io/n8n/issues/13251)) ([67a4ed1](https://github.com/n8n-io/n8n/commit/67a4ed18a13cb2bc54b3472b9a8beb2f274c2bd2)) +* **editor:** Add template id to metadata when saving workflows from json ([#13172](https://github.com/n8n-io/n8n/issues/13172)) ([2a92032](https://github.com/n8n-io/n8n/commit/2a92032704ebc4e0cdd11aa59b6834a9d891ffb0)) +* **editor:** Fix page size resetting when filters are reset on workflows page ([#13265](https://github.com/n8n-io/n8n/issues/13265)) ([b4380d0](https://github.com/n8n-io/n8n/commit/b4380d05087e1213641ee322875cf51bf706d2f5)) +* **editor:** Open autocompletion when starting an expression ([#13249](https://github.com/n8n-io/n8n/issues/13249)) ([6377635](https://github.com/n8n-io/n8n/commit/6377635bf03387c8d0ae5d54848113258bbabacc)) +* **editor:** Prevent pagination setting from being overwritten in URL ([#13266](https://github.com/n8n-io/n8n/issues/13266)) ([d1e65a1](https://github.com/n8n-io/n8n/commit/d1e65a1cd5841f1d4e815f8da36713cdb18281a4)) +* **editor:** Propagate isReadOnly to ResourceMapper `Attempt to Convert Types` switch ([#13216](https://github.com/n8n-io/n8n/issues/13216)) ([617f841](https://github.com/n8n-io/n8n/commit/617f841e0d82f2b40fcf9ac4bf2cb6a8010b517f)) +* **editor:** Render assignments without ID correctly ([#13252](https://github.com/n8n-io/n8n/issues/13252)) ([d116f12](https://github.com/n8n-io/n8n/commit/d116f121e351e3d81e1b5d6c52eb3e5c3b68ae43)) + + +### Features + +* **editor:** Add pagination to the workflows list ([#13100](https://github.com/n8n-io/n8n/issues/13100)) ([8e37088](https://github.com/n8n-io/n8n/commit/8e370882490d569ff85bba6b7f0a1320fab5eb91)) + + + # [1.79.0](https://github.com/n8n-io/n8n/compare/n8n@1.78.0...n8n@1.79.0) (2025-02-13) diff --git a/package.json b/package.json index 5aac1a8f79..6750279c84 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.79.0", + "version": "1.80.0", "private": true, "engines": { "node": ">=20.15", diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 4170fd5e35..b9c316618c 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.79.0", + "version": "1.80.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 3e67d4eae4..fbce44a1e7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.79.0", + "version": "1.80.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index c345aa0498..00ae727b93 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.79.0", + "version": "1.80.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "scripts": { From 1e5feb195d50054939f85c9e1b5a32885c579901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Wed, 19 Feb 2025 16:56:44 +0100 Subject: [PATCH 002/106] fix(core): Handle connections for missing nodes in a workflow (#13362) --- packages/workflow/src/Workflow.ts | 3 +++ packages/workflow/test/Workflow.test.ts | 27 ++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 10e0de094d..f9b70bb29b 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -508,6 +508,9 @@ export class Workflow { return; } + // Ignore connections for nodes that don't exist in this workflow + if (!(connection.node in this.nodes)) return; + addNodes = this.getHighestNode(connection.node, undefined, checkedNodes); if (addNodes.length === 0) { diff --git a/packages/workflow/test/Workflow.test.ts b/packages/workflow/test/Workflow.test.ts index f7d72cfa66..a87d3479ae 100644 --- a/packages/workflow/test/Workflow.test.ts +++ b/packages/workflow/test/Workflow.test.ts @@ -2201,7 +2201,7 @@ describe('Workflow', () => { expect(result).toEqual([targetNode.name]); }); - test('should handle connections to nodes not defined in workflow', () => { + test('should handle connections to nodes that are not defined in the workflow', () => { const node1 = createNode('Node1'); const targetNode = createNode('TargetNode'); @@ -2226,6 +2226,31 @@ describe('Workflow', () => { expect(result).toEqual([targetNode.name]); }); + + test('should handle connections from nodes that are not defined in the workflow', () => { + const node1 = createNode('Node1'); + const targetNode = createNode('TargetNode'); + + const connections = { + Node1: { + main: [[{ node: 'TargetNode', type: NodeConnectionType.Main, index: 0 }]], + }, + NonExistentNode: { + main: [[{ node: 'TargetNode', type: NodeConnectionType.Main, index: 0 }]], + }, + }; + + const workflow = new Workflow({ + id: 'test', + nodes: [node1, targetNode], + connections, + active: false, + nodeTypes, + }); + + const result = workflow.getHighestNode(targetNode.name); + expect(result).toEqual([node1.name]); + }); }); describe('getParentMainInputNode', () => { From aae55fe7ac77594444c3405161555a517902c68b Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Wed, 19 Feb 2025 19:34:29 -0500 Subject: [PATCH 003/106] feat: Add temporary env variable `N8N_FOLDERS_ENABLED` (no-changelog) (#13374) --- packages/@n8n/api-types/src/frontend-settings.ts | 3 +++ packages/cli/src/config/schema.ts | 9 +++++++++ packages/cli/src/services/frontend.service.ts | 5 +++++ packages/editor-ui/src/__tests__/defaults.ts | 3 +++ packages/editor-ui/src/stores/settings.store.ts | 6 ++++++ 5 files changed, 26 insertions(+) diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index f3f67f6eb1..e4a5acd7f3 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -156,6 +156,9 @@ export interface FrontendSettings { mfa: { enabled: boolean; }; + folders: { + enabled: boolean; + }; banners: { dismissed: string[]; }; diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 6041549ec5..0b2b3ee822 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -370,4 +370,13 @@ export const schema = { env: 'N8N_PROXY_HOPS', doc: 'Number of reverse-proxies n8n is running behind', }, + + folders: { + enabled: { + format: Boolean, + default: false, + env: 'N8N_FOLDERS_ENABLED', + doc: 'Temporary env variable to enable folders feature', + }, + }, }; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 06da1382cf..1b13ad847f 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -233,6 +233,9 @@ export class FrontendService { }, easyAIWorkflowOnboarded: false, partialExecution: this.globalConfig.partialExecutions, + folders: { + enabled: false, + }, }; } @@ -360,6 +363,8 @@ export class FrontendService { this.settings.enterprise.projects.team.limit = this.license.getTeamProjectLimit(); + this.settings.folders.enabled = config.get('folders.enabled'); + return this.settings; } diff --git a/packages/editor-ui/src/__tests__/defaults.ts b/packages/editor-ui/src/__tests__/defaults.ts index a058b33f88..818aaab3a8 100644 --- a/packages/editor-ui/src/__tests__/defaults.ts +++ b/packages/editor-ui/src/__tests__/defaults.ts @@ -140,4 +140,7 @@ export const defaultSettings: FrontendSettings = { version: 1, enforce: false, }, + folders: { + enabled: false, + }, }; diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index 9d03ec8d38..b64a3d32fa 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -43,6 +43,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const ldap = ref({ loginLabel: '', loginEnabled: false }); const saml = ref({ loginLabel: '', loginEnabled: false }); const mfa = ref({ enabled: false }); + const folders = ref({ enabled: false }); + const saveDataErrorExecution = ref('all'); const saveDataSuccessExecution = ref('all'); const saveManualExecutions = ref(false); @@ -141,6 +143,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const isMfaFeatureEnabled = computed(() => mfa.value.enabled); + const isFoldersFeatureEnabled = computed(() => folders.value.enabled); + const areTagsEnabled = computed(() => settings.value.workflowTagsDisabled !== undefined ? !settings.value.workflowTagsDisabled : true, ); @@ -203,6 +207,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { } mfa.value.enabled = settings.value.mfa?.enabled; + folders.value.enabled = settings.value.folders?.enabled; if (settings.value.enterprise?.showNonProdBanner) { useUIStore().pushBannerToStack('NON_PRODUCTION_LICENSE'); @@ -411,6 +416,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { logLevel, isTelemetryEnabled, isMfaFeatureEnabled, + isFoldersFeatureEnabled, isAiAssistantEnabled, areTagsEnabled, isHiringBannerEnabled, From ac1f6519058b9da653a24a994a235805d2e76f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Thu, 20 Feb 2025 12:38:54 +0100 Subject: [PATCH 004/106] chore(core): Stop reporting errors to Sentry for older releases (no-changelog) (#13323) --- .github/workflows/release-publish.yml | 1 + docker/images/n8n/Dockerfile | 3 ++ .../@n8n/config/src/configs/generic.config.ts | 3 ++ packages/@n8n/config/src/decorators.ts | 7 ++++ packages/@n8n/config/test/config.test.ts | 40 +++++++++++++++---- packages/cli/src/commands/base-command.ts | 4 ++ packages/core/src/errors/error-reporter.ts | 23 +++++++++++ 7 files changed, 74 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 06bc4e4465..5054e76c79 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -103,6 +103,7 @@ jobs: context: ./docker/images/n8n build-args: | N8N_VERSION=${{ needs.publish-to-npm.outputs.release }} + N8N_RELEASE_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") platforms: linux/amd64,linux/arm64 provenance: false push: true diff --git a/docker/images/n8n/Dockerfile b/docker/images/n8n/Dockerfile index 10720c63f2..8b258d157b 100644 --- a/docker/images/n8n/Dockerfile +++ b/docker/images/n8n/Dockerfile @@ -4,15 +4,18 @@ FROM n8nio/base:${NODE_VERSION} ARG N8N_VERSION RUN if [ -z "$N8N_VERSION" ] ; then echo "The N8N_VERSION argument is missing!" ; exit 1; fi +ARG N8N_RELEASE_DATE LABEL org.opencontainers.image.title="n8n" LABEL org.opencontainers.image.description="Workflow Automation Tool" LABEL org.opencontainers.image.source="https://github.com/n8n-io/n8n" LABEL org.opencontainers.image.url="https://n8n.io" LABEL org.opencontainers.image.version=${N8N_VERSION} +LABEL org.opencontainers.image.created=${N8N_RELEASE_DATE} ENV N8N_VERSION=${N8N_VERSION} ENV NODE_ENV=production ENV N8N_RELEASE_TYPE=stable +ENV N8N_RELEASE_DATE=${N8N_RELEASE_DATE} RUN set -eux; \ npm install -g --omit=dev n8n@${N8N_VERSION} --ignore-scripts && \ npm rebuild --prefix=/usr/local/lib/node_modules/n8n sqlite3 && \ diff --git a/packages/@n8n/config/src/configs/generic.config.ts b/packages/@n8n/config/src/configs/generic.config.ts index f6960b2415..133d62ee29 100644 --- a/packages/@n8n/config/src/configs/generic.config.ts +++ b/packages/@n8n/config/src/configs/generic.config.ts @@ -9,6 +9,9 @@ export class GenericConfig { @Env('N8N_RELEASE_TYPE') releaseChannel: 'stable' | 'beta' | 'nightly' | 'dev' = 'dev'; + @Env('N8N_RELEASE_DATE') + releaseDate?: Date; + /** Grace period (in seconds) to wait for components to shut down before process exit. */ @Env('N8N_GRACEFUL_SHUTDOWN_TIMEOUT') gracefulShutdownTimeout: number = 30; diff --git a/packages/@n8n/config/src/decorators.ts b/packages/@n8n/config/src/decorators.ts index 57eb1500e2..d9da07740d 100644 --- a/packages/@n8n/config/src/decorators.ts +++ b/packages/@n8n/config/src/decorators.ts @@ -55,6 +55,13 @@ export const Config: ClassDecorator = (ConfigClass: Class) => { } else { console.warn(`Invalid boolean value for ${envName}: ${value}`); } + } else if (type === Date) { + const timestamp = Date.parse(value); + if (isNaN(timestamp)) { + console.warn(`Invalid timestamp value for ${envName}: ${value}`); + } else { + config[key] = new Date(timestamp); + } } else if (type === String) { config[key] = value; } else { diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 834963bcfd..36f39e1ce7 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -8,9 +8,12 @@ jest.mock('fs'); const mockFs = mock(); fs.readFileSync = mockFs.readFileSync; +const consoleWarnMock = jest.spyOn(console, 'warn').mockImplementation(() => {}); + describe('GlobalConfig', () => { beforeEach(() => { Container.reset(); + jest.clearAllMocks(); }); const originalEnv = process.env; @@ -18,10 +21,6 @@ describe('GlobalConfig', () => { process.env = originalEnv; }); - // deepCopy for diff to show plain objects - // eslint-disable-next-line n8n-local-rules/no-json-parse-json-stringify - const deepCopy = (obj: T): T => JSON.parse(JSON.stringify(obj)); - const defaultConfig: GlobalConfig = { path: '/', host: 'localhost', @@ -314,7 +313,7 @@ describe('GlobalConfig', () => { it('should use all default values when no env variables are defined', () => { process.env = {}; const config = Container.get(GlobalConfig); - expect(deepCopy(config)).toEqual(defaultConfig); + expect(structuredClone(config)).toEqual(defaultConfig); expect(mockFs.readFileSync).not.toHaveBeenCalled(); }); @@ -327,9 +326,10 @@ describe('GlobalConfig', () => { DB_LOGGING_MAX_EXECUTION_TIME: '0', N8N_METRICS: 'TRUE', N8N_TEMPLATES_ENABLED: '0', + N8N_RELEASE_DATE: '2025-02-17T13:54:15Z', }; const config = Container.get(GlobalConfig); - expect(deepCopy(config)).toEqual({ + expect(structuredClone(config)).toEqual({ ...defaultConfig, database: { logging: defaultConfig.database.logging, @@ -358,6 +358,10 @@ describe('GlobalConfig', () => { ...defaultConfig.templates, enabled: false, }, + generic: { + ...defaultConfig.generic, + releaseDate: new Date('2025-02-17T13:54:15.000Z'), + }, }); expect(mockFs.readFileSync).not.toHaveBeenCalled(); }); @@ -370,7 +374,7 @@ describe('GlobalConfig', () => { mockFs.readFileSync.calledWith(passwordFile, 'utf8').mockReturnValueOnce('password-from-file'); const config = Container.get(GlobalConfig); - expect(deepCopy(config)).toEqual({ + expect(structuredClone(config)).toEqual({ ...defaultConfig, database: { ...defaultConfig.database, @@ -382,4 +386,26 @@ describe('GlobalConfig', () => { }); expect(mockFs.readFileSync).toHaveBeenCalled(); }); + + it('should handle invalid numbers', () => { + process.env = { + DB_LOGGING_MAX_EXECUTION_TIME: 'abcd', + }; + const config = Container.get(GlobalConfig); + expect(config.database.logging.maxQueryExecutionTime).toEqual(0); + expect(consoleWarnMock).toHaveBeenCalledWith( + 'Invalid number value for DB_LOGGING_MAX_EXECUTION_TIME: abcd', + ); + }); + + it('should handle invalid timestamps', () => { + process.env = { + N8N_RELEASE_DATE: 'abcd', + }; + const config = Container.get(GlobalConfig); + expect(config.generic.releaseDate).toBeUndefined(); + expect(consoleWarnMock).toHaveBeenCalledWith( + 'Invalid timestamp value for N8N_RELEASE_DATE: abcd', + ); + }); }); diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index 930acceb1c..d9bc8dd296 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -63,6 +63,7 @@ export abstract class BaseCommand extends Command { async init(): Promise { this.errorReporter = Container.get(ErrorReporter); + const { releaseDate } = this.globalConfig.generic; const { backendDsn, n8nVersion, environment, deploymentName } = this.globalConfig.sentry; await this.errorReporter.init({ serverType: this.instanceSettings.instanceType, @@ -70,6 +71,7 @@ export abstract class BaseCommand extends Command { environment, release: n8nVersion, serverName: deploymentName, + releaseDate, }); initExpressionEvaluator(); @@ -294,6 +296,8 @@ export abstract class BaseCommand extends Command { await this.shutdownService.waitForShutdown(); + await this.errorReporter.shutdown(); + await this.stopProcess(); clearTimeout(forceShutdownTimer); diff --git a/packages/core/src/errors/error-reporter.ts b/packages/core/src/errors/error-reporter.ts index 2f5b285787..cdcf58ea2f 100644 --- a/packages/core/src/errors/error-reporter.ts +++ b/packages/core/src/errors/error-reporter.ts @@ -16,6 +16,7 @@ type ErrorReporterInitOptions = { release: string; environment: string; serverName: string; + releaseDate?: Date; /** * Function to allow filtering out errors before they are sent to Sentry. * Return true if the error should be filtered out. @@ -23,8 +24,14 @@ type ErrorReporterInitOptions = { beforeSendFilter?: (event: ErrorEvent, hint: EventHint) => boolean; }; +const SIX_WEEKS_IN_MS = 6 * 7 * 24 * 60 * 60 * 1000; +const RELEASE_EXPIRATION_WARNING = + 'Error tracking disabled because this release is older than 6 weeks.'; + @Service() export class ErrorReporter { + private expirationTimer?: NodeJS.Timeout; + /** Hashes of error stack traces, to deduplicate error reports. */ private seenErrors = new Set(); @@ -61,6 +68,7 @@ export class ErrorReporter { } async shutdown(timeoutInMs = 1000) { + clearTimeout(this.expirationTimer); await close(timeoutInMs); } @@ -71,11 +79,26 @@ export class ErrorReporter { release, environment, serverName, + releaseDate, }: ErrorReporterInitOptions) { process.on('uncaughtException', (error) => { this.error(error); }); + if (releaseDate) { + const releaseExpiresInMs = releaseDate.getTime() + SIX_WEEKS_IN_MS - Date.now(); + if (releaseExpiresInMs <= 0) { + this.logger.warn(RELEASE_EXPIRATION_WARNING); + return; + } + // Once this release expires, reject all events + this.expirationTimer = setTimeout(() => { + this.logger.warn(RELEASE_EXPIRATION_WARNING); + // eslint-disable-next-line @typescript-eslint/unbound-method + this.report = this.defaultReport; + }, releaseExpiresInMs); + } + if (!dsn) return; // Collect longer stacktraces From b5e2f331cc446b1e90e880148a5f7ca3813c054e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Thu, 20 Feb 2025 13:00:12 +0100 Subject: [PATCH 005/106] test: Add unit tests for CredentialsOverwrites (#13372) --- .../__tests__/credentials-overwrites.test.ts | 136 ++++++++++++++++++ packages/cli/src/credentials-overwrites.ts | 2 +- 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/__tests__/credentials-overwrites.test.ts diff --git a/packages/cli/src/__tests__/credentials-overwrites.test.ts b/packages/cli/src/__tests__/credentials-overwrites.test.ts new file mode 100644 index 0000000000..c9a4f2fb27 --- /dev/null +++ b/packages/cli/src/__tests__/credentials-overwrites.test.ts @@ -0,0 +1,136 @@ +import type { GlobalConfig } from '@n8n/config'; +import { mock } from 'jest-mock-extended'; +import { UnrecognizedCredentialTypeError, type Logger } from 'n8n-core'; +import type { ICredentialType } from 'n8n-workflow'; + +import type { CredentialTypes } from '@/credential-types'; +import { CredentialsOverwrites } from '@/credentials-overwrites'; + +describe('CredentialsOverwrites', () => { + const testCredentialType = mock({ name: 'test', extends: ['parent'] }); + const parentCredentialType = mock({ name: 'parent', extends: undefined }); + + const globalConfig = mock({ credentials: { overwrite: {} } }); + const credentialTypes = mock(); + const logger = mock(); + let credentialsOverwrites: CredentialsOverwrites; + + beforeEach(() => { + jest.resetAllMocks(); + + globalConfig.credentials.overwrite.data = JSON.stringify({ + test: { username: 'user' }, + parent: { password: 'pass' }, + }); + credentialTypes.recognizes.mockReturnValue(true); + credentialTypes.getByName.mockImplementation((credentialType) => { + if (credentialType === testCredentialType.name) return testCredentialType; + if (credentialType === parentCredentialType.name) return parentCredentialType; + throw new UnrecognizedCredentialTypeError(credentialType); + }); + credentialTypes.getParentTypes + .calledWith(testCredentialType.name) + .mockReturnValue([parentCredentialType.name]); + + credentialsOverwrites = new CredentialsOverwrites(globalConfig, credentialTypes, logger); + }); + + describe('constructor', () => { + it('should parse and set overwrite data from config', () => { + expect(credentialsOverwrites.getAll()).toEqual({ + parent: { password: 'pass' }, + test: { + password: 'pass', + username: 'user', + }, + }); + }); + }); + + describe('setData', () => { + it('should reset resolvedTypes when setting new data', () => { + const newData = { test: { token: 'test-token' } }; + credentialsOverwrites.setData(newData); + + expect(credentialsOverwrites.getAll()).toEqual(newData); + }); + }); + + describe('applyOverwrite', () => { + it('should apply overwrites only for empty fields', () => { + const result = credentialsOverwrites.applyOverwrite('test', { + username: 'existingUser', + password: '', + }); + + expect(result).toEqual({ + username: 'existingUser', + password: 'pass', + }); + }); + + it('should return original data if no overwrites exist', () => { + const data = { + username: 'user1', + password: 'pass1', + }; + + credentialTypes.getParentTypes.mockReturnValueOnce([]); + + const result = credentialsOverwrites.applyOverwrite('unknownCredential', data); + expect(result).toEqual(data); + }); + }); + + describe('getOverwrites', () => { + it('should return undefined for unrecognized credential type', () => { + credentialTypes.recognizes.mockReturnValue(false); + + const result = credentialsOverwrites.getOverwrites('unknownType'); + + expect(result).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Unknown credential type')); + }); + + it('should cache resolved types', () => { + credentialsOverwrites.getOverwrites('parent'); + const firstCall = credentialsOverwrites.getOverwrites('test'); + const secondCall = credentialsOverwrites.getOverwrites('test'); + + expect(firstCall).toEqual(secondCall); + expect(credentialTypes.getByName).toHaveBeenCalledTimes(2); + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(credentialsOverwrites['resolvedTypes']).toEqual(['parent', 'test']); + }); + + it('should merge overwrites from parent types', () => { + credentialTypes.getByName.mockImplementation((credentialType) => { + if (credentialType === 'childType') + return mock({ extends: ['parentType1', 'parentType2'] }); + if (credentialType === 'parentType1') return mock({ extends: undefined }); + if (credentialType === 'parentType2') return mock({ extends: undefined }); + throw new UnrecognizedCredentialTypeError(credentialType); + }); + + globalConfig.credentials.overwrite.data = JSON.stringify({ + childType: { specificField: 'childValue' }, + parentType1: { parentField1: 'value1' }, + parentType2: { parentField2: 'value2' }, + }); + + const credentialsOverwrites = new CredentialsOverwrites( + globalConfig, + credentialTypes, + logger, + ); + + const result = credentialsOverwrites.getOverwrites('childType'); + + expect(result).toEqual({ + parentField1: 'value1', + parentField2: 'value2', + specificField: 'childValue', + }); + }); + }); +}); diff --git a/packages/cli/src/credentials-overwrites.ts b/packages/cli/src/credentials-overwrites.ts index 6689649b0f..3ba1b8043c 100644 --- a/packages/cli/src/credentials-overwrites.ts +++ b/packages/cli/src/credentials-overwrites.ts @@ -60,7 +60,7 @@ export class CredentialsOverwrites { return returnData; } - private getOverwrites(type: string): ICredentialDataDecryptedObject | undefined { + getOverwrites(type: string): ICredentialDataDecryptedObject | undefined { if (this.resolvedTypes.includes(type)) { // Type got already resolved and can so returned directly return this.overwriteData[type]; From 272f55b80f1d4576d1675040bd2775210c4ab5e9 Mon Sep 17 00:00:00 2001 From: Juuso Tapaninen Date: Thu, 20 Feb 2025 14:46:29 +0200 Subject: [PATCH 006/106] feat(core): Hackmation - Add last activity metric (#13237) --- .../prometheus-metrics.service.test.ts | 15 ++++++++- .../src/metrics/prometheus-metrics.service.ts | 32 +++++++++++++------ 2 files changed, 37 insertions(+), 10 deletions(-) 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 d78116f462..cbdd09f643 100644 --- a/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts +++ b/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts @@ -38,6 +38,13 @@ describe('PrometheusMetricsService', () => { includeApiStatusCodeLabel: false, includeQueueMetrics: false, }, + rest: 'rest', + form: 'form', + formTest: 'form-test', + formWaiting: 'form-waiting', + webhook: 'webhook', + webhookTest: 'webhook-test', + webhookWaiting: 'webhook-waiting', }, }); @@ -145,10 +152,16 @@ describe('PrometheusMetricsService', () => { includeStatusCode: false, }); + expect(promClient.Gauge).toHaveBeenNthCalledWith(2, { + name: 'n8n_last_activity', + help: 'last instance activity (backend request).', + labelNames: ['timestamp'], + }); + expect(app.use).toHaveBeenCalledWith( [ - '/rest/', '/api/', + '/rest/', '/webhook/', '/webhook-waiting/', '/webhook-test/', diff --git a/packages/cli/src/metrics/prometheus-metrics.service.ts b/packages/cli/src/metrics/prometheus-metrics.service.ts index b17b8f8578..ec66dd9a70 100644 --- a/packages/cli/src/metrics/prometheus-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus-metrics.service.ts @@ -117,7 +117,8 @@ export class PrometheusMetricsService { } /** - * Set up metrics for server routes with `express-prom-bundle` + * Set up metrics for server routes with `express-prom-bundle`. The same + * middleware is also utilized for an instance activity metric */ private initRouteMetrics(app: express.Application) { if (!this.includes.metrics.routes) return; @@ -130,18 +131,31 @@ export class PrometheusMetricsService { includeStatusCode: this.includes.labels.apiStatusCode, }); + const activityGauge = new promClient.Gauge({ + name: this.prefix + 'last_activity', + help: 'last instance activity (backend request).', + labelNames: ['timestamp'], + }); + + activityGauge.set({ timestamp: new Date().toISOString() }, 1); + app.use( [ - '/rest/', '/api/', - '/webhook/', - '/webhook-waiting/', - '/webhook-test/', - '/form/', - '/form-waiting/', - '/form-test/', + `/${this.globalConfig.endpoints.rest}/`, + `/${this.globalConfig.endpoints.webhook}/`, + `/${this.globalConfig.endpoints.webhookWaiting}/`, + `/${this.globalConfig.endpoints.webhookTest}/`, + `/${this.globalConfig.endpoints.form}/`, + `/${this.globalConfig.endpoints.formWaiting}/`, + `/${this.globalConfig.endpoints.formTest}/`, ], - metricsMiddleware, + (req, res, next) => { + activityGauge.reset(); + activityGauge.set({ timestamp: new Date().toISOString() }, 1); + + metricsMiddleware(req, res, next); + }, ); } From c9c0716a69dde99478e496782ce7426c96379b0e Mon Sep 17 00:00:00 2001 From: Eugene Date: Thu, 20 Feb 2025 17:00:13 +0300 Subject: [PATCH 007/106] feat(core): Add telemetry for workflow evaluation feature (no-changelog) (#13367) --- .../src/evaluation.ee/metrics.controller.ts | 13 ++++++- .../test-definition.service.ee.ts | 36 +++++++++++++++---- .../test-runner/test-runner.service.ee.ts | 21 +++++++++-- .../evaluation.ee/test-runs.controller.ee.ts | 6 +++- .../EditDefinition/NodesPinning.vue | 9 +++++ .../EditDefinition/WorkflowSelector.vue | 6 +++- .../EditDefinition/sections/ConfigSection.vue | 2 ++ .../WorkflowSelectorParameterInput.vue | 3 ++ .../WorkflowExecutionAnnotationPanel.ee.vue | 9 +++++ .../TestDefinition/TestDefinitionEditView.vue | 12 +++++-- 10 files changed, 103 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/evaluation.ee/metrics.controller.ts b/packages/cli/src/evaluation.ee/metrics.controller.ts index 2072b978b1..5d27931166 100644 --- a/packages/cli/src/evaluation.ee/metrics.controller.ts +++ b/packages/cli/src/evaluation.ee/metrics.controller.ts @@ -8,6 +8,7 @@ import { testMetricPatchRequestBodySchema, } from '@/evaluation.ee/metric.schema'; import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service'; +import { Telemetry } from '@/telemetry'; import { TestDefinitionService } from './test-definition.service.ee'; import { TestMetricsRequest } from './test-definitions.types.ee'; @@ -17,6 +18,7 @@ export class TestMetricsController { constructor( private readonly testDefinitionService: TestDefinitionService, private readonly testMetricRepository: TestMetricRepository, + private readonly telemetry: Telemetry, ) {} // This method is used in multiple places in the controller to get the test definition @@ -105,7 +107,16 @@ export class TestMetricsController { if (!metric) throw new NotFoundError('Metric not found'); - await this.testMetricRepository.update(metricId, bodyParseResult.data); + const updateResult = await this.testMetricRepository.update(metricId, bodyParseResult.data); + + // Send telemetry event if the metric was updated + if (updateResult.affected === 1 && metric.name !== bodyParseResult.data.name) { + this.telemetry.track('User added metrics to test', { + metric_id: metricId, + metric_name: bodyParseResult.data.name, + test_id: testDefinitionId, + }); + } // Respond with the updated metric return await this.testMetricRepository.findOneBy({ id: metricId }); diff --git a/packages/cli/src/evaluation.ee/test-definition.service.ee.ts b/packages/cli/src/evaluation.ee/test-definition.service.ee.ts index 682223e3b2..5129f3b389 100644 --- a/packages/cli/src/evaluation.ee/test-definition.service.ee.ts +++ b/packages/cli/src/evaluation.ee/test-definition.service.ee.ts @@ -7,6 +7,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { validateEntity } from '@/generic-helpers'; import type { ListQuery } from '@/requests'; +import { Telemetry } from '@/telemetry'; type TestDefinitionLike = Omit< Partial, @@ -22,6 +23,7 @@ export class TestDefinitionService { constructor( private testDefinitionRepository: TestDefinitionRepository, private annotationTagRepository: AnnotationTagRepository, + private telemetry: Telemetry, ) {} private toEntityLike(attrs: { @@ -94,6 +96,13 @@ export class TestDefinitionService { } async update(id: string, attrs: TestDefinitionLike) { + const existingTestDefinition = await this.testDefinitionRepository.findOneOrFail({ + where: { + id, + }, + relations: ['workflow'], + }); + if (attrs.name) { const updatedTest = this.toEntity(attrs); await validateEntity(updatedTest); @@ -114,13 +123,6 @@ export class TestDefinitionService { // If there are mocked nodes, validate them if (attrs.mockedNodes && attrs.mockedNodes.length > 0) { - const existingTestDefinition = await this.testDefinitionRepository.findOneOrFail({ - where: { - id, - }, - relations: ['workflow'], - }); - const existingNodeNames = new Map( existingTestDefinition.workflow.nodes.map((n) => [n.name, n]), ); @@ -146,6 +148,24 @@ export class TestDefinitionService { if (queryResult.affected === 0) { throw new NotFoundError('Test definition not found'); } + + // Send the telemetry events + if (attrs.annotationTagId && attrs.annotationTagId !== existingTestDefinition.annotationTagId) { + this.telemetry.track('User added tag to test', { + test_id: id, + tag_id: attrs.annotationTagId, + }); + } + + if ( + attrs.evaluationWorkflowId && + existingTestDefinition.evaluationWorkflowId !== attrs.evaluationWorkflowId + ) { + this.telemetry.track('User added evaluation workflow to test', { + test_id: id, + subworkflow_id: attrs.evaluationWorkflowId, + }); + } } async delete(id: string, accessibleWorkflowIds: string[]) { @@ -154,6 +174,8 @@ export class TestDefinitionService { if (deleteResult.affected === 0) { throw new NotFoundError('Test definition not found'); } + + this.telemetry.track('User deleted a test', { test_id: id }); } async getMany(options: ListQuery.Options, accessibleWorkflowIds: string[] = []) { diff --git a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts index 2c6c45ba68..433c86cbcf 100644 --- a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts @@ -292,6 +292,8 @@ export class TestRunnerService { userId: user.id, }; + let testRunEndStatusForTelemetry; + const abortSignal = abortController.signal; try { // Get the evaluation workflow @@ -338,7 +340,7 @@ export class TestRunnerService { // Update test run status await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length); - this.telemetry.track('User runs test', { + this.telemetry.track('User ran test', { user_id: user.id, test_id: test.id, run_id: testRun.id, @@ -504,13 +506,17 @@ export class TestRunnerService { await Db.transaction(async (trx) => { await this.testRunRepository.markAsCancelled(testRun.id, trx); await this.testCaseExecutionRepository.markAllPendingAsCancelled(testRun.id, trx); + + testRunEndStatusForTelemetry = 'cancelled'; }); } else { const aggregatedMetrics = metrics.getAggregatedMetrics(); await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics); - this.logger.debug('Test run finished', { testId: test.id }); + this.logger.debug('Test run finished', { testId: test.id, testRunId: testRun.id }); + + testRunEndStatusForTelemetry = 'completed'; } } catch (e) { if (e instanceof ExecutionCancelledError) { @@ -523,15 +529,26 @@ export class TestRunnerService { await this.testRunRepository.markAsCancelled(testRun.id, trx); await this.testCaseExecutionRepository.markAllPendingAsCancelled(testRun.id, trx); }); + + testRunEndStatusForTelemetry = 'cancelled'; } else if (e instanceof TestRunError) { await this.testRunRepository.markAsError(testRun.id, e.code, e.extra as IDataObject); + testRunEndStatusForTelemetry = 'error'; } else { await this.testRunRepository.markAsError(testRun.id, 'UNKNOWN_ERROR'); + testRunEndStatusForTelemetry = 'error'; throw e; } } finally { // Clean up abort controller this.abortControllers.delete(testRun.id); + + // Send telemetry event + this.telemetry.track('Test run finished', { + test_id: test.id, + run_id: testRun.id, + status: testRunEndStatusForTelemetry, + }); } } diff --git a/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts b/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts index 00f4ead191..333cbb9fae 100644 --- a/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts @@ -11,6 +11,7 @@ import { TestRunsRequest } from '@/evaluation.ee/test-definitions.types.ee'; import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; import { listQueryMiddleware } from '@/middlewares'; import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service'; +import { Telemetry } from '@/telemetry'; import { TestDefinitionService } from './test-definition.service.ee'; @@ -22,6 +23,7 @@ export class TestRunsController { private readonly testCaseExecutionRepository: TestCaseExecutionRepository, private readonly testRunnerService: TestRunnerService, private readonly instanceSettings: InstanceSettings, + private readonly telemetry: Telemetry, ) {} /** @@ -92,7 +94,7 @@ export class TestRunsController { @Delete('/:testDefinitionId/runs/:id') async delete(req: TestRunsRequest.Delete) { - const { id: testRunId } = req.params; + const { id: testRunId, testDefinitionId } = req.params; // Check test definition and test run exist await this.getTestDefinition(req); @@ -100,6 +102,8 @@ export class TestRunsController { await this.testRunRepository.delete({ id: testRunId }); + this.telemetry.track('User deleted a run', { run_id: testRunId, test_id: testDefinitionId }); + return { success: true }; } diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/NodesPinning.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/NodesPinning.vue index 2e2c2a6d9c..c92932249c 100644 --- a/packages/editor-ui/src/components/TestDefinition/EditDefinition/NodesPinning.vue +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/NodesPinning.vue @@ -9,12 +9,14 @@ import { createEventBus, N8nTooltip } from 'n8n-design-system'; import type { CanvasConnectionPort, CanvasEventBusEvents, CanvasNodeData } from '@/types'; import { useVueFlow } from '@vue-flow/core'; import { useI18n } from '@/composables/useI18n'; +import { useTelemetry } from '@/composables/useTelemetry'; const workflowsStore = useWorkflowsStore(); const nodeTypesStore = useNodeTypesStore(); const route = useRoute(); const router = useRouter(); const locale = useI18n(); +const telemetry = useTelemetry(); const { resetWorkspace, initializeWorkspace } = useCanvasOperations({ router }); @@ -101,6 +103,13 @@ function onPinButtonClick(data: CanvasNodeData) { emit('update:modelValue', updatedNodes); updateNodeClasses([data.id], !isPinned); + + if (!isPinned) { + telemetry.track('User selected node to be mocked', { + node_id: data.id, + test_id: testId.value, + }); + } } function isPinButtonVisible(outputs: CanvasConnectionPort[]) { return outputs.length === 1; diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/WorkflowSelector.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/WorkflowSelector.vue index 45b1080fa2..84757c7672 100644 --- a/packages/editor-ui/src/components/TestDefinition/EditDefinition/WorkflowSelector.vue +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/WorkflowSelector.vue @@ -21,7 +21,10 @@ const props = withDefaults(defineProps(), { sampleWorkflowName: undefined, }); -defineEmits<{ 'update:modelValue': [value: WorkflowSelectorProps['modelValue']] }>(); +defineEmits<{ + 'update:modelValue': [value: WorkflowSelectorProps['modelValue']]; + workflowCreated: [workflowId: string]; +}>(); const locale = useI18n(); const subworkflowName = computed(() => { @@ -63,6 +66,7 @@ const sampleWorkflow = computed(() => { allow-new :sample-workflow="sampleWorkflow" @update:model-value="$emit('update:modelValue', $event)" + @workflow-created="$emit('workflowCreated', $event)" /> diff --git a/packages/editor-ui/src/components/TestDefinition/EditDefinition/sections/ConfigSection.vue b/packages/editor-ui/src/components/TestDefinition/EditDefinition/sections/ConfigSection.vue index 36c9fefe3c..3591ad7831 100644 --- a/packages/editor-ui/src/components/TestDefinition/EditDefinition/sections/ConfigSection.vue +++ b/packages/editor-ui/src/components/TestDefinition/EditDefinition/sections/ConfigSection.vue @@ -29,6 +29,7 @@ defineProps<{ const emit = defineEmits<{ openPinningModal: []; deleteMetric: [metric: Partial]; + evaluationWorkflowCreated: [workflowId: string]; }>(); const locale = useI18n(); @@ -145,6 +146,7 @@ function showFieldIssues(fieldKey: string) { :class="{ 'has-issues': getFieldIssues('evaluationWorkflow').length > 0 }" :sample-workflow-name="sampleWorkflowName" @update:model-value="updateChangedFieldsKeys('evaluationWorkflow')" + @workflow-created="$emit('evaluationWorkflowCreated', $event)" /> diff --git a/packages/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue b/packages/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue index 686adcafc6..85e68c9f95 100644 --- a/packages/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue +++ b/packages/editor-ui/src/components/WorkflowSelectorParameterInput/WorkflowSelectorParameterInput.vue @@ -55,6 +55,7 @@ const emit = defineEmits<{ modalOpenerClick: []; focus: []; blur: []; + workflowCreated: [workflowId: string]; }>(); const workflowsStore = useWorkflowsStore(); @@ -232,6 +233,8 @@ const onAddResourceClicked = async () => { hideDropdown(); window.open(href, '_blank'); + + emit('workflowCreated', newWorkflow.id); }; @@ -195,8 +245,10 @@ onMounted(() => { > - -
+ {{ i18n.baseText('projects.move.resource.modal.message.sharingInfo', { adjustToNumber: props.data.resource.sharedWithProjects?.length, @@ -206,6 +258,50 @@ onMounted(() => { }) }} + + + + + + + + + + {{ @@ -219,7 +315,12 @@ onMounted(() => { {{ i18n.baseText('generic.cancel') }} - + {{ i18n.baseText('projects.move.resource.modal.button', { interpolate: { resourceTypeLabel: props.data.resourceTypeLabel }, @@ -236,4 +337,13 @@ onMounted(() => { display: flex; justify-content: flex-end; } + +.textBlock { + display: block; + margin-top: var(--spacing-s); +} + +.tooltipText { + text-decoration: underline; +} diff --git a/packages/editor-ui/src/components/Projects/ProjectMoveResourceModalCredentialsList.vue b/packages/editor-ui/src/components/Projects/ProjectMoveResourceModalCredentialsList.vue new file mode 100644 index 0000000000..661c46f845 --- /dev/null +++ b/packages/editor-ui/src/components/Projects/ProjectMoveResourceModalCredentialsList.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.test.ts b/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.test.ts index 1900173093..d60a68cae7 100644 --- a/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.test.ts +++ b/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.test.ts @@ -18,43 +18,59 @@ describe('ProjectMoveSuccessToastMessage', () => { it('should show credentials message if the resource is a workflow', async () => { const props = { routeName: VIEWS.PROJECTS_WORKFLOWS, - resource: { - id: '1', - name: 'My Workflow', - homeProject: { - id: '2', - name: 'My Project', - }, - }, resourceType: ResourceType.Workflow, - resourceTypeLabel: 'Workflow', targetProject: { id: '2', name: 'My Project', }, + isShareCredentialsChecked: false, + areAllUsedCredentialsShareable: false, }; const { getByText } = renderComponent({ props }); - expect(getByText(/Please double check any credentials/)).toBeInTheDocument(); + expect(getByText(/The workflow's credentials were not shared/)).toBeInTheDocument(); + }); + + it('should show all credentials shared message if the resource is a workflow', async () => { + const props = { + routeName: VIEWS.PROJECTS_WORKFLOWS, + resourceType: ResourceType.Workflow, + targetProject: { + id: '2', + name: 'My Project', + }, + isShareCredentialsChecked: true, + areAllUsedCredentialsShareable: true, + }; + const { getByText } = renderComponent({ props }); + expect(getByText(/The workflow's credentials were shared/)).toBeInTheDocument(); + }); + + it('should show not all credentials shared message if the resource is a workflow', async () => { + const props = { + routeName: VIEWS.PROJECTS_WORKFLOWS, + resourceType: ResourceType.Workflow, + targetProject: { + id: '2', + name: 'My Project', + }, + isShareCredentialsChecked: true, + areAllUsedCredentialsShareable: false, + }; + const { getByText } = renderComponent({ props }); + expect(getByText(/Due to missing permissions/)).toBeInTheDocument(); }); it('should show link if the target project type is team project', async () => { const props = { routeName: VIEWS.PROJECTS_WORKFLOWS, - resource: { - id: '1', - name: 'My Workflow', - homeProject: { - id: '2', - name: 'My Project', - }, - }, resourceType: ResourceType.Workflow, - resourceTypeLabel: 'workflow', targetProject: { id: '2', name: 'Team Project', type: ProjectTypes.Team, }, + isShareCredentialsChecked: false, + areAllUsedCredentialsShareable: false, }; const { getByRole } = renderComponent({ props }); expect(getByRole('link')).toBeInTheDocument(); @@ -63,25 +79,17 @@ describe('ProjectMoveSuccessToastMessage', () => { it('should show only general if the resource is credential and moved to a personal project', async () => { const props = { routeName: VIEWS.PROJECTS_WORKFLOWS, - resource: { - id: '1', - name: 'Notion API', - homeProject: { - id: '2', - name: 'My Project', - }, - }, resourceType: ResourceType.Credential, - resourceTypeLabel: 'credential', targetProject: { id: '2', name: 'Personal Project', type: ProjectTypes.Personal, }, + isShareCredentialsChecked: false, + areAllUsedCredentialsShareable: false, }; - const { getByText, queryByText, queryByRole } = renderComponent({ props }); - expect(getByText(/credential was moved to /)).toBeInTheDocument(); - expect(queryByText(/Please double check any credentials/)).not.toBeInTheDocument(); + const { queryByText, queryByRole } = renderComponent({ props }); + expect(queryByText(/The workflow's credentials were not shared/)).not.toBeInTheDocument(); expect(queryByRole('link')).not.toBeInTheDocument(); }); }); diff --git a/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.vue b/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.vue index 8bf8fe2af4..e8f3c45d33 100644 --- a/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.vue +++ b/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.vue @@ -1,57 +1,52 @@ diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 12301506a3..6c0e857619 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -2644,13 +2644,18 @@ "projects.move.resource.modal.message.note": "Note", "projects.move.resource.modal.message.sharingNote": "{note}: Moving will remove any existing sharing for this {resourceTypeLabel}.", "projects.move.resource.modal.message.sharingInfo": "(Currently shared with {numberOfProjects} project) | (Currently shared with {numberOfProjects} projects)", + "projects.move.resource.modal.message.usedCredentials": "Also share the {usedCredentials} used by this workflow to ensure it will continue to run correctly", + "projects.move.resource.modal.message.usedCredentials.number": "{number} credential | {number} credentials", + "projects.move.resource.modal.message.unAccessibleCredentials": "Some credentials", + "projects.move.resource.modal.message.unAccessibleCredentials.note": "{credentials} used in this workflow will not be shared", "projects.move.resource.modal.message.noProjects": "Currently there are not any projects or users available for you to move this {resourceTypeLabel} to.", "projects.move.resource.modal.button": "Move {resourceTypeLabel}", "projects.move.resource.modal.selectPlaceholder": "Select project or user...", "projects.move.resource.error.title": "Error moving {resourceName} {resourceTypeLabel}", - "projects.move.resource.success.title": "Successfully moved {resourceTypeLabel}", - "projects.move.resource.success.message": "{resourceName} {resourceTypeLabel} was moved to {targetProjectName}. {workflow} {link}", - "projects.move.resource.success.message.workflow": "Please double check any credentials this workflow is using are also shared with {targetProjectName}.", + "projects.move.resource.success.title": "{resourceTypeLabel} '{resourceName}' is moved to '{targetProjectName}'", + "projects.move.resource.success.message.workflow": "The workflow's credentials were not shared with the project.", + "projects.move.resource.success.message.workflow.withAllCredentials": "The workflow's credentials were shared with the project.", + "projects.move.resource.success.message.workflow.withSomeCredentials": "Due to missing permissions not all the workflow's credentials were shared with the project.", "projects.move.resource.success.link": "View {targetProjectName}", "projects.badge.tooltip.sharedOwned": "This {resourceTypeLabel} is owned by you and shared with {count} users", "projects.badge.tooltip.sharedPersonal": "This {resourceTypeLabel} is owned by {name} and shared with {count} users", diff --git a/packages/editor-ui/src/stores/projects.store.ts b/packages/editor-ui/src/stores/projects.store.ts index 8c652eb018..0183236517 100644 --- a/packages/editor-ui/src/stores/projects.store.ts +++ b/packages/editor-ui/src/stores/projects.store.ts @@ -161,9 +161,13 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => { resourceType: 'workflow' | 'credential', resourceId: string, projectId: string, + shareCredentials?: string[], ) => { if (resourceType === 'workflow') { - await workflowsEEApi.moveWorkflowToProject(rootStore.restApiContext, resourceId, projectId); + await workflowsEEApi.moveWorkflowToProject(rootStore.restApiContext, resourceId, { + destinationProjectId: projectId, + shareCredentials, + }); await workflowsStore.fetchAllWorkflows(currentProjectId.value); } else { await credentialsEEApi.moveCredentialToProject( From b2293b7ad5fcd951f9cbd62944891e1a794532ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Fri, 21 Feb 2025 12:58:28 +0100 Subject: [PATCH 012/106] feat(editor): Evaluation feature Phase one readiness (no-changelog) (#13383) Co-authored-by: Oleg Ivaniv --- cypress/pages/ndv.ts | 5 +- .../N8nActionToggle/ActionToggle.vue | 14 + .../src/components/N8nTag/Tag.vue | 3 +- .../src/components/N8nText/Text.vue | 10 +- packages/design-system/src/types/text.ts | 3 + .../editor-ui/src/api/testDefinition.ee.ts | 5 +- .../src/components/InlineNameEdit.vue | 59 ++- .../src/components/MainHeader/MainHeader.vue | 21 +- .../Projects/ProjectCreateResource.vue | 17 +- .../ResourceLocatorDropdown.vue | 2 +- .../EditDefinition/BlockArrow.vue | 127 ++++- .../EditDefinition/EvaluationStep.vue | 176 +++++-- .../EditDefinition/MetricsInput.vue | 20 +- .../EditDefinition/NodesPinning.vue | 95 ++-- .../EditDefinition/WorkflowSelector.vue | 101 +++- .../EditDefinition/sections/ConfigSection.vue | 447 ++++++++++++------ .../EditDefinition/sections/HeaderSection.vue | 134 ------ .../EditDefinition/sections/RunsSection.vue | 73 ++- .../ListDefinition/EmptyState.vue | 128 +++-- .../ListDefinition/TestItem.vue | 254 +++++----- .../ListDefinition/TestsList.vue | 43 -- .../TestDefinition/ListRuns/MetricsChart.vue | 10 +- .../TestDefinition/ListRuns/TestRunsTable.vue | 113 ++--- .../composables/useTestDefinitionForm.ts | 13 +- .../TestDefinition/shared/TestTableBase.vue | 160 +++++-- .../TestDefinition/tests/MetricsInput.test.ts | 13 +- .../tests/useTestDefinitionForm.test.ts | 10 +- .../src/components/TestDefinition/types.ts | 29 +- .../WorkflowSelectorParameterInput.vue | 6 + .../workflow/WorkflowExecutionsCard.vue | 1 + .../workflow/WorkflowExecutionsList.vue | 29 +- .../WorkflowExecutionsPreview.test.ts | 34 +- .../workflow/WorkflowExecutionsPreview.vue | 128 ++++- packages/editor-ui/src/constants.ts | 1 - packages/editor-ui/src/constants.workflows.ts | 115 ++--- .../src/plugins/i18n/locales/en.json | 88 ++-- packages/editor-ui/src/plugins/icons/index.ts | 6 + packages/editor-ui/src/router.ts | 49 +- packages/editor-ui/src/stores/tags.store.ts | 17 +- .../stores/testDefinition.store.ee.test.ts | 20 +- .../src/stores/testDefinition.store.ee.ts | 21 +- .../TestDefinition/TestDefinitionEditView.vue | 395 ++++++++-------- .../TestDefinition/TestDefinitionListView.vue | 327 +++++++------ .../TestDefinition/TestDefinitionNewView.vue | 78 +++ .../TestDefinition/TestDefinitionRootView.vue | 21 +- .../TestDefinitionRunDetailView.vue | 292 ++++++++---- .../TestDefinitionRunsListView.vue | 151 ------ .../tests/TestDefinitionEditView.test.ts | 289 +++-------- .../tests/TestDefinitionListView.test.ts | 253 +++++----- .../tests/TestDefinitionRootView.test.ts | 55 +-- .../tests/TestDefinitionRunDetailView.test.ts | 245 ---------- .../src/views/WorkflowExecutionsView.vue | 1 + 52 files changed, 2452 insertions(+), 2255 deletions(-) delete mode 100644 packages/editor-ui/src/components/TestDefinition/EditDefinition/sections/HeaderSection.vue delete mode 100644 packages/editor-ui/src/components/TestDefinition/ListDefinition/TestsList.vue create mode 100644 packages/editor-ui/src/views/TestDefinition/TestDefinitionNewView.vue delete mode 100644 packages/editor-ui/src/views/TestDefinition/TestDefinitionRunsListView.vue delete mode 100644 packages/editor-ui/src/views/TestDefinition/tests/TestDefinitionRunDetailView.test.ts diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 275d80593d..d5b09c15dd 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -92,7 +92,10 @@ export class NDV extends BasePage { resourceLocatorModeSelector: (paramName: string) => this.getters.resourceLocator(paramName).find('[data-test-id="rlc-mode-selector"]'), resourceLocatorSearch: (paramName: string) => - this.getters.resourceLocator(paramName).findChildByTestId('rlc-search'), + this.getters + .resourceLocator(paramName) + .find('[aria-describedby]') + .then(($el) => cy.get(`#${$el.attr('aria-describedby')}`).findChildByTestId('rlc-search')), resourceMapperFieldsContainer: () => cy.getByTestId('mapping-fields-container'), resourceMapperSelectColumn: () => cy.getByTestId('matching-column-select'), resourceMapperRemoveFieldButton: (fieldName: string) => diff --git a/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue b/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue index 8e0a081220..57dd20bf0d 100644 --- a/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue +++ b/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue @@ -1,5 +1,6 @@