mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge branch 'ADO-2728/feature-change-auto-add-of-chattrigger' of https://github.com/n8n-io/n8n into ADO-2728/feature-change-auto-add-of-chattrigger
This commit is contained in:
commit
b980d62150
3
.github/workflows/ci-pull-requests.yml
vendored
3
.github/workflows/ci-pull-requests.yml
vendored
|
@ -49,6 +49,9 @@ jobs:
|
||||||
with:
|
with:
|
||||||
ref: refs/pull/${{ github.event.pull_request.number }}/merge
|
ref: refs/pull/${{ github.event.pull_request.number }}/merge
|
||||||
cacheKey: ${{ github.sha }}-base:build
|
cacheKey: ${{ github.sha }}-base:build
|
||||||
|
collectCoverage: true
|
||||||
|
secrets:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
|
|
|
@ -88,8 +88,7 @@
|
||||||
"pyodide@0.23.4": "patches/pyodide@0.23.4.patch",
|
"pyodide@0.23.4": "patches/pyodide@0.23.4.patch",
|
||||||
"@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch",
|
"@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch",
|
||||||
"@types/ws@8.5.4": "patches/@types__ws@8.5.4.patch",
|
"@types/ws@8.5.4": "patches/@types__ws@8.5.4.patch",
|
||||||
"@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch",
|
"@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch"
|
||||||
"@langchain/core@0.3.3": "patches/@langchain__core@0.3.3.patch"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
35
packages/@n8n/config/src/configs/pruning.config.ts
Normal file
35
packages/@n8n/config/src/configs/pruning.config.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { Config, Env } from '../decorators';
|
||||||
|
|
||||||
|
@Config
|
||||||
|
export class PruningConfig {
|
||||||
|
/** Whether to delete past executions on a rolling basis. */
|
||||||
|
@Env('EXECUTIONS_DATA_PRUNE')
|
||||||
|
isEnabled: boolean = true;
|
||||||
|
|
||||||
|
/** How old (hours) a finished execution must be to qualify for soft-deletion. */
|
||||||
|
@Env('EXECUTIONS_DATA_MAX_AGE')
|
||||||
|
maxAge: number = 336;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max number of finished executions to keep in database. Does not necessarily
|
||||||
|
* prune to the exact max number. `0` for unlimited.
|
||||||
|
*/
|
||||||
|
@Env('EXECUTIONS_DATA_PRUNE_MAX_COUNT')
|
||||||
|
maxCount: number = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How old (hours) a finished execution must be to qualify for hard-deletion.
|
||||||
|
* This buffer by default excludes recent executions as the user may need
|
||||||
|
* them while building a workflow.
|
||||||
|
*/
|
||||||
|
@Env('EXECUTIONS_DATA_HARD_DELETE_BUFFER')
|
||||||
|
hardDeleteBuffer: number = 1;
|
||||||
|
|
||||||
|
/** How often (minutes) execution data should be hard-deleted. */
|
||||||
|
@Env('EXECUTIONS_DATA_PRUNE_HARD_DELETE_INTERVAL')
|
||||||
|
hardDeleteInterval: number = 15;
|
||||||
|
|
||||||
|
/** How often (minutes) execution data should be soft-deleted */
|
||||||
|
@Env('EXECUTIONS_DATA_PRUNE_SOFT_DELETE_INTERVAL')
|
||||||
|
softDeleteInterval: number = 60;
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ import { LicenseConfig } from './configs/license.config';
|
||||||
import { LoggingConfig } from './configs/logging.config';
|
import { LoggingConfig } from './configs/logging.config';
|
||||||
import { MultiMainSetupConfig } from './configs/multi-main-setup.config';
|
import { MultiMainSetupConfig } from './configs/multi-main-setup.config';
|
||||||
import { NodesConfig } from './configs/nodes.config';
|
import { NodesConfig } from './configs/nodes.config';
|
||||||
|
import { PruningConfig } from './configs/pruning.config';
|
||||||
import { PublicApiConfig } from './configs/public-api.config';
|
import { PublicApiConfig } from './configs/public-api.config';
|
||||||
import { TaskRunnersConfig } from './configs/runners.config';
|
import { TaskRunnersConfig } from './configs/runners.config';
|
||||||
import { ScalingModeConfig } from './configs/scaling-mode.config';
|
import { ScalingModeConfig } from './configs/scaling-mode.config';
|
||||||
|
@ -24,6 +25,7 @@ import { Config, Env, Nested } from './decorators';
|
||||||
export { Config, Env, Nested } from './decorators';
|
export { Config, Env, Nested } from './decorators';
|
||||||
export { TaskRunnersConfig } from './configs/runners.config';
|
export { TaskRunnersConfig } from './configs/runners.config';
|
||||||
export { SecurityConfig } from './configs/security.config';
|
export { SecurityConfig } from './configs/security.config';
|
||||||
|
export { PruningConfig } from './configs/pruning.config';
|
||||||
export { FrontendBetaFeatures, FrontendConfig } from './configs/frontend.config';
|
export { FrontendBetaFeatures, FrontendConfig } from './configs/frontend.config';
|
||||||
export { LOG_SCOPES } from './configs/logging.config';
|
export { LOG_SCOPES } from './configs/logging.config';
|
||||||
export type { LogScope } from './configs/logging.config';
|
export type { LogScope } from './configs/logging.config';
|
||||||
|
@ -112,4 +114,7 @@ export class GlobalConfig {
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
security: SecurityConfig;
|
security: SecurityConfig;
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
pruning: PruningConfig;
|
||||||
}
|
}
|
||||||
|
|
|
@ -271,6 +271,14 @@ describe('GlobalConfig', () => {
|
||||||
blockFileAccessToN8nFiles: true,
|
blockFileAccessToN8nFiles: true,
|
||||||
daysAbandonedWorkflow: 90,
|
daysAbandonedWorkflow: 90,
|
||||||
},
|
},
|
||||||
|
pruning: {
|
||||||
|
isEnabled: true,
|
||||||
|
maxAge: 336,
|
||||||
|
maxCount: 10_000,
|
||||||
|
hardDeleteBuffer: 1,
|
||||||
|
hardDeleteInterval: 15,
|
||||||
|
softDeleteInterval: 60,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should use all default values when no env variables are defined', () => {
|
it('should use all default values when no env variables are defined', () => {
|
||||||
|
|
|
@ -137,22 +137,23 @@
|
||||||
"@google-cloud/resource-manager": "5.3.0",
|
"@google-cloud/resource-manager": "5.3.0",
|
||||||
"@google/generative-ai": "0.19.0",
|
"@google/generative-ai": "0.19.0",
|
||||||
"@huggingface/inference": "2.8.0",
|
"@huggingface/inference": "2.8.0",
|
||||||
"@langchain/anthropic": "0.3.1",
|
"@langchain/anthropic": "0.3.7",
|
||||||
"@langchain/aws": "0.1.0",
|
"@langchain/aws": "0.1.1",
|
||||||
"@langchain/cohere": "0.3.0",
|
"@langchain/cohere": "0.3.1",
|
||||||
"@langchain/community": "0.3.2",
|
"@langchain/community": "0.3.11",
|
||||||
"@langchain/core": "catalog:",
|
"@langchain/core": "catalog:",
|
||||||
"@langchain/google-genai": "0.1.0",
|
"@langchain/google-genai": "0.1.2",
|
||||||
"@langchain/google-vertexai": "0.1.0",
|
"@langchain/google-vertexai": "0.1.0",
|
||||||
"@langchain/groq": "0.1.2",
|
"@langchain/groq": "0.1.2",
|
||||||
"@langchain/mistralai": "0.1.1",
|
"@langchain/mistralai": "0.1.1",
|
||||||
"@langchain/ollama": "0.1.0",
|
"@langchain/ollama": "0.1.1",
|
||||||
"@langchain/openai": "0.3.0",
|
"@langchain/openai": "0.3.11",
|
||||||
"@langchain/pinecone": "0.1.0",
|
"@langchain/pinecone": "0.1.1",
|
||||||
"@langchain/qdrant": "0.1.0",
|
"@langchain/qdrant": "0.1.0",
|
||||||
"@langchain/redis": "0.1.0",
|
"@langchain/redis": "0.1.0",
|
||||||
"@langchain/textsplitters": "0.1.0",
|
"@langchain/textsplitters": "0.1.0",
|
||||||
"@mozilla/readability": "0.5.0",
|
"@mozilla/readability": "0.5.0",
|
||||||
|
"@n8n/json-schema-to-zod": "workspace:*",
|
||||||
"@n8n/typeorm": "0.3.20-12",
|
"@n8n/typeorm": "0.3.20-12",
|
||||||
"@n8n/vm2": "3.9.25",
|
"@n8n/vm2": "3.9.25",
|
||||||
"@pinecone-database/pinecone": "3.0.3",
|
"@pinecone-database/pinecone": "3.0.3",
|
||||||
|
@ -168,14 +169,13 @@
|
||||||
"generate-schema": "2.6.0",
|
"generate-schema": "2.6.0",
|
||||||
"html-to-text": "9.0.5",
|
"html-to-text": "9.0.5",
|
||||||
"jsdom": "23.0.1",
|
"jsdom": "23.0.1",
|
||||||
"@n8n/json-schema-to-zod": "workspace:*",
|
"langchain": "0.3.5",
|
||||||
"langchain": "0.3.2",
|
|
||||||
"lodash": "catalog:",
|
"lodash": "catalog:",
|
||||||
"mammoth": "1.7.2",
|
"mammoth": "1.7.2",
|
||||||
"mime-types": "2.1.35",
|
"mime-types": "2.1.35",
|
||||||
"n8n-nodes-base": "workspace:*",
|
"n8n-nodes-base": "workspace:*",
|
||||||
"n8n-workflow": "workspace:*",
|
"n8n-workflow": "workspace:*",
|
||||||
"openai": "4.63.0",
|
"openai": "4.69.0",
|
||||||
"pdf-parse": "1.1.1",
|
"pdf-parse": "1.1.1",
|
||||||
"pg": "8.12.0",
|
"pg": "8.12.0",
|
||||||
"redis": "4.6.12",
|
"redis": "4.6.12",
|
||||||
|
|
|
@ -23,8 +23,10 @@
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@n8n/config": "workspace:*",
|
"@n8n/config": "workspace:*",
|
||||||
"n8n-workflow": "workspace:*",
|
"acorn": "8.14.0",
|
||||||
|
"acorn-walk": "8.3.4",
|
||||||
"n8n-core": "workspace:*",
|
"n8n-core": "workspace:*",
|
||||||
|
"n8n-workflow": "workspace:*",
|
||||||
"nanoid": "^3.3.6",
|
"nanoid": "^3.3.6",
|
||||||
"typedi": "catalog:",
|
"typedi": "catalog:",
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
|
|
|
@ -4,14 +4,11 @@ import fs from 'node:fs';
|
||||||
import { builtinModules } from 'node:module';
|
import { builtinModules } from 'node:module';
|
||||||
|
|
||||||
import { ValidationError } from '@/js-task-runner/errors/validation-error';
|
import { ValidationError } from '@/js-task-runner/errors/validation-error';
|
||||||
import {
|
import type { DataRequestResponse, JSExecSettings } from '@/js-task-runner/js-task-runner';
|
||||||
JsTaskRunner,
|
import { JsTaskRunner } from '@/js-task-runner/js-task-runner';
|
||||||
type AllCodeTaskData,
|
|
||||||
type JSExecSettings,
|
|
||||||
} from '@/js-task-runner/js-task-runner';
|
|
||||||
import type { Task } from '@/task-runner';
|
import type { Task } from '@/task-runner';
|
||||||
|
|
||||||
import { newAllCodeTaskData, newTaskWithSettings, withPairedItem, wrapIntoJson } from './test-data';
|
import { newCodeTaskData, newTaskWithSettings, withPairedItem, wrapIntoJson } from './test-data';
|
||||||
import type { JsRunnerConfig } from '../../config/js-runner-config';
|
import type { JsRunnerConfig } from '../../config/js-runner-config';
|
||||||
import { MainConfig } from '../../config/main-config';
|
import { MainConfig } from '../../config/main-config';
|
||||||
import { ExecutionError } from '../errors/execution-error';
|
import { ExecutionError } from '../errors/execution-error';
|
||||||
|
@ -43,7 +40,7 @@ describe('JsTaskRunner', () => {
|
||||||
runner = defaultTaskRunner,
|
runner = defaultTaskRunner,
|
||||||
}: {
|
}: {
|
||||||
task: Task<JSExecSettings>;
|
task: Task<JSExecSettings>;
|
||||||
taskData: AllCodeTaskData;
|
taskData: DataRequestResponse;
|
||||||
runner?: JsTaskRunner;
|
runner?: JsTaskRunner;
|
||||||
}) => {
|
}) => {
|
||||||
jest.spyOn(runner, 'requestData').mockResolvedValue(taskData);
|
jest.spyOn(runner, 'requestData').mockResolvedValue(taskData);
|
||||||
|
@ -71,7 +68,7 @@ describe('JsTaskRunner', () => {
|
||||||
nodeMode: 'runOnceForAllItems',
|
nodeMode: 'runOnceForAllItems',
|
||||||
...settings,
|
...settings,
|
||||||
}),
|
}),
|
||||||
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson)),
|
taskData: newCodeTaskData(inputItems.map(wrapIntoJson)),
|
||||||
runner,
|
runner,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -94,7 +91,7 @@ describe('JsTaskRunner', () => {
|
||||||
nodeMode: 'runOnceForEachItem',
|
nodeMode: 'runOnceForEachItem',
|
||||||
...settings,
|
...settings,
|
||||||
}),
|
}),
|
||||||
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson)),
|
taskData: newCodeTaskData(inputItems.map(wrapIntoJson)),
|
||||||
runner,
|
runner,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -111,7 +108,7 @@ describe('JsTaskRunner', () => {
|
||||||
|
|
||||||
await execTaskWithParams({
|
await execTaskWithParams({
|
||||||
task,
|
task,
|
||||||
taskData: newAllCodeTaskData([wrapIntoJson({})]),
|
taskData: newCodeTaskData([wrapIntoJson({})]),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(defaultTaskRunner.makeRpcCall).toHaveBeenCalledWith(task.taskId, 'logNodeOutput', [
|
expect(defaultTaskRunner.makeRpcCall).toHaveBeenCalledWith(task.taskId, 'logNodeOutput', [
|
||||||
|
@ -246,7 +243,7 @@ describe('JsTaskRunner', () => {
|
||||||
code: 'return { val: $env.VAR1 }',
|
code: 'return { val: $env.VAR1 }',
|
||||||
nodeMode: 'runOnceForAllItems',
|
nodeMode: 'runOnceForAllItems',
|
||||||
}),
|
}),
|
||||||
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
|
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
|
||||||
envProviderState: {
|
envProviderState: {
|
||||||
isEnvAccessBlocked: false,
|
isEnvAccessBlocked: false,
|
||||||
isProcessAvailable: true,
|
isProcessAvailable: true,
|
||||||
|
@ -265,7 +262,7 @@ describe('JsTaskRunner', () => {
|
||||||
code: 'return { val: $env.VAR1 }',
|
code: 'return { val: $env.VAR1 }',
|
||||||
nodeMode: 'runOnceForAllItems',
|
nodeMode: 'runOnceForAllItems',
|
||||||
}),
|
}),
|
||||||
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
|
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
|
||||||
envProviderState: {
|
envProviderState: {
|
||||||
isEnvAccessBlocked: true,
|
isEnvAccessBlocked: true,
|
||||||
isProcessAvailable: true,
|
isProcessAvailable: true,
|
||||||
|
@ -282,7 +279,7 @@ describe('JsTaskRunner', () => {
|
||||||
code: 'return Object.values($env).concat(Object.keys($env))',
|
code: 'return Object.values($env).concat(Object.keys($env))',
|
||||||
nodeMode: 'runOnceForAllItems',
|
nodeMode: 'runOnceForAllItems',
|
||||||
}),
|
}),
|
||||||
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
|
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
|
||||||
envProviderState: {
|
envProviderState: {
|
||||||
isEnvAccessBlocked: false,
|
isEnvAccessBlocked: false,
|
||||||
isProcessAvailable: true,
|
isProcessAvailable: true,
|
||||||
|
@ -301,7 +298,7 @@ describe('JsTaskRunner', () => {
|
||||||
code: 'return { val: $env.N8N_RUNNERS_N8N_URI }',
|
code: 'return { val: $env.N8N_RUNNERS_N8N_URI }',
|
||||||
nodeMode: 'runOnceForAllItems',
|
nodeMode: 'runOnceForAllItems',
|
||||||
}),
|
}),
|
||||||
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
|
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
|
||||||
envProviderState: undefined,
|
envProviderState: undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -316,7 +313,7 @@ describe('JsTaskRunner', () => {
|
||||||
code: 'return { val: Buffer.from("test-buffer").toString() }',
|
code: 'return { val: Buffer.from("test-buffer").toString() }',
|
||||||
nodeMode: 'runOnceForAllItems',
|
nodeMode: 'runOnceForAllItems',
|
||||||
}),
|
}),
|
||||||
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
|
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
|
||||||
envProviderState: undefined,
|
envProviderState: undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -328,7 +325,7 @@ describe('JsTaskRunner', () => {
|
||||||
code: 'return { val: Buffer.from("test-buffer").toString() }',
|
code: 'return { val: Buffer.from("test-buffer").toString() }',
|
||||||
nodeMode: 'runOnceForEachItem',
|
nodeMode: 'runOnceForEachItem',
|
||||||
}),
|
}),
|
||||||
taskData: newAllCodeTaskData(inputItems.map(wrapIntoJson), {
|
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
|
||||||
envProviderState: undefined,
|
envProviderState: undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -774,7 +771,7 @@ describe('JsTaskRunner', () => {
|
||||||
code: 'unknown',
|
code: 'unknown',
|
||||||
nodeMode,
|
nodeMode,
|
||||||
}),
|
}),
|
||||||
taskData: newAllCodeTaskData([wrapIntoJson({ a: 1 })]),
|
taskData: newCodeTaskData([wrapIntoJson({ a: 1 })]),
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(ExecutionError);
|
).rejects.toThrow(ExecutionError);
|
||||||
},
|
},
|
||||||
|
@ -796,7 +793,7 @@ describe('JsTaskRunner', () => {
|
||||||
jest.spyOn(runner, 'sendOffers').mockImplementation(() => {});
|
jest.spyOn(runner, 'sendOffers').mockImplementation(() => {});
|
||||||
jest
|
jest
|
||||||
.spyOn(runner, 'requestData')
|
.spyOn(runner, 'requestData')
|
||||||
.mockResolvedValue(newAllCodeTaskData([wrapIntoJson({ a: 1 })]));
|
.mockResolvedValue(newCodeTaskData([wrapIntoJson({ a: 1 })]));
|
||||||
|
|
||||||
await runner.receivedSettings(taskId, task.settings);
|
await runner.receivedSettings(taskId, task.settings);
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,7 @@ import type { IDataObject, INode, INodeExecutionData, ITaskData } from 'n8n-work
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
import type { AllCodeTaskData, JSExecSettings } from '@/js-task-runner/js-task-runner';
|
import type { DataRequestResponse, JSExecSettings } from '@/js-task-runner/js-task-runner';
|
||||||
import type { Task } from '@/task-runner';
|
import type { Task } from '@/task-runner';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -48,10 +48,10 @@ export const newTaskData = (opts: Partial<ITaskData> & Pick<ITaskData, 'source'>
|
||||||
/**
|
/**
|
||||||
* Creates a new all code task data with the given options
|
* Creates a new all code task data with the given options
|
||||||
*/
|
*/
|
||||||
export const newAllCodeTaskData = (
|
export const newCodeTaskData = (
|
||||||
codeNodeInputData: INodeExecutionData[],
|
codeNodeInputData: INodeExecutionData[],
|
||||||
opts: Partial<AllCodeTaskData> = {},
|
opts: Partial<DataRequestResponse> = {},
|
||||||
): AllCodeTaskData => {
|
): DataRequestResponse => {
|
||||||
const codeNode = newNode({
|
const codeNode = newNode({
|
||||||
name: 'JsCode',
|
name: 'JsCode',
|
||||||
parameters: {
|
parameters: {
|
||||||
|
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { BuiltInsParserState } from '../built-ins-parser-state';
|
||||||
|
|
||||||
|
describe('BuiltInsParserState', () => {
|
||||||
|
describe('toDataRequestSpecification', () => {
|
||||||
|
it('should return empty array when no properties are marked as needed', () => {
|
||||||
|
const state = new BuiltInsParserState();
|
||||||
|
|
||||||
|
expect(state.toDataRequestParams()).toEqual({
|
||||||
|
dataOfNodes: [],
|
||||||
|
env: false,
|
||||||
|
input: false,
|
||||||
|
prevNode: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return all nodes and input when markNeedsAllNodes is called', () => {
|
||||||
|
const state = new BuiltInsParserState();
|
||||||
|
state.markNeedsAllNodes();
|
||||||
|
|
||||||
|
expect(state.toDataRequestParams()).toEqual({
|
||||||
|
dataOfNodes: 'all',
|
||||||
|
env: false,
|
||||||
|
input: true,
|
||||||
|
prevNode: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return specific node names when nodes are marked as needed individually', () => {
|
||||||
|
const state = new BuiltInsParserState();
|
||||||
|
state.markNodeAsNeeded('Node1');
|
||||||
|
state.markNodeAsNeeded('Node2');
|
||||||
|
|
||||||
|
expect(state.toDataRequestParams()).toEqual({
|
||||||
|
dataOfNodes: ['Node1', 'Node2'],
|
||||||
|
env: false,
|
||||||
|
input: false,
|
||||||
|
prevNode: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore individual nodes when needsAllNodes is marked as true', () => {
|
||||||
|
const state = new BuiltInsParserState();
|
||||||
|
state.markNodeAsNeeded('Node1');
|
||||||
|
state.markNeedsAllNodes();
|
||||||
|
state.markNodeAsNeeded('Node2'); // should be ignored since all nodes are needed
|
||||||
|
|
||||||
|
expect(state.toDataRequestParams()).toEqual({
|
||||||
|
dataOfNodes: 'all',
|
||||||
|
env: false,
|
||||||
|
input: true,
|
||||||
|
prevNode: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark env as needed when markEnvAsNeeded is called', () => {
|
||||||
|
const state = new BuiltInsParserState();
|
||||||
|
state.markEnvAsNeeded();
|
||||||
|
|
||||||
|
expect(state.toDataRequestParams()).toEqual({
|
||||||
|
dataOfNodes: [],
|
||||||
|
env: true,
|
||||||
|
input: false,
|
||||||
|
prevNode: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark input as needed when markInputAsNeeded is called', () => {
|
||||||
|
const state = new BuiltInsParserState();
|
||||||
|
state.markInputAsNeeded();
|
||||||
|
|
||||||
|
expect(state.toDataRequestParams()).toEqual({
|
||||||
|
dataOfNodes: [],
|
||||||
|
env: false,
|
||||||
|
input: true,
|
||||||
|
prevNode: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark prevNode as needed when markPrevNodeAsNeeded is called', () => {
|
||||||
|
const state = new BuiltInsParserState();
|
||||||
|
state.markPrevNodeAsNeeded();
|
||||||
|
|
||||||
|
expect(state.toDataRequestParams()).toEqual({
|
||||||
|
dataOfNodes: [],
|
||||||
|
env: false,
|
||||||
|
input: false,
|
||||||
|
prevNode: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct specification when multiple properties are marked as needed', () => {
|
||||||
|
const state = new BuiltInsParserState();
|
||||||
|
state.markNeedsAllNodes();
|
||||||
|
state.markEnvAsNeeded();
|
||||||
|
state.markInputAsNeeded();
|
||||||
|
state.markPrevNodeAsNeeded();
|
||||||
|
|
||||||
|
expect(state.toDataRequestParams()).toEqual({
|
||||||
|
dataOfNodes: 'all',
|
||||||
|
env: true,
|
||||||
|
input: true,
|
||||||
|
prevNode: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct specification when all properties are marked as needed', () => {
|
||||||
|
const state = BuiltInsParserState.newNeedsAllDataState();
|
||||||
|
|
||||||
|
expect(state.toDataRequestParams()).toEqual({
|
||||||
|
dataOfNodes: 'all',
|
||||||
|
env: true,
|
||||||
|
input: true,
|
||||||
|
prevNode: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,251 @@
|
||||||
|
import { getAdditionalKeys } from 'n8n-core';
|
||||||
|
import type { IDataObject, INodeType, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
||||||
|
import { Workflow, WorkflowDataProxy } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { newCodeTaskData } from '../../__tests__/test-data';
|
||||||
|
import { BuiltInsParser } from '../built-ins-parser';
|
||||||
|
import { BuiltInsParserState } from '../built-ins-parser-state';
|
||||||
|
|
||||||
|
describe('BuiltInsParser', () => {
|
||||||
|
const parser = new BuiltInsParser();
|
||||||
|
|
||||||
|
const parseAndExpectOk = (code: string) => {
|
||||||
|
const result = parser.parseUsedBuiltIns(code);
|
||||||
|
if (!result.ok) {
|
||||||
|
fail(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.result;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Env, input, execution and prevNode', () => {
|
||||||
|
const cases: Array<[string, BuiltInsParserState]> = [
|
||||||
|
['$env', new BuiltInsParserState({ needs$env: true })],
|
||||||
|
['$execution', new BuiltInsParserState({ needs$execution: true })],
|
||||||
|
['$prevNode', new BuiltInsParserState({ needs$prevNode: true })],
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(cases)("should identify built-ins in '%s'", (code, expected) => {
|
||||||
|
const state = parseAndExpectOk(code);
|
||||||
|
expect(state).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Input', () => {
|
||||||
|
it('should mark input as needed when $input is used', () => {
|
||||||
|
const state = parseAndExpectOk(`
|
||||||
|
$input.item.json.age = 10 + Math.floor(Math.random() * 30);
|
||||||
|
$input.item.json.password = $input.item.json.password.split('').map(() => '*').join("")
|
||||||
|
delete $input.item.json.lastname
|
||||||
|
const emailParts = $input.item.json.email.split("@")
|
||||||
|
$input.item.json.emailData = {
|
||||||
|
user: emailParts[0],
|
||||||
|
domain: emailParts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return $input.item;
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(state).toEqual(new BuiltInsParserState({ needs$input: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark input as needed when $json is used', () => {
|
||||||
|
const state = parseAndExpectOk(`
|
||||||
|
$json.age = 10 + Math.floor(Math.random() * 30);
|
||||||
|
return $json;
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(state).toEqual(new BuiltInsParserState({ needs$input: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('$(...)', () => {
|
||||||
|
const cases: Array<[string, BuiltInsParserState]> = [
|
||||||
|
[
|
||||||
|
'$("nodeName").first()',
|
||||||
|
new BuiltInsParserState({ neededNodeNames: new Set(['nodeName']) }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'$("nodeName").all(); $("secondNode").matchingItem()',
|
||||||
|
new BuiltInsParserState({ neededNodeNames: new Set(['nodeName', 'secondNode']) }),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(cases)("should identify nodes in '%s'", (code, expected) => {
|
||||||
|
const state = parseAndExpectOk(code);
|
||||||
|
expect(state).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should need all nodes when $() is called with a variable', () => {
|
||||||
|
const state = parseAndExpectOk('var n = "name"; $(n)');
|
||||||
|
expect(state).toEqual(new BuiltInsParserState({ needsAllNodes: true, needs$input: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require all nodes when there are multiple usages of $() and one is with a variable', () => {
|
||||||
|
const state = parseAndExpectOk(`
|
||||||
|
$("nodeName");
|
||||||
|
$("secondNode");
|
||||||
|
var n = "name";
|
||||||
|
$(n)
|
||||||
|
`);
|
||||||
|
expect(state).toEqual(new BuiltInsParserState({ needsAllNodes: true, needs$input: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['without parameters', '$()'],
|
||||||
|
['number literal', '$(123)'],
|
||||||
|
])('should ignore when $ is called %s', (_, code) => {
|
||||||
|
const state = parseAndExpectOk(code);
|
||||||
|
expect(state).toEqual(new BuiltInsParserState());
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
'$("node").item',
|
||||||
|
'$("node")["item"]',
|
||||||
|
'$("node").pairedItem()',
|
||||||
|
'$("node")["pairedItem"]()',
|
||||||
|
'$("node").itemMatching(0)',
|
||||||
|
'$("node")["itemMatching"](0)',
|
||||||
|
'$("node")[variable]',
|
||||||
|
'var a = $("node")',
|
||||||
|
'let a = $("node")',
|
||||||
|
'const a = $("node")',
|
||||||
|
'a = $("node")',
|
||||||
|
])('should require all nodes if %s is used', (code) => {
|
||||||
|
const state = parseAndExpectOk(code);
|
||||||
|
expect(state).toEqual(new BuiltInsParserState({ needsAllNodes: true, needs$input: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(['$("node").first()', '$("node").last()', '$("node").all()', '$("node").params'])(
|
||||||
|
'should require only accessed node if %s is used',
|
||||||
|
(code) => {
|
||||||
|
const state = parseAndExpectOk(code);
|
||||||
|
expect(state).toEqual(
|
||||||
|
new BuiltInsParserState({
|
||||||
|
needsAllNodes: false,
|
||||||
|
neededNodeNames: new Set(['node']),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ECMAScript syntax', () => {
|
||||||
|
describe('ES2020', () => {
|
||||||
|
it('should parse optional chaining', () => {
|
||||||
|
parseAndExpectOk(`
|
||||||
|
const a = { b: { c: 1 } };
|
||||||
|
return a.b?.c;
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse nullish coalescing', () => {
|
||||||
|
parseAndExpectOk(`
|
||||||
|
const a = null;
|
||||||
|
return a ?? 1;
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ES2021', () => {
|
||||||
|
it('should parse numeric separators', () => {
|
||||||
|
parseAndExpectOk(`
|
||||||
|
const a = 1_000_000;
|
||||||
|
return a;
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WorkflowDataProxy built-ins', () => {
|
||||||
|
it('should have a known list of built-ins', () => {
|
||||||
|
const data = newCodeTaskData([]);
|
||||||
|
const dataProxy = new WorkflowDataProxy(
|
||||||
|
new Workflow({
|
||||||
|
...data.workflow,
|
||||||
|
nodeTypes: {
|
||||||
|
getByName() {
|
||||||
|
return undefined as unknown as INodeType;
|
||||||
|
},
|
||||||
|
getByNameAndVersion() {
|
||||||
|
return undefined as unknown as INodeType;
|
||||||
|
},
|
||||||
|
getKnownTypes() {
|
||||||
|
return undefined as unknown as IDataObject;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
data.runExecutionData,
|
||||||
|
data.runIndex,
|
||||||
|
0,
|
||||||
|
data.activeNodeName,
|
||||||
|
data.connectionInputData,
|
||||||
|
data.siblingParameters,
|
||||||
|
data.mode,
|
||||||
|
getAdditionalKeys(
|
||||||
|
data.additionalData as IWorkflowExecuteAdditionalData,
|
||||||
|
data.mode,
|
||||||
|
data.runExecutionData,
|
||||||
|
),
|
||||||
|
data.executeData,
|
||||||
|
data.defaultReturnRunIndex,
|
||||||
|
data.selfData,
|
||||||
|
data.contextNodeName,
|
||||||
|
// Make sure that even if we don't receive the envProviderState for
|
||||||
|
// whatever reason, we don't expose the task runner's env to the code
|
||||||
|
data.envProviderState ?? {
|
||||||
|
env: {},
|
||||||
|
isEnvAccessBlocked: false,
|
||||||
|
isProcessAvailable: true,
|
||||||
|
},
|
||||||
|
).getDataProxy({ throwOnMissingExecutionData: false });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOTE! If you are adding new built-ins to the WorkflowDataProxy class
|
||||||
|
* make sure the built-ins parser and Task Runner handle them properly.
|
||||||
|
*/
|
||||||
|
expect(Object.keys(dataProxy)).toStrictEqual([
|
||||||
|
'$',
|
||||||
|
'$input',
|
||||||
|
'$binary',
|
||||||
|
'$data',
|
||||||
|
'$env',
|
||||||
|
'$evaluateExpression',
|
||||||
|
'$item',
|
||||||
|
'$fromAI',
|
||||||
|
'$fromai',
|
||||||
|
'$fromAi',
|
||||||
|
'$items',
|
||||||
|
'$json',
|
||||||
|
'$node',
|
||||||
|
'$self',
|
||||||
|
'$parameter',
|
||||||
|
'$prevNode',
|
||||||
|
'$runIndex',
|
||||||
|
'$mode',
|
||||||
|
'$workflow',
|
||||||
|
'$itemIndex',
|
||||||
|
'$now',
|
||||||
|
'$today',
|
||||||
|
'$jmesPath',
|
||||||
|
'DateTime',
|
||||||
|
'Interval',
|
||||||
|
'Duration',
|
||||||
|
'$execution',
|
||||||
|
'$vars',
|
||||||
|
'$secrets',
|
||||||
|
'$executionId',
|
||||||
|
'$resumeWebhookUrl',
|
||||||
|
'$getPairedItem',
|
||||||
|
'$jmespath',
|
||||||
|
'$position',
|
||||||
|
'$thisItem',
|
||||||
|
'$thisItemIndex',
|
||||||
|
'$thisRunIndex',
|
||||||
|
'$nodeVersion',
|
||||||
|
'$nodeId',
|
||||||
|
'$webhookId',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type {
|
||||||
|
AssignmentExpression,
|
||||||
|
Identifier,
|
||||||
|
Literal,
|
||||||
|
MemberExpression,
|
||||||
|
Node,
|
||||||
|
VariableDeclarator,
|
||||||
|
} from 'acorn';
|
||||||
|
|
||||||
|
export function isLiteral(node?: Node): node is Literal {
|
||||||
|
return node?.type === 'Literal';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIdentifier(node?: Node): node is Identifier {
|
||||||
|
return node?.type === 'Identifier';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMemberExpression(node?: Node): node is MemberExpression {
|
||||||
|
return node?.type === 'MemberExpression';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVariableDeclarator(node?: Node): node is VariableDeclarator {
|
||||||
|
return node?.type === 'VariableDeclarator';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAssignmentExpression(node?: Node): node is AssignmentExpression {
|
||||||
|
return node?.type === 'AssignmentExpression';
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
import type { N8nMessage } from '../../runner-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class to keep track of which built-in variables are accessed in the code
|
||||||
|
*/
|
||||||
|
export class BuiltInsParserState {
|
||||||
|
neededNodeNames: Set<string> = new Set();
|
||||||
|
|
||||||
|
needsAllNodes = false;
|
||||||
|
|
||||||
|
needs$env = false;
|
||||||
|
|
||||||
|
needs$input = false;
|
||||||
|
|
||||||
|
needs$execution = false;
|
||||||
|
|
||||||
|
needs$prevNode = false;
|
||||||
|
|
||||||
|
constructor(opts: Partial<BuiltInsParserState> = {}) {
|
||||||
|
Object.assign(this, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks that all nodes are needed, including input data
|
||||||
|
*/
|
||||||
|
markNeedsAllNodes() {
|
||||||
|
this.needsAllNodes = true;
|
||||||
|
this.needs$input = true;
|
||||||
|
this.neededNodeNames = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
markNodeAsNeeded(nodeName: string) {
|
||||||
|
if (this.needsAllNodes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.neededNodeNames.add(nodeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
markEnvAsNeeded() {
|
||||||
|
this.needs$env = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
markInputAsNeeded() {
|
||||||
|
this.needs$input = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
markExecutionAsNeeded() {
|
||||||
|
this.needs$execution = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
markPrevNodeAsNeeded() {
|
||||||
|
this.needs$prevNode = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDataRequestParams(): N8nMessage.ToRequester.TaskDataRequest['requestParams'] {
|
||||||
|
return {
|
||||||
|
dataOfNodes: this.needsAllNodes ? 'all' : Array.from(this.neededNodeNames),
|
||||||
|
env: this.needs$env,
|
||||||
|
input: this.needs$input,
|
||||||
|
prevNode: this.needs$prevNode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static newNeedsAllDataState() {
|
||||||
|
const obj = new BuiltInsParserState();
|
||||||
|
obj.markNeedsAllNodes();
|
||||||
|
obj.markEnvAsNeeded();
|
||||||
|
obj.markInputAsNeeded();
|
||||||
|
obj.markExecutionAsNeeded();
|
||||||
|
obj.markPrevNodeAsNeeded();
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,142 @@
|
||||||
|
import type { CallExpression, Identifier, Node, Program } from 'acorn';
|
||||||
|
import { parse } from 'acorn';
|
||||||
|
import { ancestor } from 'acorn-walk';
|
||||||
|
import type { Result } from 'n8n-workflow';
|
||||||
|
import { toResult } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isAssignmentExpression,
|
||||||
|
isIdentifier,
|
||||||
|
isLiteral,
|
||||||
|
isMemberExpression,
|
||||||
|
isVariableDeclarator,
|
||||||
|
} from './acorn-helpers';
|
||||||
|
import { BuiltInsParserState } from './built-ins-parser-state';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class for parsing Code Node code to identify which built-in variables
|
||||||
|
* are accessed
|
||||||
|
*/
|
||||||
|
export class BuiltInsParser {
|
||||||
|
/**
|
||||||
|
* Parses which built-in variables are accessed in the given code
|
||||||
|
*/
|
||||||
|
public parseUsedBuiltIns(code: string): Result<BuiltInsParserState, Error> {
|
||||||
|
return toResult(() => {
|
||||||
|
const wrappedCode = `async function VmCodeWrapper() { ${code} }`;
|
||||||
|
const ast = parse(wrappedCode, { ecmaVersion: 2025, sourceType: 'module' });
|
||||||
|
|
||||||
|
return this.identifyBuiltInsByWalkingAst(ast);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Traverse the AST of the script and mark any data needed for it to run. */
|
||||||
|
private identifyBuiltInsByWalkingAst(ast: Program) {
|
||||||
|
const accessedBuiltIns = new BuiltInsParserState();
|
||||||
|
|
||||||
|
ancestor(
|
||||||
|
ast,
|
||||||
|
{
|
||||||
|
CallExpression: this.visitCallExpression,
|
||||||
|
Identifier: this.visitIdentifier,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
accessedBuiltIns,
|
||||||
|
);
|
||||||
|
|
||||||
|
return accessedBuiltIns;
|
||||||
|
}
|
||||||
|
|
||||||
|
private visitCallExpression = (
|
||||||
|
node: CallExpression,
|
||||||
|
state: BuiltInsParserState,
|
||||||
|
ancestors: Node[],
|
||||||
|
) => {
|
||||||
|
// $(...)
|
||||||
|
const isDollar = node.callee.type === 'Identifier' && node.callee.name === '$';
|
||||||
|
if (!isDollar) return;
|
||||||
|
|
||||||
|
// $(): This is not valid, ignore
|
||||||
|
if (node.arguments.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstArg = node.arguments[0];
|
||||||
|
if (!isLiteral(firstArg)) {
|
||||||
|
// $(variable): Can't easily determine statically, mark all nodes as needed
|
||||||
|
state.markNeedsAllNodes();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof firstArg.value !== 'string') {
|
||||||
|
// $(123): Static value, but not a string --> invalid code --> ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// $("node"): Static value, mark 'nodeName' as needed
|
||||||
|
state.markNodeAsNeeded(firstArg.value);
|
||||||
|
|
||||||
|
// Determine how $("node") is used
|
||||||
|
this.handlePrevNodeCall(node, state, ancestors);
|
||||||
|
};
|
||||||
|
|
||||||
|
private handlePrevNodeCall(_node: CallExpression, state: BuiltInsParserState, ancestors: Node[]) {
|
||||||
|
// $("node").item, .pairedItem or .itemMatching: In a case like this, the execution
|
||||||
|
// engine will traverse back from current node (i.e. the Code Node) to
|
||||||
|
// the "node" node and use `pairedItem`s to find which item is linked
|
||||||
|
// to the current item. So, we need to mark all nodes as needed.
|
||||||
|
// TODO: We could also mark all the nodes between the current node and
|
||||||
|
// the "node" node as needed, but that would require more complex logic.
|
||||||
|
const directParent = ancestors[ancestors.length - 2];
|
||||||
|
if (isMemberExpression(directParent)) {
|
||||||
|
const accessedProperty = directParent.property;
|
||||||
|
|
||||||
|
if (directParent.computed) {
|
||||||
|
// $("node")["item"], ["pairedItem"] or ["itemMatching"]
|
||||||
|
if (isLiteral(accessedProperty)) {
|
||||||
|
if (this.isPairedItemProperty(accessedProperty.value)) {
|
||||||
|
state.markNeedsAllNodes();
|
||||||
|
}
|
||||||
|
// Else: $("node")[123]: Static value, but not any of the ones above --> ignore
|
||||||
|
}
|
||||||
|
// $("node")[variable]
|
||||||
|
else if (isIdentifier(accessedProperty)) {
|
||||||
|
state.markNeedsAllNodes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// $("node").item, .pairedItem or .itemMatching
|
||||||
|
else if (isIdentifier(accessedProperty) && this.isPairedItemProperty(accessedProperty.name)) {
|
||||||
|
state.markNeedsAllNodes();
|
||||||
|
}
|
||||||
|
} else if (isVariableDeclarator(directParent) || isAssignmentExpression(directParent)) {
|
||||||
|
// const variable = $("node") or variable = $("node"):
|
||||||
|
// In this case we would need to track down all the possible use sites
|
||||||
|
// of 'variable' and determine if `.item` is accessed on it. This is
|
||||||
|
// more complex and skipped for now.
|
||||||
|
// TODO: Optimize for this case
|
||||||
|
state.markNeedsAllNodes();
|
||||||
|
} else {
|
||||||
|
// Something else than the cases above. Mark all nodes as needed as it
|
||||||
|
// could be a dynamic access.
|
||||||
|
state.markNeedsAllNodes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private visitIdentifier = (node: Identifier, state: BuiltInsParserState) => {
|
||||||
|
if (node.name === '$env') {
|
||||||
|
state.markEnvAsNeeded();
|
||||||
|
} else if (node.name === '$input' || node.name === '$json') {
|
||||||
|
state.markInputAsNeeded();
|
||||||
|
} else if (node.name === '$execution') {
|
||||||
|
state.markExecutionAsNeeded();
|
||||||
|
} else if (node.name === '$prevNode') {
|
||||||
|
state.markPrevNodeAsNeeded();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private isPairedItemProperty(
|
||||||
|
property?: string | boolean | null | number | RegExp | bigint,
|
||||||
|
): boolean {
|
||||||
|
return property === 'item' || property === 'pairedItem' || property === 'itemMatching';
|
||||||
|
}
|
||||||
|
}
|
|
@ -24,6 +24,8 @@ import { runInNewContext, type Context } from 'node:vm';
|
||||||
import type { TaskResultData } from '@/runner-types';
|
import type { TaskResultData } from '@/runner-types';
|
||||||
import { type Task, TaskRunner } from '@/task-runner';
|
import { type Task, TaskRunner } from '@/task-runner';
|
||||||
|
|
||||||
|
import { BuiltInsParser } from './built-ins-parser/built-ins-parser';
|
||||||
|
import { BuiltInsParserState } from './built-ins-parser/built-ins-parser-state';
|
||||||
import { isErrorLike } from './errors/error-like';
|
import { isErrorLike } from './errors/error-like';
|
||||||
import { ExecutionError } from './errors/execution-error';
|
import { ExecutionError } from './errors/execution-error';
|
||||||
import { makeSerializable } from './errors/serializable-error';
|
import { makeSerializable } from './errors/serializable-error';
|
||||||
|
@ -57,7 +59,7 @@ export interface PartialAdditionalData {
|
||||||
variables: IDataObject;
|
variables: IDataObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AllCodeTaskData {
|
export interface DataRequestResponse {
|
||||||
workflow: Omit<WorkflowParameters, 'nodeTypes'>;
|
workflow: Omit<WorkflowParameters, 'nodeTypes'>;
|
||||||
inputData: ITaskDataConnections;
|
inputData: ITaskDataConnections;
|
||||||
node: INode;
|
node: INode;
|
||||||
|
@ -84,6 +86,8 @@ type CustomConsole = {
|
||||||
export class JsTaskRunner extends TaskRunner {
|
export class JsTaskRunner extends TaskRunner {
|
||||||
private readonly requireResolver: RequireResolver;
|
private readonly requireResolver: RequireResolver;
|
||||||
|
|
||||||
|
private readonly builtInsParser = new BuiltInsParser();
|
||||||
|
|
||||||
constructor(config: MainConfig, name = 'JS Task Runner') {
|
constructor(config: MainConfig, name = 'JS Task Runner') {
|
||||||
super({
|
super({
|
||||||
taskType: 'javascript',
|
taskType: 'javascript',
|
||||||
|
@ -102,12 +106,20 @@ export class JsTaskRunner extends TaskRunner {
|
||||||
}
|
}
|
||||||
|
|
||||||
async executeTask(task: Task<JSExecSettings>): Promise<TaskResultData> {
|
async executeTask(task: Task<JSExecSettings>): Promise<TaskResultData> {
|
||||||
const allData = await this.requestData<AllCodeTaskData>(task.taskId, 'all');
|
|
||||||
|
|
||||||
const settings = task.settings;
|
const settings = task.settings;
|
||||||
a.ok(settings, 'JS Code not sent to runner');
|
a.ok(settings, 'JS Code not sent to runner');
|
||||||
|
|
||||||
const workflowParams = allData.workflow;
|
const neededBuiltInsResult = this.builtInsParser.parseUsedBuiltIns(settings.code);
|
||||||
|
const neededBuiltIns = neededBuiltInsResult.ok
|
||||||
|
? neededBuiltInsResult.result
|
||||||
|
: BuiltInsParserState.newNeedsAllDataState();
|
||||||
|
|
||||||
|
const data = await this.requestData<DataRequestResponse>(
|
||||||
|
task.taskId,
|
||||||
|
neededBuiltIns.toDataRequestParams(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const workflowParams = data.workflow;
|
||||||
const workflow = new Workflow({
|
const workflow = new Workflow({
|
||||||
...workflowParams,
|
...workflowParams,
|
||||||
nodeTypes: this.nodeTypes,
|
nodeTypes: this.nodeTypes,
|
||||||
|
@ -126,12 +138,12 @@ export class JsTaskRunner extends TaskRunner {
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
settings.nodeMode === 'runOnceForAllItems'
|
settings.nodeMode === 'runOnceForAllItems'
|
||||||
? await this.runForAllItems(task.taskId, settings, allData, workflow, customConsole)
|
? await this.runForAllItems(task.taskId, settings, data, workflow, customConsole)
|
||||||
: await this.runForEachItem(task.taskId, settings, allData, workflow, customConsole);
|
: await this.runForEachItem(task.taskId, settings, data, workflow, customConsole);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
result,
|
result,
|
||||||
customData: allData.runExecutionData.resultData.metadata,
|
customData: data.runExecutionData.resultData.metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -165,12 +177,12 @@ export class JsTaskRunner extends TaskRunner {
|
||||||
private async runForAllItems(
|
private async runForAllItems(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
settings: JSExecSettings,
|
settings: JSExecSettings,
|
||||||
allData: AllCodeTaskData,
|
data: DataRequestResponse,
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
customConsole: CustomConsole,
|
customConsole: CustomConsole,
|
||||||
): Promise<INodeExecutionData[]> {
|
): Promise<INodeExecutionData[]> {
|
||||||
const dataProxy = this.createDataProxy(allData, workflow, allData.itemIndex);
|
const dataProxy = this.createDataProxy(data, workflow, data.itemIndex);
|
||||||
const inputItems = allData.connectionInputData;
|
const inputItems = data.connectionInputData;
|
||||||
|
|
||||||
const context: Context = {
|
const context: Context = {
|
||||||
require: this.requireResolver,
|
require: this.requireResolver,
|
||||||
|
@ -212,16 +224,16 @@ export class JsTaskRunner extends TaskRunner {
|
||||||
private async runForEachItem(
|
private async runForEachItem(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
settings: JSExecSettings,
|
settings: JSExecSettings,
|
||||||
allData: AllCodeTaskData,
|
data: DataRequestResponse,
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
customConsole: CustomConsole,
|
customConsole: CustomConsole,
|
||||||
): Promise<INodeExecutionData[]> {
|
): Promise<INodeExecutionData[]> {
|
||||||
const inputItems = allData.connectionInputData;
|
const inputItems = data.connectionInputData;
|
||||||
const returnData: INodeExecutionData[] = [];
|
const returnData: INodeExecutionData[] = [];
|
||||||
|
|
||||||
for (let index = 0; index < inputItems.length; index++) {
|
for (let index = 0; index < inputItems.length; index++) {
|
||||||
const item = inputItems[index];
|
const item = inputItems[index];
|
||||||
const dataProxy = this.createDataProxy(allData, workflow, index);
|
const dataProxy = this.createDataProxy(data, workflow, index);
|
||||||
const context: Context = {
|
const context: Context = {
|
||||||
require: this.requireResolver,
|
require: this.requireResolver,
|
||||||
module: {},
|
module: {},
|
||||||
|
@ -279,33 +291,37 @@ export class JsTaskRunner extends TaskRunner {
|
||||||
return returnData;
|
return returnData;
|
||||||
}
|
}
|
||||||
|
|
||||||
private createDataProxy(allData: AllCodeTaskData, workflow: Workflow, itemIndex: number) {
|
private createDataProxy(data: DataRequestResponse, workflow: Workflow, itemIndex: number) {
|
||||||
return new WorkflowDataProxy(
|
return new WorkflowDataProxy(
|
||||||
workflow,
|
workflow,
|
||||||
allData.runExecutionData,
|
data.runExecutionData,
|
||||||
allData.runIndex,
|
data.runIndex,
|
||||||
itemIndex,
|
itemIndex,
|
||||||
allData.activeNodeName,
|
data.activeNodeName,
|
||||||
allData.connectionInputData,
|
data.connectionInputData,
|
||||||
allData.siblingParameters,
|
data.siblingParameters,
|
||||||
allData.mode,
|
data.mode,
|
||||||
getAdditionalKeys(
|
getAdditionalKeys(
|
||||||
allData.additionalData as IWorkflowExecuteAdditionalData,
|
data.additionalData as IWorkflowExecuteAdditionalData,
|
||||||
allData.mode,
|
data.mode,
|
||||||
allData.runExecutionData,
|
data.runExecutionData,
|
||||||
),
|
),
|
||||||
allData.executeData,
|
data.executeData,
|
||||||
allData.defaultReturnRunIndex,
|
data.defaultReturnRunIndex,
|
||||||
allData.selfData,
|
data.selfData,
|
||||||
allData.contextNodeName,
|
data.contextNodeName,
|
||||||
// Make sure that even if we don't receive the envProviderState for
|
// Make sure that even if we don't receive the envProviderState for
|
||||||
// whatever reason, we don't expose the task runner's env to the code
|
// whatever reason, we don't expose the task runner's env to the code
|
||||||
allData.envProviderState ?? {
|
data.envProviderState ?? {
|
||||||
env: {},
|
env: {},
|
||||||
isEnvAccessBlocked: false,
|
isEnvAccessBlocked: false,
|
||||||
isProcessAvailable: true,
|
isProcessAvailable: true,
|
||||||
},
|
},
|
||||||
).getDataProxy();
|
// Because we optimize the needed data, it can be partially available.
|
||||||
|
// We assign the available built-ins to the execution context, which
|
||||||
|
// means we run the getter for '$json', and by default $json throws
|
||||||
|
// if there is no data available.
|
||||||
|
).getDataProxy({ throwOnMissingExecutionData: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
private toExecutionErrorIfNeeded(error: unknown): Error {
|
private toExecutionErrorIfNeeded(error: unknown): Error {
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
import type { INodeExecutionData, INodeTypeBaseDescription } from 'n8n-workflow';
|
import type { INodeExecutionData, INodeTypeBaseDescription } from 'n8n-workflow';
|
||||||
|
|
||||||
export type DataRequestType = 'input' | 'node' | 'all';
|
export interface TaskDataRequestParams {
|
||||||
|
dataOfNodes: string[] | 'all';
|
||||||
|
prevNode: boolean;
|
||||||
|
input: boolean;
|
||||||
|
env: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskResultData {
|
export interface TaskResultData {
|
||||||
result: INodeExecutionData[];
|
result: INodeExecutionData[];
|
||||||
|
@ -89,8 +94,7 @@ export namespace N8nMessage {
|
||||||
type: 'broker:taskdatarequest';
|
type: 'broker:taskdatarequest';
|
||||||
taskId: string;
|
taskId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
requestType: DataRequestType;
|
requestParams: TaskDataRequestParams;
|
||||||
param?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RPC {
|
export interface RPC {
|
||||||
|
@ -186,8 +190,7 @@ export namespace RunnerMessage {
|
||||||
type: 'runner:taskdatarequest';
|
type: 'runner:taskdatarequest';
|
||||||
taskId: string;
|
taskId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
requestType: DataRequestType;
|
requestParams: TaskDataRequestParams;
|
||||||
param?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RPC {
|
export interface RPC {
|
||||||
|
|
|
@ -288,8 +288,7 @@ export abstract class TaskRunner {
|
||||||
|
|
||||||
async requestData<T = unknown>(
|
async requestData<T = unknown>(
|
||||||
taskId: Task['taskId'],
|
taskId: Task['taskId'],
|
||||||
type: RunnerMessage.ToN8n.TaskDataRequest['requestType'],
|
requestParams: RunnerMessage.ToN8n.TaskDataRequest['requestParams'],
|
||||||
param?: string,
|
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const requestId = nanoid();
|
const requestId = nanoid();
|
||||||
|
|
||||||
|
@ -305,8 +304,7 @@ export abstract class TaskRunner {
|
||||||
type: 'runner:taskdatarequest',
|
type: 'runner:taskdatarequest',
|
||||||
taskId,
|
taskId,
|
||||||
requestId,
|
requestId,
|
||||||
requestType: type,
|
requestParams,
|
||||||
param,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import Container from 'typedi';
|
||||||
import { ActiveExecutions } from '@/active-executions';
|
import { ActiveExecutions } from '@/active-executions';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
|
import { ExecutionNotFoundError } from '@/errors/execution-not-found-error';
|
||||||
import { Telemetry } from '@/telemetry';
|
import { Telemetry } from '@/telemetry';
|
||||||
import { WorkflowRunner } from '@/workflow-runner';
|
import { WorkflowRunner } from '@/workflow-runner';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
|
@ -64,6 +65,19 @@ test('processError should return early in Bull stalled edge case', async () => {
|
||||||
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0);
|
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('processError should return early if the error is `ExecutionNotFoundError`', async () => {
|
||||||
|
const workflow = await createWorkflow({}, owner);
|
||||||
|
const execution = await createExecution({ status: 'success', finished: true }, workflow);
|
||||||
|
await runner.processError(
|
||||||
|
new ExecutionNotFoundError(execution.id),
|
||||||
|
new Date(),
|
||||||
|
'webhook',
|
||||||
|
execution.id,
|
||||||
|
new WorkflowHooks(hookFunctions, 'webhook', execution.id, workflow),
|
||||||
|
);
|
||||||
|
expect(watchedWorkflowExecuteAfter).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('processError should process error', async () => {
|
test('processError should process error', async () => {
|
||||||
const workflow = await createWorkflow({}, owner);
|
const workflow = await createWorkflow({}, owner);
|
||||||
const execution = await createExecution(
|
const execution = await createExecution(
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
|
|
||||||
import { ActiveWorkflows, InstanceSettings, NodeExecuteFunctions } from 'n8n-core';
|
import {
|
||||||
|
ActiveWorkflows,
|
||||||
|
InstanceSettings,
|
||||||
|
NodeExecuteFunctions,
|
||||||
|
PollContext,
|
||||||
|
TriggerContext,
|
||||||
|
} from 'n8n-core';
|
||||||
import type {
|
import type {
|
||||||
ExecutionError,
|
ExecutionError,
|
||||||
IDeferredPromise,
|
IDeferredPromise,
|
||||||
|
@ -274,18 +280,11 @@ export class ActiveWorkflowManager {
|
||||||
activation: WorkflowActivateMode,
|
activation: WorkflowActivateMode,
|
||||||
): IGetExecutePollFunctions {
|
): IGetExecutePollFunctions {
|
||||||
return (workflow: Workflow, node: INode) => {
|
return (workflow: Workflow, node: INode) => {
|
||||||
const returnFunctions = NodeExecuteFunctions.getExecutePollFunctions(
|
const __emit = (
|
||||||
workflow,
|
|
||||||
node,
|
|
||||||
additionalData,
|
|
||||||
mode,
|
|
||||||
activation,
|
|
||||||
);
|
|
||||||
returnFunctions.__emit = (
|
|
||||||
data: INodeExecutionData[][],
|
data: INodeExecutionData[][],
|
||||||
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
|
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
|
||||||
donePromise?: IDeferredPromise<IRun | undefined>,
|
donePromise?: IDeferredPromise<IRun | undefined>,
|
||||||
): void => {
|
) => {
|
||||||
this.logger.debug(`Received event to trigger execution for workflow "${workflow.name}"`);
|
this.logger.debug(`Received event to trigger execution for workflow "${workflow.name}"`);
|
||||||
void this.workflowStaticDataService.saveStaticData(workflow);
|
void this.workflowStaticDataService.saveStaticData(workflow);
|
||||||
const executePromise = this.workflowExecutionService.runWorkflow(
|
const executePromise = this.workflowExecutionService.runWorkflow(
|
||||||
|
@ -309,14 +308,15 @@ export class ActiveWorkflowManager {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
returnFunctions.__emitError = (error: ExecutionError): void => {
|
const __emitError = (error: ExecutionError) => {
|
||||||
void this.executionService
|
void this.executionService
|
||||||
.createErrorExecution(error, node, workflowData, workflow, mode)
|
.createErrorExecution(error, node, workflowData, workflow, mode)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.executeErrorWorkflow(error, workflowData, mode);
|
this.executeErrorWorkflow(error, workflowData, mode);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
return returnFunctions;
|
|
||||||
|
return new PollContext(workflow, node, additionalData, mode, activation, __emit, __emitError);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -331,18 +331,11 @@ export class ActiveWorkflowManager {
|
||||||
activation: WorkflowActivateMode,
|
activation: WorkflowActivateMode,
|
||||||
): IGetExecuteTriggerFunctions {
|
): IGetExecuteTriggerFunctions {
|
||||||
return (workflow: Workflow, node: INode) => {
|
return (workflow: Workflow, node: INode) => {
|
||||||
const returnFunctions = NodeExecuteFunctions.getExecuteTriggerFunctions(
|
const emit = (
|
||||||
workflow,
|
|
||||||
node,
|
|
||||||
additionalData,
|
|
||||||
mode,
|
|
||||||
activation,
|
|
||||||
);
|
|
||||||
returnFunctions.emit = (
|
|
||||||
data: INodeExecutionData[][],
|
data: INodeExecutionData[][],
|
||||||
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
|
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
|
||||||
donePromise?: IDeferredPromise<IRun | undefined>,
|
donePromise?: IDeferredPromise<IRun | undefined>,
|
||||||
): void => {
|
) => {
|
||||||
this.logger.debug(`Received trigger for workflow "${workflow.name}"`);
|
this.logger.debug(`Received trigger for workflow "${workflow.name}"`);
|
||||||
void this.workflowStaticDataService.saveStaticData(workflow);
|
void this.workflowStaticDataService.saveStaticData(workflow);
|
||||||
|
|
||||||
|
@ -366,7 +359,7 @@ export class ActiveWorkflowManager {
|
||||||
executePromise.catch((error: Error) => this.logger.error(error.message, { error }));
|
executePromise.catch((error: Error) => this.logger.error(error.message, { error }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
returnFunctions.emitError = (error: Error): void => {
|
const emitError = (error: Error): void => {
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`The trigger node "${node.name}" of workflow "${workflowData.name}" failed with the error: "${error.message}". Will try to reactivate.`,
|
`The trigger node "${node.name}" of workflow "${workflowData.name}" failed with the error: "${error.message}". Will try to reactivate.`,
|
||||||
{
|
{
|
||||||
|
@ -391,7 +384,7 @@ export class ActiveWorkflowManager {
|
||||||
|
|
||||||
this.addQueuedWorkflowActivation(activation, workflowData as WorkflowEntity);
|
this.addQueuedWorkflowActivation(activation, workflowData as WorkflowEntity);
|
||||||
};
|
};
|
||||||
return returnFunctions;
|
return new TriggerContext(workflow, node, additionalData, mode, activation, emit, emitError);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,8 +22,6 @@ import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
import { ExecutionService } from '@/executions/execution.service';
|
import { ExecutionService } from '@/executions/execution.service';
|
||||||
import { License } from '@/license';
|
import { License } from '@/license';
|
||||||
import { LocalTaskManager } from '@/runners/task-managers/local-task-manager';
|
|
||||||
import { TaskManager } from '@/runners/task-managers/task-manager';
|
|
||||||
import { PubSubHandler } from '@/scaling/pubsub/pubsub-handler';
|
import { PubSubHandler } from '@/scaling/pubsub/pubsub-handler';
|
||||||
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
|
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
|
||||||
import { Server } from '@/server';
|
import { Server } from '@/server';
|
||||||
|
@ -224,19 +222,9 @@ export class Start extends BaseCommand {
|
||||||
|
|
||||||
const { taskRunners: taskRunnerConfig } = this.globalConfig;
|
const { taskRunners: taskRunnerConfig } = this.globalConfig;
|
||||||
if (!taskRunnerConfig.disabled) {
|
if (!taskRunnerConfig.disabled) {
|
||||||
Container.set(TaskManager, new LocalTaskManager());
|
const { TaskRunnerModule } = await import('@/runners/task-runner-module');
|
||||||
const { TaskRunnerServer } = await import('@/runners/task-runner-server');
|
const taskRunnerModule = Container.get(TaskRunnerModule);
|
||||||
const taskRunnerServer = Container.get(TaskRunnerServer);
|
await taskRunnerModule.start();
|
||||||
await taskRunnerServer.start();
|
|
||||||
|
|
||||||
if (
|
|
||||||
taskRunnerConfig.mode === 'internal_childprocess' ||
|
|
||||||
taskRunnerConfig.mode === 'internal_launcher'
|
|
||||||
) {
|
|
||||||
const { TaskRunnerProcess } = await import('@/runners/task-runner-process');
|
|
||||||
const runnerProcess = Container.get(TaskRunnerProcess);
|
|
||||||
await runnerProcess.start();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,8 +8,6 @@ import { EventMessageGeneric } from '@/eventbus/event-message-classes/event-mess
|
||||||
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
||||||
import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay';
|
import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay';
|
||||||
import { Logger } from '@/logging/logger.service';
|
import { Logger } from '@/logging/logger.service';
|
||||||
import { LocalTaskManager } from '@/runners/task-managers/local-task-manager';
|
|
||||||
import { TaskManager } from '@/runners/task-managers/task-manager';
|
|
||||||
import { PubSubHandler } from '@/scaling/pubsub/pubsub-handler';
|
import { PubSubHandler } from '@/scaling/pubsub/pubsub-handler';
|
||||||
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
|
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
|
||||||
import type { ScalingService } from '@/scaling/scaling.service';
|
import type { ScalingService } from '@/scaling/scaling.service';
|
||||||
|
@ -116,19 +114,9 @@ export class Worker extends BaseCommand {
|
||||||
|
|
||||||
const { taskRunners: taskRunnerConfig } = this.globalConfig;
|
const { taskRunners: taskRunnerConfig } = this.globalConfig;
|
||||||
if (!taskRunnerConfig.disabled) {
|
if (!taskRunnerConfig.disabled) {
|
||||||
Container.set(TaskManager, new LocalTaskManager());
|
const { TaskRunnerModule } = await import('@/runners/task-runner-module');
|
||||||
const { TaskRunnerServer } = await import('@/runners/task-runner-server');
|
const taskRunnerModule = Container.get(TaskRunnerModule);
|
||||||
const taskRunnerServer = Container.get(TaskRunnerServer);
|
await taskRunnerModule.start();
|
||||||
await taskRunnerServer.start();
|
|
||||||
|
|
||||||
if (
|
|
||||||
taskRunnerConfig.mode === 'internal_childprocess' ||
|
|
||||||
taskRunnerConfig.mode === 'internal_launcher'
|
|
||||||
) {
|
|
||||||
const { TaskRunnerProcess } = await import('@/runners/task-runner-process');
|
|
||||||
const runnerProcess = Container.get(TaskRunnerProcess);
|
|
||||||
await runnerProcess.start();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -98,54 +98,6 @@ export const schema = {
|
||||||
env: 'EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS',
|
env: 'EXECUTIONS_DATA_SAVE_MANUAL_EXECUTIONS',
|
||||||
},
|
},
|
||||||
|
|
||||||
// To not exceed the database's capacity and keep its size moderate
|
|
||||||
// the execution data gets pruned regularly (default: 15 minute interval).
|
|
||||||
// All saved execution data older than the max age will be deleted.
|
|
||||||
// Pruning is currently not activated by default, which will change in
|
|
||||||
// a future version.
|
|
||||||
pruneData: {
|
|
||||||
doc: 'Delete data of past executions on a rolling basis',
|
|
||||||
format: Boolean,
|
|
||||||
default: true,
|
|
||||||
env: 'EXECUTIONS_DATA_PRUNE',
|
|
||||||
},
|
|
||||||
pruneDataMaxAge: {
|
|
||||||
doc: 'How old (hours) the finished execution data has to be to get soft-deleted',
|
|
||||||
format: Number,
|
|
||||||
default: 336,
|
|
||||||
env: 'EXECUTIONS_DATA_MAX_AGE',
|
|
||||||
},
|
|
||||||
pruneDataHardDeleteBuffer: {
|
|
||||||
doc: 'How old (hours) the finished execution data has to be to get hard-deleted. By default, this buffer excludes recent executions as the user may need them while building a workflow.',
|
|
||||||
format: Number,
|
|
||||||
default: 1,
|
|
||||||
env: 'EXECUTIONS_DATA_HARD_DELETE_BUFFER',
|
|
||||||
},
|
|
||||||
pruneDataIntervals: {
|
|
||||||
hardDelete: {
|
|
||||||
doc: 'How often (minutes) execution data should be hard-deleted',
|
|
||||||
format: Number,
|
|
||||||
default: 15,
|
|
||||||
env: 'EXECUTIONS_DATA_PRUNE_HARD_DELETE_INTERVAL',
|
|
||||||
},
|
|
||||||
softDelete: {
|
|
||||||
doc: 'How often (minutes) execution data should be soft-deleted',
|
|
||||||
format: Number,
|
|
||||||
default: 60,
|
|
||||||
env: 'EXECUTIONS_DATA_PRUNE_SOFT_DELETE_INTERVAL',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Additional pruning option to delete executions if total count exceeds the configured max.
|
|
||||||
// Deletes the oldest entries first
|
|
||||||
// Set to 0 for No limit
|
|
||||||
pruneDataMaxCount: {
|
|
||||||
doc: "Maximum number of finished executions to keep in DB. Doesn't necessarily prune exactly to max number. 0 = no limit",
|
|
||||||
format: Number,
|
|
||||||
default: 10000,
|
|
||||||
env: 'EXECUTIONS_DATA_PRUNE_MAX_COUNT',
|
|
||||||
},
|
|
||||||
|
|
||||||
queueRecovery: {
|
queueRecovery: {
|
||||||
interval: {
|
interval: {
|
||||||
doc: 'How often (minutes) to check for queue recovery',
|
doc: 'How often (minutes) to check for queue recovery',
|
||||||
|
|
|
@ -35,7 +35,6 @@ import type {
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import config from '@/config';
|
|
||||||
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
|
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
|
||||||
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee';
|
import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping.ee';
|
||||||
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee';
|
import { ExecutionAnnotation } from '@/databases/entities/execution-annotation.ee';
|
||||||
|
@ -460,8 +459,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async softDeletePrunableExecutions() {
|
async softDeletePrunableExecutions() {
|
||||||
const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h
|
const { maxAge, maxCount } = this.globalConfig.pruning;
|
||||||
const maxCount = config.getEnv('executions.pruneDataMaxCount');
|
|
||||||
|
|
||||||
// Sub-query to exclude executions having annotations
|
// Sub-query to exclude executions having annotations
|
||||||
const annotatedExecutionsSubQuery = this.manager
|
const annotatedExecutionsSubQuery = this.manager
|
||||||
|
@ -517,7 +515,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
||||||
|
|
||||||
async hardDeleteSoftDeletedExecutions() {
|
async hardDeleteSoftDeletedExecutions() {
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
date.setHours(date.getHours() - config.getEnv('executions.pruneDataHardDeleteBuffer'));
|
date.setHours(date.getHours() - this.globalConfig.pruning.hardDeleteBuffer);
|
||||||
|
|
||||||
const workflowIdsAndExecutionIds = (
|
const workflowIdsAndExecutionIds = (
|
||||||
await this.find({
|
await this.find({
|
||||||
|
|
|
@ -771,8 +771,8 @@ export class TelemetryEventRelay extends EventRelay {
|
||||||
executions_data_save_manual_executions: config.getEnv(
|
executions_data_save_manual_executions: config.getEnv(
|
||||||
'executions.saveDataManualExecutions',
|
'executions.saveDataManualExecutions',
|
||||||
),
|
),
|
||||||
executions_data_prune: config.getEnv('executions.pruneData'),
|
executions_data_prune: this.globalConfig.pruning.isEnabled,
|
||||||
executions_data_max_age: config.getEnv('executions.pruneDataMaxAge'),
|
executions_data_max_age: this.globalConfig.pruning.maxAge,
|
||||||
},
|
},
|
||||||
n8n_deployment_type: config.getEnv('deployment.type'),
|
n8n_deployment_type: config.getEnv('deployment.type'),
|
||||||
n8n_binary_data_mode: binaryDataConfig.mode,
|
n8n_binary_data_mode: binaryDataConfig.mode,
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
deepCopy,
|
||||||
ErrorReporterProxy,
|
ErrorReporterProxy,
|
||||||
type IRunExecutionData,
|
type IRunExecutionData,
|
||||||
type ITaskData,
|
type ITaskData,
|
||||||
|
@ -57,7 +58,7 @@ test('should ignore on leftover async call', async () => {
|
||||||
expect(executionRepository.updateExistingExecution).not.toHaveBeenCalled();
|
expect(executionRepository.updateExistingExecution).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should update execution', async () => {
|
test('should update execution when saving progress is enabled', async () => {
|
||||||
jest.spyOn(fnModule, 'toSaveSettings').mockReturnValue({
|
jest.spyOn(fnModule, 'toSaveSettings').mockReturnValue({
|
||||||
...commonSettings,
|
...commonSettings,
|
||||||
progress: true,
|
progress: true,
|
||||||
|
@ -86,6 +87,37 @@ test('should update execution', async () => {
|
||||||
expect(reporterSpy).not.toHaveBeenCalled();
|
expect(reporterSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should update execution when saving progress is disabled, but waitTill is defined', async () => {
|
||||||
|
jest.spyOn(fnModule, 'toSaveSettings').mockReturnValue({
|
||||||
|
...commonSettings,
|
||||||
|
progress: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reporterSpy = jest.spyOn(ErrorReporterProxy, 'error');
|
||||||
|
|
||||||
|
executionRepository.findSingleExecution.mockResolvedValue({} as IExecutionResponse);
|
||||||
|
|
||||||
|
const args = deepCopy(commonArgs);
|
||||||
|
args[4].waitTill = new Date();
|
||||||
|
await saveExecutionProgress(...args);
|
||||||
|
|
||||||
|
expect(executionRepository.updateExistingExecution).toHaveBeenCalledWith('some-execution-id', {
|
||||||
|
data: {
|
||||||
|
executionData: undefined,
|
||||||
|
resultData: {
|
||||||
|
lastNodeExecuted: 'My Node',
|
||||||
|
runData: {
|
||||||
|
'My Node': [{}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startData: {},
|
||||||
|
},
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reporterSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test('should report error on failure', async () => {
|
test('should report error on failure', async () => {
|
||||||
jest.spyOn(fnModule, 'toSaveSettings').mockReturnValue({
|
jest.spyOn(fnModule, 'toSaveSettings').mockReturnValue({
|
||||||
...commonSettings,
|
...commonSettings,
|
||||||
|
|
|
@ -16,7 +16,7 @@ export async function saveExecutionProgress(
|
||||||
) {
|
) {
|
||||||
const saveSettings = toSaveSettings(workflowData.settings);
|
const saveSettings = toSaveSettings(workflowData.settings);
|
||||||
|
|
||||||
if (!saveSettings.progress) return;
|
if (!saveSettings.progress && !executionData.waitTill) return;
|
||||||
|
|
||||||
const logger = Container.get(Logger);
|
const logger = Container.get(Logger);
|
||||||
|
|
||||||
|
|
|
@ -494,15 +494,18 @@ describe('TaskBroker', () => {
|
||||||
const taskId = 'task1';
|
const taskId = 'task1';
|
||||||
const requesterId = 'requester1';
|
const requesterId = 'requester1';
|
||||||
const requestId = 'request1';
|
const requestId = 'request1';
|
||||||
const requestType = 'input';
|
const requestParams: RunnerMessage.ToN8n.TaskDataRequest['requestParams'] = {
|
||||||
const param = 'test_param';
|
dataOfNodes: 'all',
|
||||||
|
env: true,
|
||||||
|
input: true,
|
||||||
|
prevNode: true,
|
||||||
|
};
|
||||||
|
|
||||||
const message: RunnerMessage.ToN8n.TaskDataRequest = {
|
const message: RunnerMessage.ToN8n.TaskDataRequest = {
|
||||||
type: 'runner:taskdatarequest',
|
type: 'runner:taskdatarequest',
|
||||||
taskId,
|
taskId,
|
||||||
requestId,
|
requestId,
|
||||||
requestType,
|
requestParams,
|
||||||
param,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const requesterMessageCallback = jest.fn();
|
const requesterMessageCallback = jest.fn();
|
||||||
|
@ -519,8 +522,7 @@ describe('TaskBroker', () => {
|
||||||
type: 'broker:taskdatarequest',
|
type: 'broker:taskdatarequest',
|
||||||
taskId,
|
taskId,
|
||||||
requestId,
|
requestId,
|
||||||
requestType,
|
requestParams,
|
||||||
param,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
|
import { TaskRunnerDisconnectedError } from './errors/task-runner-disconnected-error';
|
||||||
|
import type { DisconnectAnalyzer } from './runner-types';
|
||||||
|
import type { TaskRunner } from './task-broker.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyzes the disconnect reason of a task runner to provide a more
|
||||||
|
* meaningful error message to the user.
|
||||||
|
*/
|
||||||
|
@Service()
|
||||||
|
export class DefaultTaskRunnerDisconnectAnalyzer implements DisconnectAnalyzer {
|
||||||
|
async determineDisconnectReason(runnerId: TaskRunner['id']): Promise<Error> {
|
||||||
|
return new TaskRunnerDisconnectedError(runnerId);
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ import { Service } from 'typedi';
|
||||||
|
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
|
|
||||||
import { TaskRunnerDisconnectedError } from './errors/task-runner-disconnected-error';
|
import { DefaultTaskRunnerDisconnectAnalyzer } from './default-task-runner-disconnect-analyzer';
|
||||||
import { TaskRunnerOomError } from './errors/task-runner-oom-error';
|
import { TaskRunnerOomError } from './errors/task-runner-oom-error';
|
||||||
import { SlidingWindowSignal } from './sliding-window-signal';
|
import { SlidingWindowSignal } from './sliding-window-signal';
|
||||||
import type { TaskRunner } from './task-broker.service';
|
import type { TaskRunner } from './task-broker.service';
|
||||||
|
@ -15,13 +15,19 @@ import { TaskRunnerProcess } from './task-runner-process';
|
||||||
* meaningful error message to the user.
|
* meaningful error message to the user.
|
||||||
*/
|
*/
|
||||||
@Service()
|
@Service()
|
||||||
export class TaskRunnerDisconnectAnalyzer {
|
export class InternalTaskRunnerDisconnectAnalyzer extends DefaultTaskRunnerDisconnectAnalyzer {
|
||||||
|
private get isCloudDeployment() {
|
||||||
|
return config.get('deployment.type') === 'cloud';
|
||||||
|
}
|
||||||
|
|
||||||
private readonly exitReasonSignal: SlidingWindowSignal<TaskRunnerProcessEventMap, 'exit'>;
|
private readonly exitReasonSignal: SlidingWindowSignal<TaskRunnerProcessEventMap, 'exit'>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly runnerConfig: TaskRunnersConfig,
|
private readonly runnerConfig: TaskRunnersConfig,
|
||||||
private readonly taskRunnerProcess: TaskRunnerProcess,
|
private readonly taskRunnerProcess: TaskRunnerProcess,
|
||||||
) {
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
// When the task runner process is running as a child process, there's
|
// When the task runner process is running as a child process, there's
|
||||||
// no determinate time when it exits compared to when the runner disconnects
|
// no determinate time when it exits compared to when the runner disconnects
|
||||||
// (i.e. it's a race condition). Hence we use a sliding window to determine
|
// (i.e. it's a race condition). Hence we use a sliding window to determine
|
||||||
|
@ -32,17 +38,13 @@ export class TaskRunnerDisconnectAnalyzer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private get isCloudDeployment() {
|
|
||||||
return config.get('deployment.type') === 'cloud';
|
|
||||||
}
|
|
||||||
|
|
||||||
async determineDisconnectReason(runnerId: TaskRunner['id']): Promise<Error> {
|
async determineDisconnectReason(runnerId: TaskRunner['id']): Promise<Error> {
|
||||||
const exitCode = await this.awaitExitSignal();
|
const exitCode = await this.awaitExitSignal();
|
||||||
if (exitCode === 'oom') {
|
if (exitCode === 'oom') {
|
||||||
return new TaskRunnerOomError(runnerId, this.isCloudDeployment);
|
return new TaskRunnerOomError(runnerId, this.isCloudDeployment);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new TaskRunnerDisconnectedError(runnerId);
|
return await super.determineDisconnectReason(runnerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async awaitExitSignal(): Promise<ExitReason> {
|
private async awaitExitSignal(): Promise<ExitReason> {
|
|
@ -5,6 +5,22 @@ import type WebSocket from 'ws';
|
||||||
import type { TaskRunner } from './task-broker.service';
|
import type { TaskRunner } from './task-broker.service';
|
||||||
import type { AuthlessRequest } from '../requests';
|
import type { AuthlessRequest } from '../requests';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Specifies what data should be included for a task data request.
|
||||||
|
*/
|
||||||
|
export interface TaskDataRequestParams {
|
||||||
|
dataOfNodes: string[] | 'all';
|
||||||
|
prevNode: boolean;
|
||||||
|
/** Whether input data for the node should be included */
|
||||||
|
input: boolean;
|
||||||
|
/** Whether env provider's state should be included */
|
||||||
|
env: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisconnectAnalyzer {
|
||||||
|
determineDisconnectReason(runnerId: TaskRunner['id']): Promise<Error>;
|
||||||
|
}
|
||||||
|
|
||||||
export type DataRequestType = 'input' | 'node' | 'all';
|
export type DataRequestType = 'input' | 'node' | 'all';
|
||||||
|
|
||||||
export interface TaskResultData {
|
export interface TaskResultData {
|
||||||
|
@ -101,8 +117,7 @@ export namespace N8nMessage {
|
||||||
type: 'broker:taskdatarequest';
|
type: 'broker:taskdatarequest';
|
||||||
taskId: string;
|
taskId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
requestType: DataRequestType;
|
requestParams: TaskDataRequestParams;
|
||||||
param?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RPC {
|
export interface RPC {
|
||||||
|
@ -198,8 +213,7 @@ export namespace RunnerMessage {
|
||||||
type: 'runner:taskdatarequest';
|
type: 'runner:taskdatarequest';
|
||||||
taskId: string;
|
taskId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
requestType: DataRequestType;
|
requestParams: TaskDataRequestParams;
|
||||||
param?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RPC {
|
export interface RPC {
|
||||||
|
|
|
@ -3,29 +3,38 @@ import type WebSocket from 'ws';
|
||||||
|
|
||||||
import { Logger } from '@/logging/logger.service';
|
import { Logger } from '@/logging/logger.service';
|
||||||
|
|
||||||
|
import { DefaultTaskRunnerDisconnectAnalyzer } from './default-task-runner-disconnect-analyzer';
|
||||||
import type {
|
import type {
|
||||||
RunnerMessage,
|
RunnerMessage,
|
||||||
N8nMessage,
|
N8nMessage,
|
||||||
TaskRunnerServerInitRequest,
|
TaskRunnerServerInitRequest,
|
||||||
TaskRunnerServerInitResponse,
|
TaskRunnerServerInitResponse,
|
||||||
|
DisconnectAnalyzer,
|
||||||
} from './runner-types';
|
} from './runner-types';
|
||||||
import { TaskBroker, type MessageCallback, type TaskRunner } from './task-broker.service';
|
import { TaskBroker, type MessageCallback, type TaskRunner } from './task-broker.service';
|
||||||
import { TaskRunnerDisconnectAnalyzer } from './task-runner-disconnect-analyzer';
|
|
||||||
|
|
||||||
function heartbeat(this: WebSocket) {
|
function heartbeat(this: WebSocket) {
|
||||||
this.isAlive = true;
|
this.isAlive = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class TaskRunnerService {
|
export class TaskRunnerWsServer {
|
||||||
runnerConnections: Map<TaskRunner['id'], WebSocket> = new Map();
|
runnerConnections: Map<TaskRunner['id'], WebSocket> = new Map();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly taskBroker: TaskBroker,
|
private readonly taskBroker: TaskBroker,
|
||||||
private readonly disconnectAnalyzer: TaskRunnerDisconnectAnalyzer,
|
private disconnectAnalyzer: DefaultTaskRunnerDisconnectAnalyzer,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
setDisconnectAnalyzer(disconnectAnalyzer: DisconnectAnalyzer) {
|
||||||
|
this.disconnectAnalyzer = disconnectAnalyzer;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisconnectAnalyzer() {
|
||||||
|
return this.disconnectAnalyzer;
|
||||||
|
}
|
||||||
|
|
||||||
sendMessage(id: TaskRunner['id'], message: N8nMessage.ToRunner.All) {
|
sendMessage(id: TaskRunner['id'], message: N8nMessage.ToRunner.All) {
|
||||||
this.runnerConnections.get(id)?.send(JSON.stringify(message));
|
this.runnerConnections.get(id)?.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
|
|
|
@ -178,12 +178,7 @@ export class TaskBroker {
|
||||||
await this.taskErrorHandler(message.taskId, message.error);
|
await this.taskErrorHandler(message.taskId, message.error);
|
||||||
break;
|
break;
|
||||||
case 'runner:taskdatarequest':
|
case 'runner:taskdatarequest':
|
||||||
await this.handleDataRequest(
|
await this.handleDataRequest(message.taskId, message.requestId, message.requestParams);
|
||||||
message.taskId,
|
|
||||||
message.requestId,
|
|
||||||
message.requestType,
|
|
||||||
message.param,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'runner:rpc':
|
case 'runner:rpc':
|
||||||
|
@ -233,8 +228,7 @@ export class TaskBroker {
|
||||||
async handleDataRequest(
|
async handleDataRequest(
|
||||||
taskId: Task['id'],
|
taskId: Task['id'],
|
||||||
requestId: RunnerMessage.ToN8n.TaskDataRequest['requestId'],
|
requestId: RunnerMessage.ToN8n.TaskDataRequest['requestId'],
|
||||||
requestType: RunnerMessage.ToN8n.TaskDataRequest['requestType'],
|
requestParams: RunnerMessage.ToN8n.TaskDataRequest['requestParams'],
|
||||||
param?: string,
|
|
||||||
) {
|
) {
|
||||||
const task = this.tasks.get(taskId);
|
const task = this.tasks.get(taskId);
|
||||||
if (!task) {
|
if (!task) {
|
||||||
|
@ -244,8 +238,7 @@ export class TaskBroker {
|
||||||
type: 'broker:taskdatarequest',
|
type: 'broker:taskdatarequest',
|
||||||
taskId,
|
taskId,
|
||||||
requestId,
|
requestId,
|
||||||
requestType,
|
requestParams,
|
||||||
param,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,324 @@
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { IExecuteFunctions, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
||||||
|
import { type INode, type INodeExecutionData, type Workflow } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { DataRequestResponseBuilder } from '../data-request-response-builder';
|
||||||
|
import type { TaskData } from '../task-manager';
|
||||||
|
|
||||||
|
const triggerNode: INode = mock<INode>({
|
||||||
|
name: 'Trigger',
|
||||||
|
});
|
||||||
|
const debugHelperNode: INode = mock<INode>({
|
||||||
|
name: 'DebugHelper',
|
||||||
|
});
|
||||||
|
const codeNode: INode = mock<INode>({
|
||||||
|
name: 'Code',
|
||||||
|
});
|
||||||
|
const workflow: TaskData['workflow'] = mock<Workflow>();
|
||||||
|
const debugHelperNodeOutItems: INodeExecutionData[] = [
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
uid: 'abb74fd4-bef2-4fae-9d53-ea24e9eb3032',
|
||||||
|
email: 'Dan.Schmidt31@yahoo.com',
|
||||||
|
firstname: 'Toni',
|
||||||
|
lastname: 'Schuster',
|
||||||
|
password: 'Q!D6C2',
|
||||||
|
},
|
||||||
|
pairedItem: {
|
||||||
|
item: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const codeNodeInputItems: INodeExecutionData[] = debugHelperNodeOutItems;
|
||||||
|
const connectionInputData: TaskData['connectionInputData'] = codeNodeInputItems;
|
||||||
|
const envProviderState: TaskData['envProviderState'] = mock<TaskData['envProviderState']>({
|
||||||
|
env: {},
|
||||||
|
isEnvAccessBlocked: false,
|
||||||
|
isProcessAvailable: true,
|
||||||
|
});
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||||
|
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
|
||||||
|
instanceBaseUrl: 'http://localhost:5678/',
|
||||||
|
restApiUrl: 'http://localhost:5678/rest',
|
||||||
|
variables: {},
|
||||||
|
webhookBaseUrl: 'http://localhost:5678/webhook',
|
||||||
|
webhookTestBaseUrl: 'http://localhost:5678/webhook-test',
|
||||||
|
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
|
||||||
|
executionId: '45844',
|
||||||
|
userId: '114984bc-44b3-4dd4-9b54-a4a8d34d51d5',
|
||||||
|
currentNodeParameters: undefined,
|
||||||
|
executionTimeoutTimestamp: undefined,
|
||||||
|
restartExecutionId: undefined,
|
||||||
|
});
|
||||||
|
const executeFunctions = mock<IExecuteFunctions>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drawn with https://asciiflow.com/#/
|
||||||
|
* Task data for an execution of the following WF:
|
||||||
|
* where ►► denotes the currently being executing node.
|
||||||
|
* ►►
|
||||||
|
* ┌───────────┐ ┌─────────────┐ ┌────────┐
|
||||||
|
* │ Trigger ├──►│ DebugHelper ├───►│ Code │
|
||||||
|
* └───────────┘ └─────────────┘ └────────┘
|
||||||
|
*/
|
||||||
|
const taskData: TaskData = {
|
||||||
|
executeFunctions,
|
||||||
|
workflow,
|
||||||
|
connectionInputData,
|
||||||
|
inputData: {
|
||||||
|
main: [codeNodeInputItems],
|
||||||
|
},
|
||||||
|
itemIndex: 0,
|
||||||
|
activeNodeName: codeNode.name,
|
||||||
|
contextNodeName: codeNode.name,
|
||||||
|
defaultReturnRunIndex: -1,
|
||||||
|
mode: 'manual',
|
||||||
|
envProviderState,
|
||||||
|
node: codeNode,
|
||||||
|
runExecutionData: {
|
||||||
|
startData: {
|
||||||
|
destinationNode: codeNode.name,
|
||||||
|
runNodeFilter: [triggerNode.name, debugHelperNode.name, codeNode.name],
|
||||||
|
},
|
||||||
|
resultData: {
|
||||||
|
runData: {
|
||||||
|
[triggerNode.name]: [
|
||||||
|
{
|
||||||
|
hints: [],
|
||||||
|
startTime: 1730313407328,
|
||||||
|
executionTime: 1,
|
||||||
|
source: [],
|
||||||
|
executionStatus: 'success',
|
||||||
|
data: {
|
||||||
|
main: [[]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[debugHelperNode.name]: [
|
||||||
|
{
|
||||||
|
hints: [],
|
||||||
|
startTime: 1730313407330,
|
||||||
|
executionTime: 1,
|
||||||
|
source: [
|
||||||
|
{
|
||||||
|
previousNode: triggerNode.name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
executionStatus: 'success',
|
||||||
|
data: {
|
||||||
|
main: [debugHelperNodeOutItems],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pinData: {},
|
||||||
|
},
|
||||||
|
executionData: {
|
||||||
|
contextData: {},
|
||||||
|
nodeExecutionStack: [],
|
||||||
|
metadata: {},
|
||||||
|
waitingExecution: {
|
||||||
|
[codeNode.name]: {
|
||||||
|
'0': {
|
||||||
|
main: [codeNodeInputItems],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
waitingExecutionSource: {
|
||||||
|
[codeNode.name]: {
|
||||||
|
'0': {
|
||||||
|
main: [
|
||||||
|
{
|
||||||
|
previousNode: debugHelperNode.name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runIndex: 0,
|
||||||
|
selfData: {},
|
||||||
|
siblingParameters: {},
|
||||||
|
executeData: {
|
||||||
|
node: codeNode,
|
||||||
|
data: {
|
||||||
|
main: [codeNodeInputItems],
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
main: [
|
||||||
|
{
|
||||||
|
previousNode: debugHelperNode.name,
|
||||||
|
previousNodeOutput: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalData,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
describe('DataRequestResponseBuilder', () => {
|
||||||
|
const allDataParam: DataRequestResponseBuilder['requestParams'] = {
|
||||||
|
dataOfNodes: 'all',
|
||||||
|
env: true,
|
||||||
|
input: true,
|
||||||
|
prevNode: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newRequestParam = (opts: Partial<DataRequestResponseBuilder['requestParams']>) => ({
|
||||||
|
...allDataParam,
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('all data', () => {
|
||||||
|
it('should build the runExecutionData as is when everything is requested', () => {
|
||||||
|
const dataRequestResponseBuilder = new DataRequestResponseBuilder(taskData, allDataParam);
|
||||||
|
|
||||||
|
const { runExecutionData } = dataRequestResponseBuilder.build();
|
||||||
|
|
||||||
|
expect(runExecutionData).toStrictEqual(taskData.runExecutionData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('envProviderState', () => {
|
||||||
|
it("should filter out envProviderState when it's not requested", () => {
|
||||||
|
const dataRequestResponseBuilder = new DataRequestResponseBuilder(
|
||||||
|
taskData,
|
||||||
|
newRequestParam({
|
||||||
|
env: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = dataRequestResponseBuilder.build();
|
||||||
|
|
||||||
|
expect(result.envProviderState).toStrictEqual({
|
||||||
|
env: {},
|
||||||
|
isEnvAccessBlocked: false,
|
||||||
|
isProcessAvailable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('additionalData', () => {
|
||||||
|
it('picks only specific properties for additional data', () => {
|
||||||
|
const dataRequestResponseBuilder = new DataRequestResponseBuilder(taskData, allDataParam);
|
||||||
|
|
||||||
|
const result = dataRequestResponseBuilder.build();
|
||||||
|
|
||||||
|
expect(result.additionalData).toStrictEqual({
|
||||||
|
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
|
||||||
|
instanceBaseUrl: 'http://localhost:5678/',
|
||||||
|
restApiUrl: 'http://localhost:5678/rest',
|
||||||
|
webhookBaseUrl: 'http://localhost:5678/webhook',
|
||||||
|
webhookTestBaseUrl: 'http://localhost:5678/webhook-test',
|
||||||
|
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
|
||||||
|
executionId: '45844',
|
||||||
|
userId: '114984bc-44b3-4dd4-9b54-a4a8d34d51d5',
|
||||||
|
currentNodeParameters: undefined,
|
||||||
|
executionTimeoutTimestamp: undefined,
|
||||||
|
restartExecutionId: undefined,
|
||||||
|
variables: additionalData.variables,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('input data', () => {
|
||||||
|
const allExceptInputParam = newRequestParam({
|
||||||
|
input: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops input data from executeData', () => {
|
||||||
|
const result = new DataRequestResponseBuilder(taskData, allExceptInputParam).build();
|
||||||
|
|
||||||
|
expect(result.executeData).toStrictEqual({
|
||||||
|
node: taskData.executeData!.node,
|
||||||
|
source: taskData.executeData!.source,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops input data from result', () => {
|
||||||
|
const result = new DataRequestResponseBuilder(taskData, allExceptInputParam).build();
|
||||||
|
|
||||||
|
expect(result.inputData).toStrictEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops input data from result', () => {
|
||||||
|
const result = new DataRequestResponseBuilder(taskData, allExceptInputParam).build();
|
||||||
|
|
||||||
|
expect(result.inputData).toStrictEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops input data from connectionInputData', () => {
|
||||||
|
const result = new DataRequestResponseBuilder(taskData, allExceptInputParam).build();
|
||||||
|
|
||||||
|
expect(result.connectionInputData).toStrictEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nodes', () => {
|
||||||
|
it('should return empty run data when only Code node is requested', () => {
|
||||||
|
const result = new DataRequestResponseBuilder(
|
||||||
|
taskData,
|
||||||
|
newRequestParam({ dataOfNodes: ['Code'], prevNode: false }),
|
||||||
|
).build();
|
||||||
|
|
||||||
|
expect(result.runExecutionData.resultData.runData).toStrictEqual({});
|
||||||
|
expect(result.runExecutionData.resultData.pinData).toStrictEqual({});
|
||||||
|
// executionData & startData contain only metadata --> returned as is
|
||||||
|
expect(result.runExecutionData.startData).toStrictEqual(taskData.runExecutionData.startData);
|
||||||
|
expect(result.runExecutionData.executionData).toStrictEqual(
|
||||||
|
taskData.runExecutionData.executionData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty run data when only Code node is requested', () => {
|
||||||
|
const result = new DataRequestResponseBuilder(
|
||||||
|
taskData,
|
||||||
|
newRequestParam({ dataOfNodes: [codeNode.name], prevNode: false }),
|
||||||
|
).build();
|
||||||
|
|
||||||
|
expect(result.runExecutionData.resultData.runData).toStrictEqual({});
|
||||||
|
expect(result.runExecutionData.resultData.pinData).toStrictEqual({});
|
||||||
|
// executionData & startData contain only metadata --> returned as is
|
||||||
|
expect(result.runExecutionData.startData).toStrictEqual(taskData.runExecutionData.startData);
|
||||||
|
expect(result.runExecutionData.executionData).toStrictEqual(
|
||||||
|
taskData.runExecutionData.executionData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return only DebugHelper's data when only DebugHelper node is requested", () => {
|
||||||
|
const result = new DataRequestResponseBuilder(
|
||||||
|
taskData,
|
||||||
|
newRequestParam({ dataOfNodes: [debugHelperNode.name], prevNode: false }),
|
||||||
|
).build();
|
||||||
|
|
||||||
|
expect(result.runExecutionData.resultData.runData).toStrictEqual({
|
||||||
|
[debugHelperNode.name]: taskData.runExecutionData.resultData.runData[debugHelperNode.name],
|
||||||
|
});
|
||||||
|
expect(result.runExecutionData.resultData.pinData).toStrictEqual({});
|
||||||
|
// executionData & startData contain only metadata --> returned as is
|
||||||
|
expect(result.runExecutionData.startData).toStrictEqual(taskData.runExecutionData.startData);
|
||||||
|
expect(result.runExecutionData.executionData).toStrictEqual(
|
||||||
|
taskData.runExecutionData.executionData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return DebugHelper's data when only prevNode node is requested", () => {
|
||||||
|
const result = new DataRequestResponseBuilder(
|
||||||
|
taskData,
|
||||||
|
newRequestParam({ dataOfNodes: [], prevNode: true }),
|
||||||
|
).build();
|
||||||
|
|
||||||
|
expect(result.runExecutionData.resultData.runData).toStrictEqual({
|
||||||
|
[debugHelperNode.name]: taskData.runExecutionData.resultData.runData[debugHelperNode.name],
|
||||||
|
});
|
||||||
|
expect(result.runExecutionData.resultData.pinData).toStrictEqual({});
|
||||||
|
// executionData & startData contain only metadata --> returned as is
|
||||||
|
expect(result.runExecutionData.startData).toStrictEqual(taskData.runExecutionData.startData);
|
||||||
|
expect(result.runExecutionData.executionData).toStrictEqual(
|
||||||
|
taskData.runExecutionData.executionData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,205 @@
|
||||||
|
import type {
|
||||||
|
EnvProviderState,
|
||||||
|
IExecuteData,
|
||||||
|
INodeExecutionData,
|
||||||
|
IPinData,
|
||||||
|
IRunData,
|
||||||
|
IRunExecutionData,
|
||||||
|
ITaskDataConnections,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
Workflow,
|
||||||
|
WorkflowParameters,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import type { DataRequestResponse, PartialAdditionalData, TaskData } from './task-manager';
|
||||||
|
import type { N8nMessage } from '../runner-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the response to a data request coming from a Task Runner. Tries to minimize
|
||||||
|
* the amount of data that is sent to the runner by only providing what is requested.
|
||||||
|
*/
|
||||||
|
export class DataRequestResponseBuilder {
|
||||||
|
private requestedNodeNames = new Set<string>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly taskData: TaskData,
|
||||||
|
private readonly requestParams: N8nMessage.ToRequester.TaskDataRequest['requestParams'],
|
||||||
|
) {
|
||||||
|
this.requestedNodeNames = new Set(requestParams.dataOfNodes);
|
||||||
|
|
||||||
|
if (this.requestParams.prevNode && this.requestParams.dataOfNodes !== 'all') {
|
||||||
|
this.requestedNodeNames.add(this.determinePrevNodeName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a response to the data request
|
||||||
|
*/
|
||||||
|
build(): DataRequestResponse {
|
||||||
|
const { taskData: td } = this;
|
||||||
|
|
||||||
|
return {
|
||||||
|
workflow: this.buildWorkflow(td.workflow),
|
||||||
|
connectionInputData: this.buildConnectionInputData(td.connectionInputData),
|
||||||
|
inputData: this.buildInputData(td.inputData),
|
||||||
|
itemIndex: td.itemIndex,
|
||||||
|
activeNodeName: td.activeNodeName,
|
||||||
|
contextNodeName: td.contextNodeName,
|
||||||
|
defaultReturnRunIndex: td.defaultReturnRunIndex,
|
||||||
|
mode: td.mode,
|
||||||
|
envProviderState: this.buildEnvProviderState(td.envProviderState),
|
||||||
|
node: td.node, // The current node being executed
|
||||||
|
runExecutionData: this.buildRunExecutionData(td.runExecutionData),
|
||||||
|
runIndex: td.runIndex,
|
||||||
|
selfData: td.selfData,
|
||||||
|
siblingParameters: td.siblingParameters,
|
||||||
|
executeData: this.buildExecuteData(td.executeData),
|
||||||
|
additionalData: this.buildAdditionalData(td.additionalData),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAdditionalData(
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
): PartialAdditionalData {
|
||||||
|
return {
|
||||||
|
formWaitingBaseUrl: additionalData.formWaitingBaseUrl,
|
||||||
|
instanceBaseUrl: additionalData.instanceBaseUrl,
|
||||||
|
restApiUrl: additionalData.restApiUrl,
|
||||||
|
variables: additionalData.variables,
|
||||||
|
webhookBaseUrl: additionalData.webhookBaseUrl,
|
||||||
|
webhookTestBaseUrl: additionalData.webhookTestBaseUrl,
|
||||||
|
webhookWaitingBaseUrl: additionalData.webhookWaitingBaseUrl,
|
||||||
|
currentNodeParameters: additionalData.currentNodeParameters,
|
||||||
|
executionId: additionalData.executionId,
|
||||||
|
executionTimeoutTimestamp: additionalData.executionTimeoutTimestamp,
|
||||||
|
restartExecutionId: additionalData.restartExecutionId,
|
||||||
|
userId: additionalData.userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildExecuteData(executeData: IExecuteData | undefined): IExecuteData | undefined {
|
||||||
|
if (executeData === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: executeData.node, // The current node being executed
|
||||||
|
data: this.requestParams.input ? executeData.data : {},
|
||||||
|
source: executeData.source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRunExecutionData(runExecutionData: IRunExecutionData): IRunExecutionData {
|
||||||
|
if (this.requestParams.dataOfNodes === 'all') {
|
||||||
|
return runExecutionData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startData: runExecutionData.startData,
|
||||||
|
resultData: {
|
||||||
|
error: runExecutionData.resultData.error,
|
||||||
|
lastNodeExecuted: runExecutionData.resultData.lastNodeExecuted,
|
||||||
|
metadata: runExecutionData.resultData.metadata,
|
||||||
|
runData: this.buildRunData(runExecutionData.resultData.runData),
|
||||||
|
pinData: this.buildPinData(runExecutionData.resultData.pinData),
|
||||||
|
},
|
||||||
|
executionData: runExecutionData.executionData
|
||||||
|
? {
|
||||||
|
// TODO: Figure out what these two are and can they be filtered
|
||||||
|
contextData: runExecutionData.executionData?.contextData,
|
||||||
|
nodeExecutionStack: runExecutionData.executionData.nodeExecutionStack,
|
||||||
|
|
||||||
|
metadata: runExecutionData.executionData.metadata,
|
||||||
|
waitingExecution: runExecutionData.executionData.waitingExecution,
|
||||||
|
waitingExecutionSource: runExecutionData.executionData.waitingExecutionSource,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRunData(runData: IRunData): IRunData {
|
||||||
|
return this.filterObjectByNodeNames(runData);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPinData(pinData: IPinData | undefined): IPinData | undefined {
|
||||||
|
return pinData ? this.filterObjectByNodeNames(pinData) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEnvProviderState(envProviderState: EnvProviderState): EnvProviderState {
|
||||||
|
if (this.requestParams.env) {
|
||||||
|
// In case `isEnvAccessBlocked` = true, the provider state has already sanitized
|
||||||
|
// the environment variables and we can return it as is.
|
||||||
|
return envProviderState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
env: {},
|
||||||
|
isEnvAccessBlocked: envProviderState.isEnvAccessBlocked,
|
||||||
|
isProcessAvailable: envProviderState.isProcessAvailable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildInputData(inputData: ITaskDataConnections): ITaskDataConnections {
|
||||||
|
if (this.requestParams.input) {
|
||||||
|
return inputData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildConnectionInputData(
|
||||||
|
connectionInputData: INodeExecutionData[],
|
||||||
|
): INodeExecutionData[] {
|
||||||
|
if (this.requestParams.input) {
|
||||||
|
return connectionInputData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildWorkflow(workflow: Workflow): Omit<WorkflowParameters, 'nodeTypes'> {
|
||||||
|
return {
|
||||||
|
id: workflow.id,
|
||||||
|
name: workflow.name,
|
||||||
|
active: workflow.active,
|
||||||
|
connections: workflow.connectionsBySourceNode,
|
||||||
|
nodes: Object.values(workflow.nodes),
|
||||||
|
pinData: workflow.pinData,
|
||||||
|
settings: workflow.settings,
|
||||||
|
staticData: workflow.staticData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assuming the given `obj` is an object where the keys are node names,
|
||||||
|
* filters the object to only include the node names that are requested.
|
||||||
|
*/
|
||||||
|
private filterObjectByNodeNames<T extends Record<string, unknown>>(obj: T): T {
|
||||||
|
if (this.requestParams.dataOfNodes === 'all') {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredObj: T = {} as T;
|
||||||
|
|
||||||
|
for (const nodeName in obj) {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(obj, nodeName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.requestedNodeNames.has(nodeName)) {
|
||||||
|
filteredObj[nodeName] = obj[nodeName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
private determinePrevNodeName(): string {
|
||||||
|
const sourceData = this.taskData.executeData?.source?.main?.[0];
|
||||||
|
if (!sourceData) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceData.previousNode;
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ import {
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
import { DataRequestResponseBuilder } from './data-request-response-builder';
|
||||||
import {
|
import {
|
||||||
RPC_ALLOW_LIST,
|
RPC_ALLOW_LIST,
|
||||||
type TaskResultData,
|
type TaskResultData,
|
||||||
|
@ -67,7 +68,7 @@ export interface PartialAdditionalData {
|
||||||
variables: IDataObject;
|
variables: IDataObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AllCodeTaskData {
|
export interface DataRequestResponse {
|
||||||
workflow: Omit<WorkflowParameters, 'nodeTypes'>;
|
workflow: Omit<WorkflowParameters, 'nodeTypes'>;
|
||||||
inputData: ITaskDataConnections;
|
inputData: ITaskDataConnections;
|
||||||
node: INode;
|
node: INode;
|
||||||
|
@ -104,19 +105,6 @@ interface ExecuteFunctionObject {
|
||||||
[name: string]: ((...args: unknown[]) => unknown) | ExecuteFunctionObject;
|
[name: string]: ((...args: unknown[]) => unknown) | ExecuteFunctionObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workflowToParameters = (workflow: Workflow): Omit<WorkflowParameters, 'nodeTypes'> => {
|
|
||||||
return {
|
|
||||||
id: workflow.id,
|
|
||||||
name: workflow.name,
|
|
||||||
active: workflow.active,
|
|
||||||
connections: workflow.connectionsBySourceNode,
|
|
||||||
nodes: Object.values(workflow.nodes),
|
|
||||||
pinData: workflow.pinData,
|
|
||||||
settings: workflow.settings,
|
|
||||||
staticData: workflow.staticData,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export class TaskManager {
|
export class TaskManager {
|
||||||
requestAcceptRejects: Map<string, { accept: RequestAccept; reject: RequestReject }> = new Map();
|
requestAcceptRejects: Map<string, { accept: RequestAccept; reject: RequestReject }> = new Map();
|
||||||
|
|
||||||
|
@ -245,7 +233,7 @@ export class TaskManager {
|
||||||
this.taskError(message.taskId, message.error);
|
this.taskError(message.taskId, message.error);
|
||||||
break;
|
break;
|
||||||
case 'broker:taskdatarequest':
|
case 'broker:taskdatarequest':
|
||||||
this.sendTaskData(message.taskId, message.requestId, message.requestType);
|
this.sendTaskData(message.taskId, message.requestId, message.requestParams);
|
||||||
break;
|
break;
|
||||||
case 'broker:rpc':
|
case 'broker:rpc':
|
||||||
void this.handleRpc(message.taskId, message.callId, message.name, message.params);
|
void this.handleRpc(message.taskId, message.callId, message.name, message.params);
|
||||||
|
@ -294,55 +282,24 @@ export class TaskManager {
|
||||||
sendTaskData(
|
sendTaskData(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
requestType: N8nMessage.ToRequester.TaskDataRequest['requestType'],
|
requestParams: N8nMessage.ToRequester.TaskDataRequest['requestParams'],
|
||||||
) {
|
) {
|
||||||
const job = this.tasks.get(taskId);
|
const job = this.tasks.get(taskId);
|
||||||
if (!job) {
|
if (!job) {
|
||||||
// TODO: logging
|
// TODO: logging
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (requestType === 'all') {
|
|
||||||
const jd = job.data;
|
const dataRequestResponseBuilder = new DataRequestResponseBuilder(job.data, requestParams);
|
||||||
const ad = jd.additionalData;
|
const requestedData = dataRequestResponseBuilder.build();
|
||||||
const data: AllCodeTaskData = {
|
|
||||||
workflow: workflowToParameters(jd.workflow),
|
|
||||||
connectionInputData: jd.connectionInputData,
|
|
||||||
inputData: jd.inputData,
|
|
||||||
itemIndex: jd.itemIndex,
|
|
||||||
activeNodeName: jd.activeNodeName,
|
|
||||||
contextNodeName: jd.contextNodeName,
|
|
||||||
defaultReturnRunIndex: jd.defaultReturnRunIndex,
|
|
||||||
mode: jd.mode,
|
|
||||||
envProviderState: jd.envProviderState,
|
|
||||||
node: jd.node,
|
|
||||||
runExecutionData: jd.runExecutionData,
|
|
||||||
runIndex: jd.runIndex,
|
|
||||||
selfData: jd.selfData,
|
|
||||||
siblingParameters: jd.siblingParameters,
|
|
||||||
executeData: jd.executeData,
|
|
||||||
additionalData: {
|
|
||||||
formWaitingBaseUrl: ad.formWaitingBaseUrl,
|
|
||||||
instanceBaseUrl: ad.instanceBaseUrl,
|
|
||||||
restApiUrl: ad.restApiUrl,
|
|
||||||
variables: ad.variables,
|
|
||||||
webhookBaseUrl: ad.webhookBaseUrl,
|
|
||||||
webhookTestBaseUrl: ad.webhookTestBaseUrl,
|
|
||||||
webhookWaitingBaseUrl: ad.webhookWaitingBaseUrl,
|
|
||||||
currentNodeParameters: ad.currentNodeParameters,
|
|
||||||
executionId: ad.executionId,
|
|
||||||
executionTimeoutTimestamp: ad.executionTimeoutTimestamp,
|
|
||||||
restartExecutionId: ad.restartExecutionId,
|
|
||||||
userId: ad.userId,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
this.sendMessage({
|
this.sendMessage({
|
||||||
type: 'requester:taskdataresponse',
|
type: 'requester:taskdataresponse',
|
||||||
taskId,
|
taskId,
|
||||||
requestId,
|
requestId,
|
||||||
data,
|
data: requestedData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async handleRpc(
|
async handleRpc(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
|
|
85
packages/cli/src/runners/task-runner-module.ts
Normal file
85
packages/cli/src/runners/task-runner-module.ts
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import { TaskRunnersConfig } from '@n8n/config';
|
||||||
|
import * as a from 'node:assert/strict';
|
||||||
|
import Container, { Service } from 'typedi';
|
||||||
|
|
||||||
|
import type { TaskRunnerProcess } from '@/runners/task-runner-process';
|
||||||
|
|
||||||
|
import { TaskRunnerWsServer } from './runner-ws-server';
|
||||||
|
import type { LocalTaskManager } from './task-managers/local-task-manager';
|
||||||
|
import type { TaskRunnerServer } from './task-runner-server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module responsible for loading and starting task runner. Task runner can be
|
||||||
|
* run either internally (=launched by n8n as a child process) or externally
|
||||||
|
* (=launched by some other orchestrator)
|
||||||
|
*/
|
||||||
|
@Service()
|
||||||
|
export class TaskRunnerModule {
|
||||||
|
private taskRunnerHttpServer: TaskRunnerServer | undefined;
|
||||||
|
|
||||||
|
private taskRunnerWsServer: TaskRunnerWsServer | undefined;
|
||||||
|
|
||||||
|
private taskManager: LocalTaskManager | undefined;
|
||||||
|
|
||||||
|
private taskRunnerProcess: TaskRunnerProcess | undefined;
|
||||||
|
|
||||||
|
constructor(private readonly runnerConfig: TaskRunnersConfig) {}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
a.ok(!this.runnerConfig.disabled, 'Task runner is disabled');
|
||||||
|
|
||||||
|
await this.loadTaskManager();
|
||||||
|
await this.loadTaskRunnerServer();
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.runnerConfig.mode === 'internal_childprocess' ||
|
||||||
|
this.runnerConfig.mode === 'internal_launcher'
|
||||||
|
) {
|
||||||
|
await this.startInternalTaskRunner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
if (this.taskRunnerProcess) {
|
||||||
|
await this.taskRunnerProcess.stop();
|
||||||
|
this.taskRunnerProcess = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.taskRunnerHttpServer) {
|
||||||
|
await this.taskRunnerHttpServer.stop();
|
||||||
|
this.taskRunnerHttpServer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadTaskManager() {
|
||||||
|
const { TaskManager } = await import('@/runners/task-managers/task-manager');
|
||||||
|
const { LocalTaskManager } = await import('@/runners/task-managers/local-task-manager');
|
||||||
|
this.taskManager = new LocalTaskManager();
|
||||||
|
Container.set(TaskManager, this.taskManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadTaskRunnerServer() {
|
||||||
|
// These are imported dynamically because we need to set the task manager
|
||||||
|
// instance before importing them
|
||||||
|
const { TaskRunnerServer } = await import('@/runners/task-runner-server');
|
||||||
|
this.taskRunnerHttpServer = Container.get(TaskRunnerServer);
|
||||||
|
this.taskRunnerWsServer = Container.get(TaskRunnerWsServer);
|
||||||
|
|
||||||
|
await this.taskRunnerHttpServer.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startInternalTaskRunner() {
|
||||||
|
a.ok(this.taskRunnerWsServer, 'Task Runner WS Server not loaded');
|
||||||
|
|
||||||
|
const { TaskRunnerProcess } = await import('@/runners/task-runner-process');
|
||||||
|
this.taskRunnerProcess = Container.get(TaskRunnerProcess);
|
||||||
|
await this.taskRunnerProcess.start();
|
||||||
|
|
||||||
|
const { InternalTaskRunnerDisconnectAnalyzer } = await import(
|
||||||
|
'@/runners/internal-task-runner-disconnect-analyzer'
|
||||||
|
);
|
||||||
|
this.taskRunnerWsServer.setDisconnectAnalyzer(
|
||||||
|
Container.get(InternalTaskRunnerDisconnectAnalyzer),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -69,8 +69,8 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
a.ok(
|
a.ok(
|
||||||
this.runnerConfig.mode === 'internal_childprocess' ||
|
this.runnerConfig.mode !== 'external',
|
||||||
this.runnerConfig.mode === 'internal_launcher',
|
'Task Runner Process cannot be used in external mode',
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger = logger.scoped('task-runner');
|
this.logger = logger.scoped('task-runner');
|
||||||
|
|
|
@ -19,7 +19,7 @@ import type {
|
||||||
TaskRunnerServerInitRequest,
|
TaskRunnerServerInitRequest,
|
||||||
TaskRunnerServerInitResponse,
|
TaskRunnerServerInitResponse,
|
||||||
} from '@/runners/runner-types';
|
} from '@/runners/runner-types';
|
||||||
import { TaskRunnerService } from '@/runners/runner-ws-server';
|
import { TaskRunnerWsServer } from '@/runners/runner-ws-server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Task Runner HTTP & WS server
|
* Task Runner HTTP & WS server
|
||||||
|
@ -44,7 +44,7 @@ export class TaskRunnerServer {
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly globalConfig: GlobalConfig,
|
private readonly globalConfig: GlobalConfig,
|
||||||
private readonly taskRunnerAuthController: TaskRunnerAuthController,
|
private readonly taskRunnerAuthController: TaskRunnerAuthController,
|
||||||
private readonly taskRunnerService: TaskRunnerService,
|
private readonly taskRunnerService: TaskRunnerWsServer,
|
||||||
) {
|
) {
|
||||||
this.app = express();
|
this.app = express();
|
||||||
this.app.disable('x-powered-by');
|
this.app.disable('x-powered-by');
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { NodeExecuteFunctions } from 'n8n-core';
|
import { LoadOptionsContext, NodeExecuteFunctions } from 'n8n-core';
|
||||||
import type {
|
import type {
|
||||||
ILoadOptions,
|
ILoadOptions,
|
||||||
ILoadOptionsFunctions,
|
ILoadOptionsFunctions,
|
||||||
|
@ -253,6 +253,6 @@ export class DynamicNodeParametersService {
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
) {
|
) {
|
||||||
const node = workflow.nodes['Temp-Node'];
|
const node = workflow.nodes['Temp-Node'];
|
||||||
return NodeExecuteFunctions.getLoadOptionsFunctions(workflow, node, path, additionalData);
|
return new LoadOptionsContext(workflow, node, additionalData, path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -222,9 +222,9 @@ export class FrontendService {
|
||||||
licensePruneTime: -1,
|
licensePruneTime: -1,
|
||||||
},
|
},
|
||||||
pruning: {
|
pruning: {
|
||||||
isEnabled: config.getEnv('executions.pruneData'),
|
isEnabled: this.globalConfig.pruning.isEnabled,
|
||||||
maxAge: config.getEnv('executions.pruneDataMaxAge'),
|
maxAge: this.globalConfig.pruning.maxAge,
|
||||||
maxCount: config.getEnv('executions.pruneDataMaxCount'),
|
maxCount: this.globalConfig.pruning.maxCount,
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles,
|
blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles,
|
||||||
|
|
|
@ -3,7 +3,6 @@ import { BinaryDataService, InstanceSettings } from 'n8n-core';
|
||||||
import { jsonStringify } from 'n8n-workflow';
|
import { jsonStringify } from 'n8n-workflow';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import config from '@/config';
|
|
||||||
import { inTest, TIME } from '@/constants';
|
import { inTest, TIME } from '@/constants';
|
||||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
import { OnShutdown } from '@/decorators/on-shutdown';
|
import { OnShutdown } from '@/decorators/on-shutdown';
|
||||||
|
@ -16,8 +15,8 @@ export class PruningService {
|
||||||
private hardDeletionBatchSize = 100;
|
private hardDeletionBatchSize = 100;
|
||||||
|
|
||||||
private rates: Record<string, number> = {
|
private rates: Record<string, number> = {
|
||||||
softDeletion: config.getEnv('executions.pruneDataIntervals.softDelete') * TIME.MINUTE,
|
softDeletion: this.globalConfig.pruning.softDeleteInterval * TIME.MINUTE,
|
||||||
hardDeletion: config.getEnv('executions.pruneDataIntervals.hardDelete') * TIME.MINUTE,
|
hardDeletion: this.globalConfig.pruning.hardDeleteInterval * TIME.MINUTE,
|
||||||
};
|
};
|
||||||
|
|
||||||
public softDeletionInterval: NodeJS.Timer | undefined;
|
public softDeletionInterval: NodeJS.Timer | undefined;
|
||||||
|
@ -52,7 +51,7 @@ export class PruningService {
|
||||||
|
|
||||||
private isPruningEnabled() {
|
private isPruningEnabled() {
|
||||||
const { instanceType, isFollower } = this.instanceSettings;
|
const { instanceType, isFollower } = this.instanceSettings;
|
||||||
if (!config.getEnv('executions.pruneData') || inTest || instanceType !== 'main') {
|
if (!this.globalConfig.pruning.isEnabled || inTest || instanceType !== 'main') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -468,7 +468,7 @@ function hookFunctionsSave(): IWorkflowExecuteHooks {
|
||||||
(executionStatus === 'success' && !saveSettings.success) ||
|
(executionStatus === 'success' && !saveSettings.success) ||
|
||||||
(executionStatus !== 'success' && !saveSettings.error);
|
(executionStatus !== 'success' && !saveSettings.error);
|
||||||
|
|
||||||
if (shouldNotSave) {
|
if (shouldNotSave && !fullRunData.waitTill) {
|
||||||
if (!fullRunData.waitTill && !isManualMode) {
|
if (!fullRunData.waitTill && !isManualMode) {
|
||||||
executeErrorWorkflow(
|
executeErrorWorkflow(
|
||||||
this.workflowData,
|
this.workflowData,
|
||||||
|
|
|
@ -35,6 +35,7 @@ import * as WorkflowHelpers from '@/workflow-helpers';
|
||||||
import { generateFailedExecutionFromError } from '@/workflow-helpers';
|
import { generateFailedExecutionFromError } from '@/workflow-helpers';
|
||||||
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
|
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
|
||||||
|
|
||||||
|
import { ExecutionNotFoundError } from './errors/execution-not-found-error';
|
||||||
import { EventService } from './events/event.service';
|
import { EventService } from './events/event.service';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
|
@ -57,12 +58,21 @@ export class WorkflowRunner {
|
||||||
|
|
||||||
/** The process did error */
|
/** The process did error */
|
||||||
async processError(
|
async processError(
|
||||||
error: ExecutionError,
|
error: ExecutionError | ExecutionNotFoundError,
|
||||||
startedAt: Date,
|
startedAt: Date,
|
||||||
executionMode: WorkflowExecuteMode,
|
executionMode: WorkflowExecuteMode,
|
||||||
executionId: string,
|
executionId: string,
|
||||||
hooks?: WorkflowHooks,
|
hooks?: WorkflowHooks,
|
||||||
) {
|
) {
|
||||||
|
// This means the execution was probably cancelled and has already
|
||||||
|
// been cleaned up.
|
||||||
|
//
|
||||||
|
// FIXME: This is a quick fix. The proper fix would be to not remove
|
||||||
|
// the execution from the active executions while it's still running.
|
||||||
|
if (error instanceof ExecutionNotFoundError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ErrorReporter.error(error, { executionId });
|
ErrorReporter.error(error, { executionId });
|
||||||
|
|
||||||
const isQueueMode = config.getEnv('executions.mode') === 'queue';
|
const isQueueMode = config.getEnv('executions.mode') === 'queue';
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import { BinaryDataService, InstanceSettings } from 'n8n-core';
|
import { BinaryDataService, InstanceSettings } from 'n8n-core';
|
||||||
import type { ExecutionStatus } from 'n8n-workflow';
|
import type { ExecutionStatus } from 'n8n-workflow';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
|
|
||||||
import config from '@/config';
|
|
||||||
import { TIME } from '@/constants';
|
import { TIME } from '@/constants';
|
||||||
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
|
import type { ExecutionEntity } from '@/databases/entities/execution-entity';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
|
@ -28,17 +28,19 @@ describe('softDeleteOnPruningCycle()', () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const yesterday = new Date(Date.now() - TIME.DAY);
|
const yesterday = new Date(Date.now() - TIME.DAY);
|
||||||
let workflow: WorkflowEntity;
|
let workflow: WorkflowEntity;
|
||||||
|
let globalConfig: GlobalConfig;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await testDb.init();
|
await testDb.init();
|
||||||
|
|
||||||
|
globalConfig = Container.get(GlobalConfig);
|
||||||
pruningService = new PruningService(
|
pruningService = new PruningService(
|
||||||
mockInstance(Logger),
|
mockInstance(Logger),
|
||||||
instanceSettings,
|
instanceSettings,
|
||||||
Container.get(ExecutionRepository),
|
Container.get(ExecutionRepository),
|
||||||
mockInstance(BinaryDataService),
|
mockInstance(BinaryDataService),
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
globalConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
workflow = await createWorkflow();
|
workflow = await createWorkflow();
|
||||||
|
@ -52,10 +54,6 @@ describe('softDeleteOnPruningCycle()', () => {
|
||||||
await testDb.terminate();
|
await testDb.terminate();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
config.load(config.default);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function findAllExecutions() {
|
async function findAllExecutions() {
|
||||||
return await Container.get(ExecutionRepository).find({
|
return await Container.get(ExecutionRepository).find({
|
||||||
order: { id: 'asc' },
|
order: { id: 'asc' },
|
||||||
|
@ -64,9 +62,9 @@ describe('softDeleteOnPruningCycle()', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('when EXECUTIONS_DATA_PRUNE_MAX_COUNT is set', () => {
|
describe('when EXECUTIONS_DATA_PRUNE_MAX_COUNT is set', () => {
|
||||||
beforeEach(() => {
|
beforeAll(() => {
|
||||||
config.set('executions.pruneDataMaxCount', 1);
|
globalConfig.pruning.maxAge = 336;
|
||||||
config.set('executions.pruneDataMaxAge', 336);
|
globalConfig.pruning.maxCount = 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should mark as deleted based on EXECUTIONS_DATA_PRUNE_MAX_COUNT', async () => {
|
test('should mark as deleted based on EXECUTIONS_DATA_PRUNE_MAX_COUNT', async () => {
|
||||||
|
@ -165,9 +163,9 @@ describe('softDeleteOnPruningCycle()', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when EXECUTIONS_DATA_MAX_AGE is set', () => {
|
describe('when EXECUTIONS_DATA_MAX_AGE is set', () => {
|
||||||
beforeEach(() => {
|
beforeAll(() => {
|
||||||
config.set('executions.pruneDataMaxAge', 1); // 1h
|
globalConfig.pruning.maxAge = 1;
|
||||||
config.set('executions.pruneDataMaxCount', 0);
|
globalConfig.pruning.maxCount = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should mark as deleted based on EXECUTIONS_DATA_MAX_AGE', async () => {
|
test('should mark as deleted based on EXECUTIONS_DATA_MAX_AGE', async () => {
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { TaskRunnersConfig } from '@n8n/config';
|
||||||
|
import Container from 'typedi';
|
||||||
|
|
||||||
|
import { TaskRunnerModule } from '@/runners/task-runner-module';
|
||||||
|
|
||||||
|
import { DefaultTaskRunnerDisconnectAnalyzer } from '../../../src/runners/default-task-runner-disconnect-analyzer';
|
||||||
|
import { TaskRunnerWsServer } from '../../../src/runners/runner-ws-server';
|
||||||
|
|
||||||
|
describe('TaskRunnerModule in external mode', () => {
|
||||||
|
const runnerConfig = Container.get(TaskRunnersConfig);
|
||||||
|
runnerConfig.mode = 'external';
|
||||||
|
runnerConfig.port = 0;
|
||||||
|
const module = Container.get(TaskRunnerModule);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await module.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('start', () => {
|
||||||
|
it('should throw if the task runner is disabled', async () => {
|
||||||
|
runnerConfig.disabled = true;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await expect(module.start()).rejects.toThrow('Task runner is disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start the task runner', async () => {
|
||||||
|
runnerConfig.disabled = false;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await module.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use DefaultTaskRunnerDisconnectAnalyzer', () => {
|
||||||
|
const wsServer = Container.get(TaskRunnerWsServer);
|
||||||
|
|
||||||
|
expect(wsServer.getDisconnectAnalyzer()).toBeInstanceOf(DefaultTaskRunnerDisconnectAnalyzer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { TaskRunnersConfig } from '@n8n/config';
|
||||||
|
import Container from 'typedi';
|
||||||
|
|
||||||
|
import { TaskRunnerModule } from '@/runners/task-runner-module';
|
||||||
|
|
||||||
|
import { InternalTaskRunnerDisconnectAnalyzer } from '../../../src/runners/internal-task-runner-disconnect-analyzer';
|
||||||
|
import { TaskRunnerWsServer } from '../../../src/runners/runner-ws-server';
|
||||||
|
|
||||||
|
describe('TaskRunnerModule in internal_childprocess mode', () => {
|
||||||
|
const runnerConfig = Container.get(TaskRunnersConfig);
|
||||||
|
runnerConfig.mode = 'internal_childprocess';
|
||||||
|
const module = Container.get(TaskRunnerModule);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await module.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('start', () => {
|
||||||
|
it('should throw if the task runner is disabled', async () => {
|
||||||
|
runnerConfig.disabled = true;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await expect(module.start()).rejects.toThrow('Task runner is disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start the task runner', async () => {
|
||||||
|
runnerConfig.disabled = false;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await module.start();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use InternalTaskRunnerDisconnectAnalyzer', () => {
|
||||||
|
const wsServer = Container.get(TaskRunnerWsServer);
|
||||||
|
|
||||||
|
expect(wsServer.getDisconnectAnalyzer()).toBeInstanceOf(InternalTaskRunnerDisconnectAnalyzer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,7 +1,7 @@
|
||||||
import { TaskRunnersConfig } from '@n8n/config';
|
import { TaskRunnersConfig } from '@n8n/config';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
|
|
||||||
import { TaskRunnerService } from '@/runners/runner-ws-server';
|
import { TaskRunnerWsServer } from '@/runners/runner-ws-server';
|
||||||
import { TaskBroker } from '@/runners/task-broker.service';
|
import { TaskBroker } from '@/runners/task-broker.service';
|
||||||
import { TaskRunnerProcess } from '@/runners/task-runner-process';
|
import { TaskRunnerProcess } from '@/runners/task-runner-process';
|
||||||
import { TaskRunnerServer } from '@/runners/task-runner-server';
|
import { TaskRunnerServer } from '@/runners/task-runner-server';
|
||||||
|
@ -18,7 +18,7 @@ describe('TaskRunnerProcess', () => {
|
||||||
|
|
||||||
const runnerProcess = Container.get(TaskRunnerProcess);
|
const runnerProcess = Container.get(TaskRunnerProcess);
|
||||||
const taskBroker = Container.get(TaskBroker);
|
const taskBroker = Container.get(TaskBroker);
|
||||||
const taskRunnerService = Container.get(TaskRunnerService);
|
const taskRunnerService = Container.get(TaskRunnerWsServer);
|
||||||
|
|
||||||
const startLauncherSpy = jest.spyOn(runnerProcess, 'startLauncher');
|
const startLauncherSpy = jest.spyOn(runnerProcess, 'startLauncher');
|
||||||
const startNodeSpy = jest.spyOn(runnerProcess, 'startNode');
|
const startNodeSpy = jest.spyOn(runnerProcess, 'startNode');
|
||||||
|
|
|
@ -13,13 +13,13 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "biome ci .",
|
"format:check": "biome ci .",
|
||||||
"lint": "eslint . --quiet",
|
"lint": "eslint . --quiet",
|
||||||
"lintfix": "eslint . --fix",
|
"lintfix": "eslint . --fix",
|
||||||
"watch": "tsc -p tsconfig.build.json --watch",
|
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\"",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
@ -23,12 +23,11 @@ import type {
|
||||||
} from 'axios';
|
} from 'axios';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import crypto, { createHmac } from 'crypto';
|
import crypto, { createHmac } from 'crypto';
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
import FileType from 'file-type';
|
import FileType from 'file-type';
|
||||||
import FormData from 'form-data';
|
import FormData from 'form-data';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
import { access as fsAccess, writeFile as fsWriteFile } from 'fs/promises';
|
import { access as fsAccess, writeFile as fsWriteFile } from 'fs/promises';
|
||||||
import { IncomingMessage, type IncomingHttpHeaders } from 'http';
|
import { IncomingMessage } from 'http';
|
||||||
import { Agent, type AgentOptions } from 'https';
|
import { Agent, type AgentOptions } from 'https';
|
||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import isEmpty from 'lodash/isEmpty';
|
import isEmpty from 'lodash/isEmpty';
|
||||||
|
@ -60,7 +59,6 @@ import type {
|
||||||
IGetNodeParameterOptions,
|
IGetNodeParameterOptions,
|
||||||
IHookFunctions,
|
IHookFunctions,
|
||||||
IHttpRequestOptions,
|
IHttpRequestOptions,
|
||||||
ILoadOptionsFunctions,
|
|
||||||
IN8nHttpFullResponse,
|
IN8nHttpFullResponse,
|
||||||
IN8nHttpResponse,
|
IN8nHttpResponse,
|
||||||
INode,
|
INode,
|
||||||
|
@ -101,7 +99,6 @@ import type {
|
||||||
INodeParameters,
|
INodeParameters,
|
||||||
EnsureTypeOptions,
|
EnsureTypeOptions,
|
||||||
SSHTunnelFunctions,
|
SSHTunnelFunctions,
|
||||||
SchedulingFunctions,
|
|
||||||
DeduplicationHelperFunctions,
|
DeduplicationHelperFunctions,
|
||||||
IDeduplicationOutput,
|
IDeduplicationOutput,
|
||||||
IDeduplicationOutputItems,
|
IDeduplicationOutputItems,
|
||||||
|
@ -111,6 +108,7 @@ import type {
|
||||||
ICheckProcessedContextData,
|
ICheckProcessedContextData,
|
||||||
AiEvent,
|
AiEvent,
|
||||||
ISupplyDataFunctions,
|
ISupplyDataFunctions,
|
||||||
|
WebhookType,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
|
@ -167,7 +165,8 @@ import {
|
||||||
import { extractValue } from './ExtractValue';
|
import { extractValue } from './ExtractValue';
|
||||||
import { InstanceSettings } from './InstanceSettings';
|
import { InstanceSettings } from './InstanceSettings';
|
||||||
import type { ExtendedValidationResult, IResponseError } from './Interfaces';
|
import type { ExtendedValidationResult, IResponseError } from './Interfaces';
|
||||||
import { ScheduledTaskManager } from './ScheduledTaskManager';
|
// eslint-disable-next-line import/no-cycle
|
||||||
|
import { HookContext, PollContext, TriggerContext, WebhookContext } from './node-execution-context';
|
||||||
import { getSecretsProxy } from './Secrets';
|
import { getSecretsProxy } from './Secrets';
|
||||||
import { SSHClientsManager } from './SSHClientsManager';
|
import { SSHClientsManager } from './SSHClientsManager';
|
||||||
|
|
||||||
|
@ -215,7 +214,7 @@ const createFormDataObject = (data: Record<string, unknown>) => {
|
||||||
return formData;
|
return formData;
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateUrl = (url?: string): boolean => {
|
export const validateUrl = (url?: string): boolean => {
|
||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -776,7 +775,7 @@ export function parseIncomingMessage(message: IncomingMessage) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function binaryToString(body: Buffer | Readable, encoding?: BufferEncoding) {
|
export async function binaryToString(body: Buffer | Readable, encoding?: BufferEncoding) {
|
||||||
const buffer = await binaryToBuffer(body);
|
const buffer = await binaryToBuffer(body);
|
||||||
if (!encoding && body instanceof IncomingMessage) {
|
if (!encoding && body instanceof IncomingMessage) {
|
||||||
parseIncomingMessage(body);
|
parseIncomingMessage(body);
|
||||||
|
@ -1010,7 +1009,7 @@ export const removeEmptyBody = (requestOptions: IHttpRequestOptions | IRequestOp
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function httpRequest(
|
export async function httpRequest(
|
||||||
requestOptions: IHttpRequestOptions,
|
requestOptions: IHttpRequestOptions,
|
||||||
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
|
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
|
||||||
removeEmptyBody(requestOptions);
|
removeEmptyBody(requestOptions);
|
||||||
|
@ -1205,7 +1204,7 @@ export async function copyBinaryFile(
|
||||||
* base64 and adds metadata.
|
* base64 and adds metadata.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line complexity
|
// eslint-disable-next-line complexity
|
||||||
async function prepareBinaryData(
|
export async function prepareBinaryData(
|
||||||
binaryData: Buffer | Readable,
|
binaryData: Buffer | Readable,
|
||||||
executionId: string,
|
executionId: string,
|
||||||
workflowId: string,
|
workflowId: string,
|
||||||
|
@ -1348,6 +1347,7 @@ export async function clearAllProcessedItems(
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProcessedDataCount(
|
export async function getProcessedDataCount(
|
||||||
scope: DeduplicationScope,
|
scope: DeduplicationScope,
|
||||||
contextData: ICheckProcessedContextData,
|
contextData: ICheckProcessedContextData,
|
||||||
|
@ -1359,7 +1359,8 @@ export async function getProcessedDataCount(
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function applyPaginationRequestData(
|
|
||||||
|
export function applyPaginationRequestData(
|
||||||
requestData: IRequestOptions,
|
requestData: IRequestOptions,
|
||||||
paginationRequestData: PaginationOptions['request'],
|
paginationRequestData: PaginationOptions['request'],
|
||||||
): IRequestOptions {
|
): IRequestOptions {
|
||||||
|
@ -2628,7 +2629,7 @@ export function continueOnFail(node: INode): boolean {
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export function getNodeWebhookUrl(
|
export function getNodeWebhookUrl(
|
||||||
name: string,
|
name: WebhookType,
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
node: INode,
|
node: INode,
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
@ -2673,7 +2674,7 @@ export function getNodeWebhookUrl(
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export function getWebhookDescription(
|
export function getWebhookDescription(
|
||||||
name: string,
|
name: WebhookType,
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
node: INode,
|
node: INode,
|
||||||
): IWebhookDescription | undefined {
|
): IWebhookDescription | undefined {
|
||||||
|
@ -2798,7 +2799,7 @@ const addExecutionDataFunctions = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getInputConnectionData(
|
export async function getInputConnectionData(
|
||||||
this: IAllExecuteFunctions,
|
this: IAllExecuteFunctions,
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
runExecutionData: IRunExecutionData,
|
runExecutionData: IRunExecutionData,
|
||||||
|
@ -3342,14 +3343,6 @@ const getSSHTunnelFunctions = (): SSHTunnelFunctions => ({
|
||||||
await Container.get(SSHClientsManager).getClient(credentials),
|
await Container.get(SSHClientsManager).getClient(credentials),
|
||||||
});
|
});
|
||||||
|
|
||||||
const getSchedulingFunctions = (workflow: Workflow): SchedulingFunctions => {
|
|
||||||
const scheduledTaskManager = Container.get(ScheduledTaskManager);
|
|
||||||
return {
|
|
||||||
registerCron: (cronExpression, onTick) =>
|
|
||||||
scheduledTaskManager.registerCron(workflow, cronExpression, onTick),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAllowedPaths = () => {
|
const getAllowedPaths = () => {
|
||||||
const restrictFileAccessTo = process.env[RESTRICT_FILE_ACCESS_TO];
|
const restrictFileAccessTo = process.env[RESTRICT_FILE_ACCESS_TO];
|
||||||
if (!restrictFileAccessTo) {
|
if (!restrictFileAccessTo) {
|
||||||
|
@ -3553,57 +3546,7 @@ export function getExecutePollFunctions(
|
||||||
mode: WorkflowExecuteMode,
|
mode: WorkflowExecuteMode,
|
||||||
activation: WorkflowActivateMode,
|
activation: WorkflowActivateMode,
|
||||||
): IPollFunctions {
|
): IPollFunctions {
|
||||||
return ((workflow: Workflow, node: INode) => {
|
return new PollContext(workflow, node, additionalData, mode, activation);
|
||||||
return {
|
|
||||||
...getCommonWorkflowFunctions(workflow, node, additionalData),
|
|
||||||
__emit: (): void => {
|
|
||||||
throw new ApplicationError(
|
|
||||||
'Overwrite NodeExecuteFunctions.getExecutePollFunctions.__emit function',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
__emitError() {
|
|
||||||
throw new ApplicationError(
|
|
||||||
'Overwrite NodeExecuteFunctions.getExecutePollFunctions.__emitError function',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getMode: () => mode,
|
|
||||||
getActivationMode: () => activation,
|
|
||||||
getCredentials: async (type) =>
|
|
||||||
await getCredentials(workflow, node, type, additionalData, mode),
|
|
||||||
getNodeParameter: (
|
|
||||||
parameterName: string,
|
|
||||||
fallbackValue?: any,
|
|
||||||
options?: IGetNodeParameterOptions,
|
|
||||||
): NodeParameterValueType | object => {
|
|
||||||
const runExecutionData: IRunExecutionData | null = null;
|
|
||||||
const itemIndex = 0;
|
|
||||||
const runIndex = 0;
|
|
||||||
const connectionInputData: INodeExecutionData[] = [];
|
|
||||||
|
|
||||||
return getNodeParameter(
|
|
||||||
workflow,
|
|
||||||
runExecutionData,
|
|
||||||
runIndex,
|
|
||||||
connectionInputData,
|
|
||||||
node,
|
|
||||||
parameterName,
|
|
||||||
itemIndex,
|
|
||||||
mode,
|
|
||||||
getAdditionalKeys(additionalData, mode, runExecutionData),
|
|
||||||
undefined,
|
|
||||||
fallbackValue,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
helpers: {
|
|
||||||
createDeferredPromise,
|
|
||||||
...getRequestHelperFunctions(workflow, node, additionalData),
|
|
||||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
|
||||||
...getSchedulingFunctions(workflow),
|
|
||||||
returnJsonArray,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})(workflow, node);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -3617,58 +3560,7 @@ export function getExecuteTriggerFunctions(
|
||||||
mode: WorkflowExecuteMode,
|
mode: WorkflowExecuteMode,
|
||||||
activation: WorkflowActivateMode,
|
activation: WorkflowActivateMode,
|
||||||
): ITriggerFunctions {
|
): ITriggerFunctions {
|
||||||
return ((workflow: Workflow, node: INode) => {
|
return new TriggerContext(workflow, node, additionalData, mode, activation);
|
||||||
return {
|
|
||||||
...getCommonWorkflowFunctions(workflow, node, additionalData),
|
|
||||||
emit: (): void => {
|
|
||||||
throw new ApplicationError(
|
|
||||||
'Overwrite NodeExecuteFunctions.getExecuteTriggerFunctions.emit function',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
emitError: (): void => {
|
|
||||||
throw new ApplicationError(
|
|
||||||
'Overwrite NodeExecuteFunctions.getExecuteTriggerFunctions.emit function',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getMode: () => mode,
|
|
||||||
getActivationMode: () => activation,
|
|
||||||
getCredentials: async (type) =>
|
|
||||||
await getCredentials(workflow, node, type, additionalData, mode),
|
|
||||||
getNodeParameter: (
|
|
||||||
parameterName: string,
|
|
||||||
fallbackValue?: any,
|
|
||||||
options?: IGetNodeParameterOptions,
|
|
||||||
): NodeParameterValueType | object => {
|
|
||||||
const runExecutionData: IRunExecutionData | null = null;
|
|
||||||
const itemIndex = 0;
|
|
||||||
const runIndex = 0;
|
|
||||||
const connectionInputData: INodeExecutionData[] = [];
|
|
||||||
|
|
||||||
return getNodeParameter(
|
|
||||||
workflow,
|
|
||||||
runExecutionData,
|
|
||||||
runIndex,
|
|
||||||
connectionInputData,
|
|
||||||
node,
|
|
||||||
parameterName,
|
|
||||||
itemIndex,
|
|
||||||
mode,
|
|
||||||
getAdditionalKeys(additionalData, mode, runExecutionData),
|
|
||||||
undefined,
|
|
||||||
fallbackValue,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
helpers: {
|
|
||||||
createDeferredPromise,
|
|
||||||
...getSSHTunnelFunctions(),
|
|
||||||
...getRequestHelperFunctions(workflow, node, additionalData),
|
|
||||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
|
||||||
...getSchedulingFunctions(workflow),
|
|
||||||
returnJsonArray,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})(workflow, node);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -4400,6 +4292,7 @@ export function getExecuteSingleFunctions(
|
||||||
},
|
},
|
||||||
helpers: {
|
helpers: {
|
||||||
createDeferredPromise,
|
createDeferredPromise,
|
||||||
|
returnJsonArray,
|
||||||
...getRequestHelperFunctions(
|
...getRequestHelperFunctions(
|
||||||
workflow,
|
workflow,
|
||||||
node,
|
node,
|
||||||
|
@ -4439,85 +4332,6 @@ export function getCredentialTestFunctions(): ICredentialTestFunctions {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the execute functions regular nodes have access to in load-options-function.
|
|
||||||
*/
|
|
||||||
export function getLoadOptionsFunctions(
|
|
||||||
workflow: Workflow,
|
|
||||||
node: INode,
|
|
||||||
path: string,
|
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
|
||||||
): ILoadOptionsFunctions {
|
|
||||||
return ((workflow: Workflow, node: INode, path: string) => {
|
|
||||||
return {
|
|
||||||
...getCommonWorkflowFunctions(workflow, node, additionalData),
|
|
||||||
getCredentials: async (type) =>
|
|
||||||
await getCredentials(workflow, node, type, additionalData, 'internal'),
|
|
||||||
getCurrentNodeParameter: (
|
|
||||||
parameterPath: string,
|
|
||||||
options?: IGetNodeParameterOptions,
|
|
||||||
): NodeParameterValueType | object | undefined => {
|
|
||||||
const nodeParameters = additionalData.currentNodeParameters;
|
|
||||||
|
|
||||||
if (parameterPath.charAt(0) === '&') {
|
|
||||||
parameterPath = `${path.split('.').slice(1, -1).join('.')}.${parameterPath.slice(1)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let returnData = get(nodeParameters, parameterPath);
|
|
||||||
|
|
||||||
// This is outside the try/catch because it throws errors with proper messages
|
|
||||||
if (options?.extractValue) {
|
|
||||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
|
||||||
if (nodeType === undefined) {
|
|
||||||
throw new ApplicationError('Node type is not known so cannot return parameter value', {
|
|
||||||
tags: { nodeType: node.type },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
returnData = extractValue(
|
|
||||||
returnData,
|
|
||||||
parameterPath,
|
|
||||||
node,
|
|
||||||
nodeType,
|
|
||||||
) as NodeParameterValueType;
|
|
||||||
}
|
|
||||||
|
|
||||||
return returnData;
|
|
||||||
},
|
|
||||||
getCurrentNodeParameters: () => additionalData.currentNodeParameters,
|
|
||||||
getNodeParameter: (
|
|
||||||
parameterName: string,
|
|
||||||
fallbackValue?: any,
|
|
||||||
options?: IGetNodeParameterOptions,
|
|
||||||
): NodeParameterValueType | object => {
|
|
||||||
const runExecutionData: IRunExecutionData | null = null;
|
|
||||||
const itemIndex = 0;
|
|
||||||
const runIndex = 0;
|
|
||||||
const mode = 'internal' as WorkflowExecuteMode;
|
|
||||||
const connectionInputData: INodeExecutionData[] = [];
|
|
||||||
|
|
||||||
return getNodeParameter(
|
|
||||||
workflow,
|
|
||||||
runExecutionData,
|
|
||||||
runIndex,
|
|
||||||
connectionInputData,
|
|
||||||
node,
|
|
||||||
parameterName,
|
|
||||||
itemIndex,
|
|
||||||
mode,
|
|
||||||
getAdditionalKeys(additionalData, mode, runExecutionData),
|
|
||||||
undefined,
|
|
||||||
fallbackValue,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
helpers: {
|
|
||||||
...getSSHTunnelFunctions(),
|
|
||||||
...getRequestHelperFunctions(workflow, node, additionalData),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})(workflow, node, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the execute functions regular nodes have access to in hook-function.
|
* Returns the execute functions regular nodes have access to in hook-function.
|
||||||
*/
|
*/
|
||||||
|
@ -4529,59 +4343,7 @@ export function getExecuteHookFunctions(
|
||||||
activation: WorkflowActivateMode,
|
activation: WorkflowActivateMode,
|
||||||
webhookData?: IWebhookData,
|
webhookData?: IWebhookData,
|
||||||
): IHookFunctions {
|
): IHookFunctions {
|
||||||
return ((workflow: Workflow, node: INode) => {
|
return new HookContext(workflow, node, additionalData, mode, activation, webhookData);
|
||||||
return {
|
|
||||||
...getCommonWorkflowFunctions(workflow, node, additionalData),
|
|
||||||
getCredentials: async (type) =>
|
|
||||||
await getCredentials(workflow, node, type, additionalData, mode),
|
|
||||||
getMode: () => mode,
|
|
||||||
getActivationMode: () => activation,
|
|
||||||
getNodeParameter: (
|
|
||||||
parameterName: string,
|
|
||||||
fallbackValue?: any,
|
|
||||||
options?: IGetNodeParameterOptions,
|
|
||||||
): NodeParameterValueType | object => {
|
|
||||||
const runExecutionData: IRunExecutionData | null = null;
|
|
||||||
const itemIndex = 0;
|
|
||||||
const runIndex = 0;
|
|
||||||
const connectionInputData: INodeExecutionData[] = [];
|
|
||||||
|
|
||||||
return getNodeParameter(
|
|
||||||
workflow,
|
|
||||||
runExecutionData,
|
|
||||||
runIndex,
|
|
||||||
connectionInputData,
|
|
||||||
node,
|
|
||||||
parameterName,
|
|
||||||
itemIndex,
|
|
||||||
mode,
|
|
||||||
getAdditionalKeys(additionalData, mode, runExecutionData),
|
|
||||||
undefined,
|
|
||||||
fallbackValue,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getNodeWebhookUrl: (name: string): string | undefined => {
|
|
||||||
return getNodeWebhookUrl(
|
|
||||||
name,
|
|
||||||
workflow,
|
|
||||||
node,
|
|
||||||
additionalData,
|
|
||||||
mode,
|
|
||||||
getAdditionalKeys(additionalData, mode, null),
|
|
||||||
webhookData?.isTest,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getWebhookName(): string {
|
|
||||||
if (webhookData === undefined) {
|
|
||||||
throw new ApplicationError('Only supported in webhook functions');
|
|
||||||
}
|
|
||||||
return webhookData.webhookDescription.name;
|
|
||||||
},
|
|
||||||
getWebhookDescription: (name) => getWebhookDescription(name, workflow, node),
|
|
||||||
helpers: getRequestHelperFunctions(workflow, node, additionalData),
|
|
||||||
};
|
|
||||||
})(workflow, node);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -4597,170 +4359,13 @@ export function getExecuteWebhookFunctions(
|
||||||
closeFunctions: CloseFunction[],
|
closeFunctions: CloseFunction[],
|
||||||
runExecutionData: IRunExecutionData | null,
|
runExecutionData: IRunExecutionData | null,
|
||||||
): IWebhookFunctions {
|
): IWebhookFunctions {
|
||||||
return ((workflow: Workflow, node: INode, runExecutionData: IRunExecutionData | null) => {
|
return new WebhookContext(
|
||||||
return {
|
|
||||||
...getCommonWorkflowFunctions(workflow, node, additionalData),
|
|
||||||
getBodyData(): IDataObject {
|
|
||||||
if (additionalData.httpRequest === undefined) {
|
|
||||||
throw new ApplicationError('Request is missing');
|
|
||||||
}
|
|
||||||
return additionalData.httpRequest.body;
|
|
||||||
},
|
|
||||||
getCredentials: async (type) =>
|
|
||||||
await getCredentials(workflow, node, type, additionalData, mode),
|
|
||||||
getHeaderData(): IncomingHttpHeaders {
|
|
||||||
if (additionalData.httpRequest === undefined) {
|
|
||||||
throw new ApplicationError('Request is missing');
|
|
||||||
}
|
|
||||||
return additionalData.httpRequest.headers;
|
|
||||||
},
|
|
||||||
async getInputConnectionData(
|
|
||||||
inputName: NodeConnectionType,
|
|
||||||
itemIndex: number,
|
|
||||||
): Promise<unknown> {
|
|
||||||
// To be able to use expressions like "$json.sessionId" set the
|
|
||||||
// body data the webhook received to what is normally used for
|
|
||||||
// incoming node data.
|
|
||||||
const connectionInputData: INodeExecutionData[] = [
|
|
||||||
{ json: additionalData.httpRequest?.body || {} },
|
|
||||||
];
|
|
||||||
const runExecutionData: IRunExecutionData = {
|
|
||||||
resultData: {
|
|
||||||
runData: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const executeData: IExecuteData = {
|
|
||||||
data: {
|
|
||||||
main: [connectionInputData],
|
|
||||||
},
|
|
||||||
node,
|
|
||||||
source: null,
|
|
||||||
};
|
|
||||||
const runIndex = 0;
|
|
||||||
|
|
||||||
return await getInputConnectionData.call(
|
|
||||||
this,
|
|
||||||
workflow,
|
workflow,
|
||||||
runExecutionData,
|
node,
|
||||||
runIndex,
|
|
||||||
connectionInputData,
|
|
||||||
{} as ITaskDataConnections,
|
|
||||||
additionalData,
|
additionalData,
|
||||||
executeData,
|
|
||||||
mode,
|
mode,
|
||||||
|
webhookData,
|
||||||
closeFunctions,
|
closeFunctions,
|
||||||
inputName,
|
|
||||||
itemIndex,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getMode: () => mode,
|
|
||||||
evaluateExpression: (expression: string, evaluateItemIndex?: number) => {
|
|
||||||
const itemIndex = evaluateItemIndex === undefined ? 0 : evaluateItemIndex;
|
|
||||||
const runIndex = 0;
|
|
||||||
|
|
||||||
let connectionInputData: INodeExecutionData[] = [];
|
|
||||||
let executionData: IExecuteData | undefined;
|
|
||||||
|
|
||||||
if (runExecutionData?.executionData !== undefined) {
|
|
||||||
executionData = runExecutionData.executionData.nodeExecutionStack[0];
|
|
||||||
|
|
||||||
if (executionData !== undefined) {
|
|
||||||
connectionInputData = executionData.data.main[0]!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const additionalKeys = getAdditionalKeys(additionalData, mode, runExecutionData);
|
|
||||||
|
|
||||||
return workflow.expression.resolveSimpleParameterValue(
|
|
||||||
`=${expression}`,
|
|
||||||
{},
|
|
||||||
runExecutionData,
|
runExecutionData,
|
||||||
runIndex,
|
|
||||||
itemIndex,
|
|
||||||
node.name,
|
|
||||||
connectionInputData,
|
|
||||||
mode,
|
|
||||||
additionalKeys,
|
|
||||||
executionData,
|
|
||||||
);
|
);
|
||||||
},
|
|
||||||
getNodeParameter: (
|
|
||||||
parameterName: string,
|
|
||||||
fallbackValue?: any,
|
|
||||||
options?: IGetNodeParameterOptions,
|
|
||||||
): NodeParameterValueType | object => {
|
|
||||||
const itemIndex = 0;
|
|
||||||
const runIndex = 0;
|
|
||||||
|
|
||||||
let connectionInputData: INodeExecutionData[] = [];
|
|
||||||
let executionData: IExecuteData | undefined;
|
|
||||||
|
|
||||||
if (runExecutionData?.executionData !== undefined) {
|
|
||||||
executionData = runExecutionData.executionData.nodeExecutionStack[0];
|
|
||||||
|
|
||||||
if (executionData !== undefined) {
|
|
||||||
connectionInputData = executionData.data.main[0]!;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const additionalKeys = getAdditionalKeys(additionalData, mode, runExecutionData);
|
|
||||||
|
|
||||||
return getNodeParameter(
|
|
||||||
workflow,
|
|
||||||
runExecutionData,
|
|
||||||
runIndex,
|
|
||||||
connectionInputData,
|
|
||||||
node,
|
|
||||||
parameterName,
|
|
||||||
itemIndex,
|
|
||||||
mode,
|
|
||||||
additionalKeys,
|
|
||||||
executionData,
|
|
||||||
fallbackValue,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
getParamsData(): object {
|
|
||||||
if (additionalData.httpRequest === undefined) {
|
|
||||||
throw new ApplicationError('Request is missing');
|
|
||||||
}
|
|
||||||
return additionalData.httpRequest.params;
|
|
||||||
},
|
|
||||||
getQueryData(): object {
|
|
||||||
if (additionalData.httpRequest === undefined) {
|
|
||||||
throw new ApplicationError('Request is missing');
|
|
||||||
}
|
|
||||||
return additionalData.httpRequest.query;
|
|
||||||
},
|
|
||||||
getRequestObject(): Request {
|
|
||||||
if (additionalData.httpRequest === undefined) {
|
|
||||||
throw new ApplicationError('Request is missing');
|
|
||||||
}
|
|
||||||
return additionalData.httpRequest;
|
|
||||||
},
|
|
||||||
getResponseObject(): Response {
|
|
||||||
if (additionalData.httpResponse === undefined) {
|
|
||||||
throw new ApplicationError('Response is missing');
|
|
||||||
}
|
|
||||||
return additionalData.httpResponse;
|
|
||||||
},
|
|
||||||
getNodeWebhookUrl: (name: string): string | undefined =>
|
|
||||||
getNodeWebhookUrl(
|
|
||||||
name,
|
|
||||||
workflow,
|
|
||||||
node,
|
|
||||||
additionalData,
|
|
||||||
mode,
|
|
||||||
getAdditionalKeys(additionalData, mode, null),
|
|
||||||
),
|
|
||||||
getWebhookName: () => webhookData.webhookDescription.name,
|
|
||||||
helpers: {
|
|
||||||
createDeferredPromise,
|
|
||||||
...getRequestHelperFunctions(workflow, node, additionalData),
|
|
||||||
...getBinaryHelperFunctions(additionalData, workflow.id),
|
|
||||||
returnJsonArray,
|
|
||||||
},
|
|
||||||
nodeHelpers: getNodeHelperFunctions(additionalData, workflow.id),
|
|
||||||
};
|
|
||||||
})(workflow, node, runExecutionData);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,6 +45,7 @@ import {
|
||||||
NodeExecutionOutput,
|
NodeExecutionOutput,
|
||||||
sleep,
|
sleep,
|
||||||
ErrorReporterProxy,
|
ErrorReporterProxy,
|
||||||
|
ExecutionCancelledError,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import PCancelable from 'p-cancelable';
|
import PCancelable from 'p-cancelable';
|
||||||
|
|
||||||
|
@ -154,10 +155,6 @@ export class WorkflowExecute {
|
||||||
return this.processRunExecutionData(workflow);
|
return this.processRunExecutionData(workflow);
|
||||||
}
|
}
|
||||||
|
|
||||||
static isAbortError(e?: ExecutionBaseError) {
|
|
||||||
return e?.message === 'AbortError';
|
|
||||||
}
|
|
||||||
|
|
||||||
forceInputNodeExecution(workflow: Workflow): boolean {
|
forceInputNodeExecution(workflow: Workflow): boolean {
|
||||||
return workflow.settings.executionOrder !== 'v1';
|
return workflow.settings.executionOrder !== 'v1';
|
||||||
}
|
}
|
||||||
|
@ -1479,7 +1476,7 @@ export class WorkflowExecute {
|
||||||
// Add the execution data again so that it can get restarted
|
// Add the execution data again so that it can get restarted
|
||||||
this.runExecutionData.executionData!.nodeExecutionStack.unshift(executionData);
|
this.runExecutionData.executionData!.nodeExecutionStack.unshift(executionData);
|
||||||
// Only execute the nodeExecuteAfter hook if the node did not get aborted
|
// Only execute the nodeExecuteAfter hook if the node did not get aborted
|
||||||
if (!WorkflowExecute.isAbortError(executionError)) {
|
if (!this.isCancelled) {
|
||||||
await this.executeHook('nodeExecuteAfter', [
|
await this.executeHook('nodeExecuteAfter', [
|
||||||
executionNode.name,
|
executionNode.name,
|
||||||
taskData,
|
taskData,
|
||||||
|
@ -1827,7 +1824,7 @@ export class WorkflowExecute {
|
||||||
return await this.processSuccessExecution(
|
return await this.processSuccessExecution(
|
||||||
startedAt,
|
startedAt,
|
||||||
workflow,
|
workflow,
|
||||||
new WorkflowOperationError('Workflow has been canceled or timed out'),
|
new ExecutionCancelledError(this.additionalData.executionId ?? 'unknown'),
|
||||||
closeFunction,
|
closeFunction,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1928,7 +1925,7 @@ export class WorkflowExecute {
|
||||||
|
|
||||||
this.moveNodeMetadata();
|
this.moveNodeMetadata();
|
||||||
// Prevent from running the hook if the error is an abort error as it was already handled
|
// Prevent from running the hook if the error is an abort error as it was already handled
|
||||||
if (!WorkflowExecute.isAbortError(executionError)) {
|
if (!this.isCancelled) {
|
||||||
await this.executeHook('workflowExecuteAfter', [fullRunData, newStaticData]);
|
await this.executeHook('workflowExecuteAfter', [fullRunData, newStaticData]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1959,4 +1956,8 @@ export class WorkflowExecute {
|
||||||
|
|
||||||
return fullRunData;
|
return fullRunData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get isCancelled() {
|
||||||
|
return this.abortController.signal.aborted;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,3 +20,4 @@ export { ObjectStoreService } from './ObjectStore/ObjectStore.service.ee';
|
||||||
export { BinaryData } from './BinaryData/types';
|
export { BinaryData } from './BinaryData/types';
|
||||||
export { isStoredMode as isValidNonDefaultMode } from './BinaryData/utils';
|
export { isStoredMode as isValidNonDefaultMode } from './BinaryData/utils';
|
||||||
export * from './ExecutionMetadata';
|
export * from './ExecutionMetadata';
|
||||||
|
export * from './node-execution-context';
|
||||||
|
|
|
@ -0,0 +1,168 @@
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type {
|
||||||
|
INode,
|
||||||
|
INodeExecutionData,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
Workflow,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
|
import { InstanceSettings } from '@/InstanceSettings';
|
||||||
|
|
||||||
|
import { NodeExecutionContext } from '../node-execution-context';
|
||||||
|
|
||||||
|
class TestContext extends NodeExecutionContext {}
|
||||||
|
|
||||||
|
describe('BaseContext', () => {
|
||||||
|
const instanceSettings = mock<InstanceSettings>({ instanceId: 'abc123' });
|
||||||
|
Container.set(InstanceSettings, instanceSettings);
|
||||||
|
|
||||||
|
const workflow = mock<Workflow>({
|
||||||
|
id: '123',
|
||||||
|
name: 'Test Workflow',
|
||||||
|
active: true,
|
||||||
|
nodeTypes: mock(),
|
||||||
|
timezone: 'UTC',
|
||||||
|
});
|
||||||
|
const node = mock<INode>();
|
||||||
|
let additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||||
|
credentialsHelper: mock(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mode: WorkflowExecuteMode = 'manual';
|
||||||
|
const testContext = new TestContext(workflow, node, additionalData, mode);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNode', () => {
|
||||||
|
it('should return a deep copy of the node', () => {
|
||||||
|
const result = testContext.getNode();
|
||||||
|
expect(result).not.toBe(node);
|
||||||
|
expect(JSON.stringify(result)).toEqual(JSON.stringify(node));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWorkflow', () => {
|
||||||
|
it('should return the id, name, and active properties of the workflow', () => {
|
||||||
|
const result = testContext.getWorkflow();
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: '123', name: 'Test Workflow', active: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMode', () => {
|
||||||
|
it('should return the mode property', () => {
|
||||||
|
const result = testContext.getMode();
|
||||||
|
expect(result).toBe(mode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWorkflowStaticData', () => {
|
||||||
|
it('should call getStaticData method of workflow', () => {
|
||||||
|
testContext.getWorkflowStaticData('testType');
|
||||||
|
expect(workflow.getStaticData).toHaveBeenCalledWith('testType', node);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getChildNodes', () => {
|
||||||
|
it('should return an array of NodeTypeAndVersion objects for the child nodes of the given node', () => {
|
||||||
|
const childNode1 = mock<INode>({ name: 'Child Node 1', type: 'testType1', typeVersion: 1 });
|
||||||
|
const childNode2 = mock<INode>({ name: 'Child Node 2', type: 'testType2', typeVersion: 2 });
|
||||||
|
workflow.getChildNodes.mockReturnValue(['Child Node 1', 'Child Node 2']);
|
||||||
|
workflow.nodes = {
|
||||||
|
'Child Node 1': childNode1,
|
||||||
|
'Child Node 2': childNode2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = testContext.getChildNodes('Test Node');
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ name: 'Child Node 1', type: 'testType1', typeVersion: 1 },
|
||||||
|
{ name: 'Child Node 2', type: 'testType2', typeVersion: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getParentNodes', () => {
|
||||||
|
it('should return an array of NodeTypeAndVersion objects for the parent nodes of the given node', () => {
|
||||||
|
const parentNode1 = mock<INode>({ name: 'Parent Node 1', type: 'testType1', typeVersion: 1 });
|
||||||
|
const parentNode2 = mock<INode>({ name: 'Parent Node 2', type: 'testType2', typeVersion: 2 });
|
||||||
|
workflow.getParentNodes.mockReturnValue(['Parent Node 1', 'Parent Node 2']);
|
||||||
|
workflow.nodes = {
|
||||||
|
'Parent Node 1': parentNode1,
|
||||||
|
'Parent Node 2': parentNode2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = testContext.getParentNodes('Test Node');
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ name: 'Parent Node 1', type: 'testType1', typeVersion: 1 },
|
||||||
|
{ name: 'Parent Node 2', type: 'testType2', typeVersion: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getKnownNodeTypes', () => {
|
||||||
|
it('should call getKnownTypes method of workflow.nodeTypes', () => {
|
||||||
|
testContext.getKnownNodeTypes();
|
||||||
|
expect(workflow.nodeTypes.getKnownTypes).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRestApiUrl', () => {
|
||||||
|
it('should return the restApiUrl property of additionalData', () => {
|
||||||
|
additionalData.restApiUrl = 'https://example.com/api';
|
||||||
|
|
||||||
|
const result = testContext.getRestApiUrl();
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com/api');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getInstanceBaseUrl', () => {
|
||||||
|
it('should return the instanceBaseUrl property of additionalData', () => {
|
||||||
|
additionalData.instanceBaseUrl = 'https://example.com';
|
||||||
|
|
||||||
|
const result = testContext.getInstanceBaseUrl();
|
||||||
|
|
||||||
|
expect(result).toBe('https://example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getInstanceId', () => {
|
||||||
|
it('should return the instanceId property of instanceSettings', () => {
|
||||||
|
const result = testContext.getInstanceId();
|
||||||
|
|
||||||
|
expect(result).toBe('abc123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTimezone', () => {
|
||||||
|
it('should return the timezone property of workflow', () => {
|
||||||
|
const result = testContext.getTimezone();
|
||||||
|
expect(result).toBe('UTC');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCredentialsProperties', () => {
|
||||||
|
it('should call getCredentialsProperties method of additionalData.credentialsHelper', () => {
|
||||||
|
testContext.getCredentialsProperties('testType');
|
||||||
|
expect(additionalData.credentialsHelper.getCredentialsProperties).toHaveBeenCalledWith(
|
||||||
|
'testType',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prepareOutputData', () => {
|
||||||
|
it('should return the input array wrapped in another array', async () => {
|
||||||
|
const outputData = [mock<INodeExecutionData>(), mock<INodeExecutionData>()];
|
||||||
|
|
||||||
|
const result = await testContext.prepareOutputData(outputData);
|
||||||
|
|
||||||
|
expect(result).toEqual([outputData]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type {
|
||||||
|
Expression,
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
ICredentialsHelper,
|
||||||
|
INode,
|
||||||
|
INodeType,
|
||||||
|
INodeTypes,
|
||||||
|
IWebhookDescription,
|
||||||
|
IWebhookData,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
Workflow,
|
||||||
|
WorkflowActivateMode,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { ApplicationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { HookContext } from '../hook-context';
|
||||||
|
|
||||||
|
describe('HookContext', () => {
|
||||||
|
const testCredentialType = 'testCredential';
|
||||||
|
const webhookDescription: IWebhookDescription = {
|
||||||
|
name: 'default',
|
||||||
|
httpMethod: 'GET',
|
||||||
|
responseMode: 'onReceived',
|
||||||
|
path: 'testPath',
|
||||||
|
};
|
||||||
|
const nodeType = mock<INodeType>({
|
||||||
|
description: {
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
name: testCredentialType,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
name: 'testParameter',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
nodeType.description.webhooks = [webhookDescription];
|
||||||
|
const nodeTypes = mock<INodeTypes>();
|
||||||
|
const expression = mock<Expression>();
|
||||||
|
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||||
|
const node = mock<INode>({
|
||||||
|
credentials: {
|
||||||
|
[testCredentialType]: {
|
||||||
|
id: 'testCredentialId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
node.parameters = {
|
||||||
|
testParameter: 'testValue',
|
||||||
|
};
|
||||||
|
const credentialsHelper = mock<ICredentialsHelper>();
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||||
|
const mode: WorkflowExecuteMode = 'manual';
|
||||||
|
const activation: WorkflowActivateMode = 'init';
|
||||||
|
const webhookData = mock<IWebhookData>({
|
||||||
|
webhookDescription: {
|
||||||
|
name: 'default',
|
||||||
|
isFullPath: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hookContext = new HookContext(
|
||||||
|
workflow,
|
||||||
|
node,
|
||||||
|
additionalData,
|
||||||
|
mode,
|
||||||
|
activation,
|
||||||
|
webhookData,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
expression.getParameterValue.mockImplementation((value) => value);
|
||||||
|
expression.getSimpleParameterValue.mockImplementation((_, value) => value);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getActivationMode', () => {
|
||||||
|
it('should return the activation property', () => {
|
||||||
|
const result = hookContext.getActivationMode();
|
||||||
|
expect(result).toBe(activation);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCredentials', () => {
|
||||||
|
it('should get decrypted credentials', async () => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||||
|
|
||||||
|
const credentials =
|
||||||
|
await hookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||||
|
|
||||||
|
expect(credentials).toEqual({ secret: 'token' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNodeParameter', () => {
|
||||||
|
it('should return parameter value when it exists', () => {
|
||||||
|
const parameter = hookContext.getNodeParameter('testParameter');
|
||||||
|
|
||||||
|
expect(parameter).toBe('testValue');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNodeWebhookUrl', () => {
|
||||||
|
it('should return node webhook url', () => {
|
||||||
|
const url = hookContext.getNodeWebhookUrl('default');
|
||||||
|
|
||||||
|
expect(url).toContain('testPath');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWebhookName', () => {
|
||||||
|
it('should return webhook name', () => {
|
||||||
|
const name = hookContext.getWebhookName();
|
||||||
|
|
||||||
|
expect(name).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if webhookData is undefined', () => {
|
||||||
|
const hookContextWithoutWebhookData = new HookContext(
|
||||||
|
workflow,
|
||||||
|
node,
|
||||||
|
additionalData,
|
||||||
|
mode,
|
||||||
|
activation,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => hookContextWithoutWebhookData.getWebhookName()).toThrow(ApplicationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWebhookDescription', () => {
|
||||||
|
it('should return webhook description', () => {
|
||||||
|
const description = hookContext.getWebhookDescription('default');
|
||||||
|
|
||||||
|
expect(description).toEqual<IWebhookDescription>(webhookDescription);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type {
|
||||||
|
Expression,
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
ICredentialsHelper,
|
||||||
|
INode,
|
||||||
|
INodeType,
|
||||||
|
INodeTypes,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
Workflow,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { LoadOptionsContext } from '../load-options-context';
|
||||||
|
|
||||||
|
describe('LoadOptionsContext', () => {
|
||||||
|
const testCredentialType = 'testCredential';
|
||||||
|
const nodeType = mock<INodeType>({
|
||||||
|
description: {
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
name: testCredentialType,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
name: 'testParameter',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const nodeTypes = mock<INodeTypes>();
|
||||||
|
const expression = mock<Expression>();
|
||||||
|
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||||
|
const node = mock<INode>({
|
||||||
|
credentials: {
|
||||||
|
[testCredentialType]: {
|
||||||
|
id: 'testCredentialId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
node.parameters = {
|
||||||
|
testParameter: 'testValue',
|
||||||
|
};
|
||||||
|
const credentialsHelper = mock<ICredentialsHelper>();
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||||
|
const path = 'testPath';
|
||||||
|
|
||||||
|
const loadOptionsContext = new LoadOptionsContext(workflow, node, additionalData, path);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCredentials', () => {
|
||||||
|
it('should get decrypted credentials', async () => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||||
|
|
||||||
|
const credentials =
|
||||||
|
await loadOptionsContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||||
|
|
||||||
|
expect(credentials).toEqual({ secret: 'token' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCurrentNodeParameter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return parameter value when it exists', () => {
|
||||||
|
additionalData.currentNodeParameters = {
|
||||||
|
testParameter: 'testValue',
|
||||||
|
};
|
||||||
|
|
||||||
|
const parameter = loadOptionsContext.getCurrentNodeParameter('testParameter');
|
||||||
|
|
||||||
|
expect(parameter).toBe('testValue');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNodeParameter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
expression.getParameterValue.mockImplementation((value) => value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return parameter value when it exists', () => {
|
||||||
|
const parameter = loadOptionsContext.getNodeParameter('testParameter');
|
||||||
|
|
||||||
|
expect(parameter).toBe('testValue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the fallback value when the parameter does not exist', () => {
|
||||||
|
const parameter = loadOptionsContext.getNodeParameter('otherParameter', 'fallback');
|
||||||
|
|
||||||
|
expect(parameter).toBe('fallback');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type {
|
||||||
|
Expression,
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
ICredentialsHelper,
|
||||||
|
INode,
|
||||||
|
INodeType,
|
||||||
|
INodeTypes,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
Workflow,
|
||||||
|
WorkflowActivateMode,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { PollContext } from '../poll-context';
|
||||||
|
|
||||||
|
describe('PollContext', () => {
|
||||||
|
const testCredentialType = 'testCredential';
|
||||||
|
const nodeType = mock<INodeType>({
|
||||||
|
description: {
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
name: testCredentialType,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
name: 'testParameter',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const nodeTypes = mock<INodeTypes>();
|
||||||
|
const expression = mock<Expression>();
|
||||||
|
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||||
|
const node = mock<INode>({
|
||||||
|
credentials: {
|
||||||
|
[testCredentialType]: {
|
||||||
|
id: 'testCredentialId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
node.parameters = {
|
||||||
|
testParameter: 'testValue',
|
||||||
|
};
|
||||||
|
const credentialsHelper = mock<ICredentialsHelper>();
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||||
|
const mode: WorkflowExecuteMode = 'manual';
|
||||||
|
const activation: WorkflowActivateMode = 'init';
|
||||||
|
|
||||||
|
const pollContext = new PollContext(workflow, node, additionalData, mode, activation);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getActivationMode', () => {
|
||||||
|
it('should return the activation property', () => {
|
||||||
|
const result = pollContext.getActivationMode();
|
||||||
|
expect(result).toBe(activation);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCredentials', () => {
|
||||||
|
it('should get decrypted credentials', async () => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||||
|
|
||||||
|
const credentials =
|
||||||
|
await pollContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||||
|
|
||||||
|
expect(credentials).toEqual({ secret: 'token' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNodeParameter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
expression.getParameterValue.mockImplementation((value) => value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return parameter value when it exists', () => {
|
||||||
|
const parameter = pollContext.getNodeParameter('testParameter');
|
||||||
|
|
||||||
|
expect(parameter).toBe('testValue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the fallback value when the parameter does not exist', () => {
|
||||||
|
const parameter = pollContext.getNodeParameter('otherParameter', 'fallback');
|
||||||
|
|
||||||
|
expect(parameter).toBe('fallback');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type {
|
||||||
|
Expression,
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
ICredentialsHelper,
|
||||||
|
INode,
|
||||||
|
INodeType,
|
||||||
|
INodeTypes,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
Workflow,
|
||||||
|
WorkflowActivateMode,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { TriggerContext } from '../trigger-context';
|
||||||
|
|
||||||
|
describe('TriggerContext', () => {
|
||||||
|
const testCredentialType = 'testCredential';
|
||||||
|
const nodeType = mock<INodeType>({
|
||||||
|
description: {
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
name: testCredentialType,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
name: 'testParameter',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const nodeTypes = mock<INodeTypes>();
|
||||||
|
const expression = mock<Expression>();
|
||||||
|
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||||
|
const node = mock<INode>({
|
||||||
|
credentials: {
|
||||||
|
[testCredentialType]: {
|
||||||
|
id: 'testCredentialId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
node.parameters = {
|
||||||
|
testParameter: 'testValue',
|
||||||
|
};
|
||||||
|
const credentialsHelper = mock<ICredentialsHelper>();
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||||
|
const mode: WorkflowExecuteMode = 'manual';
|
||||||
|
const activation: WorkflowActivateMode = 'init';
|
||||||
|
|
||||||
|
const triggerContext = new TriggerContext(workflow, node, additionalData, mode, activation);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getActivationMode', () => {
|
||||||
|
it('should return the activation property', () => {
|
||||||
|
const result = triggerContext.getActivationMode();
|
||||||
|
expect(result).toBe(activation);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCredentials', () => {
|
||||||
|
it('should get decrypted credentials', async () => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||||
|
|
||||||
|
const credentials =
|
||||||
|
await triggerContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||||
|
|
||||||
|
expect(credentials).toEqual({ secret: 'token' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNodeParameter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
expression.getParameterValue.mockImplementation((value) => value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return parameter value when it exists', () => {
|
||||||
|
const parameter = triggerContext.getNodeParameter('testParameter');
|
||||||
|
|
||||||
|
expect(parameter).toBe('testValue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the fallback value when the parameter does not exist', () => {
|
||||||
|
const parameter = triggerContext.getNodeParameter('otherParameter', 'fallback');
|
||||||
|
|
||||||
|
expect(parameter).toBe('fallback');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,161 @@
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type {
|
||||||
|
Expression,
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
ICredentialsHelper,
|
||||||
|
INode,
|
||||||
|
INodeType,
|
||||||
|
INodeTypes,
|
||||||
|
IWebhookData,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
Workflow,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { WebhookContext } from '../webhook-context';
|
||||||
|
|
||||||
|
describe('WebhookContext', () => {
|
||||||
|
const testCredentialType = 'testCredential';
|
||||||
|
const nodeType = mock<INodeType>({
|
||||||
|
description: {
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
name: testCredentialType,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
name: 'testParameter',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const nodeTypes = mock<INodeTypes>();
|
||||||
|
const expression = mock<Expression>();
|
||||||
|
const workflow = mock<Workflow>({ expression, nodeTypes });
|
||||||
|
const node = mock<INode>({
|
||||||
|
credentials: {
|
||||||
|
[testCredentialType]: {
|
||||||
|
id: 'testCredentialId',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
node.parameters = {
|
||||||
|
testParameter: 'testValue',
|
||||||
|
};
|
||||||
|
const credentialsHelper = mock<ICredentialsHelper>();
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||||
|
credentialsHelper,
|
||||||
|
});
|
||||||
|
additionalData.httpRequest = {
|
||||||
|
body: { test: 'body' },
|
||||||
|
headers: { test: 'header' },
|
||||||
|
params: { test: 'param' },
|
||||||
|
query: { test: 'query' },
|
||||||
|
} as unknown as Request;
|
||||||
|
additionalData.httpResponse = mock<Response>();
|
||||||
|
const mode: WorkflowExecuteMode = 'manual';
|
||||||
|
const webhookData = mock<IWebhookData>({
|
||||||
|
webhookDescription: {
|
||||||
|
name: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const runExecutionData = null;
|
||||||
|
|
||||||
|
const webhookContext = new WebhookContext(
|
||||||
|
workflow,
|
||||||
|
node,
|
||||||
|
additionalData,
|
||||||
|
mode,
|
||||||
|
webhookData,
|
||||||
|
[],
|
||||||
|
runExecutionData,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCredentials', () => {
|
||||||
|
it('should get decrypted credentials', async () => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
|
||||||
|
|
||||||
|
const credentials =
|
||||||
|
await webhookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
|
||||||
|
|
||||||
|
expect(credentials).toEqual({ secret: 'token' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBodyData', () => {
|
||||||
|
it('should return the body data of the request', () => {
|
||||||
|
const bodyData = webhookContext.getBodyData();
|
||||||
|
expect(bodyData).toEqual({ test: 'body' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getHeaderData', () => {
|
||||||
|
it('should return the header data of the request', () => {
|
||||||
|
const headerData = webhookContext.getHeaderData();
|
||||||
|
expect(headerData).toEqual({ test: 'header' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getParamsData', () => {
|
||||||
|
it('should return the params data of the request', () => {
|
||||||
|
const paramsData = webhookContext.getParamsData();
|
||||||
|
expect(paramsData).toEqual({ test: 'param' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getQueryData', () => {
|
||||||
|
it('should return the query data of the request', () => {
|
||||||
|
const queryData = webhookContext.getQueryData();
|
||||||
|
expect(queryData).toEqual({ test: 'query' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRequestObject', () => {
|
||||||
|
it('should return the request object', () => {
|
||||||
|
const request = webhookContext.getRequestObject();
|
||||||
|
expect(request).toBe(additionalData.httpRequest);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseObject', () => {
|
||||||
|
it('should return the response object', () => {
|
||||||
|
const response = webhookContext.getResponseObject();
|
||||||
|
expect(response).toBe(additionalData.httpResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getWebhookName', () => {
|
||||||
|
it('should return the name of the webhook', () => {
|
||||||
|
const webhookName = webhookContext.getWebhookName();
|
||||||
|
expect(webhookName).toBe('default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNodeParameter', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
expression.getParameterValue.mockImplementation((value) => value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return parameter value when it exists', () => {
|
||||||
|
const parameter = webhookContext.getNodeParameter('testParameter');
|
||||||
|
|
||||||
|
expect(parameter).toBe('testValue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the fallback value when the parameter does not exist', () => {
|
||||||
|
const parameter = webhookContext.getNodeParameter('otherParameter', 'fallback');
|
||||||
|
|
||||||
|
expect(parameter).toBe('fallback');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,136 @@
|
||||||
|
import FileType from 'file-type';
|
||||||
|
import { IncomingMessage, type ClientRequest } from 'http';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { Workflow, IWorkflowExecuteAdditionalData, IBinaryData } from 'n8n-workflow';
|
||||||
|
import type { Socket } from 'net';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
|
import { BinaryDataService } from '@/BinaryData/BinaryData.service';
|
||||||
|
|
||||||
|
import { BinaryHelpers } from '../binary-helpers';
|
||||||
|
|
||||||
|
jest.mock('file-type');
|
||||||
|
|
||||||
|
describe('BinaryHelpers', () => {
|
||||||
|
let binaryDataService = mock<BinaryDataService>();
|
||||||
|
Container.set(BinaryDataService, binaryDataService);
|
||||||
|
const workflow = mock<Workflow>({ id: '123' });
|
||||||
|
const additionalData = mock<IWorkflowExecuteAdditionalData>({ executionId: '456' });
|
||||||
|
const binaryHelpers = new BinaryHelpers(workflow, additionalData);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
binaryDataService.store.mockImplementation(
|
||||||
|
async (_workflowId, _executionId, _buffer, value) => value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBinaryPath', () => {
|
||||||
|
it('should call getPath method of BinaryDataService', () => {
|
||||||
|
binaryHelpers.getBinaryPath('mock-binary-data-id');
|
||||||
|
expect(binaryDataService.getPath).toHaveBeenCalledWith('mock-binary-data-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBinaryMetadata', () => {
|
||||||
|
it('should call getMetadata method of BinaryDataService', async () => {
|
||||||
|
await binaryHelpers.getBinaryMetadata('mock-binary-data-id');
|
||||||
|
expect(binaryDataService.getMetadata).toHaveBeenCalledWith('mock-binary-data-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getBinaryStream', () => {
|
||||||
|
it('should call getStream method of BinaryDataService', async () => {
|
||||||
|
await binaryHelpers.getBinaryStream('mock-binary-data-id');
|
||||||
|
expect(binaryDataService.getAsStream).toHaveBeenCalledWith('mock-binary-data-id', undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prepareBinaryData', () => {
|
||||||
|
it('should guess the mime type and file extension if not provided', async () => {
|
||||||
|
const buffer = Buffer.from('test');
|
||||||
|
const fileTypeData = { mime: 'application/pdf', ext: 'pdf' };
|
||||||
|
(FileType.fromBuffer as jest.Mock).mockResolvedValue(fileTypeData);
|
||||||
|
|
||||||
|
const binaryData = await binaryHelpers.prepareBinaryData(buffer);
|
||||||
|
|
||||||
|
expect(binaryData.mimeType).toEqual('application/pdf');
|
||||||
|
expect(binaryData.fileExtension).toEqual('pdf');
|
||||||
|
expect(binaryData.fileType).toEqual('pdf');
|
||||||
|
expect(binaryData.fileName).toBeUndefined();
|
||||||
|
expect(binaryData.directory).toBeUndefined();
|
||||||
|
expect(binaryDataService.store).toHaveBeenCalledWith(
|
||||||
|
workflow.id,
|
||||||
|
additionalData.executionId!,
|
||||||
|
buffer,
|
||||||
|
binaryData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use the provided mime type and file extension if provided', async () => {
|
||||||
|
const buffer = Buffer.from('test');
|
||||||
|
const mimeType = 'application/octet-stream';
|
||||||
|
|
||||||
|
const binaryData = await binaryHelpers.prepareBinaryData(buffer, undefined, mimeType);
|
||||||
|
|
||||||
|
expect(binaryData.mimeType).toEqual(mimeType);
|
||||||
|
expect(binaryData.fileExtension).toEqual('bin');
|
||||||
|
expect(binaryData.fileType).toBeUndefined();
|
||||||
|
expect(binaryData.fileName).toBeUndefined();
|
||||||
|
expect(binaryData.directory).toBeUndefined();
|
||||||
|
expect(binaryDataService.store).toHaveBeenCalledWith(
|
||||||
|
workflow.id,
|
||||||
|
additionalData.executionId!,
|
||||||
|
buffer,
|
||||||
|
binaryData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockSocket = mock<Socket>({ readableHighWaterMark: 0 });
|
||||||
|
|
||||||
|
it('should use the contentDisposition.filename, responseUrl, and contentType properties to set the fileName, directory, and mimeType properties of the binaryData object', async () => {
|
||||||
|
const incomingMessage = new IncomingMessage(mockSocket);
|
||||||
|
incomingMessage.contentDisposition = { filename: 'test.txt', type: 'attachment' };
|
||||||
|
incomingMessage.contentType = 'text/plain';
|
||||||
|
incomingMessage.responseUrl = 'https://example.com/test.txt';
|
||||||
|
|
||||||
|
const binaryData = await binaryHelpers.prepareBinaryData(incomingMessage);
|
||||||
|
|
||||||
|
expect(binaryData.fileName).toEqual('test.txt');
|
||||||
|
expect(binaryData.fileType).toEqual('text');
|
||||||
|
expect(binaryData.directory).toBeUndefined();
|
||||||
|
expect(binaryData.mimeType).toEqual('text/plain');
|
||||||
|
expect(binaryData.fileExtension).toEqual('txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use the req.path property to set the fileName property of the binaryData object if contentDisposition.filename and responseUrl are not provided', async () => {
|
||||||
|
const incomingMessage = new IncomingMessage(mockSocket);
|
||||||
|
incomingMessage.contentType = 'text/plain';
|
||||||
|
incomingMessage.req = mock<ClientRequest>({ path: '/test.txt' });
|
||||||
|
|
||||||
|
const binaryData = await binaryHelpers.prepareBinaryData(incomingMessage);
|
||||||
|
|
||||||
|
expect(binaryData.fileName).toEqual('test.txt');
|
||||||
|
expect(binaryData.directory).toBeUndefined();
|
||||||
|
expect(binaryData.mimeType).toEqual('text/plain');
|
||||||
|
expect(binaryData.fileExtension).toEqual('txt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setBinaryDataBuffer', () => {
|
||||||
|
it('should call store method of BinaryDataService', async () => {
|
||||||
|
const binaryData = mock<IBinaryData>();
|
||||||
|
const bufferOrStream = mock<Buffer>();
|
||||||
|
|
||||||
|
await binaryHelpers.setBinaryDataBuffer(binaryData, bufferOrStream);
|
||||||
|
|
||||||
|
expect(binaryDataService.store).toHaveBeenCalledWith(
|
||||||
|
workflow.id,
|
||||||
|
additionalData.executionId,
|
||||||
|
bufferOrStream,
|
||||||
|
binaryData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { Workflow } from 'n8n-workflow';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
|
import { ScheduledTaskManager } from '@/ScheduledTaskManager';
|
||||||
|
|
||||||
|
import { SchedulingHelpers } from '../scheduling-helpers';
|
||||||
|
|
||||||
|
describe('SchedulingHelpers', () => {
|
||||||
|
const scheduledTaskManager = mock<ScheduledTaskManager>();
|
||||||
|
Container.set(ScheduledTaskManager, scheduledTaskManager);
|
||||||
|
const workflow = mock<Workflow>();
|
||||||
|
const schedulingHelpers = new SchedulingHelpers(workflow);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerCron', () => {
|
||||||
|
it('should call registerCron method of ScheduledTaskManager', () => {
|
||||||
|
const cronExpression = '* * * * * *';
|
||||||
|
const onTick = jest.fn();
|
||||||
|
|
||||||
|
schedulingHelpers.registerCron(cronExpression, onTick);
|
||||||
|
|
||||||
|
expect(scheduledTaskManager.registerCron).toHaveBeenCalledWith(
|
||||||
|
workflow,
|
||||||
|
cronExpression,
|
||||||
|
onTick,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { SSHCredentials } from 'n8n-workflow';
|
||||||
|
import type { Client } from 'ssh2';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
|
import { SSHClientsManager } from '@/SSHClientsManager';
|
||||||
|
|
||||||
|
import { SSHTunnelHelpers } from '../ssh-tunnel-helpers';
|
||||||
|
|
||||||
|
describe('SSHTunnelHelpers', () => {
|
||||||
|
const sshClientsManager = mock<SSHClientsManager>();
|
||||||
|
Container.set(SSHClientsManager, sshClientsManager);
|
||||||
|
const sshTunnelHelpers = new SSHTunnelHelpers();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSSHClient', () => {
|
||||||
|
const credentials = mock<SSHCredentials>();
|
||||||
|
|
||||||
|
it('should call SSHClientsManager.getClient with the given credentials', async () => {
|
||||||
|
const mockClient = mock<Client>();
|
||||||
|
sshClientsManager.getClient.mockResolvedValue(mockClient);
|
||||||
|
|
||||||
|
const client = await sshTunnelHelpers.getSSHClient(credentials);
|
||||||
|
|
||||||
|
expect(sshClientsManager.getClient).toHaveBeenCalledWith(credentials);
|
||||||
|
expect(client).toBe(mockClient);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,148 @@
|
||||||
|
import FileType from 'file-type';
|
||||||
|
import { IncomingMessage } from 'http';
|
||||||
|
import MimeTypes from 'mime-types';
|
||||||
|
import { ApplicationError, fileTypeFromMimeType } from 'n8n-workflow';
|
||||||
|
import type {
|
||||||
|
BinaryHelperFunctions,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
Workflow,
|
||||||
|
IBinaryData,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import path from 'path';
|
||||||
|
import type { Readable } from 'stream';
|
||||||
|
import Container from 'typedi';
|
||||||
|
|
||||||
|
import { BinaryDataService } from '@/BinaryData/BinaryData.service';
|
||||||
|
import { binaryToBuffer } from '@/BinaryData/utils';
|
||||||
|
// eslint-disable-next-line import/no-cycle
|
||||||
|
import { binaryToString } from '@/NodeExecuteFunctions';
|
||||||
|
|
||||||
|
export class BinaryHelpers {
|
||||||
|
private readonly binaryDataService = Container.get(BinaryDataService);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly workflow: Workflow,
|
||||||
|
private readonly additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get exported(): BinaryHelperFunctions {
|
||||||
|
return {
|
||||||
|
getBinaryPath: this.getBinaryPath.bind(this),
|
||||||
|
getBinaryMetadata: this.getBinaryMetadata.bind(this),
|
||||||
|
getBinaryStream: this.getBinaryStream.bind(this),
|
||||||
|
binaryToBuffer,
|
||||||
|
binaryToString,
|
||||||
|
prepareBinaryData: this.prepareBinaryData.bind(this),
|
||||||
|
setBinaryDataBuffer: this.setBinaryDataBuffer.bind(this),
|
||||||
|
copyBinaryFile: this.copyBinaryFile.bind(this),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getBinaryPath(binaryDataId: string) {
|
||||||
|
return this.binaryDataService.getPath(binaryDataId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBinaryMetadata(binaryDataId: string) {
|
||||||
|
return await this.binaryDataService.getMetadata(binaryDataId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBinaryStream(binaryDataId: string, chunkSize?: number) {
|
||||||
|
return await this.binaryDataService.getAsStream(binaryDataId, chunkSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
async prepareBinaryData(binaryData: Buffer | Readable, filePath?: string, mimeType?: string) {
|
||||||
|
let fileExtension: string | undefined;
|
||||||
|
if (binaryData instanceof IncomingMessage) {
|
||||||
|
if (!filePath) {
|
||||||
|
try {
|
||||||
|
const { responseUrl } = binaryData;
|
||||||
|
filePath =
|
||||||
|
binaryData.contentDisposition?.filename ??
|
||||||
|
((responseUrl && new URL(responseUrl).pathname) ?? binaryData.req?.path)?.slice(1);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (!mimeType) {
|
||||||
|
mimeType = binaryData.contentType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mimeType) {
|
||||||
|
// If no mime type is given figure it out
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
// Use file path to guess mime type
|
||||||
|
const mimeTypeLookup = MimeTypes.lookup(filePath);
|
||||||
|
if (mimeTypeLookup) {
|
||||||
|
mimeType = mimeTypeLookup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mimeType) {
|
||||||
|
if (Buffer.isBuffer(binaryData)) {
|
||||||
|
// Use buffer to guess mime type
|
||||||
|
const fileTypeData = await FileType.fromBuffer(binaryData);
|
||||||
|
if (fileTypeData) {
|
||||||
|
mimeType = fileTypeData.mime;
|
||||||
|
fileExtension = fileTypeData.ext;
|
||||||
|
}
|
||||||
|
} else if (binaryData instanceof IncomingMessage) {
|
||||||
|
mimeType = binaryData.headers['content-type'];
|
||||||
|
} else {
|
||||||
|
// TODO: detect filetype from other kind of streams
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileExtension && mimeType) {
|
||||||
|
fileExtension = MimeTypes.extension(mimeType) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mimeType) {
|
||||||
|
// Fall back to text
|
||||||
|
mimeType = 'text/plain';
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnData: IBinaryData = {
|
||||||
|
mimeType,
|
||||||
|
fileType: fileTypeFromMimeType(mimeType),
|
||||||
|
fileExtension,
|
||||||
|
data: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
if (filePath.includes('?')) {
|
||||||
|
// Remove maybe present query parameters
|
||||||
|
filePath = filePath.split('?').shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePathParts = path.parse(filePath as string);
|
||||||
|
|
||||||
|
if (filePathParts.dir !== '') {
|
||||||
|
returnData.directory = filePathParts.dir;
|
||||||
|
}
|
||||||
|
returnData.fileName = filePathParts.base;
|
||||||
|
|
||||||
|
// Remove the dot
|
||||||
|
const extractedFileExtension = filePathParts.ext.slice(1);
|
||||||
|
if (extractedFileExtension) {
|
||||||
|
returnData.fileExtension = extractedFileExtension;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.setBinaryDataBuffer(returnData, binaryData);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setBinaryDataBuffer(binaryData: IBinaryData, bufferOrStream: Buffer | Readable) {
|
||||||
|
return await this.binaryDataService.store(
|
||||||
|
this.workflow.id,
|
||||||
|
this.additionalData.executionId!,
|
||||||
|
bufferOrStream,
|
||||||
|
binaryData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyBinaryFile(): Promise<never> {
|
||||||
|
throw new ApplicationError('`copyBinaryFile` has been removed. Please upgrade this node.');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,381 @@
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { pick } from 'lodash';
|
||||||
|
import { jsonParse, NodeOperationError, sleep } from 'n8n-workflow';
|
||||||
|
import type {
|
||||||
|
RequestHelperFunctions,
|
||||||
|
IAdditionalCredentialOptions,
|
||||||
|
IAllExecuteFunctions,
|
||||||
|
IExecuteData,
|
||||||
|
IHttpRequestOptions,
|
||||||
|
IN8nHttpFullResponse,
|
||||||
|
IN8nHttpResponse,
|
||||||
|
INode,
|
||||||
|
INodeExecutionData,
|
||||||
|
IOAuth2Options,
|
||||||
|
IRequestOptions,
|
||||||
|
IRunExecutionData,
|
||||||
|
IWorkflowDataProxyAdditionalKeys,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
NodeParameterValueType,
|
||||||
|
PaginationOptions,
|
||||||
|
Workflow,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-cycle
|
||||||
|
import {
|
||||||
|
applyPaginationRequestData,
|
||||||
|
binaryToString,
|
||||||
|
httpRequest,
|
||||||
|
httpRequestWithAuthentication,
|
||||||
|
proxyRequestToAxios,
|
||||||
|
requestOAuth1,
|
||||||
|
requestOAuth2,
|
||||||
|
requestWithAuthentication,
|
||||||
|
validateUrl,
|
||||||
|
} from '@/NodeExecuteFunctions';
|
||||||
|
|
||||||
|
export class RequestHelpers {
|
||||||
|
constructor(
|
||||||
|
private readonly context: IAllExecuteFunctions,
|
||||||
|
private readonly workflow: Workflow,
|
||||||
|
private readonly node: INode,
|
||||||
|
private readonly additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
private readonly runExecutionData: IRunExecutionData | null = null,
|
||||||
|
private readonly connectionInputData: INodeExecutionData[] = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get exported(): RequestHelperFunctions {
|
||||||
|
return {
|
||||||
|
httpRequest,
|
||||||
|
httpRequestWithAuthentication: this.httpRequestWithAuthentication.bind(this),
|
||||||
|
requestWithAuthenticationPaginated: this.requestWithAuthenticationPaginated.bind(this),
|
||||||
|
request: this.request.bind(this),
|
||||||
|
requestWithAuthentication: this.requestWithAuthentication.bind(this),
|
||||||
|
requestOAuth1: this.requestOAuth1.bind(this),
|
||||||
|
requestOAuth2: this.requestOAuth2.bind(this),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
get httpRequest() {
|
||||||
|
return httpRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
async httpRequestWithAuthentication(
|
||||||
|
credentialsType: string,
|
||||||
|
requestOptions: IHttpRequestOptions,
|
||||||
|
additionalCredentialOptions?: IAdditionalCredentialOptions,
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
return await httpRequestWithAuthentication.call(
|
||||||
|
this.context,
|
||||||
|
credentialsType,
|
||||||
|
requestOptions,
|
||||||
|
this.workflow,
|
||||||
|
this.node,
|
||||||
|
this.additionalData,
|
||||||
|
additionalCredentialOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line complexity
|
||||||
|
async requestWithAuthenticationPaginated(
|
||||||
|
requestOptions: IRequestOptions,
|
||||||
|
itemIndex: number,
|
||||||
|
paginationOptions: PaginationOptions,
|
||||||
|
credentialsType?: string,
|
||||||
|
additionalCredentialOptions?: IAdditionalCredentialOptions,
|
||||||
|
): Promise<unknown[]> {
|
||||||
|
const responseData = [];
|
||||||
|
if (!requestOptions.qs) {
|
||||||
|
requestOptions.qs = {};
|
||||||
|
}
|
||||||
|
requestOptions.resolveWithFullResponse = true;
|
||||||
|
requestOptions.simple = false;
|
||||||
|
|
||||||
|
let tempResponseData: IN8nHttpFullResponse;
|
||||||
|
let makeAdditionalRequest: boolean;
|
||||||
|
let paginateRequestData: PaginationOptions['request'];
|
||||||
|
|
||||||
|
const runIndex = 0;
|
||||||
|
|
||||||
|
const additionalKeys = {
|
||||||
|
$request: requestOptions,
|
||||||
|
$response: {} as IN8nHttpFullResponse,
|
||||||
|
$version: this.node.typeVersion,
|
||||||
|
$pageCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeData: IExecuteData = {
|
||||||
|
data: {},
|
||||||
|
node: this.node,
|
||||||
|
source: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hashData = {
|
||||||
|
identicalCount: 0,
|
||||||
|
previousLength: 0,
|
||||||
|
previousHash: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
do {
|
||||||
|
paginateRequestData = this.getResolvedValue(
|
||||||
|
paginationOptions.request as unknown as NodeParameterValueType,
|
||||||
|
itemIndex,
|
||||||
|
runIndex,
|
||||||
|
executeData,
|
||||||
|
additionalKeys,
|
||||||
|
false,
|
||||||
|
) as object as PaginationOptions['request'];
|
||||||
|
|
||||||
|
const tempRequestOptions = applyPaginationRequestData(requestOptions, paginateRequestData);
|
||||||
|
|
||||||
|
if (!validateUrl(tempRequestOptions.uri as string)) {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
this.node,
|
||||||
|
`'${paginateRequestData.url}' is not a valid URL.`,
|
||||||
|
{
|
||||||
|
itemIndex,
|
||||||
|
runIndex,
|
||||||
|
type: 'invalid_url',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentialsType) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
tempResponseData = await this.requestWithAuthentication(
|
||||||
|
credentialsType,
|
||||||
|
tempRequestOptions,
|
||||||
|
additionalCredentialOptions,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
tempResponseData = await this.request(tempRequestOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newResponse: IN8nHttpFullResponse = Object.assign(
|
||||||
|
{
|
||||||
|
body: {},
|
||||||
|
headers: {},
|
||||||
|
statusCode: 0,
|
||||||
|
},
|
||||||
|
pick(tempResponseData, ['body', 'headers', 'statusCode']),
|
||||||
|
);
|
||||||
|
|
||||||
|
let contentBody: Exclude<IN8nHttpResponse, Buffer>;
|
||||||
|
|
||||||
|
if (newResponse.body instanceof Readable && paginationOptions.binaryResult !== true) {
|
||||||
|
// Keep the original string version that we can use it to hash if needed
|
||||||
|
contentBody = await binaryToString(newResponse.body as Buffer | Readable);
|
||||||
|
|
||||||
|
const responseContentType = newResponse.headers['content-type']?.toString() ?? '';
|
||||||
|
if (responseContentType.includes('application/json')) {
|
||||||
|
newResponse.body = jsonParse(contentBody, { fallbackValue: {} });
|
||||||
|
} else {
|
||||||
|
newResponse.body = contentBody;
|
||||||
|
}
|
||||||
|
tempResponseData.__bodyResolved = true;
|
||||||
|
tempResponseData.body = newResponse.body;
|
||||||
|
} else {
|
||||||
|
contentBody = newResponse.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paginationOptions.binaryResult !== true || tempResponseData.headers.etag) {
|
||||||
|
// If the data is not binary (and so not a stream), or an etag is present,
|
||||||
|
// we check via etag or hash if identical data is received
|
||||||
|
|
||||||
|
let contentLength = 0;
|
||||||
|
if ('content-length' in tempResponseData.headers) {
|
||||||
|
contentLength = parseInt(tempResponseData.headers['content-length'] as string) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashData.previousLength === contentLength) {
|
||||||
|
let hash: string;
|
||||||
|
if (tempResponseData.headers.etag) {
|
||||||
|
// If an etag is provided, we use it as "hash"
|
||||||
|
hash = tempResponseData.headers.etag as string;
|
||||||
|
} else {
|
||||||
|
// If there is no etag, we calculate a hash from the data in the body
|
||||||
|
if (typeof contentBody !== 'string') {
|
||||||
|
contentBody = JSON.stringify(contentBody);
|
||||||
|
}
|
||||||
|
hash = createHash('md5').update(contentBody).digest('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hashData.previousHash === hash) {
|
||||||
|
hashData.identicalCount += 1;
|
||||||
|
if (hashData.identicalCount > 2) {
|
||||||
|
// Length was identical 5x and hash 3x
|
||||||
|
throw new NodeOperationError(
|
||||||
|
this.node,
|
||||||
|
'The returned response was identical 5x, so requests got stopped',
|
||||||
|
{
|
||||||
|
itemIndex,
|
||||||
|
description:
|
||||||
|
'Check if "Pagination Completed When" has been configured correctly.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hashData.identicalCount = 0;
|
||||||
|
}
|
||||||
|
hashData.previousHash = hash;
|
||||||
|
} else {
|
||||||
|
hashData.identicalCount = 0;
|
||||||
|
}
|
||||||
|
hashData.previousLength = contentLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
responseData.push(tempResponseData);
|
||||||
|
|
||||||
|
additionalKeys.$response = newResponse;
|
||||||
|
additionalKeys.$pageCount = additionalKeys.$pageCount + 1;
|
||||||
|
|
||||||
|
const maxRequests = this.getResolvedValue(
|
||||||
|
paginationOptions.maxRequests,
|
||||||
|
itemIndex,
|
||||||
|
runIndex,
|
||||||
|
executeData,
|
||||||
|
additionalKeys,
|
||||||
|
false,
|
||||||
|
) as number;
|
||||||
|
|
||||||
|
if (maxRequests && additionalKeys.$pageCount >= maxRequests) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
makeAdditionalRequest = this.getResolvedValue(
|
||||||
|
paginationOptions.continue,
|
||||||
|
itemIndex,
|
||||||
|
runIndex,
|
||||||
|
executeData,
|
||||||
|
additionalKeys,
|
||||||
|
false,
|
||||||
|
) as boolean;
|
||||||
|
|
||||||
|
if (makeAdditionalRequest) {
|
||||||
|
if (paginationOptions.requestInterval) {
|
||||||
|
const requestInterval = this.getResolvedValue(
|
||||||
|
paginationOptions.requestInterval,
|
||||||
|
itemIndex,
|
||||||
|
runIndex,
|
||||||
|
executeData,
|
||||||
|
additionalKeys,
|
||||||
|
false,
|
||||||
|
) as number;
|
||||||
|
|
||||||
|
await sleep(requestInterval);
|
||||||
|
}
|
||||||
|
if (tempResponseData.statusCode < 200 || tempResponseData.statusCode >= 300) {
|
||||||
|
// We have it configured to let all requests pass no matter the response code
|
||||||
|
// via "requestOptions.simple = false" to not by default fail if it is for example
|
||||||
|
// configured to stop on 404 response codes. For that reason we have to throw here
|
||||||
|
// now an error manually if the response code is not a success one.
|
||||||
|
let data = tempResponseData.body;
|
||||||
|
if (data instanceof Readable && paginationOptions.binaryResult !== true) {
|
||||||
|
data = await binaryToString(data as Buffer | Readable);
|
||||||
|
} else if (typeof data === 'object') {
|
||||||
|
data = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Object.assign(new Error(`${tempResponseData.statusCode} - "${data?.toString()}"`), {
|
||||||
|
statusCode: tempResponseData.statusCode,
|
||||||
|
error: data,
|
||||||
|
isAxiosError: true,
|
||||||
|
response: {
|
||||||
|
headers: tempResponseData.headers,
|
||||||
|
status: tempResponseData.statusCode,
|
||||||
|
statusText: tempResponseData.statusMessage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (makeAdditionalRequest);
|
||||||
|
|
||||||
|
return responseData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(uriOrObject: string | IRequestOptions, options?: IRequestOptions) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
return await proxyRequestToAxios(
|
||||||
|
this.workflow,
|
||||||
|
this.additionalData,
|
||||||
|
this.node,
|
||||||
|
uriOrObject,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestWithAuthentication(
|
||||||
|
credentialsType: string,
|
||||||
|
requestOptions: IRequestOptions,
|
||||||
|
additionalCredentialOptions?: IAdditionalCredentialOptions,
|
||||||
|
itemIndex?: number,
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
return await requestWithAuthentication.call(
|
||||||
|
this.context,
|
||||||
|
credentialsType,
|
||||||
|
requestOptions,
|
||||||
|
this.workflow,
|
||||||
|
this.node,
|
||||||
|
this.additionalData,
|
||||||
|
additionalCredentialOptions,
|
||||||
|
itemIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestOAuth1(credentialsType: string, requestOptions: IRequestOptions) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
return await requestOAuth1.call(this.context, credentialsType, requestOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestOAuth2(
|
||||||
|
credentialsType: string,
|
||||||
|
requestOptions: IRequestOptions,
|
||||||
|
oAuth2Options?: IOAuth2Options,
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
return await requestOAuth2.call(
|
||||||
|
this.context,
|
||||||
|
credentialsType,
|
||||||
|
requestOptions,
|
||||||
|
this.node,
|
||||||
|
this.additionalData,
|
||||||
|
oAuth2Options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getResolvedValue(
|
||||||
|
parameterValue: NodeParameterValueType,
|
||||||
|
itemIndex: number,
|
||||||
|
runIndex: number,
|
||||||
|
executeData: IExecuteData,
|
||||||
|
additionalKeys?: IWorkflowDataProxyAdditionalKeys,
|
||||||
|
returnObjectAsString = false,
|
||||||
|
): NodeParameterValueType {
|
||||||
|
const mode: WorkflowExecuteMode = 'internal';
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof parameterValue === 'object' ||
|
||||||
|
(typeof parameterValue === 'string' && parameterValue.charAt(0) === '=')
|
||||||
|
) {
|
||||||
|
return this.workflow.expression.getParameterValue(
|
||||||
|
parameterValue,
|
||||||
|
this.runExecutionData,
|
||||||
|
runIndex,
|
||||||
|
itemIndex,
|
||||||
|
this.node.name,
|
||||||
|
this.connectionInputData,
|
||||||
|
mode,
|
||||||
|
additionalKeys ?? {},
|
||||||
|
executeData,
|
||||||
|
returnObjectAsString,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parameterValue;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { CronExpression, Workflow, SchedulingFunctions } from 'n8n-workflow';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
|
import { ScheduledTaskManager } from '@/ScheduledTaskManager';
|
||||||
|
|
||||||
|
export class SchedulingHelpers {
|
||||||
|
private readonly scheduledTaskManager = Container.get(ScheduledTaskManager);
|
||||||
|
|
||||||
|
constructor(private readonly workflow: Workflow) {}
|
||||||
|
|
||||||
|
get exported(): SchedulingFunctions {
|
||||||
|
return {
|
||||||
|
registerCron: this.registerCron.bind(this),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
registerCron(cronExpression: CronExpression, onTick: () => void) {
|
||||||
|
this.scheduledTaskManager.registerCron(this.workflow, cronExpression, onTick);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { SSHCredentials, SSHTunnelFunctions } from 'n8n-workflow';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
|
import { SSHClientsManager } from '@/SSHClientsManager';
|
||||||
|
|
||||||
|
export class SSHTunnelHelpers {
|
||||||
|
private readonly sshClientsManager = Container.get(SSHClientsManager);
|
||||||
|
|
||||||
|
get exported(): SSHTunnelFunctions {
|
||||||
|
return {
|
||||||
|
getSSHClient: this.getSSHClient.bind(this),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSSHClient(credentials: SSHCredentials) {
|
||||||
|
return await this.sshClientsManager.getClient(credentials);
|
||||||
|
}
|
||||||
|
}
|
103
packages/core/src/node-execution-context/hook-context.ts
Normal file
103
packages/core/src/node-execution-context/hook-context.ts
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import type {
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
IGetNodeParameterOptions,
|
||||||
|
INode,
|
||||||
|
INodeExecutionData,
|
||||||
|
IHookFunctions,
|
||||||
|
IRunExecutionData,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
NodeParameterValueType,
|
||||||
|
Workflow,
|
||||||
|
WorkflowActivateMode,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
IWebhookData,
|
||||||
|
WebhookType,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { ApplicationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-cycle
|
||||||
|
import {
|
||||||
|
getAdditionalKeys,
|
||||||
|
getCredentials,
|
||||||
|
getNodeParameter,
|
||||||
|
getNodeWebhookUrl,
|
||||||
|
getWebhookDescription,
|
||||||
|
} from '@/NodeExecuteFunctions';
|
||||||
|
|
||||||
|
import { RequestHelpers } from './helpers/request-helpers';
|
||||||
|
import { NodeExecutionContext } from './node-execution-context';
|
||||||
|
|
||||||
|
export class HookContext extends NodeExecutionContext implements IHookFunctions {
|
||||||
|
readonly helpers: IHookFunctions['helpers'];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
workflow: Workflow,
|
||||||
|
node: INode,
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
private readonly activation: WorkflowActivateMode,
|
||||||
|
private readonly webhookData?: IWebhookData,
|
||||||
|
) {
|
||||||
|
super(workflow, node, additionalData, mode);
|
||||||
|
|
||||||
|
this.helpers = new RequestHelpers(this, workflow, node, additionalData);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActivationMode() {
|
||||||
|
return this.activation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||||
|
return await getCredentials<T>(this.workflow, this.node, type, this.additionalData, this.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeParameter(
|
||||||
|
parameterName: string,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
fallbackValue?: any,
|
||||||
|
options?: IGetNodeParameterOptions,
|
||||||
|
): NodeParameterValueType | object {
|
||||||
|
const runExecutionData: IRunExecutionData | null = null;
|
||||||
|
const itemIndex = 0;
|
||||||
|
const runIndex = 0;
|
||||||
|
const connectionInputData: INodeExecutionData[] = [];
|
||||||
|
|
||||||
|
return getNodeParameter(
|
||||||
|
this.workflow,
|
||||||
|
runExecutionData,
|
||||||
|
runIndex,
|
||||||
|
connectionInputData,
|
||||||
|
this.node,
|
||||||
|
parameterName,
|
||||||
|
itemIndex,
|
||||||
|
this.mode,
|
||||||
|
getAdditionalKeys(this.additionalData, this.mode, runExecutionData),
|
||||||
|
undefined,
|
||||||
|
fallbackValue,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeWebhookUrl(name: WebhookType): string | undefined {
|
||||||
|
return getNodeWebhookUrl(
|
||||||
|
name,
|
||||||
|
this.workflow,
|
||||||
|
this.node,
|
||||||
|
this.additionalData,
|
||||||
|
this.mode,
|
||||||
|
getAdditionalKeys(this.additionalData, this.mode, null),
|
||||||
|
this.webhookData?.isTest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWebhookName(): string {
|
||||||
|
if (this.webhookData === undefined) {
|
||||||
|
throw new ApplicationError('Only supported in webhook functions');
|
||||||
|
}
|
||||||
|
return this.webhookData.webhookDescription.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
getWebhookDescription(name: WebhookType) {
|
||||||
|
return getWebhookDescription(name, this.workflow, this.node);
|
||||||
|
}
|
||||||
|
}
|
6
packages/core/src/node-execution-context/index.ts
Normal file
6
packages/core/src/node-execution-context/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
// eslint-disable-next-line import/no-cycle
|
||||||
|
export { HookContext } from './hook-context';
|
||||||
|
export { LoadOptionsContext } from './load-options-context';
|
||||||
|
export { PollContext } from './poll-context';
|
||||||
|
export { TriggerContext } from './trigger-context';
|
||||||
|
export { WebhookContext } from './webhook-context';
|
102
packages/core/src/node-execution-context/load-options-context.ts
Normal file
102
packages/core/src/node-execution-context/load-options-context.ts
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
import { get } from 'lodash';
|
||||||
|
import type {
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
IGetNodeParameterOptions,
|
||||||
|
INode,
|
||||||
|
INodeExecutionData,
|
||||||
|
ILoadOptionsFunctions,
|
||||||
|
IRunExecutionData,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
NodeParameterValueType,
|
||||||
|
Workflow,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { extractValue } from '@/ExtractValue';
|
||||||
|
// eslint-disable-next-line import/no-cycle
|
||||||
|
import { getAdditionalKeys, getCredentials, getNodeParameter } from '@/NodeExecuteFunctions';
|
||||||
|
|
||||||
|
import { RequestHelpers } from './helpers/request-helpers';
|
||||||
|
import { SSHTunnelHelpers } from './helpers/ssh-tunnel-helpers';
|
||||||
|
import { NodeExecutionContext } from './node-execution-context';
|
||||||
|
|
||||||
|
export class LoadOptionsContext extends NodeExecutionContext implements ILoadOptionsFunctions {
|
||||||
|
readonly helpers: ILoadOptionsFunctions['helpers'];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
workflow: Workflow,
|
||||||
|
node: INode,
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
private readonly path: string,
|
||||||
|
) {
|
||||||
|
super(workflow, node, additionalData, 'internal');
|
||||||
|
|
||||||
|
this.helpers = {
|
||||||
|
...new RequestHelpers(this, workflow, node, additionalData).exported,
|
||||||
|
...new SSHTunnelHelpers().exported,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||||
|
return await getCredentials<T>(this.workflow, this.node, type, this.additionalData, this.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentNodeParameter(
|
||||||
|
parameterPath: string,
|
||||||
|
options?: IGetNodeParameterOptions,
|
||||||
|
): NodeParameterValueType | object | undefined {
|
||||||
|
const nodeParameters = this.additionalData.currentNodeParameters;
|
||||||
|
|
||||||
|
if (parameterPath.charAt(0) === '&') {
|
||||||
|
parameterPath = `${this.path.split('.').slice(1, -1).join('.')}.${parameterPath.slice(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let returnData = get(nodeParameters, parameterPath);
|
||||||
|
|
||||||
|
// This is outside the try/catch because it throws errors with proper messages
|
||||||
|
if (options?.extractValue) {
|
||||||
|
const nodeType = this.workflow.nodeTypes.getByNameAndVersion(
|
||||||
|
this.node.type,
|
||||||
|
this.node.typeVersion,
|
||||||
|
);
|
||||||
|
returnData = extractValue(
|
||||||
|
returnData,
|
||||||
|
parameterPath,
|
||||||
|
this.node,
|
||||||
|
nodeType,
|
||||||
|
) as NodeParameterValueType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnData;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentNodeParameters() {
|
||||||
|
return this.additionalData.currentNodeParameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeParameter(
|
||||||
|
parameterName: string,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
fallbackValue?: any,
|
||||||
|
options?: IGetNodeParameterOptions,
|
||||||
|
): NodeParameterValueType | object {
|
||||||
|
const runExecutionData: IRunExecutionData | null = null;
|
||||||
|
const itemIndex = 0;
|
||||||
|
const runIndex = 0;
|
||||||
|
const connectionInputData: INodeExecutionData[] = [];
|
||||||
|
|
||||||
|
return getNodeParameter(
|
||||||
|
this.workflow,
|
||||||
|
runExecutionData,
|
||||||
|
runIndex,
|
||||||
|
connectionInputData,
|
||||||
|
this.node,
|
||||||
|
parameterName,
|
||||||
|
itemIndex,
|
||||||
|
this.mode,
|
||||||
|
getAdditionalKeys(this.additionalData, this.mode, runExecutionData),
|
||||||
|
undefined,
|
||||||
|
fallbackValue,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,107 @@
|
||||||
|
import type {
|
||||||
|
FunctionsBase,
|
||||||
|
INode,
|
||||||
|
INodeExecutionData,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
NodeTypeAndVersion,
|
||||||
|
Workflow,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { deepCopy, LoggerProxy } from 'n8n-workflow';
|
||||||
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
|
import { InstanceSettings } from '@/InstanceSettings';
|
||||||
|
|
||||||
|
export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCredentials'> {
|
||||||
|
protected readonly instanceSettings = Container.get(InstanceSettings);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected readonly workflow: Workflow,
|
||||||
|
protected readonly node: INode,
|
||||||
|
protected readonly additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
protected readonly mode: WorkflowExecuteMode,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get logger() {
|
||||||
|
return LoggerProxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
getExecutionId() {
|
||||||
|
return this.additionalData.executionId!;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNode(): INode {
|
||||||
|
return deepCopy(this.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkflow() {
|
||||||
|
const { id, name, active } = this.workflow;
|
||||||
|
return { id, name, active };
|
||||||
|
}
|
||||||
|
|
||||||
|
getMode() {
|
||||||
|
return this.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkflowStaticData(type: string) {
|
||||||
|
return this.workflow.getStaticData(type, this.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
getChildNodes(nodeName: string) {
|
||||||
|
const output: NodeTypeAndVersion[] = [];
|
||||||
|
const nodeNames = this.workflow.getChildNodes(nodeName);
|
||||||
|
|
||||||
|
for (const n of nodeNames) {
|
||||||
|
const node = this.workflow.nodes[n];
|
||||||
|
output.push({
|
||||||
|
name: node.name,
|
||||||
|
type: node.type,
|
||||||
|
typeVersion: node.typeVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
getParentNodes(nodeName: string) {
|
||||||
|
const output: NodeTypeAndVersion[] = [];
|
||||||
|
const nodeNames = this.workflow.getParentNodes(nodeName);
|
||||||
|
|
||||||
|
for (const n of nodeNames) {
|
||||||
|
const node = this.workflow.nodes[n];
|
||||||
|
output.push({
|
||||||
|
name: node.name,
|
||||||
|
type: node.type,
|
||||||
|
typeVersion: node.typeVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
getKnownNodeTypes() {
|
||||||
|
return this.workflow.nodeTypes.getKnownTypes();
|
||||||
|
}
|
||||||
|
|
||||||
|
getRestApiUrl() {
|
||||||
|
return this.additionalData.restApiUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
getInstanceBaseUrl() {
|
||||||
|
return this.additionalData.instanceBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
getInstanceId() {
|
||||||
|
return this.instanceSettings.instanceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimezone() {
|
||||||
|
return this.workflow.timezone;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCredentialsProperties(type: string) {
|
||||||
|
return this.additionalData.credentialsHelper.getCredentialsProperties(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
async prepareOutputData(outputData: INodeExecutionData[]) {
|
||||||
|
return [outputData];
|
||||||
|
}
|
||||||
|
}
|
94
packages/core/src/node-execution-context/poll-context.ts
Normal file
94
packages/core/src/node-execution-context/poll-context.ts
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import type {
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
IGetNodeParameterOptions,
|
||||||
|
INode,
|
||||||
|
INodeExecutionData,
|
||||||
|
IPollFunctions,
|
||||||
|
IRunExecutionData,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
NodeParameterValueType,
|
||||||
|
Workflow,
|
||||||
|
WorkflowActivateMode,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-cycle
|
||||||
|
import {
|
||||||
|
getAdditionalKeys,
|
||||||
|
getCredentials,
|
||||||
|
getNodeParameter,
|
||||||
|
returnJsonArray,
|
||||||
|
} from '@/NodeExecuteFunctions';
|
||||||
|
|
||||||
|
import { BinaryHelpers } from './helpers/binary-helpers';
|
||||||
|
import { RequestHelpers } from './helpers/request-helpers';
|
||||||
|
import { SchedulingHelpers } from './helpers/scheduling-helpers';
|
||||||
|
import { NodeExecutionContext } from './node-execution-context';
|
||||||
|
|
||||||
|
const throwOnEmit = () => {
|
||||||
|
throw new ApplicationError('Overwrite PollContext.__emit function');
|
||||||
|
};
|
||||||
|
|
||||||
|
const throwOnEmitError = () => {
|
||||||
|
throw new ApplicationError('Overwrite PollContext.__emitError function');
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PollContext extends NodeExecutionContext implements IPollFunctions {
|
||||||
|
readonly helpers: IPollFunctions['helpers'];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
workflow: Workflow,
|
||||||
|
node: INode,
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
private readonly activation: WorkflowActivateMode,
|
||||||
|
readonly __emit: IPollFunctions['__emit'] = throwOnEmit,
|
||||||
|
readonly __emitError: IPollFunctions['__emitError'] = throwOnEmitError,
|
||||||
|
) {
|
||||||
|
super(workflow, node, additionalData, mode);
|
||||||
|
|
||||||
|
this.helpers = {
|
||||||
|
createDeferredPromise,
|
||||||
|
returnJsonArray,
|
||||||
|
...new BinaryHelpers(workflow, additionalData).exported,
|
||||||
|
...new RequestHelpers(this, workflow, node, additionalData).exported,
|
||||||
|
...new SchedulingHelpers(workflow).exported,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getActivationMode() {
|
||||||
|
return this.activation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||||
|
return await getCredentials<T>(this.workflow, this.node, type, this.additionalData, this.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeParameter(
|
||||||
|
parameterName: string,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
fallbackValue?: any,
|
||||||
|
options?: IGetNodeParameterOptions,
|
||||||
|
): NodeParameterValueType | object {
|
||||||
|
const runExecutionData: IRunExecutionData | null = null;
|
||||||
|
const itemIndex = 0;
|
||||||
|
const runIndex = 0;
|
||||||
|
const connectionInputData: INodeExecutionData[] = [];
|
||||||
|
|
||||||
|
return getNodeParameter(
|
||||||
|
this.workflow,
|
||||||
|
runExecutionData,
|
||||||
|
runIndex,
|
||||||
|
connectionInputData,
|
||||||
|
this.node,
|
||||||
|
parameterName,
|
||||||
|
itemIndex,
|
||||||
|
this.mode,
|
||||||
|
getAdditionalKeys(this.additionalData, this.mode, runExecutionData),
|
||||||
|
undefined,
|
||||||
|
fallbackValue,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
96
packages/core/src/node-execution-context/trigger-context.ts
Normal file
96
packages/core/src/node-execution-context/trigger-context.ts
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
import type {
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
IGetNodeParameterOptions,
|
||||||
|
INode,
|
||||||
|
INodeExecutionData,
|
||||||
|
IRunExecutionData,
|
||||||
|
ITriggerFunctions,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
NodeParameterValueType,
|
||||||
|
Workflow,
|
||||||
|
WorkflowActivateMode,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-cycle
|
||||||
|
import {
|
||||||
|
getAdditionalKeys,
|
||||||
|
getCredentials,
|
||||||
|
getNodeParameter,
|
||||||
|
returnJsonArray,
|
||||||
|
} from '@/NodeExecuteFunctions';
|
||||||
|
|
||||||
|
import { BinaryHelpers } from './helpers/binary-helpers';
|
||||||
|
import { RequestHelpers } from './helpers/request-helpers';
|
||||||
|
import { SchedulingHelpers } from './helpers/scheduling-helpers';
|
||||||
|
import { SSHTunnelHelpers } from './helpers/ssh-tunnel-helpers';
|
||||||
|
import { NodeExecutionContext } from './node-execution-context';
|
||||||
|
|
||||||
|
const throwOnEmit = () => {
|
||||||
|
throw new ApplicationError('Overwrite TriggerContext.emit function');
|
||||||
|
};
|
||||||
|
|
||||||
|
const throwOnEmitError = () => {
|
||||||
|
throw new ApplicationError('Overwrite TriggerContext.emitError function');
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TriggerContext extends NodeExecutionContext implements ITriggerFunctions {
|
||||||
|
readonly helpers: ITriggerFunctions['helpers'];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
workflow: Workflow,
|
||||||
|
node: INode,
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
private readonly activation: WorkflowActivateMode,
|
||||||
|
readonly emit: ITriggerFunctions['emit'] = throwOnEmit,
|
||||||
|
readonly emitError: ITriggerFunctions['emitError'] = throwOnEmitError,
|
||||||
|
) {
|
||||||
|
super(workflow, node, additionalData, mode);
|
||||||
|
|
||||||
|
this.helpers = {
|
||||||
|
createDeferredPromise,
|
||||||
|
returnJsonArray,
|
||||||
|
...new BinaryHelpers(workflow, additionalData).exported,
|
||||||
|
...new RequestHelpers(this, workflow, node, additionalData).exported,
|
||||||
|
...new SchedulingHelpers(workflow).exported,
|
||||||
|
...new SSHTunnelHelpers().exported,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getActivationMode() {
|
||||||
|
return this.activation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||||
|
return await getCredentials<T>(this.workflow, this.node, type, this.additionalData, this.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeParameter(
|
||||||
|
parameterName: string,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
fallbackValue?: any,
|
||||||
|
options?: IGetNodeParameterOptions,
|
||||||
|
): NodeParameterValueType | object {
|
||||||
|
const runExecutionData: IRunExecutionData | null = null;
|
||||||
|
const itemIndex = 0;
|
||||||
|
const runIndex = 0;
|
||||||
|
const connectionInputData: INodeExecutionData[] = [];
|
||||||
|
|
||||||
|
return getNodeParameter(
|
||||||
|
this.workflow,
|
||||||
|
runExecutionData,
|
||||||
|
runIndex,
|
||||||
|
connectionInputData,
|
||||||
|
this.node,
|
||||||
|
parameterName,
|
||||||
|
itemIndex,
|
||||||
|
this.mode,
|
||||||
|
getAdditionalKeys(this.additionalData, this.mode, runExecutionData),
|
||||||
|
undefined,
|
||||||
|
fallbackValue,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
233
packages/core/src/node-execution-context/webhook-context.ts
Normal file
233
packages/core/src/node-execution-context/webhook-context.ts
Normal file
|
@ -0,0 +1,233 @@
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type {
|
||||||
|
CloseFunction,
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
IDataObject,
|
||||||
|
IExecuteData,
|
||||||
|
IGetNodeParameterOptions,
|
||||||
|
INode,
|
||||||
|
INodeExecutionData,
|
||||||
|
IRunExecutionData,
|
||||||
|
ITaskDataConnections,
|
||||||
|
IWebhookData,
|
||||||
|
IWebhookFunctions,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
NodeConnectionType,
|
||||||
|
NodeParameterValueType,
|
||||||
|
WebhookType,
|
||||||
|
Workflow,
|
||||||
|
WorkflowExecuteMode,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { ApplicationError, createDeferredPromise } from 'n8n-workflow';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-cycle
|
||||||
|
import {
|
||||||
|
copyBinaryFile,
|
||||||
|
getAdditionalKeys,
|
||||||
|
getCredentials,
|
||||||
|
getInputConnectionData,
|
||||||
|
getNodeParameter,
|
||||||
|
getNodeWebhookUrl,
|
||||||
|
returnJsonArray,
|
||||||
|
} from '@/NodeExecuteFunctions';
|
||||||
|
|
||||||
|
import { BinaryHelpers } from './helpers/binary-helpers';
|
||||||
|
import { RequestHelpers } from './helpers/request-helpers';
|
||||||
|
import { NodeExecutionContext } from './node-execution-context';
|
||||||
|
|
||||||
|
export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions {
|
||||||
|
readonly helpers: IWebhookFunctions['helpers'];
|
||||||
|
|
||||||
|
readonly nodeHelpers: IWebhookFunctions['nodeHelpers'];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
workflow: Workflow,
|
||||||
|
node: INode,
|
||||||
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
|
mode: WorkflowExecuteMode,
|
||||||
|
private readonly webhookData: IWebhookData,
|
||||||
|
private readonly closeFunctions: CloseFunction[],
|
||||||
|
private readonly runExecutionData: IRunExecutionData | null,
|
||||||
|
) {
|
||||||
|
super(workflow, node, additionalData, mode);
|
||||||
|
|
||||||
|
this.helpers = {
|
||||||
|
createDeferredPromise,
|
||||||
|
returnJsonArray,
|
||||||
|
...new BinaryHelpers(workflow, additionalData).exported,
|
||||||
|
...new RequestHelpers(this, workflow, node, additionalData).exported,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.nodeHelpers = {
|
||||||
|
copyBinaryFile: async (filePath, fileName, mimeType) =>
|
||||||
|
await copyBinaryFile(
|
||||||
|
this.workflow.id,
|
||||||
|
this.additionalData.executionId!,
|
||||||
|
filePath,
|
||||||
|
fileName,
|
||||||
|
mimeType,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||||
|
return await getCredentials<T>(this.workflow, this.node, type, this.additionalData, this.mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
getBodyData() {
|
||||||
|
return this.assertHttpRequest().body as IDataObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHeaderData() {
|
||||||
|
return this.assertHttpRequest().headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
getParamsData(): object {
|
||||||
|
return this.assertHttpRequest().params;
|
||||||
|
}
|
||||||
|
|
||||||
|
getQueryData(): object {
|
||||||
|
return this.assertHttpRequest().query;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRequestObject(): Request {
|
||||||
|
return this.assertHttpRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
getResponseObject(): Response {
|
||||||
|
if (this.additionalData.httpResponse === undefined) {
|
||||||
|
throw new ApplicationError('Response is missing');
|
||||||
|
}
|
||||||
|
return this.additionalData.httpResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertHttpRequest() {
|
||||||
|
const { httpRequest } = this.additionalData;
|
||||||
|
if (httpRequest === undefined) {
|
||||||
|
throw new ApplicationError('Request is missing');
|
||||||
|
}
|
||||||
|
return httpRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeWebhookUrl(name: WebhookType): string | undefined {
|
||||||
|
return getNodeWebhookUrl(
|
||||||
|
name,
|
||||||
|
this.workflow,
|
||||||
|
this.node,
|
||||||
|
this.additionalData,
|
||||||
|
this.mode,
|
||||||
|
getAdditionalKeys(this.additionalData, this.mode, null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWebhookName() {
|
||||||
|
return this.webhookData.webhookDescription.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInputConnectionData(inputName: NodeConnectionType, itemIndex: number): Promise<unknown> {
|
||||||
|
// To be able to use expressions like "$json.sessionId" set the
|
||||||
|
// body data the webhook received to what is normally used for
|
||||||
|
// incoming node data.
|
||||||
|
const connectionInputData: INodeExecutionData[] = [
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
{ json: this.additionalData.httpRequest?.body || {} },
|
||||||
|
];
|
||||||
|
const runExecutionData: IRunExecutionData = {
|
||||||
|
resultData: {
|
||||||
|
runData: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const executeData: IExecuteData = {
|
||||||
|
data: {
|
||||||
|
main: [connectionInputData],
|
||||||
|
},
|
||||||
|
node: this.node,
|
||||||
|
source: null,
|
||||||
|
};
|
||||||
|
const runIndex = 0;
|
||||||
|
|
||||||
|
return await getInputConnectionData.call(
|
||||||
|
this,
|
||||||
|
this.workflow,
|
||||||
|
runExecutionData,
|
||||||
|
runIndex,
|
||||||
|
connectionInputData,
|
||||||
|
{} as ITaskDataConnections,
|
||||||
|
this.additionalData,
|
||||||
|
executeData,
|
||||||
|
this.mode,
|
||||||
|
this.closeFunctions,
|
||||||
|
inputName,
|
||||||
|
itemIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluateExpression(expression: string, evaluateItemIndex?: number) {
|
||||||
|
const itemIndex = evaluateItemIndex ?? 0;
|
||||||
|
const runIndex = 0;
|
||||||
|
|
||||||
|
let connectionInputData: INodeExecutionData[] = [];
|
||||||
|
let executionData: IExecuteData | undefined;
|
||||||
|
|
||||||
|
if (this.runExecutionData?.executionData !== undefined) {
|
||||||
|
executionData = this.runExecutionData.executionData.nodeExecutionStack[0];
|
||||||
|
|
||||||
|
if (executionData !== undefined) {
|
||||||
|
connectionInputData = executionData.data.main[0]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionalKeys = getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData);
|
||||||
|
|
||||||
|
return this.workflow.expression.resolveSimpleParameterValue(
|
||||||
|
`=${expression}`,
|
||||||
|
{},
|
||||||
|
this.runExecutionData,
|
||||||
|
runIndex,
|
||||||
|
itemIndex,
|
||||||
|
this.node.name,
|
||||||
|
connectionInputData,
|
||||||
|
this.mode,
|
||||||
|
additionalKeys,
|
||||||
|
executionData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeParameter(
|
||||||
|
parameterName: string,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
fallbackValue?: any,
|
||||||
|
options?: IGetNodeParameterOptions,
|
||||||
|
): NodeParameterValueType | object {
|
||||||
|
const itemIndex = 0;
|
||||||
|
const runIndex = 0;
|
||||||
|
|
||||||
|
let connectionInputData: INodeExecutionData[] = [];
|
||||||
|
let executionData: IExecuteData | undefined;
|
||||||
|
|
||||||
|
if (this.runExecutionData?.executionData !== undefined) {
|
||||||
|
executionData = this.runExecutionData.executionData.nodeExecutionStack[0];
|
||||||
|
|
||||||
|
if (executionData !== undefined) {
|
||||||
|
connectionInputData = executionData.data.main[0]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionalKeys = getAdditionalKeys(this.additionalData, this.mode, this.runExecutionData);
|
||||||
|
|
||||||
|
return getNodeParameter(
|
||||||
|
this.workflow,
|
||||||
|
this.runExecutionData,
|
||||||
|
runIndex,
|
||||||
|
connectionInputData,
|
||||||
|
this.node,
|
||||||
|
parameterName,
|
||||||
|
itemIndex,
|
||||||
|
this.mode,
|
||||||
|
additionalKeys,
|
||||||
|
executionData,
|
||||||
|
fallbackValue,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<style>@media (prefers-color-scheme: dark) { body { background-color: rgb(45, 46, 46) } }</style>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
window.BASE_PATH = '/{{BASE_PATH}}/';
|
window.BASE_PATH = '/{{BASE_PATH}}/';
|
||||||
window.REST_ENDPOINT = '{{REST_ENDPOINT}}';
|
window.REST_ENDPOINT = '{{REST_ENDPOINT}}';
|
||||||
|
|
|
@ -39,9 +39,9 @@
|
||||||
"@n8n/codemirror-lang-sql": "^1.0.2",
|
"@n8n/codemirror-lang-sql": "^1.0.2",
|
||||||
"@n8n/permissions": "workspace:*",
|
"@n8n/permissions": "workspace:*",
|
||||||
"@sentry/vue": "catalog:frontend",
|
"@sentry/vue": "catalog:frontend",
|
||||||
"@vue-flow/background": "^1.3.0",
|
"@vue-flow/background": "^1.3.1",
|
||||||
"@vue-flow/controls": "^1.1.2",
|
"@vue-flow/controls": "^1.1.2",
|
||||||
"@vue-flow/core": "^1.41.2",
|
"@vue-flow/core": "^1.41.4",
|
||||||
"@vue-flow/minimap": "^1.5.0",
|
"@vue-flow/minimap": "^1.5.0",
|
||||||
"@vue-flow/node-resizer": "^1.4.0",
|
"@vue-flow/node-resizer": "^1.4.0",
|
||||||
"@vueuse/components": "^10.11.0",
|
"@vueuse/components": "^10.11.0",
|
||||||
|
|
|
@ -1,65 +1,59 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import type { PublicInstalledPackage } from 'n8n-workflow';
|
import type { PublicInstalledPackage } from 'n8n-workflow';
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import { NPM_PACKAGE_DOCS_BASE_URL, COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '@/constants';
|
import { NPM_PACKAGE_DOCS_BASE_URL, COMMUNITY_PACKAGE_MANAGE_ACTIONS } from '@/constants';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
|
||||||
export default defineComponent({
|
interface Props {
|
||||||
name: 'CommunityPackageCard',
|
communityPackage?: PublicInstalledPackage | null;
|
||||||
props: {
|
loading?: boolean;
|
||||||
communityPackage: {
|
}
|
||||||
type: Object as () => PublicInstalledPackage | null,
|
|
||||||
required: false,
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
default: null,
|
communityPackage: null,
|
||||||
},
|
loading: false,
|
||||||
loading: {
|
});
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
const { openCommunityPackageUpdateConfirmModal, openCommunityPackageUninstallConfirmModal } =
|
||||||
},
|
useUIStore();
|
||||||
},
|
const i18n = useI18n();
|
||||||
data() {
|
const telemetry = useTelemetry();
|
||||||
return {
|
|
||||||
packageActions: [
|
const packageActions = [
|
||||||
{
|
{
|
||||||
label: this.$locale.baseText('settings.communityNodes.viewDocsAction.label'),
|
label: i18n.baseText('settings.communityNodes.viewDocsAction.label'),
|
||||||
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS,
|
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS,
|
||||||
type: 'external-link',
|
type: 'external-link',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: this.$locale.baseText('settings.communityNodes.uninstallAction.label'),
|
label: i18n.baseText('settings.communityNodes.uninstallAction.label'),
|
||||||
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL,
|
value: COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL,
|
||||||
},
|
},
|
||||||
],
|
];
|
||||||
};
|
|
||||||
},
|
async function onAction(value: string) {
|
||||||
computed: {
|
if (!props.communityPackage) return;
|
||||||
...mapStores(useUIStore),
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async onAction(value: string) {
|
|
||||||
if (!this.communityPackage) return;
|
|
||||||
switch (value) {
|
switch (value) {
|
||||||
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS:
|
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.VIEW_DOCS:
|
||||||
this.$telemetry.track('user clicked to browse the cnr package documentation', {
|
telemetry.track('user clicked to browse the cnr package documentation', {
|
||||||
package_name: this.communityPackage.packageName,
|
package_name: props.communityPackage.packageName,
|
||||||
package_version: this.communityPackage.installedVersion,
|
package_version: props.communityPackage.installedVersion,
|
||||||
});
|
});
|
||||||
window.open(`${NPM_PACKAGE_DOCS_BASE_URL}${this.communityPackage.packageName}`, '_blank');
|
window.open(`${NPM_PACKAGE_DOCS_BASE_URL}${props.communityPackage.packageName}`, '_blank');
|
||||||
break;
|
break;
|
||||||
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL:
|
case COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL:
|
||||||
this.uiStore.openCommunityPackageUninstallConfirmModal(this.communityPackage.packageName);
|
openCommunityPackageUninstallConfirmModal(props.communityPackage.packageName);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
onUpdateClick() {
|
|
||||||
if (!this.communityPackage) return;
|
function onUpdateClick() {
|
||||||
this.uiStore.openCommunityPackageUpdateConfirmModal(this.communityPackage.packageName);
|
if (!props.communityPackage) return;
|
||||||
},
|
openCommunityPackageUpdateConfirmModal(props.communityPackage.packageName);
|
||||||
},
|
}
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -76,7 +70,7 @@ export default defineComponent({
|
||||||
<div :class="$style.cardSubtitle">
|
<div :class="$style.cardSubtitle">
|
||||||
<n8n-text :bold="true" size="small" color="text-light">
|
<n8n-text :bold="true" size="small" color="text-light">
|
||||||
{{
|
{{
|
||||||
$locale.baseText('settings.communityNodes.packageNodes.label', {
|
i18n.baseText('settings.communityNodes.packageNodes.label', {
|
||||||
adjustToNumber: communityPackage.installedNodes.length,
|
adjustToNumber: communityPackage.installedNodes.length,
|
||||||
})
|
})
|
||||||
}}:
|
}}:
|
||||||
|
@ -96,7 +90,7 @@ export default defineComponent({
|
||||||
<n8n-tooltip v-if="communityPackage.failedLoading === true" placement="top">
|
<n8n-tooltip v-if="communityPackage.failedLoading === true" placement="top">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div>
|
<div>
|
||||||
{{ $locale.baseText('settings.communityNodes.failedToLoad.tooltip') }}
|
{{ i18n.baseText('settings.communityNodes.failedToLoad.tooltip') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<n8n-icon icon="exclamation-triangle" color="danger" size="large" />
|
<n8n-icon icon="exclamation-triangle" color="danger" size="large" />
|
||||||
|
@ -104,7 +98,7 @@ export default defineComponent({
|
||||||
<n8n-tooltip v-else-if="communityPackage.updateAvailable" placement="top">
|
<n8n-tooltip v-else-if="communityPackage.updateAvailable" placement="top">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div>
|
<div>
|
||||||
{{ $locale.baseText('settings.communityNodes.updateAvailable.tooltip') }}
|
{{ i18n.baseText('settings.communityNodes.updateAvailable.tooltip') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<n8n-button outline label="Update" @click="onUpdateClick" />
|
<n8n-button outline label="Update" @click="onUpdateClick" />
|
||||||
|
@ -112,7 +106,7 @@ export default defineComponent({
|
||||||
<n8n-tooltip v-else placement="top">
|
<n8n-tooltip v-else placement="top">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div>
|
<div>
|
||||||
{{ $locale.baseText('settings.communityNodes.upToDate.tooltip') }}
|
{{ i18n.baseText('settings.communityNodes.upToDate.tooltip') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<n8n-icon icon="check-circle" color="text-light" size="large" />
|
<n8n-icon icon="check-circle" color="text-light" size="large" />
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PropType } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import type { IN8nPromptResponse, ModalKey } from '@/Interface';
|
import type { IN8nPromptResponse, ModalKey } from '@/Interface';
|
||||||
import { VALID_EMAIL_REGEX } from '@/constants';
|
import { VALID_EMAIL_REGEX } from '@/constants';
|
||||||
import Modal from '@/components/Modal.vue';
|
import Modal from '@/components/Modal.vue';
|
||||||
|
@ -10,78 +8,69 @@ import { useRootStore } from '@/stores/root.store';
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
|
||||||
export default defineComponent({
|
defineProps<{
|
||||||
name: 'ContactPromptModal',
|
modalName: ModalKey;
|
||||||
components: { Modal },
|
}>();
|
||||||
props: {
|
|
||||||
modalName: {
|
const email = ref('');
|
||||||
type: String as PropType<ModalKey>,
|
const modalBus = createEventBus();
|
||||||
required: true,
|
|
||||||
},
|
const npsSurveyStore = useNpsSurveyStore();
|
||||||
},
|
const rootStore = useRootStore();
|
||||||
setup() {
|
const settingsStore = useSettingsStore();
|
||||||
return {
|
|
||||||
...useToast(),
|
const toast = useToast();
|
||||||
};
|
const telemetry = useTelemetry();
|
||||||
},
|
|
||||||
data() {
|
const title = computed(() => {
|
||||||
return {
|
if (npsSurveyStore.promptsData?.title) {
|
||||||
email: '',
|
return npsSurveyStore.promptsData.title;
|
||||||
modalBus: createEventBus(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useRootStore, useSettingsStore, useNpsSurveyStore),
|
|
||||||
title(): string {
|
|
||||||
if (this.npsSurveyStore.promptsData?.title) {
|
|
||||||
return this.npsSurveyStore.promptsData.title;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'You’re a power user 💪';
|
return 'You’re a power user 💪';
|
||||||
},
|
});
|
||||||
description(): string {
|
|
||||||
if (this.npsSurveyStore.promptsData?.message) {
|
const description = computed(() => {
|
||||||
return this.npsSurveyStore.promptsData.message;
|
if (npsSurveyStore.promptsData?.message) {
|
||||||
|
return npsSurveyStore.promptsData.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Your experience with n8n can help us improve — for you and our entire community.';
|
return 'Your experience with n8n can help us improve — for you and our entire community.';
|
||||||
},
|
});
|
||||||
isEmailValid(): boolean {
|
|
||||||
return VALID_EMAIL_REGEX.test(String(this.email).toLowerCase());
|
const isEmailValid = computed(() => {
|
||||||
},
|
return VALID_EMAIL_REGEX.test(String(email.value).toLowerCase());
|
||||||
},
|
});
|
||||||
methods: {
|
|
||||||
closeDialog(): void {
|
const closeDialog = () => {
|
||||||
if (!this.isEmailValid) {
|
if (!isEmailValid.value) {
|
||||||
this.$telemetry.track('User closed email modal', {
|
telemetry.track('User closed email modal', {
|
||||||
instance_id: this.rootStore.instanceId,
|
instance_id: rootStore.instanceId,
|
||||||
email: null,
|
email: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
async send() {
|
|
||||||
if (this.isEmailValid) {
|
const send = async () => {
|
||||||
const response = (await this.settingsStore.submitContactInfo(
|
if (isEmailValid.value) {
|
||||||
this.email,
|
const response = (await settingsStore.submitContactInfo(email.value)) as IN8nPromptResponse;
|
||||||
)) as IN8nPromptResponse;
|
|
||||||
|
|
||||||
if (response.updated) {
|
if (response.updated) {
|
||||||
this.$telemetry.track('User closed email modal', {
|
telemetry.track('User closed email modal', {
|
||||||
instance_id: this.rootStore.instanceId,
|
instance_id: rootStore.instanceId,
|
||||||
email: this.email,
|
email: email.value,
|
||||||
});
|
});
|
||||||
this.showMessage({
|
toast.showMessage({
|
||||||
title: 'Thanks!',
|
title: 'Thanks!',
|
||||||
message: "It's people like you that help make n8n better",
|
message: "It's people like you that help make n8n better",
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.modalBus.emit('close');
|
modalBus.emit('close');
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -1,26 +1,25 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { type PropType, defineComponent } from 'vue';
|
import { computed } from 'vue';
|
||||||
import type { EnterpriseEditionFeatureValue } from '@/Interface';
|
import type { EnterpriseEditionFeatureValue } from '@/Interface';
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
export default defineComponent({
|
const props = withDefaults(
|
||||||
name: 'EnterpriseEdition',
|
defineProps<{
|
||||||
props: {
|
features: EnterpriseEditionFeatureValue[];
|
||||||
features: {
|
}>(),
|
||||||
type: Array as PropType<EnterpriseEditionFeatureValue[]>,
|
{
|
||||||
default: () => [],
|
features: () => [],
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
computed: {
|
|
||||||
...mapStores(useSettingsStore),
|
const settingsStore = useSettingsStore();
|
||||||
canAccess(): boolean {
|
|
||||||
return this.features.reduce((acc: boolean, feature) => {
|
const canAccess = computed(() =>
|
||||||
return acc && !!this.settingsStore.isEnterpriseFeatureEnabled[feature];
|
props.features.reduce(
|
||||||
}, true);
|
(acc: boolean, feature) => acc && !!settingsStore.isEnterpriseFeatureEnabled[feature],
|
||||||
},
|
true,
|
||||||
},
|
),
|
||||||
});
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -51,6 +51,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
pushRef: {
|
pushRef: {
|
||||||
type: String,
|
type: String,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
readOnly: {
|
readOnly: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|
|
@ -1,34 +1,36 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PropType } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
import { defineComponent } from 'vue';
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import type { EventBus } from 'n8n-design-system/utils';
|
import type { EventBus } from 'n8n-design-system/utils';
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
|
|
||||||
export default defineComponent({
|
const props = withDefaults(
|
||||||
name: 'IntersectionObserved',
|
defineProps<{
|
||||||
props: {
|
enabled: boolean;
|
||||||
enabled: {
|
eventBus: EventBus;
|
||||||
type: Boolean,
|
}>(),
|
||||||
default: false,
|
{
|
||||||
},
|
enabled: false,
|
||||||
eventBus: {
|
|
||||||
type: Object as PropType<EventBus>,
|
|
||||||
default: () => createEventBus(),
|
default: () => createEventBus(),
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
async mounted() {
|
|
||||||
if (!this.enabled) {
|
const observed = ref<IntersectionObserver | null>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!props.enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.$nextTick();
|
await nextTick();
|
||||||
this.eventBus.emit('observe', this.$refs.observed);
|
|
||||||
},
|
props.eventBus.emit('observe', observed.value);
|
||||||
beforeUnmount() {
|
});
|
||||||
if (this.enabled) {
|
|
||||||
this.eventBus.emit('unobserve', this.$refs.observed);
|
onBeforeUnmount(() => {
|
||||||
|
if (props.enabled) {
|
||||||
|
props.eventBus.emit('unobserve', observed.value);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,67 +1,65 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PropType } from 'vue';
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import type { EventBus } from 'n8n-design-system/utils';
|
import type { EventBus } from 'n8n-design-system/utils';
|
||||||
import { createEventBus } from 'n8n-design-system/utils';
|
import { createEventBus } from 'n8n-design-system/utils';
|
||||||
|
|
||||||
export default defineComponent({
|
const props = withDefaults(
|
||||||
name: 'IntersectionObserver',
|
defineProps<{
|
||||||
props: {
|
threshold: number;
|
||||||
threshold: {
|
enabled: boolean;
|
||||||
type: Number,
|
eventBus: EventBus;
|
||||||
default: 0,
|
}>(),
|
||||||
},
|
{
|
||||||
enabled: {
|
threshold: 0,
|
||||||
type: Boolean,
|
enabled: false,
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
eventBus: {
|
|
||||||
type: Object as PropType<EventBus>,
|
|
||||||
default: () => createEventBus(),
|
default: () => createEventBus(),
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
data() {
|
|
||||||
return {
|
const emit = defineEmits<{
|
||||||
observer: null as IntersectionObserver | null,
|
observed: [{ el: HTMLElement; isIntersecting: boolean }];
|
||||||
};
|
}>();
|
||||||
},
|
|
||||||
mounted() {
|
const observer = ref<IntersectionObserver | null>(null);
|
||||||
if (!this.enabled) {
|
const root = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (props.enabled && observer.value) {
|
||||||
|
observer.value.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!props.enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
root: this.$refs.root as Element,
|
root: root.value,
|
||||||
rootMargin: '0px',
|
rootMargin: '0px',
|
||||||
threshold: this.threshold,
|
threshold: props.threshold,
|
||||||
};
|
};
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const intersectionObserver = new IntersectionObserver((entries) => {
|
||||||
entries.forEach(({ target, isIntersecting }) => {
|
entries.forEach(({ target, isIntersecting }) => {
|
||||||
this.$emit('observed', {
|
emit('observed', {
|
||||||
el: target,
|
el: target as HTMLElement,
|
||||||
isIntersecting,
|
isIntersecting,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, options);
|
}, options);
|
||||||
|
|
||||||
this.observer = observer;
|
observer.value = intersectionObserver;
|
||||||
|
|
||||||
this.eventBus.on('observe', (observed: Element) => {
|
props.eventBus.on('observe', (observed: Element) => {
|
||||||
if (observed) {
|
if (observed) {
|
||||||
observer.observe(observed);
|
intersectionObserver.observe(observed);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.eventBus.on('unobserve', (observed: Element) => {
|
props.eventBus.on('unobserve', (observed: Element) => {
|
||||||
observer.unobserve(observed);
|
intersectionObserver.unobserve(observed);
|
||||||
});
|
});
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
if (this.enabled && this.observer) {
|
|
||||||
this.observer.disconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import Modal from './Modal.vue';
|
import Modal from './Modal.vue';
|
||||||
import type { IFormInputs, IInviteResponse, IUser, InvitableRoleName } from '@/Interface';
|
import type { IFormInputs, IInviteResponse, IUser, InvitableRoleName } from '@/Interface';
|
||||||
|
@ -12,64 +11,40 @@ import {
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
|
||||||
import { createFormEventBus, createEventBus } from 'n8n-design-system/utils';
|
import { createFormEventBus, createEventBus } from 'n8n-design-system/utils';
|
||||||
import { useClipboard } from '@/composables/useClipboard';
|
import { useClipboard } from '@/composables/useClipboard';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
|
||||||
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
|
const NAME_EMAIL_FORMAT_REGEX = /^.* <(.*)>$/;
|
||||||
|
|
||||||
function getEmail(email: string): string {
|
const usersStore = useUsersStore();
|
||||||
let parsed = email.trim();
|
const settingsStore = useSettingsStore();
|
||||||
if (NAME_EMAIL_FORMAT_REGEX.test(parsed)) {
|
|
||||||
const matches = parsed.match(NAME_EMAIL_FORMAT_REGEX);
|
|
||||||
if (matches && matches.length === 2) {
|
|
||||||
parsed = matches[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: 'InviteUsersModal',
|
|
||||||
components: { Modal },
|
|
||||||
props: {
|
|
||||||
modalName: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
|
const { showMessage, showError } = useToast();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const { goToUpgrade } = usePageRedirectionHelper();
|
||||||
|
|
||||||
return {
|
const formBus = createFormEventBus();
|
||||||
clipboard,
|
const modalBus = createEventBus();
|
||||||
...useToast(),
|
const config = ref<IFormInputs | null>();
|
||||||
...usePageRedirectionHelper(),
|
const emails = ref('');
|
||||||
};
|
const role = ref<InvitableRoleName>(ROLE.Member);
|
||||||
},
|
const showInviteUrls = ref<IInviteResponse[] | null>(null);
|
||||||
data() {
|
const loading = ref(false);
|
||||||
return {
|
|
||||||
config: null as IFormInputs | null,
|
onMounted(() => {
|
||||||
formBus: createFormEventBus(),
|
config.value = [
|
||||||
modalBus: createEventBus(),
|
|
||||||
emails: '',
|
|
||||||
role: ROLE.Member as InvitableRoleName,
|
|
||||||
showInviteUrls: null as IInviteResponse[] | null,
|
|
||||||
loading: false,
|
|
||||||
INVITE_USER_MODAL_KEY,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.config = [
|
|
||||||
{
|
{
|
||||||
name: 'emails',
|
name: 'emails',
|
||||||
properties: {
|
properties: {
|
||||||
label: this.$locale.baseText('settings.users.newEmailsToInvite'),
|
label: i18n.baseText('settings.users.newEmailsToInvite'),
|
||||||
required: true,
|
required: true,
|
||||||
validationRules: [{ name: 'VALID_EMAILS' }],
|
validationRules: [{ name: 'VALID_EMAILS' }],
|
||||||
validators: {
|
validators: {
|
||||||
VALID_EMAILS: {
|
VALID_EMAILS: {
|
||||||
validate: this.validateEmails,
|
validate: validateEmails,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
placeholder: 'name1@email.com, name2@email.com, ...',
|
placeholder: 'name1@email.com, name2@email.com, ...',
|
||||||
|
@ -81,62 +56,60 @@ export default defineComponent({
|
||||||
name: 'role',
|
name: 'role',
|
||||||
initialValue: ROLE.Member,
|
initialValue: ROLE.Member,
|
||||||
properties: {
|
properties: {
|
||||||
label: this.$locale.baseText('auth.role'),
|
label: i18n.baseText('auth.role'),
|
||||||
required: true,
|
required: true,
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
value: ROLE.Member,
|
value: ROLE.Member,
|
||||||
label: this.$locale.baseText('auth.roles.member'),
|
label: i18n.baseText('auth.roles.member'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: ROLE.Admin,
|
value: ROLE.Admin,
|
||||||
label: this.$locale.baseText('auth.roles.admin'),
|
label: i18n.baseText('auth.roles.admin'),
|
||||||
disabled: !this.isAdvancedPermissionsEnabled,
|
disabled: !isAdvancedPermissionsEnabled.value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
capitalize: true,
|
capitalize: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
});
|
||||||
computed: {
|
|
||||||
...mapStores(useUsersStore, useSettingsStore, useUIStore),
|
const emailsCount = computed((): number => {
|
||||||
emailsCount(): number {
|
return emails.value.split(',').filter((email: string) => !!email.trim()).length;
|
||||||
return this.emails.split(',').filter((email: string) => !!email.trim()).length;
|
});
|
||||||
},
|
|
||||||
buttonLabel(): string {
|
const buttonLabel = computed((): string => {
|
||||||
if (this.emailsCount > 1) {
|
if (emailsCount.value > 1) {
|
||||||
return this.$locale.baseText(
|
return i18n.baseText(
|
||||||
`settings.users.inviteXUser${this.settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`,
|
`settings.users.inviteXUser${settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`,
|
||||||
{
|
{
|
||||||
interpolate: { count: this.emailsCount.toString() },
|
interpolate: { count: emailsCount.value.toString() },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.$locale.baseText(
|
return i18n.baseText(`settings.users.inviteUser${settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`);
|
||||||
`settings.users.inviteUser${this.settingsStore.isSmtpSetup ? '' : '.inviteUrl'}`,
|
});
|
||||||
);
|
|
||||||
},
|
const enabledButton = computed((): boolean => {
|
||||||
enabledButton(): boolean {
|
return emailsCount.value >= 1;
|
||||||
return this.emailsCount >= 1;
|
});
|
||||||
},
|
|
||||||
invitedUsers(): IUser[] {
|
const invitedUsers = computed((): IUser[] => {
|
||||||
return this.showInviteUrls
|
return showInviteUrls.value
|
||||||
? this.usersStore.allUsers.filter((user) =>
|
? usersStore.allUsers.filter((user) =>
|
||||||
this.showInviteUrls!.find((invite) => invite.user.id === user.id),
|
showInviteUrls.value?.find((invite) => invite.user.id === user.id),
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
},
|
});
|
||||||
isAdvancedPermissionsEnabled(): boolean {
|
|
||||||
return this.settingsStore.isEnterpriseFeatureEnabled[
|
const isAdvancedPermissionsEnabled = computed((): boolean => {
|
||||||
EnterpriseEditionFeature.AdvancedPermissions
|
return settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedPermissions];
|
||||||
];
|
});
|
||||||
},
|
|
||||||
},
|
const validateEmails = (value: string | number | boolean | null | undefined) => {
|
||||||
methods: {
|
|
||||||
validateEmails(value: string | number | boolean | null | undefined) {
|
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -155,29 +128,31 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
};
|
||||||
onInput(e: { name: string; value: InvitableRoleName }) {
|
|
||||||
|
function onInput(e: { name: string; value: InvitableRoleName }) {
|
||||||
if (e.name === 'emails') {
|
if (e.name === 'emails') {
|
||||||
this.emails = e.value;
|
emails.value = e.value;
|
||||||
}
|
}
|
||||||
if (e.name === 'role') {
|
if (e.name === 'role') {
|
||||||
this.role = e.value;
|
role.value = e.value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
async onSubmit() {
|
|
||||||
try {
|
|
||||||
this.loading = true;
|
|
||||||
|
|
||||||
const emails = this.emails
|
async function onSubmit() {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
const emailList = emails.value
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((email) => ({ email: getEmail(email), role: this.role }))
|
.map((email) => ({ email: getEmail(email), role: role.value }))
|
||||||
.filter((invite) => !!invite.email);
|
.filter((invite) => !!invite.email);
|
||||||
|
|
||||||
if (emails.length === 0) {
|
if (emailList.length === 0) {
|
||||||
throw new Error(this.$locale.baseText('settings.users.noUsersToInvite'));
|
throw new Error(i18n.baseText('settings.users.noUsersToInvite'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const invited = await this.usersStore.inviteUsers(emails);
|
const invited = await usersStore.inviteUsers(emailList);
|
||||||
const erroredInvites = invited.filter((invite) => invite.error);
|
const erroredInvites = invited.filter((invite) => invite.error);
|
||||||
const successfulEmailInvites = invited.filter(
|
const successfulEmailInvites = invited.filter(
|
||||||
(invite) => !invite.error && invite.user.emailSent,
|
(invite) => !invite.error && invite.user.emailSent,
|
||||||
|
@ -187,14 +162,14 @@ export default defineComponent({
|
||||||
);
|
);
|
||||||
|
|
||||||
if (successfulEmailInvites.length) {
|
if (successfulEmailInvites.length) {
|
||||||
this.showMessage({
|
showMessage({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: this.$locale.baseText(
|
title: i18n.baseText(
|
||||||
successfulEmailInvites.length > 1
|
successfulEmailInvites.length > 1
|
||||||
? 'settings.users.usersInvited'
|
? 'settings.users.usersInvited'
|
||||||
: 'settings.users.userInvited',
|
: 'settings.users.userInvited',
|
||||||
),
|
),
|
||||||
message: this.$locale.baseText('settings.users.emailInvitesSent', {
|
message: i18n.baseText('settings.users.emailInvitesSent', {
|
||||||
interpolate: {
|
interpolate: {
|
||||||
emails: successfulEmailInvites.map(({ user }) => user.email).join(', '),
|
emails: successfulEmailInvites.map(({ user }) => user.email).join(', '),
|
||||||
},
|
},
|
||||||
|
@ -204,17 +179,17 @@ export default defineComponent({
|
||||||
|
|
||||||
if (successfulUrlInvites.length) {
|
if (successfulUrlInvites.length) {
|
||||||
if (successfulUrlInvites.length === 1) {
|
if (successfulUrlInvites.length === 1) {
|
||||||
void this.clipboard.copy(successfulUrlInvites[0].user.inviteAcceptUrl);
|
void clipboard.copy(successfulUrlInvites[0].user.inviteAcceptUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.showMessage({
|
showMessage({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: this.$locale.baseText(
|
title: i18n.baseText(
|
||||||
successfulUrlInvites.length > 1
|
successfulUrlInvites.length > 1
|
||||||
? 'settings.users.multipleInviteUrlsCreated'
|
? 'settings.users.multipleInviteUrlsCreated'
|
||||||
: 'settings.users.inviteUrlCreated',
|
: 'settings.users.inviteUrlCreated',
|
||||||
),
|
),
|
||||||
message: this.$locale.baseText(
|
message: i18n.baseText(
|
||||||
successfulUrlInvites.length > 1
|
successfulUrlInvites.length > 1
|
||||||
? 'settings.users.multipleInviteUrlsCreated.message'
|
? 'settings.users.multipleInviteUrlsCreated.message'
|
||||||
: 'settings.users.inviteUrlCreated.message',
|
: 'settings.users.inviteUrlCreated.message',
|
||||||
|
@ -229,10 +204,10 @@ export default defineComponent({
|
||||||
|
|
||||||
if (erroredInvites.length) {
|
if (erroredInvites.length) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.showMessage({
|
showMessage({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: this.$locale.baseText('settings.users.usersEmailedError'),
|
title: i18n.baseText('settings.users.usersEmailedError'),
|
||||||
message: this.$locale.baseText('settings.users.emailInvitesSentError', {
|
message: i18n.baseText('settings.users.emailInvitesSentError', {
|
||||||
interpolate: { emails: erroredInvites.map(({ error }) => error).join(', ') },
|
interpolate: { emails: erroredInvites.map(({ error }) => error).join(', ') },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@ -240,24 +215,25 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (successfulUrlInvites.length > 1) {
|
if (successfulUrlInvites.length > 1) {
|
||||||
this.showInviteUrls = successfulUrlInvites;
|
showInviteUrls.value = successfulUrlInvites;
|
||||||
} else {
|
} else {
|
||||||
this.modalBus.emit('close');
|
modalBus.emit('close');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showError(error, this.$locale.baseText('settings.users.usersInvitedError'));
|
showError(error, i18n.baseText('settings.users.usersInvitedError'));
|
||||||
}
|
}
|
||||||
this.loading = false;
|
loading.value = false;
|
||||||
},
|
}
|
||||||
showCopyInviteLinkToast(successfulUrlInvites: IInviteResponse[]) {
|
|
||||||
this.showMessage({
|
function showCopyInviteLinkToast(successfulUrlInvites: IInviteResponse[]) {
|
||||||
|
showMessage({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: this.$locale.baseText(
|
title: i18n.baseText(
|
||||||
successfulUrlInvites.length > 1
|
successfulUrlInvites.length > 1
|
||||||
? 'settings.users.multipleInviteUrlsCreated'
|
? 'settings.users.multipleInviteUrlsCreated'
|
||||||
: 'settings.users.inviteUrlCreated',
|
: 'settings.users.inviteUrlCreated',
|
||||||
),
|
),
|
||||||
message: this.$locale.baseText(
|
message: i18n.baseText(
|
||||||
successfulUrlInvites.length > 1
|
successfulUrlInvites.length > 1
|
||||||
? 'settings.users.multipleInviteUrlsCreated.message'
|
? 'settings.users.multipleInviteUrlsCreated.message'
|
||||||
: 'settings.users.inviteUrlCreated.message',
|
: 'settings.users.inviteUrlCreated.message',
|
||||||
|
@ -268,28 +244,40 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
},
|
|
||||||
onSubmitClick() {
|
|
||||||
this.formBus.emit('submit');
|
|
||||||
},
|
|
||||||
onCopyInviteLink(user: IUser) {
|
|
||||||
if (user.inviteAcceptUrl && this.showInviteUrls) {
|
|
||||||
void this.clipboard.copy(user.inviteAcceptUrl);
|
|
||||||
this.showCopyInviteLinkToast([]);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
goToUpgradeAdvancedPermissions() {
|
function onSubmitClick() {
|
||||||
void this.goToUpgrade('advanced-permissions', 'upgrade-advanced-permissions');
|
formBus.emit('submit');
|
||||||
},
|
}
|
||||||
},
|
|
||||||
});
|
function onCopyInviteLink(user: IUser) {
|
||||||
|
if (user.inviteAcceptUrl && showInviteUrls.value) {
|
||||||
|
void clipboard.copy(user.inviteAcceptUrl);
|
||||||
|
showCopyInviteLinkToast([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToUpgradeAdvancedPermissions() {
|
||||||
|
void goToUpgrade('advanced-permissions', 'upgrade-advanced-permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmail(email: string): string {
|
||||||
|
let parsed = email.trim();
|
||||||
|
if (NAME_EMAIL_FORMAT_REGEX.test(parsed)) {
|
||||||
|
const matches = parsed.match(NAME_EMAIL_FORMAT_REGEX);
|
||||||
|
if (matches && matches.length === 2) {
|
||||||
|
parsed = matches[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal
|
<Modal
|
||||||
:name="INVITE_USER_MODAL_KEY"
|
:name="INVITE_USER_MODAL_KEY"
|
||||||
:title="
|
:title="
|
||||||
$locale.baseText(
|
i18n.baseText(
|
||||||
showInviteUrls ? 'settings.users.copyInviteUrls' : 'settings.users.inviteNewUsers',
|
showInviteUrls ? 'settings.users.copyInviteUrls' : 'settings.users.inviteNewUsers',
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
@ -303,7 +291,7 @@ export default defineComponent({
|
||||||
<i18n-t keypath="settings.users.advancedPermissions.warning">
|
<i18n-t keypath="settings.users.advancedPermissions.warning">
|
||||||
<template #link>
|
<template #link>
|
||||||
<n8n-link size="small" @click="goToUpgradeAdvancedPermissions">
|
<n8n-link size="small" @click="goToUpgradeAdvancedPermissions">
|
||||||
{{ $locale.baseText('settings.users.advancedPermissions.warning.link') }}
|
{{ i18n.baseText('settings.users.advancedPermissions.warning.link') }}
|
||||||
</n8n-link>
|
</n8n-link>
|
||||||
</template>
|
</template>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
|
@ -313,7 +301,7 @@ export default defineComponent({
|
||||||
<template #actions="{ user }">
|
<template #actions="{ user }">
|
||||||
<n8n-tooltip>
|
<n8n-tooltip>
|
||||||
<template #content>
|
<template #content>
|
||||||
{{ $locale.baseText('settings.users.inviteLink.copy') }}
|
{{ i18n.baseText('settings.users.inviteLink.copy') }}
|
||||||
</template>
|
</template>
|
||||||
<n8n-icon-button
|
<n8n-icon-button
|
||||||
icon="link"
|
icon="link"
|
||||||
|
|
|
@ -1,20 +1,14 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
|
||||||
export default defineComponent({
|
const rootStore = useRootStore();
|
||||||
computed: {
|
const uiStore = useUIStore();
|
||||||
...mapStores(useRootStore, useUIStore),
|
|
||||||
basePath(): string {
|
const basePath = computed(() => rootStore.baseUrl);
|
||||||
return this.rootStore.baseUrl;
|
|
||||||
},
|
const logoPath = computed(() => basePath.value + uiStore.logo);
|
||||||
logoPath(): string {
|
|
||||||
return this.basePath + this.uiStore.logo;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -1,85 +1,71 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { mapStores } from 'pinia';
|
import { onBeforeUnmount, onMounted } from 'vue';
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import type { PropType } from 'vue';
|
|
||||||
import type { EventBus } from 'n8n-design-system';
|
import type { EventBus } from 'n8n-design-system';
|
||||||
import { ElDrawer } from 'element-plus';
|
import { ElDrawer } from 'element-plus';
|
||||||
|
|
||||||
export default defineComponent({
|
const props = withDefaults(
|
||||||
name: 'ModalDrawer',
|
defineProps<{
|
||||||
components: {
|
name: string;
|
||||||
ElDrawer,
|
beforeClose?: Function;
|
||||||
|
eventBus?: EventBus;
|
||||||
|
direction: 'ltr' | 'rtl' | 'ttb' | 'btt';
|
||||||
|
modal?: boolean;
|
||||||
|
width: string;
|
||||||
|
wrapperClosable?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modal: true,
|
||||||
|
wrapperClosable: true,
|
||||||
},
|
},
|
||||||
props: {
|
);
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
beforeClose: {
|
|
||||||
type: Function,
|
|
||||||
},
|
|
||||||
eventBus: {
|
|
||||||
type: Object as PropType<EventBus>,
|
|
||||||
},
|
|
||||||
direction: {
|
|
||||||
type: String as PropType<'ltr' | 'rtl' | 'ttb' | 'btt'>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
modal: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
wrapperClosable: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
window.addEventListener('keydown', this.onWindowKeydown);
|
|
||||||
this.eventBus?.on('close', this.close);
|
|
||||||
|
|
||||||
const activeElement = document.activeElement as HTMLElement;
|
const emit = defineEmits<{
|
||||||
if (activeElement) {
|
enter: [];
|
||||||
activeElement.blur();
|
}>();
|
||||||
|
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
|
const handleEnter = () => {
|
||||||
|
if (uiStore.isModalActiveById[props.name]) {
|
||||||
|
emit('enter');
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
beforeUnmount() {
|
|
||||||
this.eventBus?.off('close', this.close);
|
const onWindowKeydown = (event: KeyboardEvent) => {
|
||||||
window.removeEventListener('keydown', this.onWindowKeydown);
|
if (!uiStore.isModalActiveById[props.name]) {
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useUIStore),
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
onWindowKeydown(event: KeyboardEvent) {
|
|
||||||
if (!this.uiStore.isModalActiveById[this.name]) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event && event.keyCode === 13) {
|
if (event && event.keyCode === 13) {
|
||||||
this.handleEnter();
|
handleEnter();
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
handleEnter() {
|
|
||||||
if (this.uiStore.isModalActiveById[this.name]) {
|
const close = async () => {
|
||||||
this.$emit('enter');
|
if (props.beforeClose) {
|
||||||
}
|
const shouldClose = await props.beforeClose();
|
||||||
},
|
|
||||||
async close() {
|
|
||||||
if (this.beforeClose) {
|
|
||||||
const shouldClose = await this.beforeClose();
|
|
||||||
if (shouldClose === false) {
|
if (shouldClose === false) {
|
||||||
// must be strictly false to stop modal from closing
|
// must be strictly false to stop modal from closing
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.uiStore.closeModal(this.name);
|
uiStore.closeModal(props.name);
|
||||||
},
|
};
|
||||||
},
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', onWindowKeydown);
|
||||||
|
props.eventBus?.on('close', close);
|
||||||
|
|
||||||
|
const activeElement = document.activeElement as HTMLElement;
|
||||||
|
if (activeElement) {
|
||||||
|
activeElement.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
props.eventBus?.off('close', close);
|
||||||
|
window.removeEventListener('keydown', onWindowKeydown);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,25 +1,23 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PropType } from 'vue';
|
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import type { ModalKey } from '@/Interface';
|
|
||||||
|
|
||||||
export default defineComponent({
|
defineProps<{
|
||||||
name: 'ModalRoot',
|
name: string;
|
||||||
props: {
|
keepAlive?: boolean;
|
||||||
name: {
|
}>();
|
||||||
type: String as PropType<ModalKey>,
|
|
||||||
required: true,
|
defineSlots<{
|
||||||
},
|
default: {
|
||||||
keepAlive: {
|
modalName: string;
|
||||||
type: Boolean,
|
active: boolean;
|
||||||
},
|
open: boolean;
|
||||||
},
|
activeId: string;
|
||||||
computed: {
|
mode: string;
|
||||||
...mapStores(useUIStore),
|
data: Record<string, unknown>;
|
||||||
},
|
};
|
||||||
});
|
}>();
|
||||||
|
|
||||||
|
const uiStore = useUIStore();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -69,6 +69,7 @@ import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceM
|
||||||
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
|
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
|
||||||
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
|
||||||
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
|
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
|
||||||
|
import type { EventBus } from 'n8n-design-system';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -183,7 +184,15 @@ import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentMo
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
<ModalRoot :name="LOG_STREAM_MODAL_KEY">
|
<ModalRoot :name="LOG_STREAM_MODAL_KEY">
|
||||||
<template #default="{ modalName, data }">
|
<template
|
||||||
|
#default="{
|
||||||
|
modalName,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
modalName: string;
|
||||||
|
data: { destination: Object; isNew: boolean; eventBus: EventBus };
|
||||||
|
}"
|
||||||
|
>
|
||||||
<EventDestinationSettingsModal
|
<EventDestinationSettingsModal
|
||||||
:modal-name="modalName"
|
:modal-name="modalName"
|
||||||
:destination="data.destination"
|
:destination="data.destination"
|
||||||
|
|
|
@ -122,7 +122,7 @@ const badge = computed(() => {
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:size="size"
|
:size="size"
|
||||||
:circle="circle"
|
:circle="circle"
|
||||||
:node-type-name="nodeName ?? nodeType?.displayName ?? ''"
|
:node-type-name="nodeName ? nodeName : nodeType?.displayName"
|
||||||
:show-tooltip="showTooltip"
|
:show-tooltip="showTooltip"
|
||||||
:tooltip-position="tooltipPosition"
|
:tooltip-position="tooltipPosition"
|
||||||
:badge="badge"
|
:badge="badge"
|
||||||
|
|
|
@ -1,48 +1,40 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { computed } from 'vue';
|
||||||
import NodeIcon from '@/components/NodeIcon.vue';
|
import NodeIcon from '@/components/NodeIcon.vue';
|
||||||
import type { ITemplatesNode } from '@/Interface';
|
import type { ITemplatesNode } from '@/Interface';
|
||||||
import { filterTemplateNodes } from '@/utils/nodeTypesUtils';
|
import { filterTemplateNodes } from '@/utils/nodeTypesUtils';
|
||||||
|
|
||||||
export default defineComponent({
|
const props = withDefaults(
|
||||||
name: 'NodeList',
|
defineProps<{
|
||||||
components: {
|
nodes: ITemplatesNode[];
|
||||||
NodeIcon,
|
limit?: number;
|
||||||
},
|
size?: string;
|
||||||
props: {
|
}>(),
|
||||||
nodes: {
|
{
|
||||||
type: Array,
|
limit: 4,
|
||||||
},
|
size: 'sm',
|
||||||
limit: {
|
|
||||||
type: Number,
|
|
||||||
default: 4,
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
type: String,
|
|
||||||
default: 'sm',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
filteredCoreNodes() {
|
|
||||||
return filterTemplateNodes(this.nodes as ITemplatesNode[]);
|
|
||||||
},
|
|
||||||
hiddenNodes(): number {
|
|
||||||
return this.filteredCoreNodes.length - this.countNodesToBeSliced(this.filteredCoreNodes);
|
|
||||||
},
|
|
||||||
slicedNodes(): ITemplatesNode[] {
|
|
||||||
return this.filteredCoreNodes.slice(0, this.countNodesToBeSliced(this.filteredCoreNodes));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
countNodesToBeSliced(nodes: ITemplatesNode[]): number {
|
|
||||||
if (nodes.length > this.limit) {
|
|
||||||
return this.limit - 1;
|
|
||||||
} else {
|
|
||||||
return this.limit;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredCoreNodes = computed(() => {
|
||||||
|
return filterTemplateNodes(props.nodes);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hiddenNodes = computed(() => {
|
||||||
|
return filteredCoreNodes.value.length - countNodesToBeSliced(filteredCoreNodes.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const slicedNodes = computed(() => {
|
||||||
|
return filteredCoreNodes.value.slice(0, countNodesToBeSliced(filteredCoreNodes.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
const countNodesToBeSliced = (nodes: ITemplatesNode[]): number => {
|
||||||
|
if (nodes.length > props.limit) {
|
||||||
|
return props.limit - 1;
|
||||||
|
} else {
|
||||||
|
return props.limit;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -35,7 +35,7 @@ type Props = {
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
linkedRuns?: boolean;
|
linkedRuns?: boolean;
|
||||||
canLinkRuns?: boolean;
|
canLinkRuns?: boolean;
|
||||||
pushRef?: string;
|
pushRef: string;
|
||||||
blockUI?: boolean;
|
blockUI?: boolean;
|
||||||
isProductionExecutionPreview?: boolean;
|
isProductionExecutionPreview?: boolean;
|
||||||
isPaneActive?: boolean;
|
isPaneActive?: boolean;
|
||||||
|
|
|
@ -1,32 +1,29 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import Draggable from './Draggable.vue';
|
import Draggable from './Draggable.vue';
|
||||||
import type { XYPosition } from '@/Interface';
|
import type { XYPosition } from '@/Interface';
|
||||||
|
|
||||||
export default defineComponent({
|
defineProps<{
|
||||||
components: {
|
canMoveRight: boolean;
|
||||||
Draggable,
|
canMoveLeft: boolean;
|
||||||
},
|
}>();
|
||||||
props: {
|
|
||||||
canMoveRight: {
|
const emit = defineEmits<{
|
||||||
type: Boolean,
|
drag: [e: XYPosition];
|
||||||
},
|
dragstart: [];
|
||||||
canMoveLeft: {
|
dragend: [];
|
||||||
type: Boolean,
|
}>();
|
||||||
},
|
|
||||||
},
|
const onDrag = (e: XYPosition) => {
|
||||||
methods: {
|
emit('drag', e);
|
||||||
onDrag(e: XYPosition) {
|
};
|
||||||
this.$emit('drag', e);
|
|
||||||
},
|
const onDragEnd = () => {
|
||||||
onDragStart() {
|
emit('dragend');
|
||||||
this.$emit('dragstart');
|
};
|
||||||
},
|
|
||||||
onDragEnd() {
|
const onDragStart = () => {
|
||||||
this.$emit('dragend');
|
emit('dragstart');
|
||||||
},
|
};
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -1,14 +1,9 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import TitledList from '@/components/TitledList.vue';
|
import TitledList from '@/components/TitledList.vue';
|
||||||
|
|
||||||
export default defineComponent({
|
defineProps<{
|
||||||
name: 'ParameterIssues',
|
issues: string[];
|
||||||
components: {
|
}>();
|
||||||
TitledList,
|
|
||||||
},
|
|
||||||
props: ['issues'],
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -1,26 +1,23 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
export default defineComponent({
|
const rootStore = useRootStore();
|
||||||
name: 'PushConnectionTracker',
|
const pushConnectionActive = computed(() => rootStore.pushConnectionActive);
|
||||||
computed: {
|
const i18n = useI18n();
|
||||||
...mapStores(useRootStore),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span>
|
<span>
|
||||||
<div v-if="!rootStore.pushConnectionActive" class="push-connection-lost primary-color">
|
<div v-if="!pushConnectionActive" class="push-connection-lost primary-color">
|
||||||
<n8n-tooltip placement="bottom-end">
|
<n8n-tooltip placement="bottom-end">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div v-n8n-html="$locale.baseText('pushConnectionTracker.cannotConnectToServer')"></div>
|
<div v-n8n-html="i18n.baseText('pushConnectionTracker.cannotConnectToServer')"></div>
|
||||||
</template>
|
</template>
|
||||||
<span>
|
<span>
|
||||||
<font-awesome-icon icon="exclamation-triangle" />
|
<font-awesome-icon icon="exclamation-triangle" />
|
||||||
{{ $locale.baseText('pushConnectionTracker.connectionLost') }}
|
{{ i18n.baseText('pushConnectionTracker.connectionLost') }}
|
||||||
</span>
|
</span>
|
||||||
</n8n-tooltip>
|
</n8n-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -135,6 +135,7 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
pushRef: {
|
pushRef: {
|
||||||
type: String,
|
type: String,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
paneType: {
|
paneType: {
|
||||||
type: String as PropType<NodePanelType>,
|
type: String as PropType<NodePanelType>,
|
||||||
|
|
|
@ -23,15 +23,15 @@ const LazyRunDataJsonActions = defineAsyncComponent(
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
editMode: { enabled?: boolean; value?: string };
|
editMode: { enabled?: boolean; value?: string };
|
||||||
pushRef?: string;
|
pushRef: string;
|
||||||
paneType?: string;
|
paneType: string;
|
||||||
node: INodeUi;
|
node: INodeUi;
|
||||||
inputData: INodeExecutionData[];
|
inputData: INodeExecutionData[];
|
||||||
mappingEnabled?: boolean;
|
mappingEnabled?: boolean;
|
||||||
distanceFromActive: number;
|
distanceFromActive: number;
|
||||||
runIndex?: number;
|
runIndex: number | undefined;
|
||||||
totalRuns?: number;
|
totalRuns: number | undefined;
|
||||||
search?: string;
|
search: string | undefined;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
editMode: () => ({}),
|
editMode: () => ({}),
|
||||||
|
|
|
@ -1,7 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import type { PropType } from 'vue';
|
|
||||||
import { mapStores, storeToRefs } from 'pinia';
|
|
||||||
import jp from 'jsonpath';
|
import jp from 'jsonpath';
|
||||||
import type { INodeUi } from '@/Interface';
|
import type { INodeUi } from '@/Interface';
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
|
@ -14,92 +11,67 @@ import { useToast } from '@/composables/useToast';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { nonExistingJsonPath } from '@/constants';
|
import { nonExistingJsonPath } from '@/constants';
|
||||||
import { useClipboard } from '@/composables/useClipboard';
|
import { useClipboard } from '@/composables/useClipboard';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
|
||||||
import { usePinnedData } from '@/composables/usePinnedData';
|
import { usePinnedData } from '@/composables/usePinnedData';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
|
||||||
type JsonPathData = {
|
type JsonPathData = {
|
||||||
path: string;
|
path: string;
|
||||||
startPath: string;
|
startPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
const props = withDefaults(
|
||||||
name: 'RunDataJsonActions',
|
defineProps<{
|
||||||
props: {
|
node: INodeUi;
|
||||||
node: {
|
paneType: string;
|
||||||
type: Object as PropType<INodeUi>,
|
pushRef: string;
|
||||||
required: true,
|
displayMode: string;
|
||||||
|
distanceFromActive: number;
|
||||||
|
selectedJsonPath: string;
|
||||||
|
jsonData: IDataObject[];
|
||||||
|
currentOutputIndex?: number;
|
||||||
|
runIndex?: number;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
selectedJsonPath: nonExistingJsonPath,
|
||||||
},
|
},
|
||||||
paneType: {
|
);
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
pushRef: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
currentOutputIndex: {
|
|
||||||
type: Number,
|
|
||||||
},
|
|
||||||
runIndex: {
|
|
||||||
type: Number,
|
|
||||||
},
|
|
||||||
displayMode: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
distanceFromActive: {
|
|
||||||
type: Number,
|
|
||||||
},
|
|
||||||
selectedJsonPath: {
|
|
||||||
type: String,
|
|
||||||
default: nonExistingJsonPath,
|
|
||||||
},
|
|
||||||
jsonData: {
|
|
||||||
type: Array as PropType<IDataObject[]>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
const ndvStore = useNDVStore();
|
const ndvStore = useNDVStore();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const nodeHelpers = useNodeHelpers();
|
const nodeHelpers = useNodeHelpers();
|
||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
const { activeNode } = storeToRefs(ndvStore);
|
const { activeNode } = ndvStore;
|
||||||
const pinnedData = usePinnedData(activeNode);
|
const pinnedData = usePinnedData(activeNode);
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
return {
|
const route = useRoute();
|
||||||
i18n,
|
|
||||||
nodeHelpers,
|
const isReadOnlyRoute = computed(() => {
|
||||||
clipboard,
|
return route?.meta?.readOnlyCanvas === true;
|
||||||
pinnedData,
|
});
|
||||||
...useToast(),
|
|
||||||
};
|
const noSelection = computed(() => {
|
||||||
},
|
return props.selectedJsonPath === nonExistingJsonPath;
|
||||||
computed: {
|
});
|
||||||
...mapStores(useNodeTypesStore, useNDVStore, useWorkflowsStore, useSourceControlStore),
|
const normalisedJsonPath = computed((): string => {
|
||||||
isReadOnlyRoute() {
|
return noSelection.value ? '[""]' : props.selectedJsonPath;
|
||||||
return this.$route?.meta?.readOnlyCanvas === true;
|
});
|
||||||
},
|
|
||||||
activeNode(): INodeUi | null {
|
function getJsonValue(): string {
|
||||||
return this.ndvStore.activeNode;
|
let selectedValue = jp.query(props.jsonData, `$${normalisedJsonPath.value}`)[0];
|
||||||
},
|
if (noSelection.value) {
|
||||||
noSelection() {
|
|
||||||
return this.selectedJsonPath === nonExistingJsonPath;
|
|
||||||
},
|
|
||||||
normalisedJsonPath(): string {
|
|
||||||
return this.noSelection ? '[""]' : this.selectedJsonPath;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getJsonValue(): string {
|
|
||||||
let selectedValue = jp.query(this.jsonData, `$${this.normalisedJsonPath}`)[0];
|
|
||||||
if (this.noSelection) {
|
|
||||||
const inExecutionsFrame =
|
const inExecutionsFrame =
|
||||||
window !== window.parent && window.parent.location.pathname.includes('/executions');
|
window !== window.parent && window.parent.location.pathname.includes('/executions');
|
||||||
|
|
||||||
if (this.pinnedData.hasData.value && !inExecutionsFrame) {
|
if (pinnedData.hasData.value && !inExecutionsFrame) {
|
||||||
selectedValue = clearJsonKey(this.pinnedData.data.value as object);
|
selectedValue = clearJsonKey(pinnedData.data.value as object);
|
||||||
} else {
|
} else {
|
||||||
selectedValue = executionDataToJson(
|
selectedValue = executionDataToJson(
|
||||||
this.nodeHelpers.getNodeInputData(this.node, this.runIndex, this.currentOutputIndex),
|
nodeHelpers.getNodeInputData(props.node, props.runIndex, props.currentOutputIndex),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -112,37 +84,40 @@ export default defineComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
},
|
}
|
||||||
getJsonItemPath(): JsonPathData {
|
|
||||||
const newPath = convertPath(this.normalisedJsonPath);
|
function getJsonItemPath(): JsonPathData {
|
||||||
|
const newPath = convertPath(normalisedJsonPath.value);
|
||||||
let startPath = '';
|
let startPath = '';
|
||||||
let path = '';
|
let path = '';
|
||||||
|
|
||||||
const pathParts = newPath.split(']');
|
const pathParts = newPath.split(']');
|
||||||
const index = pathParts[0].slice(1);
|
const index = pathParts[0].slice(1);
|
||||||
path = pathParts.slice(1).join(']');
|
path = pathParts.slice(1).join(']');
|
||||||
startPath = `$item(${index}).$node["${this.node.name}"].json`;
|
startPath = `$item(${index}).$node["${props.node.name}"].json`;
|
||||||
|
|
||||||
return { path, startPath };
|
return { path, startPath };
|
||||||
},
|
}
|
||||||
getJsonParameterPath(): JsonPathData {
|
|
||||||
const newPath = convertPath(this.normalisedJsonPath);
|
|
||||||
const path = newPath.split(']').slice(1).join(']');
|
|
||||||
let startPath = `$node["${this.node.name}"].json`;
|
|
||||||
|
|
||||||
if (this.distanceFromActive === 1) {
|
function getJsonParameterPath(): JsonPathData {
|
||||||
|
const newPath = convertPath(normalisedJsonPath.value);
|
||||||
|
const path = newPath.split(']').slice(1).join(']');
|
||||||
|
let startPath = `$node["${props.node.name}"].json`;
|
||||||
|
|
||||||
|
if (props.distanceFromActive === 1) {
|
||||||
startPath = '$json';
|
startPath = '$json';
|
||||||
}
|
}
|
||||||
|
|
||||||
return { path, startPath };
|
return { path, startPath };
|
||||||
},
|
}
|
||||||
handleCopyClick(commandData: { command: string }) {
|
|
||||||
|
function handleCopyClick(commandData: { command: string }) {
|
||||||
let value: string;
|
let value: string;
|
||||||
if (commandData.command === 'value') {
|
if (commandData.command === 'value') {
|
||||||
value = this.getJsonValue();
|
value = getJsonValue();
|
||||||
|
|
||||||
this.showToast({
|
showToast({
|
||||||
title: this.i18n.baseText('runData.copyValue.toast'),
|
title: i18n.baseText('runData.copyValue.toast'),
|
||||||
message: '',
|
message: '',
|
||||||
type: 'success',
|
type: 'success',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
|
@ -151,23 +126,23 @@ export default defineComponent({
|
||||||
let startPath = '';
|
let startPath = '';
|
||||||
let path = '';
|
let path = '';
|
||||||
if (commandData.command === 'itemPath') {
|
if (commandData.command === 'itemPath') {
|
||||||
const jsonItemPath = this.getJsonItemPath();
|
const jsonItemPath = getJsonItemPath();
|
||||||
startPath = jsonItemPath.startPath;
|
startPath = jsonItemPath.startPath;
|
||||||
path = jsonItemPath.path;
|
path = jsonItemPath.path;
|
||||||
|
|
||||||
this.showToast({
|
showToast({
|
||||||
title: this.i18n.baseText('runData.copyItemPath.toast'),
|
title: i18n.baseText('runData.copyItemPath.toast'),
|
||||||
message: '',
|
message: '',
|
||||||
type: 'success',
|
type: 'success',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
});
|
});
|
||||||
} else if (commandData.command === 'parameterPath') {
|
} else if (commandData.command === 'parameterPath') {
|
||||||
const jsonParameterPath = this.getJsonParameterPath();
|
const jsonParameterPath = getJsonParameterPath();
|
||||||
startPath = jsonParameterPath.startPath;
|
startPath = jsonParameterPath.startPath;
|
||||||
path = jsonParameterPath.path;
|
path = jsonParameterPath.path;
|
||||||
|
|
||||||
this.showToast({
|
showToast({
|
||||||
title: this.i18n.baseText('runData.copyParameterPath.toast'),
|
title: i18n.baseText('runData.copyParameterPath.toast'),
|
||||||
message: '',
|
message: '',
|
||||||
type: 'success',
|
type: 'success',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
|
@ -185,21 +160,19 @@ export default defineComponent({
|
||||||
parameterPath: 'parameter_path',
|
parameterPath: 'parameter_path',
|
||||||
}[commandData.command];
|
}[commandData.command];
|
||||||
|
|
||||||
this.$telemetry.track('User copied ndv data', {
|
telemetry.track('User copied ndv data', {
|
||||||
node_type: this.activeNode?.type,
|
node_type: activeNode?.type,
|
||||||
push_ref: this.pushRef,
|
push_ref: props.pushRef,
|
||||||
run_index: this.runIndex,
|
run_index: props.runIndex,
|
||||||
view: 'json',
|
view: 'json',
|
||||||
copy_type: copyType,
|
copy_type: copyType,
|
||||||
workflow_id: this.workflowsStore.workflowId,
|
workflow_id: workflowsStore.workflowId,
|
||||||
pane: this.paneType,
|
pane: props.paneType,
|
||||||
in_execution_log: this.isReadOnlyRoute,
|
in_execution_log: isReadOnlyRoute.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
void this.clipboard.copy(value);
|
void clipboard.copy(value);
|
||||||
},
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -1,120 +1,123 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from 'vue';
|
import { computed, onBeforeMount, onMounted, ref } from 'vue';
|
||||||
import { EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants';
|
import { MODAL_CONFIRM } from '@/constants';
|
||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import { useLogStreamingStore } from '@/stores/logStreaming.store';
|
import { useLogStreamingStore } from '@/stores/logStreaming.store';
|
||||||
import type { PropType } from 'vue';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import type { MessageEventBusDestinationOptions } from 'n8n-workflow';
|
import type { MessageEventBusDestinationOptions } from 'n8n-workflow';
|
||||||
import { deepCopy, defaultMessageEventBusDestinationOptions } from 'n8n-workflow';
|
import { deepCopy, defaultMessageEventBusDestinationOptions } from 'n8n-workflow';
|
||||||
import type { BaseTextKey } from '@/plugins/i18n';
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
import type { EventBus } from 'n8n-design-system';
|
import type { EventBus } from 'n8n-design-system';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { assert } from '@/utils/assert';
|
||||||
|
|
||||||
export const DESTINATION_LIST_ITEM_ACTIONS = {
|
const DESTINATION_LIST_ITEM_ACTIONS = {
|
||||||
OPEN: 'open',
|
OPEN: 'open',
|
||||||
DELETE: 'delete',
|
DELETE: 'delete',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
const { confirm } = useMessage();
|
||||||
components: {},
|
const i18n = useI18n();
|
||||||
setup() {
|
const logStreamingStore = useLogStreamingStore();
|
||||||
return {
|
|
||||||
...useMessage(),
|
const nodeParameters = ref<MessageEventBusDestinationOptions>({});
|
||||||
};
|
const cardActions = ref<HTMLDivElement | null>(null);
|
||||||
},
|
|
||||||
data() {
|
const props = withDefaults(
|
||||||
return {
|
defineProps<{
|
||||||
EnterpriseEditionFeature,
|
eventBus: EventBus;
|
||||||
nodeParameters: {} as MessageEventBusDestinationOptions,
|
destination: MessageEventBusDestinationOptions;
|
||||||
};
|
readonly: boolean;
|
||||||
},
|
}>(),
|
||||||
props: {
|
|
||||||
eventBus: {
|
|
||||||
type: Object as PropType<EventBus>,
|
|
||||||
},
|
|
||||||
destination: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
default: deepCopy(defaultMessageEventBusDestinationOptions),
|
|
||||||
},
|
|
||||||
readonly: Boolean,
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.nodeParameters = Object.assign(
|
|
||||||
deepCopy(defaultMessageEventBusDestinationOptions),
|
|
||||||
this.destination,
|
|
||||||
);
|
|
||||||
this.eventBus?.on('destinationWasSaved', this.onDestinationWasSaved);
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
this.eventBus?.off('destinationWasSaved', this.onDestinationWasSaved);
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useLogStreamingStore),
|
|
||||||
actions(): Array<{ label: string; value: string }> {
|
|
||||||
const actions = [
|
|
||||||
{
|
{
|
||||||
label: this.$locale.baseText('workflows.item.open'),
|
destination: () => deepCopy(defaultMessageEventBusDestinationOptions),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: [id: string | undefined];
|
||||||
|
remove: [id: string | undefined];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nodeParameters.value = Object.assign(
|
||||||
|
deepCopy(defaultMessageEventBusDestinationOptions),
|
||||||
|
props.destination,
|
||||||
|
);
|
||||||
|
props.eventBus?.on('destinationWasSaved', onDestinationWasSaved);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
props.eventBus?.off('destinationWasSaved', onDestinationWasSaved);
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = computed((): Array<{ label: string; value: string }> => {
|
||||||
|
const actionList = [
|
||||||
|
{
|
||||||
|
label: i18n.baseText('workflows.item.open'),
|
||||||
value: DESTINATION_LIST_ITEM_ACTIONS.OPEN,
|
value: DESTINATION_LIST_ITEM_ACTIONS.OPEN,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (!this.readonly) {
|
if (!props.readonly) {
|
||||||
actions.push({
|
actionList.push({
|
||||||
label: this.$locale.baseText('workflows.item.delete'),
|
label: i18n.baseText('workflows.item.delete'),
|
||||||
value: DESTINATION_LIST_ITEM_ACTIONS.DELETE,
|
value: DESTINATION_LIST_ITEM_ACTIONS.DELETE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return actions;
|
return actionList;
|
||||||
},
|
});
|
||||||
typeLabelName(): BaseTextKey {
|
|
||||||
return `settings.log-streaming.${this.destination.__type}` as BaseTextKey;
|
const typeLabelName = computed((): BaseTextKey => {
|
||||||
},
|
return `settings.log-streaming.${props.destination.__type}` as BaseTextKey;
|
||||||
},
|
});
|
||||||
methods: {
|
|
||||||
onDestinationWasSaved() {
|
function onDestinationWasSaved() {
|
||||||
const updatedDestination = this.logStreamingStore.getDestination(this.destination.id);
|
assert(props.destination.id);
|
||||||
|
const updatedDestination = logStreamingStore.getDestination(props.destination.id);
|
||||||
if (updatedDestination) {
|
if (updatedDestination) {
|
||||||
this.nodeParameters = Object.assign(
|
nodeParameters.value = Object.assign(
|
||||||
deepCopy(defaultMessageEventBusDestinationOptions),
|
deepCopy(defaultMessageEventBusDestinationOptions),
|
||||||
this.destination,
|
props.destination,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
async onClick(event: Event) {
|
|
||||||
const cardActions = this.$refs.cardActions as HTMLDivElement | null;
|
async function onClick(event: Event) {
|
||||||
const target = event.target as HTMLDivElement | null;
|
const target = event.target as HTMLDivElement | null;
|
||||||
if (
|
if (
|
||||||
cardActions === target ||
|
cardActions.value === target ||
|
||||||
cardActions?.contains(target) ||
|
cardActions.value?.contains(target) ||
|
||||||
target?.contains(cardActions)
|
target?.contains(cardActions.value)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$emit('edit', this.destination.id);
|
emit('edit', props.destination.id);
|
||||||
},
|
}
|
||||||
onEnabledSwitched(state: boolean) {
|
|
||||||
this.nodeParameters.enabled = state;
|
function onEnabledSwitched(state: boolean) {
|
||||||
void this.saveDestination();
|
nodeParameters.value.enabled = state;
|
||||||
},
|
void saveDestination();
|
||||||
async saveDestination() {
|
}
|
||||||
await this.logStreamingStore.saveDestination(this.nodeParameters);
|
|
||||||
},
|
async function saveDestination() {
|
||||||
async onAction(action: string) {
|
await logStreamingStore.saveDestination(nodeParameters.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAction(action: string) {
|
||||||
if (action === DESTINATION_LIST_ITEM_ACTIONS.OPEN) {
|
if (action === DESTINATION_LIST_ITEM_ACTIONS.OPEN) {
|
||||||
this.$emit('edit', this.destination.id);
|
emit('edit', props.destination.id);
|
||||||
} else if (action === DESTINATION_LIST_ITEM_ACTIONS.DELETE) {
|
} else if (action === DESTINATION_LIST_ITEM_ACTIONS.DELETE) {
|
||||||
const deleteConfirmed = await this.confirm(
|
const deleteConfirmed = await confirm(
|
||||||
this.$locale.baseText('settings.log-streaming.destinationDelete.message', {
|
i18n.baseText('settings.log-streaming.destinationDelete.message', {
|
||||||
interpolate: { destinationName: this.destination.label },
|
interpolate: { destinationName: props.destination.label ?? '' },
|
||||||
}),
|
}),
|
||||||
this.$locale.baseText('settings.log-streaming.destinationDelete.headline'),
|
i18n.baseText('settings.log-streaming.destinationDelete.headline'),
|
||||||
{
|
{
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
confirmButtonText: this.$locale.baseText(
|
confirmButtonText: i18n.baseText(
|
||||||
'settings.log-streaming.destinationDelete.confirmButtonText',
|
'settings.log-streaming.destinationDelete.confirmButtonText',
|
||||||
),
|
),
|
||||||
cancelButtonText: this.$locale.baseText(
|
cancelButtonText: i18n.baseText(
|
||||||
'settings.log-streaming.destinationDelete.cancelButtonText',
|
'settings.log-streaming.destinationDelete.cancelButtonText',
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
@ -124,11 +127,9 @@ export default defineComponent({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$emit('remove', this.destination.id);
|
emit('remove', props.destination.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -140,7 +141,7 @@ export default defineComponent({
|
||||||
</n8n-heading>
|
</n8n-heading>
|
||||||
<div :class="$style.cardDescription">
|
<div :class="$style.cardDescription">
|
||||||
<n8n-text color="text-light" size="small">
|
<n8n-text color="text-light" size="small">
|
||||||
<span>{{ $locale.baseText(typeLabelName) }}</span>
|
<span>{{ i18n.baseText(typeLabelName) }}</span>
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -149,10 +150,10 @@ export default defineComponent({
|
||||||
<div ref="cardActions" :class="$style.cardActions">
|
<div ref="cardActions" :class="$style.cardActions">
|
||||||
<div :class="$style.activeStatusText" data-test-id="destination-activator-status">
|
<div :class="$style.activeStatusText" data-test-id="destination-activator-status">
|
||||||
<n8n-text v-if="nodeParameters.enabled" :color="'success'" size="small" bold>
|
<n8n-text v-if="nodeParameters.enabled" :color="'success'" size="small" bold>
|
||||||
{{ $locale.baseText('workflowActivator.active') }}
|
{{ i18n.baseText('workflowActivator.active') }}
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
<n8n-text v-else color="text-base" size="small" bold>
|
<n8n-text v-else color="text-base" size="small" bold>
|
||||||
{{ $locale.baseText('workflowActivator.inactive') }}
|
{{ i18n.baseText('workflowActivator.inactive') }}
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -162,8 +163,8 @@ export default defineComponent({
|
||||||
:model-value="nodeParameters.enabled"
|
:model-value="nodeParameters.enabled"
|
||||||
:title="
|
:title="
|
||||||
nodeParameters.enabled
|
nodeParameters.enabled
|
||||||
? $locale.baseText('workflowActivator.deactivateWorkflow')
|
? i18n.baseText('workflowActivator.deactivateWorkflow')
|
||||||
: $locale.baseText('workflowActivator.activateWorkflow')
|
: i18n.baseText('workflowActivator.activateWorkflow')
|
||||||
"
|
"
|
||||||
active-color="#13ce66"
|
active-color="#13ce66"
|
||||||
inactive-color="#8899AA"
|
inactive-color="#8899AA"
|
||||||
|
|
|
@ -1,63 +1,45 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { type PropType, defineComponent } from 'vue';
|
|
||||||
import { filterTemplateNodes } from '@/utils/nodeTypesUtils';
|
|
||||||
import { abbreviateNumber } from '@/utils/typesUtils';
|
import { abbreviateNumber } from '@/utils/typesUtils';
|
||||||
import NodeList from './NodeList.vue';
|
import NodeList from './NodeList.vue';
|
||||||
import TimeAgo from '@/components/TimeAgo.vue';
|
import TimeAgo from '@/components/TimeAgo.vue';
|
||||||
import type { ITemplatesWorkflow } from '@/Interface';
|
import type { ITemplatesWorkflow } from '@/Interface';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
|
|
||||||
export default defineComponent({
|
const i18n = useI18n();
|
||||||
name: 'TemplateCard',
|
|
||||||
components: {
|
const nodesToBeShown = 5;
|
||||||
TimeAgo,
|
|
||||||
NodeList,
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
workflow?: ITemplatesWorkflow;
|
||||||
|
lastItem?: boolean;
|
||||||
|
firstItem?: boolean;
|
||||||
|
useWorkflowButton?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
simpleView?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
lastItem: false,
|
||||||
|
firstItem: false,
|
||||||
|
useWorkflowButton: false,
|
||||||
|
loading: false,
|
||||||
|
simpleView: false,
|
||||||
},
|
},
|
||||||
props: {
|
);
|
||||||
workflow: {
|
|
||||||
type: Object as PropType<ITemplatesWorkflow>,
|
const emit = defineEmits<{
|
||||||
},
|
useWorkflow: [e: MouseEvent];
|
||||||
lastItem: {
|
click: [e: MouseEvent];
|
||||||
type: Boolean,
|
}>();
|
||||||
default: false,
|
|
||||||
},
|
function onUseWorkflowClick(e: MouseEvent) {
|
||||||
firstItem: {
|
emit('useWorkflow', e);
|
||||||
type: Boolean,
|
}
|
||||||
default: false,
|
|
||||||
},
|
function onCardClick(e: MouseEvent) {
|
||||||
useWorkflowButton: {
|
emit('click', e);
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
simpleView: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
nodesToBeShown: 5,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
filterTemplateNodes,
|
|
||||||
abbreviateNumber,
|
|
||||||
countNodesToBeSliced(nodes: []): number {
|
|
||||||
if (nodes.length > this.nodesToBeShown) {
|
|
||||||
return this.nodesToBeShown - 1;
|
|
||||||
} else {
|
|
||||||
return this.nodesToBeShown;
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onUseWorkflowClick(e: MouseEvent) {
|
|
||||||
this.$emit('useWorkflow', e);
|
|
||||||
},
|
|
||||||
onCardClick(e: MouseEvent) {
|
|
||||||
this.$emit('click', e);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -88,8 +70,12 @@ export default defineComponent({
|
||||||
<TimeAgo :date="workflow.createdAt" />
|
<TimeAgo :date="workflow.createdAt" />
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
<div v-if="workflow.user" :class="$style.line" v-text="'|'" />
|
<div v-if="workflow.user" :class="$style.line" v-text="'|'" />
|
||||||
<n8n-text v-if="workflow.user" size="small" color="text-light"
|
<n8n-text v-if="workflow.user" size="small" color="text-light">
|
||||||
>By {{ workflow.user.username }}</n8n-text
|
{{
|
||||||
|
i18n.baseText('template.byAuthor' as BaseTextKey, {
|
||||||
|
interpolate: { name: workflow.user.username },
|
||||||
|
})
|
||||||
|
}}</n8n-text
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
|
||||||
import type { PropType } from 'vue';
|
|
||||||
import TemplateDetailsBlock from '@/components/TemplateDetailsBlock.vue';
|
import TemplateDetailsBlock from '@/components/TemplateDetailsBlock.vue';
|
||||||
import NodeIcon from '@/components/NodeIcon.vue';
|
import NodeIcon from '@/components/NodeIcon.vue';
|
||||||
import { filterTemplateNodes } from '@/utils/nodeTypesUtils';
|
import { filterTemplateNodes } from '@/utils/nodeTypesUtils';
|
||||||
|
@ -11,51 +9,32 @@ import type {
|
||||||
ITemplatesNode,
|
ITemplatesNode,
|
||||||
ITemplatesWorkflow,
|
ITemplatesWorkflow,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
import TimeAgo from '@/components/TimeAgo.vue';
|
import TimeAgo from '@/components/TimeAgo.vue';
|
||||||
import { isFullTemplatesCollection, isTemplatesWorkflow } from '@/utils/templates/typeGuards';
|
import { isFullTemplatesCollection, isTemplatesWorkflow } from '@/utils/templates/typeGuards';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
export default defineComponent({
|
defineProps<{
|
||||||
name: 'TemplateDetails',
|
template: ITemplatesWorkflow | ITemplatesCollection | ITemplatesCollectionFull | null;
|
||||||
components: {
|
blockTitle: string;
|
||||||
NodeIcon,
|
loading: boolean;
|
||||||
TemplateDetailsBlock,
|
}>();
|
||||||
TimeAgo,
|
|
||||||
},
|
const router = useRouter();
|
||||||
props: {
|
const i18n = useI18n();
|
||||||
template: {
|
|
||||||
type: Object as PropType<
|
const templatesStore = useTemplatesStore();
|
||||||
ITemplatesWorkflow | ITemplatesCollection | ITemplatesCollectionFull | null
|
|
||||||
>,
|
const redirectToCategory = (id: string) => {
|
||||||
required: true,
|
templatesStore.resetSessionId();
|
||||||
},
|
void router.push(`/templates?categories=${id}`);
|
||||||
blockTitle: {
|
};
|
||||||
type: String,
|
|
||||||
required: true,
|
const redirectToSearchPage = (node: ITemplatesNode) => {
|
||||||
},
|
templatesStore.resetSessionId();
|
||||||
loading: {
|
void router.push(`/templates?search=${node.displayName}`);
|
||||||
type: Boolean,
|
};
|
||||||
},
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useTemplatesStore),
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
abbreviateNumber,
|
|
||||||
filterTemplateNodes,
|
|
||||||
redirectToCategory(id: string) {
|
|
||||||
this.templatesStore.resetSessionId();
|
|
||||||
void this.$router.push(`/templates?categories=${id}`);
|
|
||||||
},
|
|
||||||
redirectToSearchPage(node: ITemplatesNode) {
|
|
||||||
this.templatesStore.resetSessionId();
|
|
||||||
void this.$router.push(`/templates?search=${node.displayName}`);
|
|
||||||
},
|
|
||||||
isFullTemplatesCollection,
|
|
||||||
isTemplatesWorkflow,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -91,13 +70,13 @@ export default defineComponent({
|
||||||
|
|
||||||
<TemplateDetailsBlock
|
<TemplateDetailsBlock
|
||||||
v-if="!loading && template"
|
v-if="!loading && template"
|
||||||
:title="$locale.baseText('template.details.details')"
|
:title="i18n.baseText('template.details.details')"
|
||||||
>
|
>
|
||||||
<div :class="$style.text">
|
<div :class="$style.text">
|
||||||
<n8n-text v-if="isTemplatesWorkflow(template)" size="small" color="text-base">
|
<n8n-text v-if="isTemplatesWorkflow(template)" size="small" color="text-base">
|
||||||
{{ $locale.baseText('template.details.created') }}
|
{{ i18n.baseText('template.details.created') }}
|
||||||
<TimeAgo :date="template.createdAt" />
|
<TimeAgo :date="template.createdAt" />
|
||||||
{{ $locale.baseText('template.details.by') }}
|
{{ i18n.baseText('template.details.by') }}
|
||||||
{{ template.user ? template.user.username : 'n8n team' }}
|
{{ template.user ? template.user.username : 'n8n team' }}
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
</div>
|
</div>
|
||||||
|
@ -107,9 +86,9 @@ export default defineComponent({
|
||||||
size="small"
|
size="small"
|
||||||
color="text-base"
|
color="text-base"
|
||||||
>
|
>
|
||||||
{{ $locale.baseText('template.details.viewed') }}
|
{{ i18n.baseText('template.details.viewed') }}
|
||||||
{{ abbreviateNumber(template.totalViews) }}
|
{{ abbreviateNumber(template.totalViews) }}
|
||||||
{{ $locale.baseText('template.details.times') }}
|
{{ i18n.baseText('template.details.times') }}
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
</div>
|
</div>
|
||||||
</TemplateDetailsBlock>
|
</TemplateDetailsBlock>
|
||||||
|
|
|
@ -1,101 +1,104 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { defineComponent } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import type { ITemplatesCategory } from '@/Interface';
|
import type { ITemplatesCategory } from '@/Interface';
|
||||||
import type { PropType } from 'vue';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
|
|
||||||
export default defineComponent({
|
interface Props {
|
||||||
name: 'TemplateFilters',
|
categories?: ITemplatesCategory[];
|
||||||
props: {
|
sortOnPopulate?: boolean;
|
||||||
categories: {
|
expandLimit?: number;
|
||||||
type: Array as PropType<ITemplatesCategory[]>,
|
loading?: boolean;
|
||||||
default: () => [],
|
selected?: ITemplatesCategory[];
|
||||||
},
|
|
||||||
sortOnPopulate: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
expandLimit: {
|
|
||||||
type: Number,
|
|
||||||
default: 12,
|
|
||||||
},
|
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
selected: {
|
|
||||||
type: Array as PropType<ITemplatesCategory[]>,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ['clearAll', 'select', 'clear'],
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
collapsed: true,
|
|
||||||
sortedCategories: [] as ITemplatesCategory[],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useTemplatesStore),
|
|
||||||
allSelected(): boolean {
|
|
||||||
return this.selected.length === 0;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
sortOnPopulate: {
|
|
||||||
handler(value: boolean) {
|
|
||||||
if (value) {
|
|
||||||
this.sortCategories();
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
immediate: true,
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
},
|
categories: () => [],
|
||||||
categories: {
|
sortOnPopulate: false,
|
||||||
handler(categories: ITemplatesCategory[]) {
|
expandLimit: 12,
|
||||||
if (categories.length > 0) {
|
loading: false,
|
||||||
this.sortCategories();
|
selected: () => [],
|
||||||
}
|
|
||||||
},
|
|
||||||
immediate: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
sortCategories() {
|
|
||||||
if (!this.sortOnPopulate) {
|
|
||||||
this.sortedCategories = this.categories;
|
|
||||||
} else {
|
|
||||||
const selected = this.selected || [];
|
|
||||||
const selectedCategories = this.categories.filter((cat) => selected.includes(cat));
|
|
||||||
const notSelectedCategories = this.categories.filter((cat) => !selected.includes(cat));
|
|
||||||
this.sortedCategories = selectedCategories.concat(notSelectedCategories);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
collapseAction() {
|
|
||||||
this.collapsed = false;
|
|
||||||
},
|
|
||||||
handleCheckboxChanged(value: boolean, selectedCategory: ITemplatesCategory) {
|
|
||||||
this.$emit(value ? 'select' : 'clear', selectedCategory);
|
|
||||||
},
|
|
||||||
isSelected(category: ITemplatesCategory) {
|
|
||||||
return this.selected.includes(category);
|
|
||||||
},
|
|
||||||
resetCategories() {
|
|
||||||
this.$emit('clearAll');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
clearAll: [];
|
||||||
|
select: [category: ITemplatesCategory];
|
||||||
|
clear: [category: ITemplatesCategory];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const collapsed = ref(true);
|
||||||
|
const sortedCategories = ref<ITemplatesCategory[]>([]);
|
||||||
|
|
||||||
|
const allSelected = computed((): boolean => {
|
||||||
|
return props.selected.length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function sortCategories() {
|
||||||
|
if (!props.sortOnPopulate) {
|
||||||
|
sortedCategories.value = props.categories;
|
||||||
|
} else {
|
||||||
|
const selected = props.selected || [];
|
||||||
|
const selectedCategories = props.categories.filter((cat) => selected.includes(cat));
|
||||||
|
const notSelectedCategories = props.categories.filter((cat) => !selected.includes(cat));
|
||||||
|
sortedCategories.value = selectedCategories.concat(notSelectedCategories);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function collapseAction() {
|
||||||
|
collapsed.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCheckboxChanged(value: boolean, selectedCategory: ITemplatesCategory) {
|
||||||
|
if (value) {
|
||||||
|
emit('select', selectedCategory);
|
||||||
|
} else {
|
||||||
|
emit('clear', selectedCategory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(category: ITemplatesCategory) {
|
||||||
|
return props.selected.includes(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCategories() {
|
||||||
|
emit('clearAll');
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.sortOnPopulate,
|
||||||
|
(value: boolean) => {
|
||||||
|
if (value) {
|
||||||
|
sortCategories();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.categories,
|
||||||
|
(categories: ITemplatesCategory[]) => {
|
||||||
|
if (categories.length > 0) {
|
||||||
|
sortCategories();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.filters" class="template-filters" data-test-id="templates-filter-container">
|
<div :class="$style.filters" class="template-filters" data-test-id="templates-filter-container">
|
||||||
<div :class="$style.title" v-text="$locale.baseText('templates.categoriesHeading')" />
|
<div :class="$style.title" v-text="i18n.baseText('templates.categoriesHeading')" />
|
||||||
<div v-if="loading" :class="$style.list">
|
<div v-if="loading" :class="$style.list">
|
||||||
<n8n-loading :loading="loading" :rows="expandLimit" />
|
<n8n-loading :loading="loading" :rows="expandLimit" />
|
||||||
</div>
|
</div>
|
||||||
<ul v-if="!loading" :class="$style.categories">
|
<ul v-if="!loading" :class="$style.categories">
|
||||||
<li :class="$style.item" data-test-id="template-filter-all-categories">
|
<li :class="$style.item" data-test-id="template-filter-all-categories">
|
||||||
<el-checkbox :model-value="allSelected" @update:model-value="() => resetCategories()">
|
<el-checkbox :model-value="allSelected" @update:model-value="() => resetCategories()">
|
||||||
{{ $locale.baseText('templates.allCategories') }}
|
{{ i18n.baseText('templates.allCategories') }}
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
|
|
|
@ -1,33 +1,23 @@
|
||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import { type PropType, defineComponent } from 'vue';
|
|
||||||
import Card from '@/components/CollectionWorkflowCard.vue';
|
import Card from '@/components/CollectionWorkflowCard.vue';
|
||||||
import NodeList from '@/components/NodeList.vue';
|
import NodeList from '@/components/NodeList.vue';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import type { ITemplatesCollection } from '@/Interface';
|
import type { ITemplatesCollection } from '@/Interface';
|
||||||
|
|
||||||
export default defineComponent({
|
withDefaults(
|
||||||
name: 'TemplatesInfoCard',
|
defineProps<{
|
||||||
components: {
|
collection: ITemplatesCollection;
|
||||||
Card,
|
loading?: boolean;
|
||||||
NodeList,
|
showItemCount?: boolean;
|
||||||
|
width: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
loading: false,
|
||||||
|
showItemCount: true,
|
||||||
},
|
},
|
||||||
props: {
|
);
|
||||||
collection: {
|
|
||||||
type: Object as PropType<ITemplatesCollection>,
|
const i18n = useI18n();
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
},
|
|
||||||
showItemCount: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -36,7 +26,7 @@ export default defineComponent({
|
||||||
<span>
|
<span>
|
||||||
<n8n-text v-show="showItemCount" size="small" color="text-light">
|
<n8n-text v-show="showItemCount" size="small" color="text-light">
|
||||||
{{ collection.workflows.length }}
|
{{ collection.workflows.length }}
|
||||||
{{ $locale.baseText('templates.workflows') }}
|
{{ i18n.baseText('templates.workflows') }}
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
</span>
|
</span>
|
||||||
<NodeList :nodes="collection.nodes" :show-more="false" />
|
<NodeList :nodes="collection.nodes" :show-more="false" />
|
||||||
|
|
|
@ -1,103 +1,66 @@
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { computed, onBeforeMount, onBeforeUnmount, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { mapStores } from 'pinia';
|
|
||||||
import type { WorkerStatus } from '@n8n/api-types';
|
|
||||||
import type { ExecutionStatus } from 'n8n-workflow';
|
|
||||||
|
|
||||||
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useToast } from '@/composables/useToast';
|
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
|
||||||
import { useOrchestrationStore } from '@/stores/orchestration.store';
|
import { useOrchestrationStore } from '@/stores/orchestration.store';
|
||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
import WorkerCard from './Workers/WorkerCard.ee.vue';
|
|
||||||
import { usePushConnection } from '@/composables/usePushConnection';
|
import { usePushConnection } from '@/composables/usePushConnection';
|
||||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-default-export
|
withDefaults(
|
||||||
export default defineComponent({
|
defineProps<{
|
||||||
name: 'WorkerList',
|
autoRefreshEnabled?: boolean;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/naming-convention
|
}>(),
|
||||||
components: { PushConnectionTracker, WorkerCard },
|
{
|
||||||
props: {
|
autoRefreshEnabled: true,
|
||||||
autoRefreshEnabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
setup() {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const pushConnection = usePushConnection({ router });
|
const pushConnection = usePushConnection({ router });
|
||||||
|
const documentTitle = useDocumentTitle();
|
||||||
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
return {
|
const orchestrationManagerStore = useOrchestrationStore();
|
||||||
i18n,
|
const rootStore = useRootStore();
|
||||||
pushConnection,
|
const pushStore = usePushConnectionStore();
|
||||||
...useToast(),
|
|
||||||
documentTitle: useDocumentTitle(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapStores(useRootStore, useUIStore, usePushConnectionStore, useOrchestrationStore),
|
|
||||||
combinedWorkers(): WorkerStatus[] {
|
|
||||||
const returnData: WorkerStatus[] = [];
|
|
||||||
for (const workerId in this.orchestrationManagerStore.workers) {
|
|
||||||
returnData.push(this.orchestrationManagerStore.workers[workerId]);
|
|
||||||
}
|
|
||||||
return returnData;
|
|
||||||
},
|
|
||||||
initialStatusReceived(): boolean {
|
|
||||||
return this.orchestrationManagerStore.initialStatusReceived;
|
|
||||||
},
|
|
||||||
workerIds(): string[] {
|
|
||||||
return Object.keys(this.orchestrationManagerStore.workers);
|
|
||||||
},
|
|
||||||
pageTitle() {
|
|
||||||
return this.i18n.baseText('workerList.pageTitle');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.documentTitle.set(this.pageTitle);
|
|
||||||
|
|
||||||
this.$telemetry.track('User viewed worker view', {
|
const initialStatusReceived = computed(() => orchestrationManagerStore.initialStatusReceived);
|
||||||
instance_id: this.rootStore.instanceId,
|
|
||||||
|
const workerIds = computed(() => Object.keys(orchestrationManagerStore.workers));
|
||||||
|
|
||||||
|
const pageTitle = computed(() => i18n.baseText('workerList.pageTitle'));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
documentTitle.set(pageTitle.value);
|
||||||
|
|
||||||
|
telemetry.track('User viewed worker view', {
|
||||||
|
instance_id: rootStore.instanceId,
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
beforeMount() {
|
|
||||||
|
onBeforeMount(() => {
|
||||||
if (window.Cypress !== undefined) {
|
if (window.Cypress !== undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pushConnection.initialize();
|
pushConnection.initialize();
|
||||||
this.pushStore.pushConnect();
|
pushStore.pushConnect();
|
||||||
this.orchestrationManagerStore.startWorkerStatusPolling();
|
orchestrationManagerStore.startWorkerStatusPolling();
|
||||||
},
|
});
|
||||||
beforeUnmount() {
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
if (window.Cypress !== undefined) {
|
if (window.Cypress !== undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.orchestrationManagerStore.stopWorkerStatusPolling();
|
orchestrationManagerStore.stopWorkerStatusPolling();
|
||||||
this.pushStore.pushDisconnect();
|
pushStore.pushDisconnect();
|
||||||
this.pushConnection.terminate();
|
pushConnection.terminate();
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
averageLoadAvg(loads: number[]) {
|
|
||||||
return (loads.reduce((prev, curr) => prev + curr, 0) / loads.length).toFixed(2);
|
|
||||||
},
|
|
||||||
getStatus(payload: WorkerStatus): ExecutionStatus {
|
|
||||||
if (payload.runningJobsSummary.length > 0) {
|
|
||||||
return 'running';
|
|
||||||
} else {
|
|
||||||
return 'success';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getRowClass(payload: WorkerStatus): string {
|
|
||||||
return [this.$style.execRow, this.$style[this.getStatus(payload)]].join(' ');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -111,7 +74,7 @@ export default defineComponent({
|
||||||
<n8n-spinner />
|
<n8n-spinner />
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-if="workerIds.length === 0">{{ $locale.baseText('workerList.empty') }}</div>
|
<div v-if="workerIds.length === 0">{{ i18n.baseText('workerList.empty') }}</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-for="workerId in workerIds" :key="workerId" :class="$style.card">
|
<div v-for="workerId in workerIds" :key="workerId" :class="$style.card">
|
||||||
<WorkerCard :worker-id="workerId" data-test-id="worker-card" />
|
<WorkerCard :worker-id="workerId" data-test-id="worker-card" />
|
||||||
|
|
|
@ -220,20 +220,4 @@ describe('Canvas', () => {
|
||||||
expect(container.querySelector('#diagonalHatch')).toBeInTheDocument();
|
expect(container.querySelector('#diagonalHatch')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pane', () => {
|
|
||||||
describe('onPaneMouseDown', () => {
|
|
||||||
it('should enable panning when middle mouse button is pressed', async () => {
|
|
||||||
const { getByTestId } = renderComponent();
|
|
||||||
const canvas = getByTestId('canvas');
|
|
||||||
const pane = canvas.querySelector('.vue-flow__pane');
|
|
||||||
|
|
||||||
if (!pane) throw new Error('VueFlow pane not in the document');
|
|
||||||
|
|
||||||
await fireEvent.mouseDown(canvas, { button: 1, view: window });
|
|
||||||
|
|
||||||
expect(canvas).toHaveClass('draggable');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue