Merge branch 'n8n-io:master' into master

This commit is contained in:
alexwitkowski 2022-01-09 22:05:11 -05:00 committed by GitHub
commit 3afacff564
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
1293 changed files with 76668 additions and 8514 deletions

View file

@ -34,10 +34,10 @@ jobs:
-
name: Install dependencies
run: |
apt update -y
echo 'tzdata tzdata/Areas select Europe' | debconf-set-selections
echo 'tzdata tzdata/Zones/Europe select Paris' | debconf-set-selections
DEBIAN_FRONTEND="noninteractive" apt-get install -y graphicsmagick
sudo apt update -y
echo 'tzdata tzdata/Areas select Europe' | sudo debconf-set-selections
echo 'tzdata tzdata/Zones/Europe select Paris' | sudo debconf-set-selections
DEBIAN_FRONTEND="noninteractive" sudo apt-get install -y graphicsmagick
shell: bash
-
name: npm install and build
@ -75,19 +75,19 @@ jobs:
shell: bash
env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
-
name: Export credentials
if: always()
run: n8n/packages/cli/bin/n8n export:credentials --output=test-workflows/credentials.json --all --pretty
shell: bash
env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
-
name: Commit and push credential changes
if: always()
run: |
cd test-workflows
git config --global user.name 'n8n test bot'
git config --global user.email 'n8n-test-bot@users.noreply.github.com'
git commit -am "Automated credential update"
git push --force --quiet "https://janober:${{ secrets.TOKEN }}@github.com/n8n-io/test-workflows.git" main:main
# -
# name: Export credentials
# if: always()
# run: n8n/packages/cli/bin/n8n export:credentials --output=test-workflows/credentials.json --all --pretty
# shell: bash
# env:
# N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
# -
# name: Commit and push credential changes
# if: always()
# run: |
# cd test-workflows
# git config --global user.name 'n8n test bot'
# git config --global user.email 'n8n-test-bot@users.noreply.github.com'
# git commit -am "Automated credential update"
# git push --force --quiet "https://janober:${{ secrets.TOKEN }}@github.com/n8n-io/test-workflows.git" main:main

3
.gitignore vendored
View file

@ -5,7 +5,6 @@ tmp
dist
npm-debug.log*
lerna-debug.log
package-lock.json
yarn.lock
google-generated-credentials.json
_START_PACKAGE
@ -13,5 +12,5 @@ _START_PACKAGE
.vscode/*
!.vscode/extensions.json
.idea
vetur.config.js
nodelinter.config.json
packages/*/package-lock.json

1
.npmrc Normal file
View file

@ -0,0 +1 @@
legacy-peer-deps=true

View file

@ -6,6 +6,8 @@ if [ -d /root/.n8n ] ; then
ln -s /root/.n8n /home/node/
fi
chown -R node /home/node
if [ "$#" -gt 0 ]; then
# Got started with arguments
COMMAND=$1;

44431
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -26,7 +26,7 @@ var nodeVersion = process.versions.node.split('.');
if (parseInt(nodeVersion[0], 10) < 14) {
console.log(`\nYour Node.js version (${process.versions.node}) is too old to run n8n.\nPlease update to version 14 or later!\n`);
process.exit(0);
process.exit(1);
}
require('@oclif/command').run()

View file

@ -2,7 +2,7 @@
/* eslint-disable no-console */
import { promises as fs } from 'fs';
import { Command, flags } from '@oclif/command';
import { UserSettings } from 'n8n-core';
import { BinaryDataManager, IBinaryDataConfig, UserSettings } from 'n8n-core';
import { INode, LoggerProxy } from 'n8n-workflow';
import {
@ -11,17 +11,18 @@ import {
CredentialTypes,
Db,
ExternalHooks,
GenericHelpers,
InternalHooksManager,
IWorkflowBase,
IWorkflowExecutionDataProcess,
LoadNodesAndCredentials,
NodeTypes,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
WorkflowCredentials,
WorkflowHelpers,
WorkflowRunner,
} from '../src';
import { getLogger } from '../src/Logger';
import config = require('../config');
export class Execute extends Command {
static description = '\nExecutes a given workflow';
@ -45,6 +46,8 @@ export class Execute extends Command {
async run() {
const logger = getLogger();
LoggerProxy.init(logger);
const binaryDataConfig = config.get('binaryDataManager') as IBinaryDataConfig;
await BinaryDataManager.init(binaryDataConfig, true);
// eslint-disable-next-line @typescript-eslint/no-shadow
const { flags } = this.parse(Execute);
@ -131,6 +134,10 @@ export class Execute extends Command {
const credentialTypes = CredentialTypes();
await credentialTypes.init(loadNodesAndCredentials.credentialTypes);
const instanceId = await UserSettings.getInstanceId();
const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli, nodeTypes);
if (!WorkflowHelpers.isWorkflowIdValid(workflowId)) {
workflowId = undefined;
}

View file

@ -9,10 +9,10 @@
import * as fs from 'fs';
import { Command, flags } from '@oclif/command';
import { UserSettings } from 'n8n-core';
import { BinaryDataManager, IBinaryDataConfig, UserSettings } from 'n8n-core';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { INode, INodeExecutionData, ITaskData, LoggerProxy } from 'n8n-workflow';
import { INode, ITaskData, LoggerProxy } from 'n8n-workflow';
import { sep } from 'path';
@ -28,16 +28,15 @@ import {
CredentialTypes,
Db,
ExternalHooks,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
IExecutionsCurrentSummary,
GenericHelpers,
InternalHooksManager,
IWorkflowDb,
IWorkflowExecutionDataProcess,
LoadNodesAndCredentials,
NodeTypes,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
WorkflowCredentials,
WorkflowRunner,
} from '../src';
import config = require('../config');
export class ExecuteBatch extends Command {
static description = '\nExecutes multiple workflows once';
@ -59,12 +58,12 @@ export class ExecuteBatch extends Command {
static executionTimeout = 3 * 60 * 1000;
static examples = [
`$ n8n executeAll`,
`$ n8n executeAll --concurrency=10 --skipList=/data/skipList.txt`,
`$ n8n executeAll --debug --output=/data/output.json`,
`$ n8n executeAll --ids=10,13,15 --shortOutput`,
`$ n8n executeAll --snapshot=/data/snapshots --shallow`,
`$ n8n executeAll --compare=/data/previousExecutionData --retries=2`,
`$ n8n executeBatch`,
`$ n8n executeBatch --concurrency=10 --skipList=/data/skipList.txt`,
`$ n8n executeBatch --debug --output=/data/output.json`,
`$ n8n executeBatch --ids=10,13,15 --shortOutput`,
`$ n8n executeBatch --snapshot=/data/snapshots --shallow`,
`$ n8n executeBatch --compare=/data/previousExecutionData --retries=2`,
];
static flags = {
@ -192,6 +191,8 @@ export class ExecuteBatch extends Command {
const logger = getLogger();
LoggerProxy.init(logger);
const binaryDataConfig = config.get('binaryDataManager') as IBinaryDataConfig;
await BinaryDataManager.init(binaryDataConfig, true);
// eslint-disable-next-line @typescript-eslint/no-shadow
const { flags } = this.parse(ExecuteBatch);
@ -313,6 +314,10 @@ export class ExecuteBatch extends Command {
const credentialTypes = CredentialTypes();
await credentialTypes.init(loadNodesAndCredentials.credentialTypes);
const instanceId = await UserSettings.getInstanceId();
const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli, nodeTypes);
// Send a shallow copy of allWorkflows so we still have all workflow data.
const results = await this.runTests([...allWorkflows]);
@ -817,10 +822,22 @@ export class ExecuteBatch extends Command {
const changes = diff(JSON.parse(contents), data, { keysOnly: true });
if (changes !== undefined) {
// we have structural changes. Report them.
executionResult.error = `Workflow may contain breaking changes`;
executionResult.changes = changes;
executionResult.executionStatus = 'error';
// If we had only additions with no removals
// Then we treat as a warning and not an error.
// To find this, we convert the object to JSON
// and search for the `__deleted` string
const changesJson = JSON.stringify(changes);
if (changesJson.includes('__deleted')) {
// we have structural changes. Report them.
executionResult.error = 'Workflow may contain breaking changes';
executionResult.changes = changes;
executionResult.executionStatus = 'error';
} else {
executionResult.error =
'Workflow contains new data that previously did not exist.';
executionResult.changes = changes;
executionResult.executionStatus = 'warning';
}
} else {
executionResult.executionStatus = 'success';
}
@ -842,7 +859,8 @@ export class ExecuteBatch extends Command {
}
}
} catch (e) {
executionResult.error = 'Workflow failed to execute.';
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access
executionResult.error = `Workflow failed to execute: ${e.message}`;
executionResult.executionStatus = 'error';
}
clearTimeout(timeoutTimer);

View file

@ -6,7 +6,6 @@ import { INode, INodeCredentialsDetails, LoggerProxy } from 'n8n-workflow';
import * as fs from 'fs';
import * as glob from 'fast-glob';
import * as path from 'path';
import { UserSettings } from 'n8n-core';
import { getLogger } from '../../src/Logger';
import { Db, ICredentialsDb } from '../../src';
@ -86,9 +85,12 @@ export class ImportWorkflowsCommand extends Command {
const credentialsEntities = (await Db.collections.Credentials?.find()) ?? [];
let i;
if (flags.separate) {
const files = await glob(
`${flags.input.endsWith(path.sep) ? flags.input : flags.input + path.sep}*.json`,
);
let inputPath = flags.input;
if (process.platform === 'win32') {
inputPath = inputPath.replace(/\\/g, '/');
}
inputPath = inputPath.replace(/\/$/g, '');
const files = await glob(`${inputPath}/*.json`);
for (i = 0; i < files.length; i++) {
const workflow = JSON.parse(fs.readFileSync(files[i], { encoding: 'utf8' }));
if (credentialsEntities.length > 0) {

View file

@ -6,7 +6,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import * as localtunnel from 'localtunnel';
import { TUNNEL_SUBDOMAIN_ENV, UserSettings } from 'n8n-core';
import { BinaryDataManager, IBinaryDataConfig, TUNNEL_SUBDOMAIN_ENV, UserSettings } from 'n8n-core';
import { Command, flags } from '@oclif/command';
// eslint-disable-next-line import/no-extraneous-dependencies
import * as Redis from 'ioredis';
@ -22,8 +22,6 @@ import {
Db,
ExternalHooks,
GenericHelpers,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
IExecutionsCurrentSummary,
InternalHooksManager,
LoadNodesAndCredentials,
NodeTypes,
@ -155,17 +153,6 @@ export class Start extends Command {
LoggerProxy.init(logger);
logger.info('Initializing n8n process');
logger.info(
'\n' +
'****************************************************\n' +
'* *\n' +
'* n8n now sends selected, anonymous telemetry. *\n' +
'* For more details (and how to opt out): *\n' +
'* https://docs.n8n.io/reference/telemetry.html *\n' +
'* *\n' +
'****************************************************\n',
);
// Start directly with the init of the database to improve startup time
const startDbInitPromise = Db.init().catch((error: Error) => {
logger.error(`There was an error initializing DB: "${error.message}"`);
@ -183,10 +170,6 @@ export class Start extends Command {
const loadNodesAndCredentials = LoadNodesAndCredentials();
await loadNodesAndCredentials.init();
// Load the credentials overwrites if any exist
const credentialsOverwrites = CredentialsOverwrites();
await credentialsOverwrites.init();
// Load all external hooks
const externalHooks = ExternalHooks();
await externalHooks.init();
@ -197,6 +180,10 @@ export class Start extends Command {
const credentialTypes = CredentialTypes();
await credentialTypes.init(loadNodesAndCredentials.credentialTypes);
// Load the credentials overwrites if any exist
const credentialsOverwrites = CredentialsOverwrites();
await credentialsOverwrites.init();
// Wait till the database is ready
await startDbInitPromise;
@ -314,14 +301,20 @@ export class Start extends Command {
);
}
const instanceId = await UserSettings.getInstanceId();
const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli, nodeTypes);
const binaryDataConfig = config.get('binaryDataManager') as IBinaryDataConfig;
await BinaryDataManager.init(binaryDataConfig, true);
await Server.start();
// Start to get active workflows and run their triggers
activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
await activeWorkflowRunner.init();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const waitTracker = WaitTracker();
WaitTracker();
const editorUrl = GenericHelpers.getBaseUrl();
this.log(`\nEditor is now accessible via:\n${editorUrl}`);

View file

@ -4,8 +4,7 @@ import { Command, flags } from '@oclif/command';
import { IDataObject, LoggerProxy } from 'n8n-workflow';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { Db, GenericHelpers } from '../../src';
import { Db } from '../../src';
import { getLogger } from '../../src/Logger';

View file

@ -3,7 +3,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/unbound-method */
import { UserSettings } from 'n8n-core';
import { BinaryDataManager, IBinaryDataConfig, UserSettings } from 'n8n-core';
import { Command, flags } from '@oclif/command';
// eslint-disable-next-line import/no-extraneous-dependencies
import * as Redis from 'ioredis';
@ -18,10 +18,9 @@ import {
Db,
ExternalHooks,
GenericHelpers,
InternalHooksManager,
LoadNodesAndCredentials,
NodeTypes,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
TestWebhooks,
WebhookServer,
} from '../src';
@ -149,6 +148,13 @@ export class Webhook extends Command {
// Wait till the database is ready
await startDbInitPromise;
const instanceId = await UserSettings.getInstanceId();
const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli, nodeTypes);
const binaryDataConfig = config.get('binaryDataManager') as IBinaryDataConfig;
await BinaryDataManager.init(binaryDataConfig);
if (config.get('executions.mode') === 'queue') {
const redisHost = config.get('queue.bull.redis.host');
const redisPassword = config.get('queue.bull.redis.password');

View file

@ -10,23 +10,14 @@
import * as PCancelable from 'p-cancelable';
import { Command, flags } from '@oclif/command';
import { UserSettings, WorkflowExecute } from 'n8n-core';
import { BinaryDataManager, IBinaryDataConfig, UserSettings, WorkflowExecute } from 'n8n-core';
import {
IDataObject,
INodeTypes,
IRun,
IWorkflowExecuteHooks,
Workflow,
WorkflowHooks,
LoggerProxy,
} from 'n8n-workflow';
import { IExecuteResponsePromiseData, INodeTypes, IRun, Workflow, LoggerProxy } from 'n8n-workflow';
import { FindOneOptions } from 'typeorm';
import * as Bull from 'bull';
import {
ActiveExecutions,
CredentialsOverwrites,
CredentialTypes,
Db,
@ -34,12 +25,13 @@ import {
GenericHelpers,
IBullJobData,
IBullJobResponse,
IBullWebhookResponse,
IExecutionFlattedDb,
IExecutionResponse,
InternalHooksManager,
LoadNodesAndCredentials,
NodeTypes,
ResponseHelper,
WorkflowCredentials,
WebhookHelpers,
WorkflowExecuteAdditionalData,
} from '../src';
@ -182,6 +174,16 @@ export class Worker extends Command {
currentExecutionDb.workflowData,
{ retryOf: currentExecutionDb.retryOf as string },
);
additionalData.hooks.hookFunctions.sendResponse = [
async (response: IExecuteResponsePromiseData): Promise<void> => {
await job.progress({
executionId: job.data.executionId as string,
response: WebhookHelpers.encodeWebhookResponse(response),
} as IBullWebhookResponse);
},
];
additionalData.executionId = jobData.executionId;
let workflowExecute: WorkflowExecute;
@ -203,7 +205,7 @@ export class Worker extends Command {
Worker.runningJobs[job.id] = workflowRun;
// Wait till the execution is finished
const runData = await workflowRun;
await workflowRun;
delete Worker.runningJobs[job.id];
@ -270,6 +272,12 @@ export class Worker extends Command {
Worker.jobQueue.process(flags.concurrency, async (job) => this.runJob(job, nodeTypes));
const versions = await GenericHelpers.getVersions();
const instanceId = await UserSettings.getInstanceId();
InternalHooksManager.init(instanceId, versions.cli, nodeTypes);
const binaryDataConfig = config.get('binaryDataManager') as IBinaryDataConfig;
await BinaryDataManager.init(binaryDataConfig);
console.info('\nn8n worker is now ready');
console.info(` * Version: ${versions.cli}`);

View file

@ -507,6 +507,12 @@ const config = convict({
env: 'N8N_ENDPOINT_WEBHOOK_TEST',
doc: 'Path for test-webhook endpoint',
},
disableUi: {
format: Boolean,
default: false,
env: 'N8N_DISABLE_UI',
doc: 'Disable N8N UI (Frontend).',
},
disableProductionWebhooksOnMainProcess: {
format: Boolean,
default: false,
@ -650,6 +656,39 @@ const config = convict({
},
},
binaryDataManager: {
availableModes: {
format: String,
default: 'filesystem',
env: 'N8N_AVAILABLE_BINARY_DATA_MODES',
doc: 'Available modes of binary data storage, as comma separated strings',
},
mode: {
format: String,
default: 'default',
env: 'N8N_DEFAULT_BINARY_DATA_MODE',
doc: 'Storage mode for binary data, default | filesystem',
},
localStoragePath: {
format: String,
default: path.join(core.UserSettings.getUserN8nFolderPath(), 'binaryData'),
env: 'N8N_BINARY_DATA_STORAGE_PATH',
doc: 'Path for binary data storage in "filesystem" mode',
},
binaryDataTTL: {
format: Number,
default: 60,
env: 'N8N_BINARY_DATA_TTL',
doc: 'TTL for binary data of unsaved executions in minutes',
},
persistedBinaryDataTTL: {
format: Number,
default: 1440,
env: 'N8N_PERSISTED_BINARY_DATA_TTL',
doc: 'TTL for persisted binary data in minutes (binary data gets deleted if not persisted before TTL expires)',
},
},
deployment: {
type: {
format: String,
@ -689,6 +728,13 @@ const config = convict({
},
},
},
defaultLocale: {
doc: 'Default locale for the UI',
format: String,
default: 'en',
env: 'N8N_DEFAULT_LOCALE',
},
});
// Overwrite default configuration with settings which got defined in

View file

@ -1,6 +1,6 @@
{
"name": "n8n",
"version": "0.144.0",
"version": "0.158.0",
"description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -66,10 +66,11 @@
"@types/jest": "^26.0.13",
"@types/localtunnel": "^1.9.0",
"@types/lodash.get": "^4.4.6",
"@types/node": "^14.14.40",
"@types/node": "14.17.27",
"@types/open": "^6.1.0",
"@types/parseurl": "^1.3.1",
"@types/request-promise-native": "~1.0.15",
"@types/validator": "^13.7.0",
"concurrently": "^5.1.0",
"jest": "^26.4.2",
"nodemon": "^2.0.2",
@ -83,7 +84,7 @@
"dependencies": {
"@oclif/command": "^1.5.18",
"@oclif/errors": "^1.2.2",
"@rudderstack/rudder-sdk-node": "^1.0.2",
"@rudderstack/rudder-sdk-node": "1.0.6",
"@types/json-diff": "^0.5.1",
"@types/jsonwebtoken": "^8.5.2",
"basic-auth": "^2.0.1",
@ -110,10 +111,10 @@
"localtunnel": "^2.0.0",
"lodash.get": "^4.4.2",
"mysql2": "~2.3.0",
"n8n-core": "~0.89.0",
"n8n-editor-ui": "~0.112.0",
"n8n-nodes-base": "~0.141.0",
"n8n-workflow": "~0.72.0",
"n8n-core": "~0.100.0",
"n8n-editor-ui": "~0.125.0",
"n8n-nodes-base": "~0.156.0",
"n8n-workflow": "~0.82.0",
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",
"pg": "^8.3.0",
@ -122,7 +123,7 @@
"sqlite3": "^5.0.1",
"sse-channel": "^3.1.1",
"tslib": "1.14.1",
"typeorm": "^0.2.30",
"typeorm": "0.2.30",
"winston": "^3.3.3"
},
"jest": {

View file

@ -5,11 +5,15 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { IRun } from 'n8n-workflow';
import { createDeferredPromise } from 'n8n-core';
import {
createDeferredPromise,
IDeferredPromise,
IExecuteResponsePromiseData,
IRun,
} from 'n8n-workflow';
import { ChildProcess } from 'child_process';
import { stringify } from 'flatted';
// eslint-disable-next-line import/no-extraneous-dependencies
import * as PCancelable from 'p-cancelable';
// eslint-disable-next-line import/no-cycle
@ -79,6 +83,7 @@ export class ActiveExecutions {
const execution = {
id: executionId,
data: stringify(executionData.executionData!),
waitTill: null,
};
@ -116,6 +121,28 @@ export class ActiveExecutions {
this.activeExecutions[executionId].workflowExecution = workflowExecution;
}
attachResponsePromise(
executionId: string,
responsePromise: IDeferredPromise<IExecuteResponsePromiseData>,
): void {
if (this.activeExecutions[executionId] === undefined) {
throw new Error(
`No active execution with id "${executionId}" got found to attach to workflowExecution to!`,
);
}
this.activeExecutions[executionId].responsePromise = responsePromise;
}
resolveResponsePromise(executionId: string, response: IExecuteResponsePromiseData): void {
if (this.activeExecutions[executionId] === undefined) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
this.activeExecutions[executionId].responsePromise?.resolve(response);
}
/**
* Remove an active execution
*
@ -193,6 +220,7 @@ export class ActiveExecutions {
this.activeExecutions[executionId].postExecutePromises.push(waitPromise);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
return waitPromise.promise();
}

View file

@ -12,7 +12,9 @@
import { ActiveWorkflows, NodeExecuteFunctions } from 'n8n-core';
import {
IDeferredPromise,
IExecuteData,
IExecuteResponsePromiseData,
IGetExecutePollFunctions,
IGetExecuteTriggerFunctions,
INode,
@ -40,11 +42,10 @@ import {
NodeTypes,
ResponseHelper,
WebhookHelpers,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
WorkflowCredentials,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
WorkflowRunner,
ExternalHooks,
} from '.';
import config = require('../config');
@ -112,6 +113,8 @@ export class ActiveWorkflowRunner {
}
Logger.verbose('Finished initializing active workflows (startup)');
}
const externalHooks = ExternalHooks();
await externalHooks.run('activeWorkflows.initialized', []);
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@ -550,6 +553,7 @@ export class ActiveWorkflowRunner {
data: INodeExecutionData[][],
additionalData: IWorkflowExecuteAdditionalDataWorkflow,
mode: WorkflowExecuteMode,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
) {
const nodeExecutionStack: IExecuteData[] = [
{
@ -580,7 +584,7 @@ export class ActiveWorkflowRunner {
};
const workflowRunner = new WorkflowRunner();
return workflowRunner.run(runData, true);
return workflowRunner.run(runData, true, undefined, undefined, responsePromise);
}
/**
@ -641,13 +645,16 @@ export class ActiveWorkflowRunner {
mode,
activation,
);
returnFunctions.emit = (data: INodeExecutionData[][]): void => {
returnFunctions.emit = (
data: INodeExecutionData[][],
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): void => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
Logger.debug(`Received trigger for workflow "${workflow.name}"`);
WorkflowHelpers.saveStaticData(workflow);
// eslint-disable-next-line id-denylist
this.runWorkflow(workflowData, node, data, additionalData, mode).catch((err) =>
console.error(err),
this.runWorkflow(workflowData, node, data, additionalData, mode, responsePromise).catch(
(error) => console.error(error),
);
};
return returnFunctions;

View file

@ -1,31 +1,13 @@
import { ICredentialType, ICredentialTypes as ICredentialTypesInterface } from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import { CredentialsOverwrites, ICredentialsTypeData } from '.';
import { ICredentialsTypeData } from '.';
class CredentialTypesClass implements ICredentialTypesInterface {
credentialTypes: ICredentialsTypeData = {};
async init(credentialTypes: ICredentialsTypeData): Promise<void> {
this.credentialTypes = credentialTypes;
// Load the credentials overwrites if any exist
const credentialsOverwrites = CredentialsOverwrites().getAll();
// eslint-disable-next-line no-restricted-syntax
for (const credentialType of Object.keys(credentialsOverwrites)) {
if (credentialTypes[credentialType] === undefined) {
// eslint-disable-next-line no-continue
continue;
}
// Add which properties got overwritten that the Editor-UI knows
// which properties it should hide
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
credentialTypes[credentialType].__overwrittenProperties = Object.keys(
credentialsOverwrites[credentialType],
);
}
}
getAll(): ICredentialType[] {

View file

@ -12,6 +12,9 @@ class CredentialsOverwritesClass {
private resolvedTypes: string[] = [];
async init(overwriteData?: ICredentialsOverwrite) {
// If data gets reinitialized reset the resolved types cache
this.resolvedTypes.length = 0;
if (overwriteData !== undefined) {
// If data is already given it can directly be set instead of
// loaded from environment
@ -41,6 +44,7 @@ class CredentialsOverwritesClass {
if (overwrites && Object.keys(overwrites).length) {
this.overwriteData[type] = overwrites;
credentialTypeData.__overwrittenProperties = Object.keys(overwrites);
}
}
}

View file

@ -7,19 +7,19 @@ import {
ICredentialsEncrypted,
ICredentialType,
IDataObject,
IDeferredPromise,
IExecuteResponsePromiseData,
IRun,
IRunData,
IRunExecutionData,
ITaskData,
ITelemetrySettings,
IWorkflowBase as IWorkflowBaseWorkflow,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
IWorkflowCredentials,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { IDeferredPromise, WorkflowExecute } from 'n8n-core';
import { WorkflowExecute } from 'n8n-core';
// eslint-disable-next-line import/no-extraneous-dependencies
import * as PCancelable from 'p-cancelable';
@ -47,6 +47,11 @@ export interface IBullJobResponse {
success: boolean;
}
export interface IBullWebhookResponse {
executionId: string;
response: IExecuteResponsePromiseData;
}
export interface ICustomRequest extends Request {
parsedUrl: Url | undefined;
}
@ -237,6 +242,7 @@ export interface IExecutingWorkflowData {
process?: ChildProcess;
startedAt: Date;
postExecutePromises: Array<IDeferredPromise<IRun | undefined>>;
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>;
workflowExecution?: PCancelable<IRun>;
}
@ -304,16 +310,24 @@ export interface IDiagnosticInfo {
[key: string]: string | number | undefined;
};
deploymentType: string;
binaryDataMode: string;
}
export interface IInternalHooksClass {
onN8nStop(): Promise<void>;
onServerStarted(diagnosticInfo: IDiagnosticInfo): Promise<void>;
onServerStarted(
diagnosticInfo: IDiagnosticInfo,
firstWorkflowCreatedAt?: Date,
): Promise<unknown[]>;
onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise<void>;
onWorkflowCreated(workflow: IWorkflowBase): Promise<void>;
onWorkflowDeleted(workflowId: string): Promise<void>;
onWorkflowSaved(workflow: IWorkflowBase): Promise<void>;
onWorkflowPostExecute(workflow: IWorkflowBase, runData?: IRun): Promise<void>;
onWorkflowPostExecute(
executionId: string,
workflow: IWorkflowBase,
runData?: IRun,
): Promise<void>;
}
export interface IN8nConfig {
@ -394,13 +408,16 @@ export interface IN8nUISettings {
instanceId: string;
telemetry: ITelemetrySettings;
personalizationSurvey: IPersonalizationSurvey;
defaultLocale: string;
}
export interface IPersonalizationSurveyAnswers {
companySize: string | null;
codingSkill: string | null;
workArea: string | null;
companyIndustry: string[];
companySize: string | null;
otherCompanyIndustry: string | null;
otherWorkArea: string | null;
workArea: string[] | string | null;
}
export interface IPersonalizationSurvey {
@ -490,6 +507,7 @@ export interface IPushDataConsoleMessage {
export interface IResponseCallbackData {
data?: IDataObject | IDataObject[];
headers?: object;
noWebhookResponse?: boolean;
responseCode?: number;
}

View file

@ -1,17 +1,29 @@
/* eslint-disable import/no-cycle */
import { IDataObject, IRun, TelemetryHelpers } from 'n8n-workflow';
import { BinaryDataManager } from 'n8n-core';
import { IDataObject, INodeTypes, IRun, TelemetryHelpers } from 'n8n-workflow';
import {
IDiagnosticInfo,
IInternalHooksClass,
IPersonalizationSurveyAnswers,
IWorkflowBase,
IWorkflowDb,
} from '.';
import { Telemetry } from './telemetry';
export class InternalHooksClass implements IInternalHooksClass {
constructor(private telemetry: Telemetry) {}
private versionCli: string;
async onServerStarted(diagnosticInfo: IDiagnosticInfo): Promise<void> {
private nodeTypes: INodeTypes;
constructor(private telemetry: Telemetry, versionCli: string, nodeTypes: INodeTypes) {
this.versionCli = versionCli;
this.nodeTypes = nodeTypes;
}
async onServerStarted(
diagnosticInfo: IDiagnosticInfo,
earliestWorkflowCreatedAt?: Date,
): Promise<unknown[]> {
const info = {
version_cli: diagnosticInfo.versionCli,
db_type: diagnosticInfo.databaseType,
@ -21,53 +33,74 @@ export class InternalHooksClass implements IInternalHooksClass {
system_info: diagnosticInfo.systemInfo,
execution_variables: diagnosticInfo.executionVariables,
n8n_deployment_type: diagnosticInfo.deploymentType,
n8n_binary_data_mode: diagnosticInfo.binaryDataMode,
};
await this.telemetry.identify(info);
await this.telemetry.track('Instance started', info);
return Promise.all([
this.telemetry.identify(info),
this.telemetry.track('Instance started', {
...info,
earliest_workflow_created: earliestWorkflowCreatedAt,
}),
]);
}
async onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise<void> {
await this.telemetry.track('User responded to personalization questions', {
return this.telemetry.track('User responded to personalization questions', {
company_size: answers.companySize,
coding_skill: answers.codingSkill,
work_area: answers.workArea,
other_work_area: answers.otherWorkArea,
company_industry: answers.companyIndustry,
other_company_industry: answers.otherCompanyIndustry,
});
}
async onWorkflowCreated(workflow: IWorkflowBase): Promise<void> {
await this.telemetry.track('User created workflow', {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
return this.telemetry.track('User created workflow', {
workflow_id: workflow.id,
node_graph: TelemetryHelpers.generateNodesGraph(workflow).nodeGraph,
node_graph: nodeGraph,
node_graph_string: JSON.stringify(nodeGraph),
});
}
async onWorkflowDeleted(workflowId: string): Promise<void> {
await this.telemetry.track('User deleted workflow', {
return this.telemetry.track('User deleted workflow', {
workflow_id: workflowId,
});
}
async onWorkflowSaved(workflow: IWorkflowBase): Promise<void> {
await this.telemetry.track('User saved workflow', {
async onWorkflowSaved(workflow: IWorkflowDb): Promise<void> {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
return this.telemetry.track('User saved workflow', {
workflow_id: workflow.id,
node_graph: TelemetryHelpers.generateNodesGraph(workflow).nodeGraph,
node_graph: nodeGraph,
node_graph_string: JSON.stringify(nodeGraph),
version_cli: this.versionCli,
num_tags: workflow.tags.length,
});
}
async onWorkflowPostExecute(workflow: IWorkflowBase, runData?: IRun): Promise<void> {
async onWorkflowPostExecute(
executionId: string,
workflow: IWorkflowBase,
runData?: IRun,
): Promise<void> {
const promises = [Promise.resolve()];
const properties: IDataObject = {
workflow_id: workflow.id,
is_manual: false,
version_cli: this.versionCli,
};
if (runData !== undefined) {
properties.execution_mode = runData.mode;
if (runData.mode === 'manual') {
properties.is_manual = true;
}
properties.success = !!runData.finished;
properties.is_manual = runData.mode === 'manual';
let nodeGraphResult;
if (!properties.success && runData?.data.resultData.error) {
properties.error_message = runData?.data.resultData.error.message;
@ -87,19 +120,72 @@ export class InternalHooksClass implements IInternalHooksClass {
}
if (properties.is_manual) {
const nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow);
nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
properties.node_graph = nodeGraphResult.nodeGraph;
properties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph);
if (errorNodeName) {
properties.error_node_id = nodeGraphResult.nameIndices[errorNodeName];
}
}
}
if (properties.is_manual) {
if (!nodeGraphResult) {
nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
}
const manualExecEventProperties = {
workflow_id: workflow.id,
status: properties.success ? 'success' : 'failed',
error_message: properties.error_message,
error_node_type: properties.error_node_type,
node_graph: properties.node_graph,
node_graph_string: properties.node_graph_string,
error_node_id: properties.error_node_id,
};
if (!manualExecEventProperties.node_graph) {
nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
manualExecEventProperties.node_graph = nodeGraphResult.nodeGraph;
manualExecEventProperties.node_graph_string = JSON.stringify(
manualExecEventProperties.node_graph,
);
}
if (runData.data.startData?.destinationNode) {
promises.push(
this.telemetry.track('Manual node exec finished', {
...manualExecEventProperties,
node_type: TelemetryHelpers.getNodeTypeForName(
workflow,
runData.data.startData?.destinationNode,
)?.type,
node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode],
}),
);
} else {
promises.push(
this.telemetry.track('Manual workflow exec finished', manualExecEventProperties),
);
}
}
}
void this.telemetry.trackWorkflowExecution(properties);
return Promise.all([
...promises,
BinaryDataManager.getInstance().persistBinaryDataForExecutionId(executionId),
this.telemetry.trackWorkflowExecution(properties),
]).then(() => {});
}
async onN8nStop(): Promise<void> {
await this.telemetry.trackN8nStop();
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, 3000);
});
return Promise.race([timeoutPromise, this.telemetry.trackN8nStop()]);
}
}

View file

@ -1,4 +1,5 @@
/* eslint-disable import/no-cycle */
import { INodeTypes } from 'n8n-workflow';
import { InternalHooksClass } from './InternalHooks';
import { Telemetry } from './telemetry';
@ -13,9 +14,13 @@ export class InternalHooksManager {
throw new Error('InternalHooks not initialized');
}
static init(instanceId: string): InternalHooksClass {
static init(instanceId: string, versionCli: string, nodeTypes: INodeTypes): InternalHooksClass {
if (!this.internalHooksInstance) {
this.internalHooksInstance = new InternalHooksClass(new Telemetry(instanceId));
this.internalHooksInstance = new InternalHooksClass(
new Telemetry(instanceId, versionCli),
versionCli,
nodeTypes,
);
}
return this.internalHooksInstance;

View file

@ -5,6 +5,7 @@
import {
INodeType,
INodeTypeData,
INodeTypeDescription,
INodeTypes,
INodeVersionedType,
NodeHelpers,
@ -18,7 +19,7 @@ class NodeTypesClass implements INodeTypes {
// polling nodes the polling times
// eslint-disable-next-line no-restricted-syntax
for (const nodeTypeData of Object.values(nodeTypes)) {
const nodeType = NodeHelpers.getVersionedTypeNode(nodeTypeData.type);
const nodeType = NodeHelpers.getVersionedNodeType(nodeTypeData.type);
const applyParameters = NodeHelpers.getSpecialNodeParameters(nodeType);
if (applyParameters.length) {
@ -39,8 +40,29 @@ class NodeTypesClass implements INodeTypes {
return this.nodeTypes[nodeType].type;
}
/**
* Variant of `getByNameAndVersion` that includes the node's source path, used to locate a node's translations.
*/
getWithSourcePath(
nodeTypeName: string,
version: number,
): { description: INodeTypeDescription } & { sourcePath: string } {
const nodeType = this.nodeTypes[nodeTypeName];
if (!nodeType) {
throw new Error(`Unknown node type: ${nodeTypeName}`);
}
const { description } = NodeHelpers.getVersionedNodeType(nodeType.type, version);
return { description: { ...description }, sourcePath: nodeType.sourcePath };
}
getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedTypeNode(this.nodeTypes[nodeType].type, version);
if (this.nodeTypes[nodeType] === undefined) {
throw new Error(`The node-type "${nodeType}" is not known!`);
}
return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version);
}
}

View file

@ -1,12 +1,21 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import * as Bull from 'bull';
import * as config from '../config';
// eslint-disable-next-line import/no-cycle
import { IBullJobData } from './Interfaces';
import { IBullJobData, IBullWebhookResponse } from './Interfaces';
// eslint-disable-next-line import/no-cycle
import * as ActiveExecutions from './ActiveExecutions';
// eslint-disable-next-line import/no-cycle
import * as WebhookHelpers from './WebhookHelpers';
export class Queue {
private activeExecutions: ActiveExecutions.ActiveExecutions;
private jobQueue: Bull.Queue;
constructor() {
this.activeExecutions = ActiveExecutions.getInstance();
const prefix = config.get('queue.bull.prefix') as string;
const redisOptions = config.get('queue.bull.redis') as object;
// Disabling ready check is necessary as it allows worker to
@ -16,6 +25,14 @@ export class Queue {
// More here: https://github.com/OptimalBits/bull/issues/890
// @ts-ignore
this.jobQueue = new Bull('jobs', { prefix, redis: redisOptions, enableReadyCheck: false });
this.jobQueue.on('global:progress', (jobId, progress: IBullWebhookResponse) => {
this.activeExecutions.resolveResponsePromise(
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
progress.executionId,
WebhookHelpers.decodeWebhookResponse(progress.response),
);
});
}
async add(jobData: IBullJobData, jobOptions: object): Promise<Bull.Job> {

View file

@ -72,11 +72,16 @@ export function sendSuccessResponse(
data: any,
raw?: boolean,
responseCode?: number,
responseHeader?: object,
) {
if (responseCode !== undefined) {
res.status(responseCode);
}
if (responseHeader) {
res.header(responseHeader);
}
if (raw === true) {
if (typeof data === 'string') {
res.send(data);

View file

@ -24,19 +24,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable no-restricted-syntax */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable import/no-dynamic-require */
/* eslint-disable no-await-in-loop */
import * as express from 'express';
import { readFileSync } from 'fs';
import { readFileSync, existsSync } from 'fs';
import { readFile } from 'fs/promises';
import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'path';
import {
FindManyOptions,
FindOneOptions,
getConnectionManager,
In,
IsNull,
LessThanOrEqual,
Like,
Not,
} from 'typeorm';
import { FindManyOptions, getConnectionManager, In, IsNull, LessThanOrEqual, Not } from 'typeorm';
import * as bodyParser from 'body-parser';
import * as history from 'connect-history-api-fallback';
import * as os from 'os';
@ -47,14 +42,16 @@ import * as clientOAuth1 from 'oauth-1.0a';
import { RequestOptions } from 'oauth-1.0a';
import * as csrf from 'csrf';
import * as requestPromise from 'request-promise-native';
import { createHash, createHmac } from 'crypto';
import { createHmac } from 'crypto';
// IMPORTANT! Do not switch to anther bcrypt library unless really necessary and
// tested with all possible systems like Windows, Alpine on ARM, FreeBSD, ...
import { compare } from 'bcryptjs';
import * as promClient from 'prom-client';
import {
BinaryDataManager,
Credentials,
IBinaryDataConfig,
ICredentialTestFunctions,
LoadNodeParameterOptions,
NodeExecuteFunctions,
@ -63,7 +60,6 @@ import {
import {
ICredentialsDecrypted,
ICredentialsEncrypted,
ICredentialType,
IDataObject,
INodeCredentials,
@ -73,17 +69,15 @@ import {
INodeType,
INodeTypeDescription,
INodeTypeNameVersion,
IRunData,
INodeVersionedType,
ITelemetryClientConfig,
ITelemetrySettings,
IWorkflowBase,
IWorkflowCredentials,
LoggerProxy,
NodeCredentialTestRequest,
NodeCredentialTestResult,
NodeHelpers,
Workflow,
ICredentialsEncrypted,
WorkflowExecuteMode,
} from 'n8n-workflow';
@ -134,7 +128,6 @@ import {
IWorkflowExecutionDataProcess,
IWorkflowResponse,
IPersonalizationSurveyAnswers,
LoadNodesAndCredentials,
NodeTypes,
Push,
ResponseHelper,
@ -157,6 +150,7 @@ import { InternalHooksManager } from './InternalHooksManager';
import { TagEntity } from './databases/entities/TagEntity';
import { WorkflowEntity } from './databases/entities/WorkflowEntity';
import { NameRequest } from './WorkflowHelpers';
import { getCredentialTranslationPath, getNodeTranslationPath } from './TranslationHelpers';
require('body-parser-xml')(bodyParser);
@ -293,6 +287,7 @@ class App {
personalizationSurvey: {
shouldShow: false,
},
defaultLocale: config.get('defaultLocale'),
};
}
@ -325,8 +320,6 @@ class App {
this.frontendSettings.personalizationSurvey =
await PersonalizationSurvey.preparePersonalizationSurvey();
InternalHooksManager.init(this.frontendSettings.instanceId);
await this.externalHooks.run('frontend.settings', [this.frontendSettings]);
const excludeEndpoints = config.get('security.excludeEndpoints') as string;
@ -694,6 +687,7 @@ class App {
// @ts-ignore
savedWorkflow.id = savedWorkflow.id.toString();
await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]);
void InternalHooksManager.getInstance().onWorkflowCreated(newWorkflow as IWorkflowBase);
return savedWorkflow;
},
@ -897,7 +891,7 @@ class App {
}
await this.externalHooks.run('workflow.afterUpdate', [workflow]);
void InternalHooksManager.getInstance().onWorkflowSaved(workflow as IWorkflowBase);
void InternalHooksManager.getInstance().onWorkflowSaved(workflow);
if (workflow.active) {
// When the workflow is supposed to be active add it again
@ -1165,13 +1159,13 @@ class App {
if (onlyLatest) {
allNodes.forEach((nodeData) => {
const nodeType = NodeHelpers.getVersionedTypeNode(nodeData);
const nodeType = NodeHelpers.getVersionedNodeType(nodeData);
const nodeInfo: INodeTypeDescription = getNodeDescription(nodeType);
returnData.push(nodeInfo);
});
} else {
allNodes.forEach((nodeData) => {
const allNodeTypes = NodeHelpers.getVersionedTypeNodeAll(nodeData);
const allNodeTypes = NodeHelpers.getVersionedNodeTypeAll(nodeData);
allNodeTypes.forEach((element) => {
const nodeInfo: INodeTypeDescription = getNodeDescription(element);
returnData.push(nodeInfo);
@ -1184,23 +1178,91 @@ class App {
),
);
this.app.get(
`/${this.restEndpoint}/credential-translation`,
ResponseHelper.send(
async (
req: express.Request & { query: { credentialType: string } },
res: express.Response,
): Promise<object | null> => {
const translationPath = getCredentialTranslationPath({
locale: this.frontendSettings.defaultLocale,
credentialType: req.query.credentialType,
});
try {
return require(translationPath);
} catch (error) {
return null;
}
},
),
);
// Returns node information based on node names and versions
this.app.post(
`/${this.restEndpoint}/node-types`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<INodeTypeDescription[]> => {
const nodeInfos = _.get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[];
const nodeTypes = NodeTypes();
const returnData: INodeTypeDescription[] = [];
nodeInfos.forEach((nodeInfo) => {
const nodeType = nodeTypes.getByNameAndVersion(nodeInfo.name, nodeInfo.version);
if (nodeType?.description) {
returnData.push(nodeType.description);
const { defaultLocale } = this.frontendSettings;
if (defaultLocale === 'en') {
return nodeInfos.reduce<INodeTypeDescription[]>((acc, { name, version }) => {
const { description } = NodeTypes().getByNameAndVersion(name, version);
acc.push(description);
return acc;
}, []);
}
async function populateTranslation(
name: string,
version: number,
nodeTypes: INodeTypeDescription[],
) {
const { description, sourcePath } = NodeTypes().getWithSourcePath(name, version);
const translationPath = await getNodeTranslationPath({
nodeSourcePath: sourcePath,
longNodeType: description.name,
locale: defaultLocale,
});
try {
const translation = await readFile(translationPath, 'utf8');
description.translation = JSON.parse(translation);
} catch (error) {
// ignore - no translation exists at path
}
});
return returnData;
nodeTypes.push(description);
}
const nodeTypes: INodeTypeDescription[] = [];
const promises = nodeInfos.map(async ({ name, version }) =>
populateTranslation(name, version, nodeTypes),
);
await Promise.all(promises);
return nodeTypes;
},
),
);
// Returns node information based on node names and versions
this.app.get(
`/${this.restEndpoint}/node-translation-headers`,
ResponseHelper.send(
async (req: express.Request, res: express.Response): Promise<object | void> => {
const packagesPath = pathJoin(__dirname, '..', '..', '..');
const headersPath = pathJoin(packagesPath, 'nodes-base', 'dist', 'nodes', 'headers');
try {
return require(headersPath);
} catch (error) {
res.status(500).send('Failed to find headers file');
}
},
),
);
@ -1399,7 +1461,7 @@ class App {
const testFunctionSearch =
credential.name === credentialType && !!credential.testedBy;
if (testFunctionSearch) {
foundTestFunction = (node as unknown as INodeType).methods!.credentialTest![
foundTestFunction = (nodeType as unknown as INodeType).methods!.credentialTest![
credential.testedBy!
];
}
@ -1593,12 +1655,13 @@ class App {
async (req: express.Request, res: express.Response): Promise<ICredentialsResponse[]> => {
const findQuery = {} as FindManyOptions;
if (req.query.filter) {
findQuery.where = JSON.parse(req.query.filter as string);
if ((findQuery.where! as IDataObject).id !== undefined) {
findQuery.where = JSON.parse(req.query.filter as string) as IDataObject;
if (findQuery.where.id !== undefined) {
// No idea if multiple where parameters make db search
// slower but to be sure that that is not the case we
// remove all unnecessary fields in case the id is defined.
findQuery.where = { id: (findQuery.where! as IDataObject).id };
// @ts-ignore
findQuery.where = { id: findQuery.where.id };
}
}
@ -1832,12 +1895,6 @@ class App {
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
// Decrypt the currently saved credentials
const workflowCredentials: IWorkflowCredentials = {
[result.type]: {
[result.id.toString()]: result as ICredentialsEncrypted,
},
};
const mode: WorkflowExecuteMode = 'internal';
const credentialsHelper = new CredentialsHelper(encryptionKey);
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
@ -2056,13 +2113,6 @@ class App {
return ResponseHelper.sendErrorResponse(res, errorResponse);
}
// Decrypt the currently saved credentials
const workflowCredentials: IWorkflowCredentials = {
[result.type]: {
[result.id.toString()]: result as ICredentialsEncrypted,
},
};
const mode: WorkflowExecuteMode = 'internal';
const credentialsHelper = new CredentialsHelper(encryptionKey);
const decryptedDataOriginal = await credentialsHelper.getDecrypted(
@ -2426,12 +2476,27 @@ class App {
const filters = {
startedAt: LessThanOrEqual(deleteData.deleteBefore),
};
if (deleteData.filters !== undefined) {
Object.assign(filters, deleteData.filters);
}
const execs = await Db.collections.Execution!.find({ ...filters, select: ['id'] });
await Promise.all(
execs.map(async (item) =>
BinaryDataManager.getInstance().deleteBinaryDataByExecutionId(item.id.toString()),
),
);
await Db.collections.Execution!.delete(filters);
} else if (deleteData.ids !== undefined) {
await Promise.all(
deleteData.ids.map(async (id) =>
BinaryDataManager.getInstance().deleteBinaryDataByExecutionId(id),
),
);
// Deletes all executions with the given ids
await Db.collections.Execution!.delete(deleteData.ids);
} else {
@ -2627,6 +2692,23 @@ class App {
}),
);
// ----------------------------------------
// Binary data
// ----------------------------------------
// Returns binary buffer
this.app.get(
`/${this.restEndpoint}/data/:path`,
ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<string> => {
const dataPath = req.params.path;
return BinaryDataManager.getInstance()
.retrieveBinaryDataByIdentifier(dataPath)
.then((buffer: Buffer) => {
return buffer.toString('base64');
});
}),
);
// ----------------------------------------
// Settings
// ----------------------------------------
@ -2696,7 +2778,13 @@ class App {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -2747,7 +2835,13 @@ class App {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -2773,7 +2867,13 @@ class App {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -2795,16 +2895,10 @@ class App {
return;
}
const loadNodesAndCredentials = LoadNodesAndCredentials();
const credentialsOverwrites = CredentialsOverwrites();
await credentialsOverwrites.init(body);
const credentialTypes = CredentialTypes();
await credentialTypes.init(loadNodesAndCredentials.credentialTypes);
this.presetCredentialsLoaded = true;
ResponseHelper.sendSuccessResponse(res, { success: true }, true, 200);
@ -2815,36 +2909,38 @@ class App {
);
}
// Read the index file and replace the path placeholder
const editorUiPath = require.resolve('n8n-editor-ui');
const filePath = pathJoin(pathDirname(editorUiPath), 'dist', 'index.html');
const n8nPath = config.get('path');
if (config.get('endpoints.disableUi') !== true) {
// Read the index file and replace the path placeholder
const editorUiPath = require.resolve('n8n-editor-ui');
const filePath = pathJoin(pathDirname(editorUiPath), 'dist', 'index.html');
const n8nPath = config.get('path');
let readIndexFile = readFileSync(filePath, 'utf8');
readIndexFile = readIndexFile.replace(/\/%BASE_PATH%\//g, n8nPath);
readIndexFile = readIndexFile.replace(/\/favicon.ico/g, `${n8nPath}favicon.ico`);
let readIndexFile = readFileSync(filePath, 'utf8');
readIndexFile = readIndexFile.replace(/\/%BASE_PATH%\//g, n8nPath);
readIndexFile = readIndexFile.replace(/\/favicon.ico/g, `${n8nPath}favicon.ico`);
// Serve the altered index.html file separately
this.app.get(`/index.html`, async (req: express.Request, res: express.Response) => {
res.send(readIndexFile);
});
// Serve the altered index.html file separately
this.app.get(`/index.html`, async (req: express.Request, res: express.Response) => {
res.send(readIndexFile);
});
// Serve the website
// Serve the website
this.app.use(
'/',
express.static(pathJoin(pathDirname(editorUiPath), 'dist'), {
index: 'index.html',
setHeaders: (res, path) => {
if (res.req && res.req.url === '/index.html') {
// Set last modified date manually to n8n start time so
// that it hopefully refreshes the page when a new version
// got used
res.setHeader('Last-Modified', startTime);
}
},
}),
);
}
const startTime = new Date().toUTCString();
this.app.use(
'/',
express.static(pathJoin(pathDirname(editorUiPath), 'dist'), {
index: 'index.html',
setHeaders: (res, path) => {
if (res.req && res.req.url === '/index.html') {
// Set last modified date manually to n8n start time so
// that it hopefully refreshes the page when a new version
// got used
res.setHeader('Last-Modified', startTime);
}
},
}),
);
}
}
@ -2874,8 +2970,15 @@ export async function start(): Promise<void> {
console.log(`n8n ready on ${ADDRESS}, port ${PORT}`);
console.log(`Version: ${versions.cli}`);
const defaultLocale = config.get('defaultLocale');
if (defaultLocale !== 'en') {
console.log(`Locale: ${defaultLocale}`);
}
await app.externalHooks.run('n8n.ready', [app]);
const cpus = os.cpus();
const binarDataConfig = config.get('binaryDataManager') as IBinaryDataConfig;
const diagnosticInfo: IDiagnosticInfo = {
basicAuthActive: config.get('security.basicAuth.active') as boolean,
databaseType: (await GenericHelpers.getConfigValue('database.type')) as DatabaseType,
@ -2909,9 +3012,26 @@ export async function start(): Promise<void> {
executions_data_prune_timeout: config.get('executions.pruneDataTimeout'),
},
deploymentType: config.get('deployment.type'),
binaryDataMode: binarDataConfig.mode,
};
void InternalHooksManager.getInstance().onServerStarted(diagnosticInfo);
void Db.collections
.Workflow!.findOne({
select: ['createdAt'],
order: { createdAt: 'ASC' },
})
.then(async (workflow) =>
InternalHooksManager.getInstance().onServerStarted(diagnosticInfo, workflow?.createdAt),
);
});
server.on('error', (error: Error & { code: string }) => {
if (error.code === 'EADDRINUSE') {
console.log(
`n8n's port ${PORT} is already in use. Do you have another instance of n8n running already?`,
);
process.exit(1);
}
});
}

View file

@ -0,0 +1,64 @@
import { join, dirname } from 'path';
import { readdir } from 'fs/promises';
import { Dirent } from 'fs';
const ALLOWED_VERSIONED_DIRNAME_LENGTH = [2, 3]; // e.g. v1, v10
function isVersionedDirname(dirent: Dirent) {
if (!dirent.isDirectory()) return false;
return (
ALLOWED_VERSIONED_DIRNAME_LENGTH.includes(dirent.name.length) &&
dirent.name.toLowerCase().startsWith('v')
);
}
async function getMaxVersion(from: string) {
const entries = await readdir(from, { withFileTypes: true });
const dirnames = entries.reduce<string[]>((acc, cur) => {
if (isVersionedDirname(cur)) acc.push(cur.name);
return acc;
}, []);
if (!dirnames.length) return null;
return Math.max(...dirnames.map((d) => parseInt(d.charAt(1), 10)));
}
/**
* Get the full path to a node translation file in `/dist`.
*/
export async function getNodeTranslationPath({
nodeSourcePath,
longNodeType,
locale,
}: {
nodeSourcePath: string;
longNodeType: string;
locale: string;
}): Promise<string> {
const nodeDir = dirname(nodeSourcePath);
const maxVersion = await getMaxVersion(nodeDir);
const nodeType = longNodeType.replace('n8n-nodes-base.', '');
return maxVersion
? join(nodeDir, `v${maxVersion}`, 'translations', locale, `${nodeType}.json`)
: join(nodeDir, 'translations', locale, `${nodeType}.json`);
}
/**
* Get the full path to a credential translation file in `/dist`.
*/
export function getCredentialTranslationPath({
locale,
credentialType,
}: {
locale: string;
credentialType: string;
}): string {
const packagesPath = join(__dirname, '..', '..', '..');
const credsPath = join(packagesPath, 'nodes-base', 'dist', 'credentials');
return join(credsPath, 'translations', locale, `${credentialType}.json`);
}

View file

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/prefer-optional-chain */
/* eslint-disable @typescript-eslint/no-shadow */
@ -15,12 +16,16 @@ import * as express from 'express';
// eslint-disable-next-line import/no-extraneous-dependencies
import { get } from 'lodash';
import { BINARY_ENCODING, NodeExecuteFunctions } from 'n8n-core';
import { BINARY_ENCODING, BinaryDataManager, NodeExecuteFunctions } from 'n8n-core';
import {
createDeferredPromise,
IBinaryKeyData,
IDataObject,
IDeferredPromise,
IExecuteData,
IExecuteResponsePromiseData,
IN8nHttpFullResponse,
INode,
IRunExecutionData,
IWebhookData,
@ -32,22 +37,23 @@ import {
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
// eslint-disable-next-line import/no-cycle
import {
ActiveExecutions,
GenericHelpers,
IExecutionDb,
IResponseCallbackData,
IWorkflowDb,
IWorkflowExecutionDataProcess,
ResponseHelper,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
WorkflowCredentials,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
WorkflowRunner,
} from '.';
// eslint-disable-next-line import/no-cycle
import * as ActiveExecutions from './ActiveExecutions';
const activeExecutions = ActiveExecutions.getInstance();
/**
@ -91,6 +97,35 @@ export function getWorkflowWebhooks(
return returnData;
}
export function decodeWebhookResponse(
response: IExecuteResponsePromiseData,
): IExecuteResponsePromiseData {
if (
typeof response === 'object' &&
typeof response.body === 'object' &&
(response.body as IDataObject)['__@N8nEncodedBuffer@__']
) {
response.body = Buffer.from(
(response.body as IDataObject)['__@N8nEncodedBuffer@__'] as string,
BINARY_ENCODING,
);
}
return response;
}
export function encodeWebhookResponse(
response: IExecuteResponsePromiseData,
): IExecuteResponsePromiseData {
if (typeof response === 'object' && Buffer.isBuffer(response.body)) {
response.body = {
'__@N8nEncodedBuffer@__': response.body.toString(BINARY_ENCODING),
};
}
return response;
}
/**
* Returns all the webhooks which should be created for the give workflow
*
@ -169,7 +204,7 @@ export async function executeWebhook(
200,
) as number;
if (!['onReceived', 'lastNode'].includes(responseMode as string)) {
if (!['onReceived', 'lastNode', 'responseNode'].includes(responseMode as string)) {
// If the mode is not known we error. Is probably best like that instead of using
// the default that people know as early as possible (probably already testing phase)
// that something does not resolve properly.
@ -356,9 +391,52 @@ export async function executeWebhook(
workflowData,
};
let responsePromise: IDeferredPromise<IN8nHttpFullResponse> | undefined;
if (responseMode === 'responseNode') {
responsePromise = await createDeferredPromise<IN8nHttpFullResponse>();
responsePromise
.promise()
.then((response: IN8nHttpFullResponse) => {
if (didSendResponse) {
return;
}
if (Buffer.isBuffer(response.body)) {
res.header(response.headers);
res.end(response.body);
responseCallback(null, {
noWebhookResponse: true,
});
} else {
// TODO: This probably needs some more changes depending on the options on the
// Webhook Response node
responseCallback(null, {
data: response.body as IDataObject,
headers: response.headers,
responseCode: response.statusCode,
});
}
didSendResponse = true;
})
.catch(async (error) => {
Logger.error(
`Error with Webhook-Response for execution "${executionId}": "${error.message}"`,
{ executionId, workflowId: workflow.id },
);
});
}
// Start now to run the workflow
const workflowRunner = new WorkflowRunner();
executionId = await workflowRunner.run(runData, true, !didSendResponse, executionId);
executionId = await workflowRunner.run(
runData,
true,
!didSendResponse,
executionId,
responsePromise,
);
Logger.verbose(
`Started execution of workflow "${workflow.name}" from webhook with execution ID ${executionId}`,
@ -370,7 +448,7 @@ export async function executeWebhook(
IExecutionDb | undefined
>;
executePromise
.then((data) => {
.then(async (data) => {
if (data === undefined) {
if (!didSendResponse) {
responseCallback(null, {
@ -398,6 +476,20 @@ export async function executeWebhook(
return data;
}
if (responseMode === 'responseNode') {
if (!didSendResponse) {
// Return an error if no Webhook-Response node did send any data
responseCallback(null, {
data: {
message: 'Workflow executed sucessfully.',
},
responseCode,
});
didSendResponse = true;
}
return undefined;
}
if (returnData === undefined) {
if (!didSendResponse) {
responseCallback(null, {
@ -520,7 +612,10 @@ export async function executeWebhook(
if (!didSendResponse) {
// Send the webhook response manually
res.setHeader('Content-Type', binaryData.mimeType);
res.end(Buffer.from(binaryData.data, BINARY_ENCODING));
const binaryDataBuffer = await BinaryDataManager.getInstance().retrieveBinaryData(
binaryData,
);
res.end(binaryDataBuffer);
responseCallback(null, {
noWebhookResponse: true,

View file

@ -64,7 +64,13 @@ export function registerProductionWebhooks() {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -115,7 +121,13 @@ export function registerProductionWebhooks() {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -141,7 +153,13 @@ export function registerProductionWebhooks() {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -173,7 +191,13 @@ export function registerProductionWebhooks() {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -199,7 +223,13 @@ export function registerProductionWebhooks() {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
@ -225,7 +255,13 @@ export function registerProductionWebhooks() {
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true, response.responseCode);
ResponseHelper.sendSuccessResponse(
res,
response.data,
true,
response.responseCode,
response.headers,
);
},
);
}

View file

@ -14,7 +14,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable func-names */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { UserSettings, WorkflowExecute } from 'n8n-core';
import { BinaryDataManager, UserSettings, WorkflowExecute } from 'n8n-core';
import {
IDataObject,
@ -481,8 +481,11 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
if (isManualMode && !saveManualExecutions && !fullRunData.waitTill) {
// Data is always saved, so we remove from database
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await Db.collections.Execution!.delete(this.executionId);
await BinaryDataManager.getInstance().markDataForDeletionByExecutionId(
this.executionId,
);
return;
}
@ -509,12 +512,16 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
this.workflowData,
fullRunData,
this.mode,
undefined,
this.executionId,
this.retryOf,
);
}
// Data is always saved, so we remove from database
await Db.collections.Execution!.delete(this.executionId);
await BinaryDataManager.getInstance().markDataForDeletionByExecutionId(
this.executionId,
);
return;
}
}
@ -585,7 +592,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
this.workflowData,
fullRunData,
this.mode,
undefined,
this.executionId,
this.retryOf,
);
}
@ -635,7 +642,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
this.workflowData,
fullRunData,
this.mode,
undefined,
this.executionId,
this.retryOf,
);
}
@ -676,7 +683,13 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
});
}
} catch (error) {
executeErrorWorkflow(this.workflowData, fullRunData, this.mode, undefined, this.retryOf);
executeErrorWorkflow(
this.workflowData,
fullRunData,
this.mode,
this.executionId,
this.retryOf,
);
}
},
],
@ -830,6 +843,8 @@ export async function executeWorkflow(
workflowData,
{ parentProcessMode: additionalData.hooks!.mode },
);
additionalDataIntegrated.executionId = executionId;
// Make sure we pass on the original executeWorkflow function we received
// This one already contains changes to talk to parent process
// and get executionID from `activeExecutions` running on main process
@ -903,8 +918,8 @@ export async function executeWorkflow(
};
}
await externalHooks.run('workflow.postExecute', [data, workflowData]);
void InternalHooksManager.getInstance().onWorkflowPostExecute(workflowData, data);
await externalHooks.run('workflow.postExecute', [data, workflowData, executionId]);
void InternalHooksManager.getInstance().onWorkflowPostExecute(executionId, workflowData, data);
if (data.finished === true) {
// Workflow did finish successfully
@ -924,7 +939,7 @@ export async function executeWorkflow(
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function sendMessageToUI(source: string, message: any) {
export function sendMessageToUI(source: string, messages: any[]) {
if (this.sessionId === undefined) {
return;
}
@ -936,7 +951,7 @@ export function sendMessageToUI(source: string, message: any) {
'sendConsoleMessage',
{
source: `Node: "${source}"`,
message,
messages,
},
this.sessionId,
);

View file

@ -11,10 +11,12 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable import/no-cycle */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { IProcessMessage, WorkflowExecute } from 'n8n-core';
import { BinaryDataManager, IProcessMessage, WorkflowExecute } from 'n8n-core';
import {
ExecutionError,
IDeferredPromise,
IExecuteResponsePromiseData,
IRun,
LoggerProxy as Logger,
Workflow,
@ -41,9 +43,7 @@ import {
IBullJobResponse,
ICredentialsOverwrite,
ICredentialsTypeData,
IExecutionDb,
IExecutionFlattedDb,
IExecutionResponse,
IProcessMessageDataHook,
ITransferNodeTypes,
IWorkflowExecutionDataProcess,
@ -51,6 +51,7 @@ import {
NodeTypes,
Push,
ResponseHelper,
WebhookHelpers,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
} from '.';
@ -146,6 +147,7 @@ export class WorkflowRunner {
loadStaticData?: boolean,
realtime?: boolean,
executionId?: string,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<string> {
const executionsProcess = config.get('executions.process') as string;
const executionsMode = config.get('executions.mode') as string;
@ -153,11 +155,17 @@ export class WorkflowRunner {
if (executionsMode === 'queue' && data.executionMode !== 'manual') {
// Do not run "manual" executions in bull because sending events to the
// frontend would not be possible
executionId = await this.runBull(data, loadStaticData, realtime, executionId);
executionId = await this.runBull(
data,
loadStaticData,
realtime,
executionId,
responsePromise,
);
} else if (executionsProcess === 'main') {
executionId = await this.runMainProcess(data, loadStaticData, executionId);
executionId = await this.runMainProcess(data, loadStaticData, executionId, responsePromise);
} else {
executionId = await this.runSubprocess(data, loadStaticData, executionId);
executionId = await this.runSubprocess(data, loadStaticData, executionId, responsePromise);
}
const postExecutePromise = this.activeExecutions.getPostExecutePromise(executionId);
@ -166,6 +174,7 @@ export class WorkflowRunner {
postExecutePromise
.then(async (executionData) => {
void InternalHooksManager.getInstance().onWorkflowPostExecute(
executionId!,
data.workflowData,
executionData,
);
@ -177,7 +186,11 @@ export class WorkflowRunner {
if (externalHooks.exists('workflow.postExecute')) {
postExecutePromise
.then(async (executionData) => {
await externalHooks.run('workflow.postExecute', [executionData, data.workflowData]);
await externalHooks.run('workflow.postExecute', [
executionData,
data.workflowData,
executionId,
]);
})
.catch((error) => {
console.error('There was a problem running hook "workflow.postExecute"', error);
@ -200,6 +213,7 @@ export class WorkflowRunner {
data: IWorkflowExecutionDataProcess,
loadStaticData?: boolean,
restartExecutionId?: string,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<string> {
if (loadStaticData === true && data.workflowData.id) {
data.workflowData.staticData = await WorkflowHelpers.getStaticDataById(
@ -256,6 +270,15 @@ export class WorkflowRunner {
executionId,
true,
);
additionalData.hooks.hookFunctions.sendResponse = [
async (response: IExecuteResponsePromiseData): Promise<void> => {
if (responsePromise) {
responsePromise.resolve(response);
}
},
];
additionalData.sendMessageToUI = WorkflowExecuteAdditionalData.sendMessageToUI.bind({
sessionId: data.sessionId,
});
@ -341,11 +364,15 @@ export class WorkflowRunner {
loadStaticData?: boolean,
realtime?: boolean,
restartExecutionId?: string,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<string> {
// TODO: If "loadStaticData" is set to true it has to load data new on worker
// Register the active execution
const executionId = await this.activeExecutions.add(data, undefined, restartExecutionId);
if (responsePromise) {
this.activeExecutions.attachResponsePromise(executionId, responsePromise);
}
const jobData: IBullJobData = {
executionId,
@ -517,6 +544,7 @@ export class WorkflowRunner {
(!workflowDidSucceed && saveDataErrorExecution === 'none')
) {
await Db.collections.Execution!.delete(executionId);
await BinaryDataManager.getInstance().markDataForDeletionByExecutionId(executionId);
}
// eslint-disable-next-line id-denylist
} catch (err) {
@ -545,6 +573,7 @@ export class WorkflowRunner {
data: IWorkflowExecutionDataProcess,
loadStaticData?: boolean,
restartExecutionId?: string,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<string> {
let startedAt = new Date();
const subprocess = fork(pathJoin(__dirname, 'WorkflowRunnerProcess.js'));
@ -653,6 +682,10 @@ export class WorkflowRunner {
} else if (message.type === 'end') {
clearTimeout(executionTimeout);
this.activeExecutions.remove(executionId, message.data.runData);
} else if (message.type === 'sendResponse') {
if (responsePromise) {
responsePromise.resolve(WebhookHelpers.decodeWebhookResponse(message.data.response));
}
} else if (message.type === 'sendMessageToUI') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
WorkflowExecuteAdditionalData.sendMessageToUI.bind({ sessionId: data.sessionId })(

View file

@ -5,11 +5,18 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-use-before-define */
/* eslint-disable @typescript-eslint/unbound-method */
import { IProcessMessage, UserSettings, WorkflowExecute } from 'n8n-core';
import {
BinaryDataManager,
IBinaryDataConfig,
IProcessMessage,
UserSettings,
WorkflowExecute,
} from 'n8n-core';
import {
ExecutionError,
IDataObject,
IExecuteResponsePromiseData,
IExecuteWorkflowInfo,
ILogger,
INodeExecutionData,
@ -30,9 +37,11 @@ import {
CredentialTypes,
Db,
ExternalHooks,
GenericHelpers,
IWorkflowExecuteProcess,
IWorkflowExecutionDataProcessWithExecution,
NodeTypes,
WebhookHelpers,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
} from '.';
@ -135,7 +144,11 @@ export class WorkflowRunnerProcess {
await externalHooks.init();
const instanceId = (await UserSettings.prepareUserSettings()).instanceId ?? '';
InternalHooksManager.init(instanceId);
const { cli } = await GenericHelpers.getVersions();
InternalHooksManager.init(instanceId, cli, nodeTypes);
const binaryDataConfig = config.get('binaryDataManager') as IBinaryDataConfig;
await BinaryDataManager.init(binaryDataConfig);
// Credentials should now be loaded from database.
// We check if any node uses credentials. If it does, then
@ -200,6 +213,15 @@ export class WorkflowRunnerProcess {
workflowTimeout <= 0 ? undefined : Date.now() + workflowTimeout * 1000,
);
additionalData.hooks = this.getProcessForwardHooks();
additionalData.hooks.hookFunctions.sendResponse = [
async (response: IExecuteResponsePromiseData): Promise<void> => {
await sendToParentProcess('sendResponse', {
response: WebhookHelpers.encodeWebhookResponse(response),
});
},
];
additionalData.executionId = inputData.executionId;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -246,8 +268,12 @@ export class WorkflowRunnerProcess {
this.childExecutions[executionId] = executeWorkflowFunctionOutput;
const { workflow } = executeWorkflowFunctionOutput;
result = await workflowExecute.processRunExecutionData(workflow);
await externalHooks.run('workflow.postExecute', [result, workflowData]);
void InternalHooksManager.getInstance().onWorkflowPostExecute(workflowData, result);
await externalHooks.run('workflow.postExecute', [result, workflowData, executionId]);
void InternalHooksManager.getInstance().onWorkflowPostExecute(
executionId,
workflowData,
result,
);
await sendToParentProcess('finishExecution', { executionId, result });
delete this.childExecutions[executionId];
} catch (e) {

View file

@ -0,0 +1,39 @@
import { QueryRunner } from 'typeorm';
export class MigrationHelpers {
queryRunner: QueryRunner;
constructor(queryRunner: QueryRunner) {
this.queryRunner = queryRunner;
}
// runs an operation sequential on chunks of a query that returns a potentially large Array.
/* eslint-disable no-await-in-loop */
async runChunked(
query: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
operation: (results: any[]) => Promise<void>,
limit = 100,
): Promise<void> {
let offset = 0;
let chunkedQuery: string;
let chunkedQueryResults: unknown[];
do {
chunkedQuery = this.chunkQuery(query, limit, offset);
chunkedQueryResults = (await this.queryRunner.query(chunkedQuery)) as unknown[];
// pass a copy to prevent errors from mutation
await operation([...chunkedQueryResults]);
offset += limit;
} while (chunkedQueryResults.length === limit);
}
/* eslint-enable no-await-in-loop */
private chunkQuery(query: string, limit: number, offset = 0): string {
return `
${query}
LIMIT ${limit}
OFFSET ${offset}
`;
}
}

View file

@ -1,5 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config');
import { MigrationHelpers } from '../../MigrationHelpers';
// replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }`
@ -8,58 +9,100 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
name = 'UpdateWorkflowCredentials1630451444017';
public async up(queryRunner: QueryRunner): Promise<void> {
console.log('Start migration', this.name);
console.time(this.name);
const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM ${tablePrefix}credentials_entity
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, workflowData
FROM ${tablePrefix}execution_entity
WHERE waitTill IS NOT NULL AND finished = 0
`);
`;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, workflowData
@ -68,8 +111,8 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
ORDER BY startedAt DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
@ -78,7 +121,6 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
@ -92,77 +134,124 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
console.timeEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM ${tablePrefix}credentials_entity
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
credentialsUpdated = true;
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, workflowData
FROM ${tablePrefix}execution_entity
WHERE waitTill IS NOT NULL AND finished = 0
`);
`;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, workflowData
@ -171,8 +260,8 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
ORDER BY startedAt DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
@ -200,15 +289,15 @@ export class UpdateWorkflowCredentials1630451444017 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
UPDATE ${tablePrefix}execution_entity
SET workflowData = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
}

View file

@ -1,5 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config');
import { MigrationHelpers } from '../../MigrationHelpers';
// replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }`
@ -8,62 +9,104 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
name = 'UpdateWorkflowCredentials1630419189837';
public async up(queryRunner: QueryRunner): Promise<void> {
console.log('Start migration', this.name);
console.time(this.name);
let tablePrefix = config.get('database.tablePrefix');
const schema = config.get('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM ${tablePrefix}credentials_entity
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, "workflowData"
FROM ${tablePrefix}execution_entity
WHERE "waitTill" IS NOT NULL AND finished = FALSE
`);
`;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, "workflowData"
@ -73,7 +116,8 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
@ -104,9 +148,10 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
console.timeEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
@ -115,62 +160,109 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM ${tablePrefix}credentials_entity
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM ${tablePrefix}workflow_entity
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = workflow.nodes;
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
credentialsUpdated = true;
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}workflow_entity
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, "workflowData"
FROM ${tablePrefix}execution_entity
WHERE "waitTill" IS NOT NULL AND finished = FALSE
`);
`;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, "workflowData"
@ -179,8 +271,8 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
ORDER BY "startedAt" DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = execution.workflowData;
let credentialsUpdated = false;
// @ts-ignore
@ -208,15 +300,15 @@ export class UpdateWorkflowCredentials1630419189837 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE ${tablePrefix}execution_entity
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
UPDATE ${tablePrefix}execution_entity
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
}

View file

@ -1,5 +1,6 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config = require('../../../../config');
import { MigrationHelpers } from '../../MigrationHelpers';
// replacing the credentials in workflows and execution
// `nodeType: name` changes to `nodeType: { id, name }`
@ -8,58 +9,101 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
name = 'UpdateWorkflowCredentials1630330987096';
public async up(queryRunner: QueryRunner): Promise<void> {
console.log('Start migration', this.name);
console.time(this.name);
const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM "${tablePrefix}credentials_entity"
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM "${tablePrefix}workflow_entity"
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id || null, name };
credentialsUpdated = true;
await helpers.runChunked(workflowsQuery, (workflows) => {
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, "workflowData"
FROM "${tablePrefix}execution_entity"
WHERE "waitTill" IS NOT NULL AND finished = 0
`);
`;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
waitingExecutions.forEach(async (execution) => {
const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, "workflowData"
@ -68,8 +112,8 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
ORDER BY "startedAt" DESC
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false;
// @ts-ignore
@ -78,12 +122,11 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, name] of allNodeCredentials) {
if (typeof name === 'string') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.name === name && credentials.type === type,
);
node.credentials[type] = { id: matchingCredentials?.id || null, name };
node.credentials[type] = { id: matchingCredentials?.id.toString() || null, name };
credentialsUpdated = true;
}
}
@ -92,77 +135,127 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
console.timeEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
const helpers = new MigrationHelpers(queryRunner);
const credentialsEntities = await queryRunner.query(`
SELECT id, name, type
FROM "${tablePrefix}credentials_entity"
`);
const workflows = await queryRunner.query(`
const workflowsQuery = `
SELECT id, nodes
FROM "${tablePrefix}workflow_entity"
`);
`;
// @ts-ignore
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
let credentialsUpdated = false;
await helpers.runChunked(workflowsQuery, (workflows) => {
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
workflows.forEach(async (workflow) => {
const nodes = JSON.parse(workflow.nodes);
let credentialsUpdated = false;
// @ts-ignore
nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore double-equals because creds.id can be string or number
(credentials) => credentials.id == creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
credentialsUpdated = true;
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
queryRunner.query(updateQuery, updateParams);
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}workflow_entity"
SET nodes = :nodes
WHERE id = '${workflow.id}'
`,
{ nodes: JSON.stringify(nodes) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
const waitingExecutions = await queryRunner.query(`
const waitingExecutionsQuery = `
SELECT id, "workflowData"
FROM "${tablePrefix}execution_entity"
WHERE "waitTill" IS NOT NULL AND finished = 0
`);
`;
// @ts-ignore
await helpers.runChunked(waitingExecutionsQuery, (waitingExecutions) => {
// @ts-ignore
waitingExecutions.forEach(async (execution) => {
const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false;
// @ts-ignore
data.nodes.forEach((node) => {
if (node.credentials) {
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
const matchingCredentials = credentialsEntities.find(
// @ts-ignore double-equals because creds.id can be string or number
(credentials) => credentials.id == creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
} else {
// @ts-ignore
node.credentials[type] = creds.name;
}
credentialsUpdated = true;
}
}
}
});
if (credentialsUpdated) {
const [updateQuery, updateParams] =
queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
}
});
});
const retryableExecutions = await queryRunner.query(`
SELECT id, "workflowData"
@ -172,7 +265,8 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
LIMIT 200
`);
[...waitingExecutions, ...retryableExecutions].forEach(async (execution) => {
// @ts-ignore
retryableExecutions.forEach(async (execution) => {
const data = JSON.parse(execution.workflowData);
let credentialsUpdated = false;
// @ts-ignore
@ -181,10 +275,9 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
const allNodeCredentials = Object.entries(node.credentials);
for (const [type, creds] of allNodeCredentials) {
if (typeof creds === 'object') {
// @ts-ignore
const matchingCredentials = credentialsEntities.find(
// @ts-ignore
(credentials) => credentials.id === creds.id && credentials.type === type,
// @ts-ignore double-equals because creds.id can be string or number
(credentials) => credentials.id == creds.id && credentials.type === type,
);
if (matchingCredentials) {
node.credentials[type] = matchingCredentials.name;
@ -200,15 +293,15 @@ export class UpdateWorkflowCredentials1630330987096 implements MigrationInterfac
if (credentialsUpdated) {
const [updateQuery, updateParams] = queryRunner.connection.driver.escapeQueryWithParameters(
`
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
UPDATE "${tablePrefix}execution_entity"
SET "workflowData" = :data
WHERE id = '${execution.id}'
`,
{ data: JSON.stringify(data) },
{},
);
await queryRunner.query(updateQuery, updateParams);
queryRunner.query(updateQuery, updateParams);
}
});
}

View file

@ -5,28 +5,57 @@ import { IDataObject, LoggerProxy } from 'n8n-workflow';
import config = require('../../config');
import { getLogger } from '../Logger';
interface IExecutionCountsBufferItem {
manual_success_count: number;
manual_error_count: number;
prod_success_count: number;
prod_error_count: number;
}
type CountBufferItemKey =
| 'manual_success_count'
| 'manual_error_count'
| 'prod_success_count'
| 'prod_error_count';
type FirstExecutionItemKey =
| 'first_manual_success'
| 'first_manual_error'
| 'first_prod_success'
| 'first_prod_error';
type IExecutionCountsBufferItem = {
[key in CountBufferItemKey]: number;
};
interface IExecutionCountsBuffer {
[workflowId: string]: IExecutionCountsBufferItem;
}
type IFirstExecutions = {
[key in FirstExecutionItemKey]: Date | undefined;
};
interface IExecutionsBuffer {
counts: IExecutionCountsBuffer;
firstExecutions: IFirstExecutions;
}
export class Telemetry {
private client?: TelemetryClient;
private instanceId: string;
private versionCli: string;
private pulseIntervalReference: NodeJS.Timeout;
private executionCountsBuffer: IExecutionCountsBuffer = {};
private executionCountsBuffer: IExecutionsBuffer = {
counts: {},
firstExecutions: {
first_manual_error: undefined,
first_manual_success: undefined,
first_prod_error: undefined,
first_prod_success: undefined,
},
};
constructor(instanceId: string) {
constructor(instanceId: string, versionCli: string) {
this.instanceId = instanceId;
this.versionCli = versionCli;
const enabled = config.get('diagnostics.enabled') as boolean;
if (enabled) {
@ -53,33 +82,41 @@ export class Telemetry {
return Promise.resolve();
}
const allPromises = Object.keys(this.executionCountsBuffer).map(async (workflowId) => {
const allPromises = Object.keys(this.executionCountsBuffer.counts).map(async (workflowId) => {
const promise = this.track('Workflow execution count', {
version_cli: this.versionCli,
workflow_id: workflowId,
...this.executionCountsBuffer[workflowId],
...this.executionCountsBuffer.counts[workflowId],
...this.executionCountsBuffer.firstExecutions,
});
this.executionCountsBuffer[workflowId].manual_error_count = 0;
this.executionCountsBuffer[workflowId].manual_success_count = 0;
this.executionCountsBuffer[workflowId].prod_error_count = 0;
this.executionCountsBuffer[workflowId].prod_success_count = 0;
this.executionCountsBuffer.counts[workflowId].manual_error_count = 0;
this.executionCountsBuffer.counts[workflowId].manual_success_count = 0;
this.executionCountsBuffer.counts[workflowId].prod_error_count = 0;
this.executionCountsBuffer.counts[workflowId].prod_success_count = 0;
return promise;
});
allPromises.push(this.track('pulse'));
allPromises.push(this.track('pulse', { version_cli: this.versionCli }));
return Promise.all(allPromises);
}
async trackWorkflowExecution(properties: IDataObject): Promise<void> {
if (this.client) {
const workflowId = properties.workflow_id as string;
this.executionCountsBuffer[workflowId] = this.executionCountsBuffer[workflowId] ?? {
this.executionCountsBuffer.counts[workflowId] = this.executionCountsBuffer.counts[
workflowId
] ?? {
manual_error_count: 0,
manual_success_count: 0,
prod_error_count: 0,
prod_success_count: 0,
};
let countKey: CountBufferItemKey;
let firstExecKey: FirstExecutionItemKey;
if (
properties.success === false &&
properties.error_node_type &&
@ -89,15 +126,28 @@ export class Telemetry {
void this.track('Workflow execution errored', properties);
if (properties.is_manual) {
this.executionCountsBuffer[workflowId].manual_error_count++;
firstExecKey = 'first_manual_error';
countKey = 'manual_error_count';
} else {
this.executionCountsBuffer[workflowId].prod_error_count++;
firstExecKey = 'first_prod_error';
countKey = 'prod_error_count';
}
} else if (properties.is_manual) {
this.executionCountsBuffer[workflowId].manual_success_count++;
countKey = 'manual_success_count';
firstExecKey = 'first_manual_success';
} else {
this.executionCountsBuffer[workflowId].prod_success_count++;
countKey = 'prod_success_count';
firstExecKey = 'first_prod_success';
}
if (
!this.executionCountsBuffer.firstExecutions[firstExecKey] &&
this.executionCountsBuffer.counts[workflowId][countKey] === 0
) {
this.executionCountsBuffer.firstExecutions[firstExecKey] = new Date();
}
this.executionCountsBuffer.counts[workflowId][countKey]++;
}
}
@ -119,6 +169,7 @@ export class Telemetry {
this.client.identify(
{
userId: this.instanceId,
anonymousId: '000000000000',
traits: {
...traits,
instanceId: this.instanceId,
@ -138,6 +189,7 @@ export class Telemetry {
this.client.track(
{
userId: this.instanceId,
anonymousId: '000000000000',
event: eventName,
properties,
},

View file

@ -1,6 +1,6 @@
{
"name": "n8n-core",
"version": "0.89.0",
"version": "0.100.0",
"description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -33,7 +33,7 @@
"@types/jest": "^26.0.13",
"@types/lodash.get": "^4.4.6",
"@types/mime-types": "^2.1.0",
"@types/node": "^14.14.40",
"@types/node": "14.17.27",
"@types/request-promise-native": "~1.0.15",
"jest": "^26.4.2",
"source-map-support": "^0.5.9",
@ -47,15 +47,17 @@
"cron": "~1.7.2",
"crypto-js": "~4.1.1",
"file-type": "^14.6.2",
"flatted": "^3.2.4",
"form-data": "^4.0.0",
"lodash.get": "^4.4.2",
"mime-types": "^2.1.27",
"n8n-workflow": "~0.72.0",
"n8n-workflow": "~0.82.0",
"oauth-1.0a": "^2.2.6",
"p-cancelable": "^2.0.0",
"qs": "^6.10.1",
"request": "^2.88.2",
"request-promise-native": "^1.0.7"
"request-promise-native": "^1.0.7",
"uuid": "^8.3.2"
},
"jest": {
"transform": {

View file

@ -194,7 +194,7 @@ export class ActiveWorkflows {
// The trigger function to execute when the cron-time got reached
const executeTrigger = async () => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
Logger.info(`Polling trigger initiated for workflow "${workflow.name}"`, {
Logger.debug(`Polling trigger initiated for workflow "${workflow.name}"`, {
workflowName: workflow.name,
workflowId: workflow.id,
});

View file

@ -0,0 +1,214 @@
import { promises as fs } from 'fs';
import * as path from 'path';
import { v4 as uuid } from 'uuid';
import { IBinaryDataConfig, IBinaryDataManager } from '../Interfaces';
const PREFIX_METAFILE = 'binarymeta';
const PREFIX_PERSISTED_METAFILE = 'persistedmeta';
export class BinaryDataFileSystem implements IBinaryDataManager {
private storagePath: string;
private binaryDataTTL: number;
private persistedBinaryDataTTL: number;
constructor(config: IBinaryDataConfig) {
this.storagePath = config.localStoragePath;
this.binaryDataTTL = config.binaryDataTTL;
this.persistedBinaryDataTTL = config.persistedBinaryDataTTL;
}
async init(startPurger = false): Promise<void> {
if (startPurger) {
setInterval(async () => {
await this.deleteMarkedFiles();
}, this.binaryDataTTL * 30000);
setInterval(async () => {
await this.deleteMarkedPersistedFiles();
}, this.persistedBinaryDataTTL * 30000);
}
return fs
.readdir(this.storagePath)
.catch(async () => fs.mkdir(this.storagePath, { recursive: true }))
.then(async () => fs.readdir(this.getBinaryDataMetaPath()))
.catch(async () => fs.mkdir(this.getBinaryDataMetaPath(), { recursive: true }))
.then(async () => fs.readdir(this.getBinaryDataPersistMetaPath()))
.catch(async () => fs.mkdir(this.getBinaryDataPersistMetaPath(), { recursive: true }))
.then(async () => this.deleteMarkedFiles())
.then(async () => this.deleteMarkedPersistedFiles())
.then(() => {});
}
async storeBinaryData(binaryBuffer: Buffer, executionId: string): Promise<string> {
const binaryDataId = this.generateFileName(executionId);
return this.addBinaryIdToPersistMeta(executionId, binaryDataId).then(async () =>
this.saveToLocalStorage(binaryBuffer, binaryDataId).then(() => binaryDataId),
);
}
async retrieveBinaryDataByIdentifier(identifier: string): Promise<Buffer> {
return this.retrieveFromLocalStorage(identifier);
}
async markDataForDeletionByExecutionId(executionId: string): Promise<void> {
const tt = new Date(new Date().getTime() + this.binaryDataTTL * 60000);
return fs.writeFile(
path.join(this.getBinaryDataMetaPath(), `${PREFIX_METAFILE}_${executionId}_${tt.valueOf()}`),
'',
);
}
async deleteMarkedFiles(): Promise<void> {
return this.deleteMarkedFilesByMeta(this.getBinaryDataMetaPath(), PREFIX_METAFILE);
}
async deleteMarkedPersistedFiles(): Promise<void> {
return this.deleteMarkedFilesByMeta(
this.getBinaryDataPersistMetaPath(),
PREFIX_PERSISTED_METAFILE,
);
}
private async addBinaryIdToPersistMeta(executionId: string, identifier: string): Promise<void> {
const currentTime = new Date().getTime();
const timeAtNextHour = currentTime + 3600000 - (currentTime % 3600000);
const timeoutTime = timeAtNextHour + this.persistedBinaryDataTTL * 60000;
const filePath = path.join(
this.getBinaryDataPersistMetaPath(),
`${PREFIX_PERSISTED_METAFILE}_${executionId}_${timeoutTime}`,
);
return fs
.readFile(filePath)
.catch(async () => fs.writeFile(filePath, identifier))
.then(() => {});
}
private async deleteMarkedFilesByMeta(metaPath: string, filePrefix: string): Promise<void> {
const currentTimeValue = new Date().valueOf();
const metaFileNames = await fs.readdir(metaPath);
const execsAdded: { [key: string]: number } = {};
const proms = metaFileNames.reduce(
(prev, curr) => {
const [prefix, executionId, ts] = curr.split('_');
if (prefix !== filePrefix) {
return prev;
}
const execTimestamp = parseInt(ts, 10);
if (execTimestamp < currentTimeValue) {
if (execsAdded[executionId]) {
// do not delete data, only meta file
prev.push(this.deleteMetaFileByPath(path.join(metaPath, curr)));
return prev;
}
execsAdded[executionId] = 1;
prev.push(
this.deleteBinaryDataByExecutionId(executionId).then(async () =>
this.deleteMetaFileByPath(path.join(metaPath, curr)),
),
);
}
return prev;
},
[Promise.resolve()],
);
return Promise.all(proms).then(() => {});
}
async duplicateBinaryDataByIdentifier(binaryDataId: string, prefix: string): Promise<string> {
const newBinaryDataId = this.generateFileName(prefix);
return fs
.copyFile(
path.join(this.storagePath, binaryDataId),
path.join(this.storagePath, newBinaryDataId),
)
.then(() => newBinaryDataId);
}
async deleteBinaryDataByExecutionId(executionId: string): Promise<void> {
const regex = new RegExp(`${executionId}_*`);
const filenames = await fs.readdir(path.join(this.storagePath));
const proms = filenames.reduce(
(allProms, filename) => {
if (regex.test(filename)) {
allProms.push(fs.rm(path.join(this.storagePath, filename)));
}
return allProms;
},
[Promise.resolve()],
);
return Promise.all(proms).then(async () => Promise.resolve());
}
async deleteBinaryDataByIdentifier(identifier: string): Promise<void> {
return this.deleteFromLocalStorage(identifier);
}
async persistBinaryDataForExecutionId(executionId: string): Promise<void> {
return fs.readdir(this.getBinaryDataPersistMetaPath()).then(async (metafiles) => {
const proms = metafiles.reduce(
(prev, curr) => {
if (curr.startsWith(`${PREFIX_PERSISTED_METAFILE}_${executionId}_`)) {
prev.push(fs.rm(path.join(this.getBinaryDataPersistMetaPath(), curr)));
return prev;
}
return prev;
},
[Promise.resolve()],
);
return Promise.all(proms).then(() => {});
});
}
private generateFileName(prefix: string): string {
return `${prefix}_${uuid()}`;
}
private getBinaryDataMetaPath() {
return path.join(this.storagePath, 'meta');
}
private getBinaryDataPersistMetaPath() {
return path.join(this.storagePath, 'persistMeta');
}
private async deleteMetaFileByPath(metafilePath: string): Promise<void> {
return fs.rm(metafilePath);
}
private async deleteFromLocalStorage(identifier: string) {
return fs.rm(path.join(this.storagePath, identifier));
}
private async saveToLocalStorage(data: Buffer, identifier: string) {
await fs.writeFile(path.join(this.storagePath, identifier), data);
}
private async retrieveFromLocalStorage(identifier: string): Promise<Buffer> {
const filePath = path.join(this.storagePath, identifier);
try {
return await fs.readFile(filePath);
} catch (e) {
throw new Error(`Error finding file: ${filePath}`);
}
}
}

View file

@ -0,0 +1,187 @@
import { IBinaryData, INodeExecutionData } from 'n8n-workflow';
import { BINARY_ENCODING } from '../Constants';
import { IBinaryDataConfig, IBinaryDataManager } from '../Interfaces';
import { BinaryDataFileSystem } from './FileSystem';
export class BinaryDataManager {
private static instance: BinaryDataManager;
private managers: {
[key: string]: IBinaryDataManager;
};
private binaryDataMode: string;
private availableModes: string[];
constructor(config: IBinaryDataConfig) {
this.binaryDataMode = config.mode;
this.availableModes = config.availableModes.split(',');
this.managers = {};
}
static async init(config: IBinaryDataConfig, mainManager = false): Promise<void> {
if (BinaryDataManager.instance) {
throw new Error('Binary Data Manager already initialized');
}
BinaryDataManager.instance = new BinaryDataManager(config);
if (BinaryDataManager.instance.availableModes.includes('filesystem')) {
BinaryDataManager.instance.managers.filesystem = new BinaryDataFileSystem(config);
await BinaryDataManager.instance.managers.filesystem.init(mainManager);
}
return undefined;
}
static getInstance(): BinaryDataManager {
if (!BinaryDataManager.instance) {
throw new Error('Binary Data Manager not initialized');
}
return BinaryDataManager.instance;
}
async storeBinaryData(
binaryData: IBinaryData,
binaryBuffer: Buffer,
executionId: string,
): Promise<IBinaryData> {
const retBinaryData = binaryData;
if (this.managers[this.binaryDataMode]) {
return this.managers[this.binaryDataMode]
.storeBinaryData(binaryBuffer, executionId)
.then((filename) => {
retBinaryData.id = this.generateBinaryId(filename);
return retBinaryData;
});
}
retBinaryData.data = binaryBuffer.toString(BINARY_ENCODING);
return binaryData;
}
async retrieveBinaryData(binaryData: IBinaryData): Promise<Buffer> {
if (binaryData.id) {
return this.retrieveBinaryDataByIdentifier(binaryData.id);
}
return Buffer.from(binaryData.data, BINARY_ENCODING);
}
async retrieveBinaryDataByIdentifier(identifier: string): Promise<Buffer> {
const { mode, id } = this.splitBinaryModeFileId(identifier);
if (this.managers[mode]) {
return this.managers[mode].retrieveBinaryDataByIdentifier(id);
}
throw new Error('Storage mode used to store binary data not available');
}
async markDataForDeletionByExecutionId(executionId: string): Promise<void> {
if (this.managers[this.binaryDataMode]) {
return this.managers[this.binaryDataMode].markDataForDeletionByExecutionId(executionId);
}
return Promise.resolve();
}
async persistBinaryDataForExecutionId(executionId: string): Promise<void> {
if (this.managers[this.binaryDataMode]) {
return this.managers[this.binaryDataMode].persistBinaryDataForExecutionId(executionId);
}
return Promise.resolve();
}
async deleteBinaryDataByExecutionId(executionId: string): Promise<void> {
if (this.managers[this.binaryDataMode]) {
return this.managers[this.binaryDataMode].deleteBinaryDataByExecutionId(executionId);
}
return Promise.resolve();
}
async duplicateBinaryData(
inputData: Array<INodeExecutionData[] | null> | unknown,
executionId: string,
): Promise<INodeExecutionData[][]> {
if (inputData && this.managers[this.binaryDataMode]) {
const returnInputData = (inputData as INodeExecutionData[][]).map(
async (executionDataArray) => {
if (executionDataArray) {
return Promise.all(
executionDataArray.map((executionData) => {
if (executionData.binary) {
return this.duplicateBinaryDataInExecData(executionData, executionId);
}
return executionData;
}),
);
}
return executionDataArray;
},
);
return Promise.all(returnInputData);
}
return Promise.resolve(inputData as INodeExecutionData[][]);
}
private generateBinaryId(filename: string) {
return `${this.binaryDataMode}:${filename}`;
}
private splitBinaryModeFileId(fileId: string): { mode: string; id: string } {
const [mode, id] = fileId.split(':');
return { mode, id };
}
private async duplicateBinaryDataInExecData(
executionData: INodeExecutionData,
executionId: string,
): Promise<INodeExecutionData> {
const binaryManager = this.managers[this.binaryDataMode];
if (executionData.binary) {
const binaryDataKeys = Object.keys(executionData.binary);
const bdPromises = binaryDataKeys.map(async (key: string) => {
if (!executionData.binary) {
return { key, newId: undefined };
}
const binaryDataId = executionData.binary[key].id;
if (!binaryDataId) {
return { key, newId: undefined };
}
return binaryManager
?.duplicateBinaryDataByIdentifier(
this.splitBinaryModeFileId(binaryDataId).id,
executionId,
)
.then((filename) => ({
newId: this.generateBinaryId(filename),
key,
}));
});
return Promise.all(bdPromises).then((b) => {
return b.reduce((acc, curr) => {
if (acc.binary && curr) {
acc.binary[curr.key].id = curr.newId;
}
return acc;
}, executionData);
});
}
return executionData;
}
}

View file

@ -234,3 +234,23 @@ export interface IWorkflowData {
pollResponses?: IPollResponse[];
triggerResponses?: ITriggerResponse[];
}
export interface IBinaryDataConfig {
mode: 'default' | 'filesystem';
availableModes: string;
localStoragePath: string;
binaryDataTTL: number;
persistedBinaryDataTTL: number;
}
export interface IBinaryDataManager {
init(startPurger: boolean): Promise<void>;
storeBinaryData(binaryBuffer: Buffer, executionId: string): Promise<string>;
retrieveBinaryDataByIdentifier(identifier: string): Promise<Buffer>;
markDataForDeletionByExecutionId(executionId: string): Promise<void>;
deleteMarkedFiles(): Promise<unknown>;
deleteBinaryDataByIdentifier(identifier: string): Promise<void>;
duplicateBinaryDataByIdentifier(binaryDataId: string, prefix: string): Promise<string>;
deleteBinaryDataByExecutionId(executionId: string): Promise<void>;
persistBinaryDataForExecutionId(executionId: string): Promise<void>;
}

View file

@ -22,6 +22,7 @@ import {
ICredentialsExpressionResolveValues,
IDataObject,
IExecuteFunctions,
IExecuteResponsePromiseData,
IExecuteSingleFunctions,
IExecuteWorkflowInfo,
IHttpRequestOptions,
@ -58,6 +59,8 @@ import { stringify } from 'qs';
import * as clientOAuth1 from 'oauth-1.0a';
import { Token } from 'oauth-1.0a';
import * as clientOAuth2 from 'client-oauth2';
import * as crypto from 'crypto';
import * as url from 'url';
// eslint-disable-next-line import/no-extraneous-dependencies
import { get } from 'lodash';
// eslint-disable-next-line import/no-extraneous-dependencies
@ -70,11 +73,17 @@ import { createHmac } from 'crypto';
import { fromBuffer } from 'file-type';
import { lookup } from 'mime-types';
import axios, { AxiosProxyConfig, AxiosRequestConfig, Method } from 'axios';
import { URLSearchParams } from 'url';
import axios, {
AxiosPromise,
AxiosProxyConfig,
AxiosRequestConfig,
AxiosResponse,
Method,
} from 'axios';
import { URL, URLSearchParams } from 'url';
import { BinaryDataManager } from './BinaryDataManager';
// eslint-disable-next-line import/no-cycle
import {
BINARY_ENCODING,
ICredentialTestFunctions,
IHookFunctions,
ILoadOptionsFunctions,
@ -86,6 +95,14 @@ import {
axios.defaults.timeout = 300000;
// Prevent axios from adding x-form-www-urlencoded headers by default
axios.defaults.headers.post = {};
axios.defaults.headers.put = {};
axios.defaults.headers.patch = {};
axios.defaults.paramsSerializer = (params) => {
if (params instanceof URLSearchParams) {
return params.toString();
}
return stringify(params, { arrayFormat: 'indices' });
};
const requestPromiseWithDefaults = requestPromise.defaults({
timeout: 300000, // 5 minutes
@ -128,6 +145,28 @@ function searchForHeader(headers: IDataObject, headerName: string) {
return headerNames.find((thisHeader) => thisHeader.toLowerCase() === headerName);
}
async function generateContentLengthHeader(formData: FormData, headers: IDataObject) {
if (!formData || !formData.getLength) {
return;
}
try {
const length = await new Promise((res, rej) => {
formData.getLength((error: Error | null, length: number) => {
if (error) {
rej(error);
return;
}
res(length);
});
});
headers = Object.assign(headers, {
'content-length': length,
});
} catch (error) {
Logger.error('Unable to calculate form data length', { error });
}
}
async function parseRequestObject(requestObject: IDataObject) {
// This function is a temporary implementation
// That translates all http requests done via
@ -162,16 +201,19 @@ async function parseRequestObject(requestObject: IDataObject) {
// and also using formData. Request lib takes precedence for the formData.
// We will do the same.
// Merge body and form properties.
// @ts-ignore
axiosConfig.data =
typeof requestObject.body === 'string'
? requestObject.body
: new URLSearchParams(
Object.assign(requestObject.body || {}, requestObject.form || {}) as Record<
string,
string
>,
);
if (typeof requestObject.body === 'string') {
axiosConfig.data = requestObject.body;
} else {
const allData = Object.assign(requestObject.body || {}, requestObject.form || {}) as Record<
string,
string
>;
if (requestObject.useQuerystring === true) {
axiosConfig.data = stringify(allData, { arrayFormat: 'repeat' });
} else {
axiosConfig.data = stringify(allData);
}
}
} else if (contentType && contentType.includes('multipart/form-data') !== false) {
if (requestObject.formData !== undefined && requestObject.formData instanceof FormData) {
axiosConfig.data = requestObject.formData;
@ -189,6 +231,7 @@ async function parseRequestObject(requestObject: IDataObject) {
delete axiosConfig.headers[contentTypeHeaderKeyName];
const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig.data, axiosConfig.headers);
} else {
// When using the `form` property it means the content should be x-www-form-urlencoded.
if (requestObject.form !== undefined && requestObject.body === undefined) {
@ -225,6 +268,7 @@ async function parseRequestObject(requestObject: IDataObject) {
// Mix in headers as FormData creates the boundary.
const headers = axiosConfig.data.getHeaders();
axiosConfig.headers = Object.assign(axiosConfig.headers || {}, headers);
await generateContentLengthHeader(axiosConfig.data, axiosConfig.headers);
} else if (requestObject.body !== undefined) {
// If we have body and possibly form
if (requestObject.form !== undefined) {
@ -335,7 +379,63 @@ async function parseRequestObject(requestObject: IDataObject) {
}
if (requestObject.proxy !== undefined) {
axiosConfig.proxy = requestObject.proxy as AxiosProxyConfig;
// try our best to parse the url provided.
if (typeof requestObject.proxy === 'string') {
try {
const url = new URL(requestObject.proxy);
axiosConfig.proxy = {
host: url.hostname,
port: parseInt(url.port, 10),
protocol: url.protocol,
};
if (!url.port) {
// Sets port to a default if not informed
if (url.protocol === 'http') {
axiosConfig.proxy.port = 80;
} else if (url.protocol === 'https') {
axiosConfig.proxy.port = 443;
}
}
if (url.username || url.password) {
axiosConfig.proxy.auth = {
username: url.username,
password: url.password,
};
}
} catch (error) {
// Not a valid URL. We will try to simply parse stuff
// such as user:pass@host:port without protocol (we'll assume http)
if (requestObject.proxy.includes('@')) {
const [userpass, hostport] = requestObject.proxy.split('@');
const [username, password] = userpass.split(':');
const [hostname, port] = hostport.split(':');
axiosConfig.proxy = {
host: hostname,
port: parseInt(port, 10),
protocol: 'http',
auth: {
username,
password,
},
};
} else if (requestObject.proxy.includes(':')) {
const [hostname, port] = requestObject.proxy.split(':');
axiosConfig.proxy = {
host: hostname,
port: parseInt(port, 10),
protocol: 'http',
};
} else {
axiosConfig.proxy = {
host: requestObject.proxy,
port: 80,
protocol: 'http',
};
}
}
} else {
axiosConfig.proxy = requestObject.proxy as AxiosProxyConfig;
}
}
if (requestObject.encoding === null) {
@ -354,6 +454,7 @@ async function parseRequestObject(requestObject: IDataObject) {
if (
requestObject.json !== false &&
axiosConfig.data !== undefined &&
axiosConfig.data !== '' &&
!(axiosConfig.data instanceof Buffer) &&
!allHeaders.some((headerKey) => headerKey.toLowerCase() === 'content-type')
) {
@ -377,6 +478,49 @@ async function parseRequestObject(requestObject: IDataObject) {
return axiosConfig;
}
function digestAuthAxiosConfig(
axiosConfig: AxiosRequestConfig,
response: AxiosResponse,
auth: AxiosRequestConfig['auth'],
): AxiosRequestConfig {
const authDetails = response.headers['www-authenticate']
.split(',')
.map((v: string) => v.split('='));
if (authDetails) {
const nonceCount = `000000001`;
const cnonce = crypto.randomBytes(24).toString('hex');
const realm: string = authDetails
.find((el: any) => el[0].toLowerCase().indexOf('realm') > -1)[1]
.replace(/"/g, '');
const nonce: string = authDetails
.find((el: any) => el[0].toLowerCase().indexOf('nonce') > -1)[1]
.replace(/"/g, '');
const ha1 = crypto
.createHash('md5')
.update(`${auth?.username as string}:${realm}:${auth?.password as string}`)
.digest('hex');
const path = new url.URL(axiosConfig.url!).pathname;
const ha2 = crypto
.createHash('md5')
.update(`${axiosConfig.method ?? 'GET'}:${path}`)
.digest('hex');
const response = crypto
.createHash('md5')
.update(`${ha1}:${nonce}:${nonceCount}:${cnonce}:auth:${ha2}`)
.digest('hex');
const authorization =
`Digest username="${auth?.username as string}",realm="${realm}",` +
`nonce="${nonce}",uri="${path}",qop="auth",algorithm="MD5",` +
`response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`;
if (axiosConfig.headers) {
axiosConfig.headers.authorization = authorization;
} else {
axiosConfig.headers = { authorization };
}
}
return axiosConfig;
}
async function proxyRequestToAxios(
uriOrObject: string | IDataObject,
options?: IDataObject,
@ -390,8 +534,13 @@ async function proxyRequestToAxios(
}
let axiosConfig: AxiosRequestConfig = {};
let configObject: IDataObject;
let axiosPromise: AxiosPromise;
type ConfigObject = {
auth?: { sendImmediately: boolean };
resolveWithFullResponse?: boolean;
simple?: boolean;
};
let configObject: ConfigObject;
if (uriOrObject !== undefined && typeof uriOrObject === 'string') {
axiosConfig.url = uriOrObject;
}
@ -403,8 +552,38 @@ async function proxyRequestToAxios(
axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject));
Logger.debug('Proxying request to axios', {
originalConfig: configObject,
parsedConfig: axiosConfig,
});
if (configObject.auth?.sendImmediately === false) {
// for digest-auth
const { auth } = axiosConfig;
delete axiosConfig.auth;
// eslint-disable-next-line no-async-promise-executor
axiosPromise = new Promise(async (resolve, reject) => {
try {
const result = await axios(axiosConfig);
resolve(result);
} catch (resp: any) {
if (
resp.response === undefined ||
resp.response.status !== 401 ||
!resp.response.headers['www-authenticate']?.includes('nonce')
) {
reject(resp);
}
axiosConfig = digestAuthAxiosConfig(axiosConfig, resp.response, auth);
resolve(axios(axiosConfig));
}
});
} else {
axiosPromise = axios(axiosConfig);
}
return new Promise((resolve, reject) => {
axios(axiosConfig)
axiosPromise
.then((response) => {
if (configObject.resolveWithFullResponse === true) {
let body = response.data;
@ -435,25 +614,30 @@ async function proxyRequestToAxios(
}
})
.catch((error) => {
if (configObject.simple === true && error.response) {
resolve({
body: error.response.data,
headers: error.response.headers,
statusCode: error.response.status,
statusMessage: error.response.statusText,
});
return;
}
if (configObject.simple === false && error.response) {
resolve(error.response.data);
if (configObject.resolveWithFullResponse) {
resolve({
body: error.response.data,
headers: error.response.headers,
statusCode: error.response.status,
statusMessage: error.response.statusText,
});
} else {
resolve(error.response.data);
}
return;
}
Logger.debug('Request proxied to Axios failed', { error });
// Axios hydrates the original error with more data. We extract them.
// https://github.com/axios/axios/blob/master/lib/core/enhanceError.js
// Note: `code` is ignored as it's an expected part of the errorData.
const { request, response, isAxiosError, toJSON, config, ...errorData } = error;
if (response) {
error.message = `${response.status as number} - ${JSON.stringify(response.data)}`;
}
error.cause = errorData;
error.error = error.response?.data || errorData;
error.statusCode = error.response?.status;
@ -579,7 +763,7 @@ export async function getBinaryDataBuffer(
inputIndex: number,
): Promise<Buffer> {
const binaryData = inputData.main![inputIndex]![itemIndex]!.binary![propertyName]!;
return Buffer.from(binaryData.data, BINARY_ENCODING);
return BinaryDataManager.getInstance().retrieveBinaryData(binaryData);
}
/**
@ -594,6 +778,7 @@ export async function getBinaryDataBuffer(
*/
export async function prepareBinaryData(
binaryData: Buffer,
executionId: string,
filePath?: string,
mimeType?: string,
): Promise<IBinaryData> {
@ -624,10 +809,7 @@ export async function prepareBinaryData(
const returnData: IBinaryData = {
mimeType,
// TODO: Should program it in a way that it does not have to converted to base64
// It should only convert to and from base64 when saved in database because
// of for example an error or when there is a wait node.
data: binaryData.toString(BINARY_ENCODING),
data: '',
};
if (filePath) {
@ -650,7 +832,7 @@ export async function prepareBinaryData(
}
}
return returnData;
return BinaryDataManager.getInstance().storeBinaryData(returnData, binaryData, executionId);
}
/**
@ -1267,7 +1449,19 @@ export function getExecutePollFunctions(
},
helpers: {
httpRequest,
prepareBinaryData,
async prepareBinaryData(
binaryData: Buffer,
filePath?: string,
mimeType?: string,
): Promise<IBinaryData> {
return prepareBinaryData.call(
this,
binaryData,
additionalData.executionId!,
filePath,
mimeType,
);
},
request: proxyRequestToAxios,
async requestOAuth2(
this: IAllExecuteFunctions,
@ -1373,8 +1567,19 @@ export function getExecuteTriggerFunctions(
},
helpers: {
httpRequest,
prepareBinaryData,
async prepareBinaryData(
binaryData: Buffer,
filePath?: string,
mimeType?: string,
): Promise<IBinaryData> {
return prepareBinaryData.call(
this,
binaryData,
additionalData.executionId!,
filePath,
mimeType,
);
},
request: proxyRequestToAxios,
async requestOAuth2(
this: IAllExecuteFunctions,
@ -1450,7 +1655,14 @@ export function getExecuteFunctions(
workflowInfo: IExecuteWorkflowInfo,
inputData?: INodeExecutionData[],
): Promise<any> {
return additionalData.executeWorkflow(workflowInfo, additionalData, inputData);
return additionalData
.executeWorkflow(workflowInfo, additionalData, inputData)
.then(async (result) =>
BinaryDataManager.getInstance().duplicateBinaryData(
result,
additionalData.executionId!,
),
);
},
getContext(type: string): IContextObject {
return NodeHelpers.getContext(runExecutionData, type, node);
@ -1551,22 +1763,37 @@ export function getExecuteFunctions(
async putExecutionToWait(waitTill: Date): Promise<void> {
runExecutionData.waitTill = waitTill;
},
sendMessageToUI(message: any): void {
sendMessageToUI(...args: any[]): void {
if (mode !== 'manual') {
return;
}
try {
if (additionalData.sendMessageToUI) {
additionalData.sendMessageToUI(node.name, message);
additionalData.sendMessageToUI(node.name, args);
}
} catch (error) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
Logger.warn(`There was a problem sending messsage to UI: ${error.message}`);
}
},
async sendResponse(response: IExecuteResponsePromiseData): Promise<void> {
await additionalData.hooks?.executeHookFunctions('sendResponse', [response]);
},
helpers: {
httpRequest,
prepareBinaryData,
async prepareBinaryData(
binaryData: Buffer,
filePath?: string,
mimeType?: string,
): Promise<IBinaryData> {
return prepareBinaryData.call(
this,
binaryData,
additionalData.executionId!,
filePath,
mimeType,
);
},
async getBinaryDataBuffer(
itemIndex: number,
propertyName: string,
@ -1747,7 +1974,19 @@ export function getExecuteSingleFunctions(
},
helpers: {
httpRequest,
prepareBinaryData,
async prepareBinaryData(
binaryData: Buffer,
filePath?: string,
mimeType?: string,
): Promise<IBinaryData> {
return prepareBinaryData.call(
this,
binaryData,
additionalData.executionId!,
filePath,
mimeType,
);
},
request: proxyRequestToAxios,
async requestOAuth2(
this: IAllExecuteFunctions,
@ -2128,7 +2367,19 @@ export function getExecuteWebhookFunctions(
prepareOutputData: NodeHelpers.prepareOutputData,
helpers: {
httpRequest,
prepareBinaryData,
async prepareBinaryData(
binaryData: Buffer,
filePath?: string,
mimeType?: string,
): Promise<IBinaryData> {
return prepareBinaryData.call(
this,
binaryData,
additionalData.executionId!,
filePath,
mimeType,
);
},
request: proxyRequestToAxios,
async requestOAuth2(
this: IAllExecuteFunctions,

View file

@ -10,9 +10,9 @@ try {
export * from './ActiveWorkflows';
export * from './ActiveWebhooks';
export * from './BinaryDataManager';
export * from './Constants';
export * from './Credentials';
export * from './DeferredPromise';
export * from './Interfaces';
export * from './LoadNodeParameterOptions';
export * from './NodeExecuteFunctions';

View file

@ -4,6 +4,7 @@ import {
ICredentialDataDecryptedObject,
ICredentialsHelper,
IDataObject,
IDeferredPromise,
IExecuteWorkflowInfo,
INodeCredentialsDetails,
INodeExecutionData,
@ -20,7 +21,7 @@ import {
WorkflowHooks,
} from 'n8n-workflow';
import { Credentials, IDeferredPromise, IExecuteFunctions } from '../src';
import { Credentials, IExecuteFunctions } from '../src';
export class CredentialsHelper extends ICredentialsHelper {
getDecrypted(
@ -615,10 +616,7 @@ class NodeTypesClass implements INodeTypes {
name: 'dotNotation',
type: 'boolean',
default: true,
description: `By default does dot-notation get used in property names..<br />
This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }.<br />
If that is not intended this can be deactivated, it will then set { "a.b": value } instead.
`,
description: `<p>By default, dot-notation is used in property names. This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }.</p><p>If that is not intended this can be deactivated, it will then set { "a.b": value } instead.</p>`,
},
],
},
@ -725,7 +723,7 @@ class NodeTypesClass implements INodeTypes {
async init(nodeTypes: INodeTypeData): Promise<void> {}
getAll(): INodeType[] {
return Object.values(this.nodeTypes).map((data) => NodeHelpers.getVersionedTypeNode(data.type));
return Object.values(this.nodeTypes).map((data) => NodeHelpers.getVersionedNodeType(data.type));
}
getByName(nodeType: string): INodeType {
@ -733,7 +731,7 @@ class NodeTypesClass implements INodeTypes {
}
getByNameAndVersion(nodeType: string, version?: number): INodeType {
return NodeHelpers.getVersionedTypeNode(this.nodeTypes[nodeType].type, version);
return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version);
}
}

View file

@ -1,6 +1,14 @@
import { IConnections, ILogger, INode, IRun, LoggerProxy, Workflow } from 'n8n-workflow';
import {
createDeferredPromise,
IConnections,
ILogger,
INode,
IRun,
LoggerProxy,
Workflow,
} from 'n8n-workflow';
import { createDeferredPromise, WorkflowExecute } from '../src';
import { WorkflowExecute } from '../src';
import * as Helpers from './Helpers';

View file

@ -1,6 +1,6 @@
{
"name": "n8n-design-system",
"version": "0.4.0",
"version": "0.9.0",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
"author": {

View file

@ -18,7 +18,7 @@ export default {
size: {
control: {
type: 'select',
options: ['small', 'medium', 'large'],
options: ['mini', 'small', 'medium', 'large', 'xlarge'],
},
},
loading: {
@ -31,12 +31,6 @@ export default {
type: 'text',
},
},
iconSize: {
control: {
type: 'select',
options: ['small', 'medium', 'large'],
},
},
circle: {
control: {
type: 'boolean',

View file

@ -16,13 +16,13 @@
<component
:is="$options.components.N8nSpinner"
v-if="props.loading"
:size="props.iconSize"
:size="props.size"
/>
<component
:is="$options.components.N8nIcon"
v-else-if="props.icon"
:icon="props.icon"
:size="props.iconSize"
:size="props.size"
/>
</span>
<span v-if="props.label">{{ props.label }}</span>
@ -58,7 +58,7 @@ export default {
type: String,
default: 'medium',
validator: (value: string): boolean =>
['small', 'medium', 'large'].indexOf(value) !== -1,
['mini', 'small', 'medium', 'large', 'xlarge'].indexOf(value) !== -1,
},
loading: {
type: Boolean,
@ -71,9 +71,6 @@ export default {
icon: {
type: String,
},
iconSize: {
type: String,
},
round: {
type: Boolean,
default: true,

View file

@ -35,9 +35,6 @@ export declare class N8nButton extends N8nComponent {
/** Button icon, accepts an icon name of font awesome icon component */
icon: string;
/** Size of icon */
iconSize: N8nComponentSize;
/** Full width */
fullWidth: boolean;

View file

@ -13,7 +13,7 @@ export default {
color: {
control: {
type: 'select',
options: ['primary', 'text-dark', 'text-base', 'text-light'],
options: ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'],
},
},
},

View file

@ -23,7 +23,7 @@ export default {
},
color: {
type: String,
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light'].includes(value),
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'].includes(value),
},
},
methods: {

View file

@ -1,19 +1,27 @@
<template functional>
<component
:is="$options.components.FontAwesomeIcon"
:class="$style[`_${props.size}`]"
:icon="props.icon"
:spin="props.spin"
/>
:is="$options.components.N8nText"
:size="props.size"
:compact="true"
>
<component
:is="$options.components.FontAwesomeIcon"
:icon="props.icon"
:spin="props.spin"
:class="$style[props.size]"
/>
</component>
</template>
<script lang="ts">
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import N8nText from '../N8nText';
export default {
name: 'n8n-icon',
components: {
FontAwesomeIcon,
N8nText,
},
props: {
icon: {
@ -23,9 +31,6 @@ export default {
size: {
type: String,
default: 'medium',
validator: function (value: string): boolean {
return ['small', 'medium', 'large'].indexOf(value) !== -1;
},
},
spin: {
type: Boolean,
@ -35,22 +40,21 @@ export default {
};
</script>
<style lang="scss" module>
._small {
font-size: 0.85em;
height: 0.85em;
width: 0.85em !important;
.xlarge {
width: var(--font-size-xl) !important;
}
._medium {
font-size: 0.95em;
height: 0.95em;
width: 0.95em !important;
.large {
width: var(--font-size-m) !important;
}
._large {
font-size: 1.22em;
height: 1.22em;
width: 1.22em !important;
.medium {
width: var(--font-size-s) !important;
}
.small {
width: var(--font-size-2xs) !important;
}
</style>

View file

@ -2,11 +2,10 @@
<component :is="$options.components.N8nButton"
:type="props.type"
:disabled="props.disabled"
:size="props.size === 'xlarge' ? 'large' : props.size"
:size="props.size"
:loading="props.loading"
:title="props.title"
:icon="props.icon"
:iconSize="$options.iconSizeMap[props.size] || props.size"
:theme="props.theme"
@click="(e) => listeners.click && listeners.click(e)"
circle
@ -16,11 +15,6 @@
<script lang="ts">
import N8nButton from '../N8nButton';
const iconSizeMap = {
large: 'medium',
xlarge: 'large',
};
export default {
name: 'n8n-icon-button',
components: {
@ -36,8 +30,6 @@ export default {
size: {
type: String,
default: 'medium',
validator: (value: string): boolean =>
['small', 'medium', 'large', 'xlarge'].indexOf(value) !== -1,
},
loading: {
type: Boolean,
@ -55,6 +47,5 @@ export default {
type: String,
},
},
iconSizeMap,
};
</script>

View file

@ -12,9 +12,6 @@ export declare class N8nIconButton extends N8nComponent {
/** Button size */
size: N8nComponentSize | 'xlarge';
/** icon size */
iconSize: N8nComponentSize;
/** Determine whether it's loading */
loading: boolean;

View file

@ -1,13 +1,13 @@
<template functional>
<div :class="$style.inputLabel">
<div :class="props.label ? $style.label: ''">
<component v-if="props.label" :is="$options.components.N8nText" :bold="true">
<div :class="{[$style.inputLabelContainer]: !props.labelHoverableOnly}">
<div :class="{[$style.inputLabel]: props.labelHoverableOnly, [$options.methods.getLabelClass(props, $style)]: true}">
<component v-if="props.label" :is="$options.components.N8nText" :bold="props.bold" :size="props.size" :compact="!props.underline">
{{ props.label }}
<component :is="$options.components.N8nText" color="primary" :bold="true" v-if="props.required">*</component>
<component :is="$options.components.N8nText" color="primary" :bold="props.bold" :size="props.size" v-if="props.required">*</component>
</component>
<span :class="$style.infoIcon" v-if="props.tooltipText">
<span :class="[$style.infoIcon, props.showTooltip ? $style.showIcon: $style.hiddenIcon]" v-if="props.tooltipText">
<component :is="$options.components.N8nTooltip" placement="top" :popper-class="$style.tooltipPopper">
<component :is="$options.components.N8nIcon" icon="question-circle" />
<component :is="$options.components.N8nIcon" icon="question-circle" size="small" />
<div slot="content" v-html="$options.methods.addTargetBlank(props.tooltipText)"></div>
</component>
</span>
@ -40,34 +40,104 @@ export default {
required: {
type: Boolean,
},
bold: {
type: Boolean,
default: true,
},
size: {
type: String,
default: 'medium',
validator: (value: string): boolean =>
['small', 'medium'].includes(value),
},
underline: {
type: Boolean,
},
showTooltip: {
type: Boolean,
},
labelHoverableOnly: {
type: Boolean,
default: false,
},
},
methods: {
addTargetBlank,
getLabelClass(props: {label: string, size: string, underline: boolean}, $style: any) {
if (!props.label) {
return '';
}
if (props.underline) {
return $style[`label-${props.size}-underline`];
}
return $style[`label-${props.size}`];
},
},
};
</script>
<style lang="scss" module>
.inputLabel {
&:hover {
--info-icon-display: inline-block;
.inputLabelContainer:hover {
> div > .infoIcon {
display: inline-block;
}
}
.label {
margin-bottom: var(--spacing-2xs);
* {
margin-right: var(--spacing-4xs);
.inputLabel:hover {
> .infoIcon {
display: inline-block;
}
}
.infoIcon {
color: var(--color-text-light);
display: var(--info-icon-display, none);
}
.showIcon {
display: inline-block;
}
.hiddenIcon {
display: none;
}
.label {
* {
margin-right: var(--spacing-5xs);
}
}
.label-small {
composes: label;
margin-bottom: var(--spacing-4xs);
}
.label-medium {
composes: label;
margin-bottom: var(--spacing-2xs);
}
.underline {
border-bottom: var(--border-base);
}
.label-small-underline {
composes: label-small;
composes: underline;
}
.label-medium-underline {
composes: label-medium;
composes: underline;
}
.tooltipPopper {
max-width: 400px;
li {
margin-left: var(--spacing-s);
}
}
</style>

View file

@ -0,0 +1,27 @@
import N8nSquareButton from './SquareButton.vue';
import { action } from '@storybook/addon-actions';
export default {
title: 'Atoms/SquareButton',
component: N8nSquareButton,
argTypes: {
label: {
control: 'text',
},
},
};
const methods = {
onClick: action('click'),
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nSquareButton,
},
template: '<n8n-square-button v-bind="$props" @click="onClick"></n8n-square-button>',
methods,
});
export const SquareButton = Template.bind({});

View file

@ -0,0 +1,43 @@
<template functional>
<button :class="$style.button" @click="(e) => listeners.click && listeners.click(e)">
<span :class="$style.text" v-text="props.label" />
</button>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'n8n-square-button',
props: {
label: {
type: String,
},
},
});
</script>
<style lang="scss" module>
.button {
width: 28px;
height: 29px;
border-radius: var(--border-radius-base);
border: var(--color-background-xlight);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
.text {
color: var(--color-primary) !important;
}
}
}
.text {
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-loose);
color: var(--color-background-dark);
}
</style>

View file

@ -0,0 +1,3 @@
import N8nSquareButton from './SquareButton.vue';
export default N8nSquareButton;

View file

@ -13,7 +13,7 @@ export default {
color: {
control: {
type: 'select',
options: ['primary', 'text-dark', 'text-base', 'text-light'],
options: ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'],
},
},
},

View file

@ -16,26 +16,33 @@ export default Vue.extend({
size: {
type: String,
default: 'medium',
validator: (value: string): boolean => ['large', 'medium', 'small'].includes(value),
validator: (value: string): boolean => ['mini', 'small', 'medium', 'large', 'xlarge'].includes(value),
},
color: {
type: String,
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light'].includes(value),
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight'].includes(value),
},
align: {
type: String,
validator: (value: string): boolean => ['right', 'left', 'center'].includes(value),
},
compact: {
type: Boolean,
default: false,
},
},
methods: {
getClass(props: {size: string, bold: boolean}) {
return `body-${props.size}${props.bold ? '-bold' : '-regular'}`;
},
getStyles(props: {color: string, align: string}) {
getStyles(props: {color: string, align: string, compact: false}) {
const styles = {} as any;
if (props.color) {
styles.color = `var(--color-${props.color})`;
}
if (props.compact) {
styles['line-height'] = 1;
}
if (props.align) {
styles['text-align'] = props.align;
}
@ -54,6 +61,22 @@ export default Vue.extend({
font-weight: var(--font-weight-regular);
}
.body-xlarge {
font-size: var(--font-size-xl);
line-height: var(--font-line-height-xloose);
}
.body-xlarge-regular {
composes: regular;
composes: body-xlarge;
}
.body-xlarge-bold {
composes: bold;
composes: body-xlarge;
}
.body-large {
font-size: var(--font-size-m);
line-height: var(--font-line-height-xloose);

View file

@ -7,4 +7,4 @@ export declare class N8nComponent extends Vue {
}
/** Component size definition for button, input, etc */
export type N8nComponentSize = 'large' | 'medium' | 'small';
export type N8nComponentSize = 'xlarge' | 'large' | 'medium' | 'small' | 'mini';

View file

@ -10,6 +10,7 @@ import N8nMenu from './N8nMenu';
import N8nMenuItem from './N8nMenuItem';
import N8nSelect from './N8nSelect';
import N8nSpinner from './N8nSpinner';
import N8nSquareButton from './N8nSquareButton';
import N8nText from './N8nText';
import N8nTooltip from './N8nTooltip';
import N8nOption from './N8nOption';
@ -27,6 +28,7 @@ export {
N8nMenuItem,
N8nSelect,
N8nSpinner,
N8nSquareButton,
N8nText,
N8nTooltip,
N8nOption,

View file

@ -16,7 +16,7 @@ import VariableTable from './VariableTable.vue';
<Canvas>
<Story name="border-radius">
{{
template: `<variable-table :variables="['--border-radius-small','--border-radius-base']" />`,
template: `<variable-table :variables="['--border-radius-small','--border-radius-base', '--border-radius-large', '--border-radius-xlarge']" />`,
components: {
VariableTable,
},

View file

@ -44,7 +44,7 @@ import ColorCircles from './ColorCircles.vue';
<Canvas>
<Story name="success">
{{
template: `<color-circles :colors="['--color-success', '--color-success-tint-1', '--color-success-tint-2']" />`,
template: `<color-circles :colors="['--color-success', '--color-success-tint-1', '--color-success-tint-2', '--color-success-light']" />`,
components: {
ColorCircles,
},
@ -109,7 +109,7 @@ import ColorCircles from './ColorCircles.vue';
<Canvas>
<Story name="foreground">
{{
template: `<color-circles :colors="['--color-foreground-base', '--color-foreground-light', '--color-foreground-xlight']" />`,
template: `<color-circles :colors="['--color-foreground-xdark', '--color-foreground-dark', '--color-foreground-base', '--color-foreground-light', '--color-foreground-xlight']" />`,
components: {
ColorCircles,
},
@ -129,3 +129,16 @@ import ColorCircles from './ColorCircles.vue';
}}
</Story>
</Canvas>
## Canvas
<Canvas>
<Story name="canvas">
{{
template: `<color-circles :colors="['--color-canvas-background', '--color-canvas-dot']" />`,
components: {
ColorCircles,
},
}}
</Story>
</Canvas>

View file

@ -75,6 +75,15 @@
var(--color-success-tint-2-l)
);
--color-success-light-h: 150;
--color-success-light-s: 54%;
--color-success-light-l: 70%;
--color-success-light: hsl(
var(--color-success-light-h),
var(--color-success-light-s),
var(--color-success-light-l)
);
--color-warning-h: 36;
--color-warning-s: 77%;
--color-warning-l: 57%;
@ -187,6 +196,24 @@
var(--color-text-xlight-l)
);
--color-foreground-xdark-h: 220;
--color-foreground-xdark-s: 7.4%;
--color-foreground-xdark-l: 52.5%;
--color-foreground-xdark: hsl(
var(--color-foreground-xdark-h),
var(--color-foreground-xdark-s),
var(--color-foreground-xdark-l)
);
--color-foreground-dark-h: 228;
--color-foreground-dark-s: 9.6%;
--color-foreground-dark-l: 79.6%;
--color-foreground-dark: hsl(
var(--color-foreground-dark-h),
var(--color-foreground-dark-s),
var(--color-foreground-dark-l)
);
--color-foreground-base-h: 220;
--color-foreground-base-s: 20%;
--color-foreground-base-l: 88.2%;
@ -259,6 +286,25 @@
var(--color-background-xlight-l)
);
--color-canvas-dot-h: 204;
--color-canvas-dot-s: 15.6%;
--color-canvas-dot-l: 87.5%;
--color-canvas-dot: hsl(
var(--color-canvas-dot-h),
var(--color-canvas-dot-s),
var(--color-canvas-dot-l)
);
--color-canvas-background-h: 260;
--color-canvas-background-s: 100%;
--color-canvas-background-l: 99.4%;
--color-canvas-background: hsl(
var(--color-canvas-background-h),
var(--color-canvas-background-s),
var(--color-canvas-background-l)
);
--border-radius-xlarge: 12px;
--border-radius-large: 8px;
--border-radius-base: 4px;
--border-radius-small: 2px;

View file

@ -83,6 +83,17 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0.35);
--button-border-radius: 50%;
}
@include mixins.m(mini) {
--button-padding-vertical: var(--spacing-4xs);
--button-padding-horizontal: var(--spacing-2xs);
--button-font-size: var(--font-size-2xs);
@include mixins.when(circle) {
--button-padding-vertical: var(--spacing-4xs);
--button-padding-horizontal: var(--spacing-4xs);
}
}
@include mixins.m(small) {
--button-padding-vertical: var(--spacing-3xs);
--button-padding-horizontal: var(--spacing-xs);
@ -104,4 +115,15 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0.35);
--button-padding-horizontal: var(--spacing-2xs);
}
}
@include mixins.m(xlarge) {
--button-padding-vertical: var(--spacing-xs);
--button-padding-horizontal: var(--spacing-s);
--button-font-size: var(--font-size-m);
@include mixins.when(circle) {
--button-padding-vertical: var(--spacing-xs);
--button-padding-horizontal: var(--spacing-xs);
}
}
}

View file

@ -68,8 +68,6 @@
@include mixins.e(body) {
padding: var.$dialog-padding-primary;
color: var(--color-text-base);
font-size: var.$dialog-content-font-size;
word-break: break-all;
}
@include mixins.e(footer) {

View file

@ -5,7 +5,7 @@
@include mixins.e(header) {
padding: 0;
position: relative;
margin: 0 0 15px;
margin: 0;
}
@include mixins.e(active-bar) {
position: absolute;

View file

@ -1,6 +1,6 @@
{
"name": "n8n-editor-ui",
"version": "0.112.0",
"version": "0.125.0",
"description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE.md",
"homepage": "https://n8n.io",
@ -26,10 +26,12 @@
},
"dependencies": {
"@fontsource/open-sans": "^4.5.0",
"n8n-design-system": "~0.4.0",
"n8n-design-system": "~0.9.0",
"monaco-editor": "^0.29.1",
"timeago.js": "^4.0.2",
"v-click-outside": "^3.1.2",
"vue-fragment": "^1.5.2"
"vue-fragment": "^1.5.2",
"vue-i18n": "^8.26.7"
},
"devDependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.35",
@ -39,9 +41,10 @@
"@types/express": "^4.17.6",
"@types/file-saver": "^2.0.1",
"@types/jest": "^26.0.13",
"@types/lodash.camelcase": "^4.3.6",
"@types/lodash.get": "^4.4.6",
"@types/lodash.set": "^4.3.6",
"@types/node": "^14.14.40",
"@types/node": "14.17.27",
"@types/quill": "^2.0.1",
"@types/uuid": "^8.3.0",
"@typescript-eslint/eslint-plugin": "^4.29.0",
@ -68,15 +71,17 @@
"jquery": "^3.4.1",
"jshint": "^2.9.7",
"jsplumb": "2.15.4",
"lodash.camelcase": "^4.3.0",
"lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"n8n-workflow": "~0.72.0",
"sass": "^1.26.5",
"n8n-workflow": "~0.82.0",
"monaco-editor-webpack-plugin": "^5.0.0",
"normalize-wheel": "^1.0.1",
"prismjs": "^1.17.1",
"quill": "^2.0.0-dev.3",
"quill-autoformat": "^0.1.1",
"sass": "^1.26.5",
"sass-loader": "^8.0.2",
"string-template-parser": "^1.2.6",
"ts-jest": "^26.3.0",

View file

@ -14,14 +14,20 @@
</template>
<script lang="ts">
import Vue from 'vue';
import Telemetry from './components/Telemetry.vue';
export default {
export default Vue.extend({
name: 'App',
components: {
Telemetry,
},
};
watch: {
'$route'(route) {
this.$telemetry.page('Editor', route.name);
},
},
});
</script>
<style lang="scss">

View file

@ -22,32 +22,72 @@ import {
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
PaintStyle,
} from 'jsplumb';
declare module 'jsplumb' {
interface PaintStyle {
stroke?: string;
fill?: string;
strokeWidth?: number;
outlineStroke?: string;
outlineWidth?: number;
}
interface Anchor {
lastReturnValue: number[];
}
interface Connection {
__meta?: {
sourceNodeName: string,
sourceOutputIndex: number,
targetNodeName: string,
targetOutputIndex: number,
};
canvas?: HTMLElement;
connector?: {
setTargetEndpoint: (endpoint: Endpoint) => void;
resetTargetEndpoint: () => void;
bounds: {
minX: number;
maxX: number;
minY: number;
maxY: number;
}
};
// bind(event: string, (connection: Connection): void;): void; // tslint:disable-line:no-any
bind(event: string, callback: Function): void; // tslint:disable-line:no-any
bind(event: string, callback: Function): void;
removeOverlay(name: string): void;
removeOverlays(): void;
setParameter(name: string, value: any): void; // tslint:disable-line:no-any
setPaintStyle(arg0: PaintStyle): void;
addOverlay(arg0: any[]): void; // tslint:disable-line:no-any
setConnector(arg0: any[]): void; // tslint:disable-line:no-any
getUuids(): [string, string];
}
interface Endpoint {
endpoint: any; // tslint:disable-line:no-any
elementId: string;
__meta?: {
nodeName: string,
nodeId: string,
index: number,
totalEndpoints: number;
};
getUuid(): string;
getOverlay(name: string): any; // tslint:disable-line:no-any
repaint(params?: object): void;
}
interface N8nPlusEndpoint extends Endpoint {
setSuccessOutput(message: string): void;
clearSuccessOutput(): void;
}
interface Overlay {
setVisible(visible: boolean): void;
setLocation(location: number): void;
canvas?: HTMLElement;
}
interface OnConnectionBindInfo {
@ -66,18 +106,15 @@ export interface IEndpointOptions {
dragProxy?: any; // tslint:disable-line:no-any
endpoint?: string;
endpointStyle?: object;
endpointHoverStyle?: object;
isSource?: boolean;
isTarget?: boolean;
maxConnections?: number;
overlays?: any; // tslint:disable-line:no-any
parameters?: any; // tslint:disable-line:no-any
uuid?: string;
}
export interface IConnectionsUi {
[key: string]: {
[key: string]: IEndpointOptions;
};
enabled?: boolean;
cssClass?: string;
}
export interface IUpdateInformation {
@ -95,20 +132,16 @@ export interface INodeUpdatePropertiesInformation {
};
}
export type XYPositon = [number, number];
export type XYPosition = [number, number];
export type MessageType = 'success' | 'warning' | 'info' | 'error';
export interface INodeUi extends INode {
position: XYPositon;
position: XYPosition;
color?: string;
notes?: string;
issues?: INodeIssues;
_jsPlumb?: {
endpoints?: {
[key: string]: IEndpointOptions[];
};
};
name: string;
}
export interface INodeTypesMaxCount {
@ -130,6 +163,8 @@ export interface IRestApi {
getPastExecutions(filter: object, limit: number, lastId?: string | number, firstId?: string | number): Promise<IExecutionsListResponse>;
stopCurrentExecution(executionId: string): Promise<IExecutionsStopData>;
makeRestApiRequest(method: string, endpoint: string, data?: any): Promise<any>; // tslint:disable-line:no-any
getCredentialTranslation(credentialType: string): Promise<object>;
getNodeTranslationHeaders(): Promise<INodeTranslationHeaders>;
getNodeTypes(onlyLatest?: boolean): Promise<INodeTypeDescription[]>;
getNodesInformation(nodeInfos: INodeTypeNameVersion[]): Promise<INodeTypeDescription[]>;
getNodeParameterOptions(nodeTypeAndVersion: INodeTypeNameVersion, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise<INodePropertyOptions[]>;
@ -145,6 +180,16 @@ export interface IRestApi {
deleteExecutions(sendData: IExecutionDeleteFilter): Promise<void>;
retryExecution(id: string, loadWorkflow?: boolean): Promise<boolean>;
getTimezones(): Promise<IDataObject>;
getBinaryBufferString(dataPath: string): Promise<string>;
}
export interface INodeTranslationHeaders {
data: {
[key: string]: {
displayName: string;
description: string;
},
};
}
export interface IBinaryDisplayData {
@ -428,7 +473,7 @@ export interface IPushDataTestWebhook {
export interface IPushDataConsoleMessage {
source: string;
message: string;
messages: string[];
}
export interface IVersionNotificationSettings {
@ -437,10 +482,15 @@ export interface IVersionNotificationSettings {
infoUrl: string;
}
export type IPersonalizationSurveyKeys = 'companySize' | 'codingSkill' | 'workArea' | 'otherWorkArea';
export type IPersonalizationSurveyKeys = 'codingSkill' | 'companyIndustry' | 'companySize' | 'otherCompanyIndustry' | 'otherWorkArea' | 'workArea';
export type IPersonalizationSurveyAnswers = {
[key in IPersonalizationSurveyKeys]: string | null
codingSkill: string | null;
companyIndustry: string[];
companySize: string | null;
otherCompanyIndustry: string | null;
otherWorkArea: string | null;
workArea: string[] | string | null;
};
export interface IPersonalizationSurvey {
@ -448,6 +498,21 @@ export interface IPersonalizationSurvey {
shouldShow: boolean;
}
export interface IN8nPrompts {
message: string;
title: string;
showContactPrompt: boolean;
showValueSurvey: boolean;
}
export interface IN8nValueSurveyData {
[key: string]: string;
}
export interface IN8nPromptResponse {
updated: boolean;
}
export interface IN8nUISettings {
endpointWebhook: string;
endpointWebhookTest: string;
@ -470,6 +535,7 @@ export interface IN8nUISettings {
instanceId: string;
personalizationSurvey?: IPersonalizationSurvey;
telemetry: ITelemetrySettings;
defaultLocale: string;
}
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
@ -581,8 +647,10 @@ export interface IRootState {
activeExecutions: IExecutionsCurrentSummaryExtended[];
activeWorkflows: string[];
activeActions: string[];
activeCredentialType: string | null;
activeNode: string | null;
baseUrl: string;
defaultLocale: string;
endpointWebhook: string;
endpointWebhookTest: string;
executionId: string | null;
@ -604,7 +672,7 @@ export interface IRootState {
lastSelectedNodeOutputIndex: number | null;
nodeIndex: Array<string | null>;
nodeTypes: INodeTypeDescription[];
nodeViewOffsetPosition: XYPositon;
nodeViewOffsetPosition: XYPosition;
nodeViewMoveInProgress: boolean;
selectedNodes: INodeUi[];
sessionId: string;
@ -652,6 +720,7 @@ export interface IUiState {
export interface ISettingsState {
settings: IN8nUISettings;
promptsData: IN8nPrompts;
}
export interface IVersionsState {
@ -670,5 +739,12 @@ export interface IRestApiContext {
export interface IZoomConfig {
scale: number;
offset: XYPositon;
offset: XYPosition;
}
export interface IBounds {
minX: number;
minY: number;
maxX: number;
maxY: number;
}

View file

@ -93,3 +93,7 @@ export async function makeRestApiRequest(context: IRestApiContext, method: Metho
export async function get(baseURL: string, endpoint: string, params?: IDataObject, headers?: IDataObject) {
return await request({method: 'GET', baseURL, endpoint, headers, data: params});
}
export async function post(baseURL: string, endpoint: string, params?: IDataObject, headers?: IDataObject) {
return await request({method: 'POST', baseURL, endpoint, headers, data: params});
}

View file

@ -1,6 +1,7 @@
import { IDataObject } from 'n8n-workflow';
import { IRestApiContext, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface';
import { makeRestApiRequest } from './helpers';
import { IRestApiContext, IN8nPrompts, IN8nValueSurveyData, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface';
import { makeRestApiRequest, get, post } from './helpers';
import { TEMPLATES_BASE_URL } from '@/constants';
export async function getSettings(context: IRestApiContext): Promise<IN8nUISettings> {
return await makeRestApiRequest(context, 'GET', '/settings');
@ -10,3 +11,15 @@ export async function submitPersonalizationSurvey(context: IRestApiContext, para
await makeRestApiRequest(context, 'POST', '/user-survey', params as unknown as IDataObject);
}
export async function getPromptsData(instanceId: string): Promise<IN8nPrompts> {
return await get(TEMPLATES_BASE_URL, '/prompts', {}, {'n8n-instance-id': instanceId});
}
export async function submitContactInfo(instanceId: string, email: string): Promise<void> {
return await post(TEMPLATES_BASE_URL, '/prompt', { email }, {'n8n-instance-id': instanceId});
}
export async function submitValueSurvey(instanceId: string, params: IN8nValueSurveyData): Promise<IN8nPrompts> {
return await post(TEMPLATES_BASE_URL, '/value-survey', params, {'n8n-instance-id': instanceId});
}

View file

@ -1,18 +1,18 @@
<template>
<span>
<el-dialog class="n8n-about" :visible="dialogVisible" append-to-body width="50%" title="About n8n" :before-close="closeDialog">
<el-dialog class="n8n-about" :visible="dialogVisible" append-to-body width="50%" :title="$locale.baseText('about.aboutN8n')" :before-close="closeDialog">
<div>
<el-row>
<el-col :span="8" class="info-name">
n8n Version:
{{ $locale.baseText('about.n8nVersion') }}
</el-col>
<el-col :span="16">
{{versionCli}}
{{ versionCli }}
</el-col>
</el-row>
<el-row>
<el-col :span="8" class="info-name">
Source Code:
{{ $locale.baseText('about.sourceCode') }}
</el-col>
<el-col :span="16">
<a href="https://github.com/n8n-io/n8n" target="_blank">https://github.com/n8n-io/n8n</a>
@ -20,15 +20,17 @@
</el-row>
<el-row>
<el-col :span="8" class="info-name">
License:
{{ $locale.baseText('about.license') }}
</el-col>
<el-col :span="16">
<a href="https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md" target="_blank">Apache 2.0 with Commons Clause</a>
<a href="https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md" target="_blank">
{{ $locale.baseText('about.apacheWithCommons20Clause') }}
</a>
</el-col>
</el-row>
<div class="action-buttons">
<n8n-button @click="closeDialog" label="Close" />
<n8n-button @click="closeDialog" :label="$locale.baseText('about.close')" />
</div>
</div>
</el-dialog>
@ -67,6 +69,7 @@ export default mixins(
<style scoped lang="scss">
.n8n-about {
font-size: var(--font-size-s);
.el-row {
padding: 0.25em 0;
}

View file

@ -4,20 +4,16 @@
@click.stop="closeWindow"
size="small"
class="binary-data-window-back"
title="Back to overview page"
:title="$locale.baseText('binaryDataDisplay.backToOverviewPage')"
icon="arrow-left"
label="Back to list"
:label="$locale.baseText('binaryDataDisplay.backToList')"
/>
<div class="binary-data-window-wrapper">
<div v-if="!binaryData">
Data to display did not get found
{{ $locale.baseText('binaryDataDisplay.noDataFoundToDisplay') }}
</div>
<video v-else-if="binaryData.mimeType && binaryData.mimeType.startsWith('video/')" controls autoplay>
<source :src="'data:' + binaryData.mimeType + ';base64,' + binaryData.data" :type="binaryData.mimeType">
Your browser does not support the video element. Kindly update it to latest version.
</video>
<embed v-else :src="'data:' + binaryData.mimeType + ';base64,' + binaryData.data" class="binary-data" :class="embedClass"/>
<BinaryDataDisplayEmbed v-else :binaryData="binaryData"/>
</div>
</div>
@ -30,15 +26,22 @@ import {
IRunExecutionData,
} from 'n8n-workflow';
import BinaryDataDisplayEmbed from '@/components/BinaryDataDisplayEmbed.vue';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import mixins from 'vue-typed-mixins';
import { restApi } from '@/components/mixins/restApi';
export default mixins(
nodeHelpers,
restApi,
)
.extend({
name: 'BinaryDataDisplay',
components: {
BinaryDataDisplayEmbed,
},
props: [
'displayData', // IBinaryDisplayData
'windowVisible', // boolean
@ -54,14 +57,15 @@ export default mixins(
if (this.displayData.index >= binaryData.length || binaryData[this.displayData.index][this.displayData.key] === undefined) {
return null;
}
return binaryData[this.displayData.index][this.displayData.key];
const binaryDataItem: IBinaryData = binaryData[this.displayData.index][this.displayData.key];
return binaryDataItem;
},
embedClass (): string[] {
if (this.binaryData !== null &&
this.binaryData.mimeType !== undefined &&
(this.binaryData.mimeType as string).startsWith('image')
) {
// @ts-ignore
if (this.binaryData! !== null && this.binaryData!.mimeType! !== undefined && (this.binaryData!.mimeType! as string).startsWith('image')) {
return ['image'];
}
return ['other'];

View file

@ -0,0 +1,84 @@
<template>
<div>
<div v-if="isLoading">
Loading binary data...
</div>
<div v-else-if="error">
Error loading binary data
</div>
<div v-else>
<video v-if="binaryData.mimeType && binaryData.mimeType.startsWith('video/')" controls autoplay>
<source :src="embedSource" :type="binaryData.mimeType">
{{ $locale.baseText('binaryDataDisplay.yourBrowserDoesNotSupport') }}
</video>
<embed v-else :src="embedSource" class="binary-data" :class="embedClass"/>
</div>
</div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { restApi } from '@/components/mixins/restApi';
export default mixins(
restApi,
)
.extend({
name: 'BinaryDataDisplayEmbed',
props: [
'binaryData', // IBinaryDisplayData
],
data() {
return {
isLoading: true,
embedSource: '',
error: false,
};
},
async mounted() {
if(!this.binaryData.id) {
this.embedSource = 'data:' + this.binaryData.mimeType + ';base64,' + this.binaryData.data;
this.isLoading = false;
return;
}
try {
const bufferString = await this.restApi().getBinaryBufferString(this.binaryData!.id!);
this.embedSource = 'data:' + this.binaryData.mimeType + ';base64,' + bufferString;
this.isLoading = false;
} catch (e) {
this.isLoading = false;
this.error = true;
}
},
methods: {
embedClass (): string[] {
// @ts-ignore
if (this.binaryData! !== null && this.binaryData!.mimeType! !== undefined && (this.binaryData!.mimeType! as string).startsWith('image')) {
return ['image'];
}
return ['other'];
},
},
});
</script>
<style lang="scss">
.binary-data {
background-color: #fff;
&.image {
max-height: calc(100% - 1em);
max-width: calc(100% - 1em);
}
&.other {
height: calc(100% - 1em);
width: calc(100% - 1em);
}
}
</style>

View file

@ -1,61 +1,247 @@
<template>
<div v-if="dialogVisible">
<el-dialog :visible="dialogVisible" append-to-body :close-on-click-modal="false" width="80%" :title="`Edit ${parameter.displayName}`" :before-close="closeDialog">
<div class="text-editor-wrapper ignore-key-press">
<div class="editor-description">
{{parameter.displayName}}:
</div>
<div class="text-editor" @keydown.stop>
<prism-editor :lineNumbers="true" :code="value" :readonly="isReadOnly" @change="valueChanged" language="js"></prism-editor>
</div>
</div>
</el-dialog>
</div>
<el-dialog
visible
append-to-body
:close-on-click-modal="false"
width="80%"
:title="`${$locale.baseText('codeEdit.edit')} ${$locale.nodeText().inputLabelDisplayName(parameter, path)}`"
:before-close="closeDialog"
>
<div class="text-editor-wrapper ignore-key-press">
<div ref="code" class="text-editor" @keydown.stop></div>
</div>
</el-dialog>
</template>
<script lang="ts">
// @ts-ignore
import PrismEditor from 'vue-prism-editor';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
import { IExecutionResponse, INodeUi } from '@/Interface';
import {
IBinaryKeyData,
IDataObject,
INodeExecutionData,
IRunExecutionData,
IWorkflowDataProxyAdditionalKeys,
WorkflowDataProxy,
} from 'n8n-workflow';
import {
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
} from '@/constants';
export default mixins(
genericHelpers,
)
.extend({
name: 'CodeEdit',
props: [
'dialogVisible',
'parameter',
'value',
],
components: {
PrismEditor,
workflowHelpers,
).extend({
name: 'CodeEdit',
props: ['codeAutocomplete', 'parameter', 'path', 'type', 'value'],
data() {
return {
monacoInstance: null as monaco.editor.IStandaloneCodeEditor | null,
monacoLibrary: null as monaco.IDisposable | null,
};
},
mounted() {
setTimeout(this.loadEditor);
},
destroyed() {
if (this.monacoLibrary) {
this.monacoLibrary.dispose();
}
},
methods: {
closeDialog() {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
data () {
return {
};
},
methods: {
valueChanged (value: string) {
this.$emit('valueChanged', value);
},
closeDialog () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
createSimpleRepresentation(inputData: object | null | undefined | boolean | string | number | boolean[] | string[] | number[] | object[]): object | null | undefined | boolean | string | number | boolean[] | string[] | number[] | object[] {
if (inputData === null || inputData === undefined) {
return inputData;
} else if (typeof inputData === 'string') {
return '';
} else if (typeof inputData === 'boolean') {
return true;
} else if (typeof inputData === 'number') {
return 1;
} else if (Array.isArray(inputData)) {
return inputData.map(value => this.createSimpleRepresentation(value));
} else if (typeof inputData === 'object') {
const returnData: { [key: string]: object } = {};
Object.keys(inputData).forEach(key => {
// @ts-ignore
returnData[key] = this.createSimpleRepresentation(inputData[key]);
});
return returnData;
}
return inputData;
},
});
loadEditor() {
if (!this.$refs.code) return;
this.monacoInstance = monaco.editor.create(this.$refs.code as HTMLElement, {
value: this.value,
language: this.type === 'code' ? 'javascript' : 'json',
tabSize: 2,
wordBasedSuggestions: false,
readOnly: this.isReadOnly,
minimap: {
enabled: false,
},
});
this.monacoInstance.onDidChangeModelContent(() => {
const model = this.monacoInstance!.getModel();
if (model) {
this.$emit('valueChanged', model.getValue());
}
});
monaco.editor.defineTheme('n8nCustomTheme', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#f5f2f0',
},
});
monaco.editor.setTheme('n8nCustomTheme');
if (this.type === 'code') {
// As wordBasedSuggestions: false does not have any effect does it however seem
// to remove all all suggestions from the editor if I do this
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
allowNonTsExtensions: true,
});
this.loadAutocompleteData();
} else if (this.type === 'json') {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
});
}
},
loadAutocompleteData(): void {
if (['function', 'functionItem'].includes(this.codeAutocomplete)) {
const itemIndex = 0;
const inputName = 'main';
const mode = 'manual';
let runIndex = 0;
const executedWorkflow: IExecutionResponse | null = this.$store.getters.getWorkflowExecution;
const workflow = this.getWorkflow();
const activeNode: INodeUi | null = this.$store.getters.activeNode;
const parentNode = workflow.getParentNodes(activeNode!.name, inputName, 1);
const inputIndex = workflow.getNodeConnectionOutputIndex(activeNode!.name, parentNode[0]) || 0;
const autocompleteData: string[] = [];
const executionData = this.$store.getters.getWorkflowExecution as IExecutionResponse | null;
let runExecutionData: IRunExecutionData;
if (executionData === null) {
runExecutionData = {
resultData: {
runData: {},
},
};
} else {
runExecutionData = executionData.data;
if (runExecutionData.resultData.runData[activeNode!.name]) {
runIndex = runExecutionData.resultData.runData[activeNode!.name].length - 1;
}
}
const connectionInputData = this.connectionInputData(parentNode, inputName, runIndex, inputIndex);
const additionalProxyKeys: IWorkflowDataProxyAdditionalKeys = {
$executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
$resumeWebhookUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
};
const dataProxy = new WorkflowDataProxy(workflow, runExecutionData, runIndex, itemIndex, activeNode!.name, connectionInputData || [], {}, mode, additionalProxyKeys);
const proxy = dataProxy.getDataProxy();
const autoCompleteItems = [
`function $evaluateExpression(expression: string, itemIndex?: number): any {};`,
`function getNodeParameter(parameterName: string, itemIndex: number, fallbackValue?: any): any {};`,
`function getWorkflowStaticData(type: string): object {};`,
`function $item(itemIndex: number, runIndex?: number) {};`,
`function $items(nodeName?: string, outputIndex?: number, runIndex?: number) {};`,
];
const baseKeys = ['$env', '$executionId', '$mode', '$parameter', '$position', '$resumeWebhookUrl', '$workflow'];
const additionalKeys = ['$json', '$binary'];
if (executedWorkflow && connectionInputData && connectionInputData.length) {
baseKeys.push(...additionalKeys);
} else {
additionalKeys.forEach(key => {
autoCompleteItems.push(`const ${key} = {}`);
});
}
for (const key of baseKeys) {
autoCompleteItems.push(`const ${key} = ${JSON.stringify(this.createSimpleRepresentation(proxy[key]))}`);
}
// Add the nodes and their simplified data
const nodes: {
[key: string]: INodeExecutionData;
} = {};
for (const [nodeName, node] of Object.entries(workflow.nodes)) {
// To not load to much data create a simple representation.
nodes[nodeName] = {
json: {} as IDataObject,
parameter: this.createSimpleRepresentation(proxy.$node[nodeName].parameter) as IDataObject,
};
try {
nodes[nodeName]!.json = this.createSimpleRepresentation(proxy.$node[nodeName].json) as IDataObject;
nodes[nodeName]!.context = this.createSimpleRepresentation(proxy.$node[nodeName].context) as IDataObject;
nodes[nodeName]!.runIndex = proxy.$node[nodeName].runIndex;
if (Object.keys(proxy.$node[nodeName].binary).length) {
nodes[nodeName]!.binary = this.createSimpleRepresentation(proxy.$node[nodeName].binary) as IBinaryKeyData;
}
} catch(error) {}
}
autoCompleteItems.push(`const $node = ${JSON.stringify(nodes)}`);
if (this.codeAutocomplete === 'function') {
if (connectionInputData) {
autoCompleteItems.push(`const items = ${JSON.stringify(this.createSimpleRepresentation(connectionInputData))}`);
} else {
autoCompleteItems.push(`const items: {json: {[key: string]: any}}[] = []`);
}
} else if (this.codeAutocomplete === 'functionItem') {
if (connectionInputData) {
autoCompleteItems.push(`const item = $json`);
} else {
autoCompleteItems.push(`const item: {[key: string]: any} = {}`);
}
}
this.monacoLibrary = monaco.languages.typescript.javascriptDefaults.addExtraLib(
autoCompleteItems.join('\n'),
);
}
},
},
});
</script>
<style scoped>
.editor-description {
font-weight: bold;
padding: 0 0 0.5em 0.2em;;
.text-editor {
min-height: 30rem;
}
</style>

View file

@ -2,10 +2,10 @@
<div @keydown.stop class="collection-parameter">
<div class="collection-parameter-wrapper">
<div v-if="getProperties.length === 0" class="no-items-exist">
Currently no properties exist
<n8n-text size="small">{{ $locale.baseText('collectionParameter.noProperties') }}</n8n-text>
</div>
<parameter-input-list :parameters="getProperties" :nodeValues="nodeValues" :path="path" :hideDelete="hideDelete" @valueChanged="valueChanged" />
<parameter-input-list :parameters="getProperties" :nodeValues="nodeValues" :path="path" :hideDelete="hideDelete" :indent="true" @valueChanged="valueChanged" />
<div v-if="parameterOptions.length > 0 && !isReadOnly" class="param-options">
<n8n-button
@ -19,7 +19,7 @@
<n8n-option
v-for="item in parameterOptions"
:key="item.name"
:label="item.displayName"
:label="$locale.nodeText().collectionOptionDisplayName(parameter, item, path)"
:value="item.name">
</n8n-option>
</n8n-select>
@ -67,7 +67,8 @@ export default mixins(
},
computed: {
getPlaceholderText (): string {
return this.parameter.placeholder ? this.parameter.placeholder : 'Choose Option To Add';
const placeholder = this.$locale.nodeText().placeholder(this.parameter, this.path);
return placeholder ? placeholder : this.$locale.baseText('collectionParameter.choose');
},
getProperties (): INodeProperties[] {
const returnProperties = [];
@ -184,14 +185,14 @@ export default mixins(
<style lang="scss">
.collection-parameter {
padding-left: 2em;
padding-left: var(--spacing-s);
.param-options {
padding-top: 0.5em;
margin-top: var(--spacing-xs);
}
.no-items-exist {
margin: 0.8em 0 0.4em 0;
margin: var(--spacing-xs) 0;
}
.option {
position: relative;

View file

@ -0,0 +1,128 @@
<template>
<Modal
:name="modalName"
:eventBus="modalBus"
:center="true"
:closeOnPressEscape="false"
:beforeClose="closeDialog"
customClass="contact-prompt-modal"
width="460px"
>
<template slot="header">
<n8n-heading tag="h2" size="xlarge" color="text-dark">{{ title }}</n8n-heading>
</template>
<template v-slot:content>
<div :class="$style.description">
<n8n-text size="medium" color="text-base">{{ description }}</n8n-text>
</div>
<div @keyup.enter="send">
<n8n-input v-model="email" placeholder="Your email address" />
</div>
<div :class="$style.disclaimer">
<n8n-text size="small" color="text-base"
>David from our product team will get in touch personally</n8n-text
>
</div>
</template>
<template v-slot:footer>
<div :class="$style.footer">
<n8n-button label="Send" float="right" @click="send" :disabled="!isEmailValid" />
</div>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import { IN8nPromptResponse } from '@/Interface';
import { VALID_EMAIL_REGEX } from '@/constants';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import Modal from './Modal.vue';
export default mixins(workflowHelpers).extend({
components: { Modal },
name: 'ContactPromptModal',
props: ['modalName'],
data() {
return {
email: '',
modalBus: new Vue(),
};
},
computed: {
...mapGetters({
promptsData: 'settings/getPromptsData',
}),
title(): string {
if (this.promptsData && this.promptsData.title) {
return this.promptsData.title;
}
return 'Youre a power user 💪';
},
description(): string {
if (this.promptsData && this.promptsData.message) {
return this.promptsData.message;
}
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());
},
},
methods: {
closeDialog(): void {
this.$telemetry.track('User closed email modal', {
instance_id: this.$store.getters.instanceId,
email: null,
});
this.$store.commit('ui/closeTopModal');
},
async send() {
if (this.isEmailValid) {
const response: IN8nPromptResponse = await this.$store.dispatch(
'settings/submitContactInfo',
this.email,
);
if (response.updated) {
this.$telemetry.track('User closed email modal', {
instance_id: this.$store.getters.instanceId,
email: this.email,
});
this.$showMessage({
title: 'Thanks!',
message: "It's people like you that help make n8n better",
type: 'success',
});
}
this.$store.commit('ui/closeTopModal');
}
},
},
});
</script>
<style lang="scss" module>
.description {
margin-bottom: var(--spacing-s);
}
.disclaimer {
margin-top: var(--spacing-4xs);
}
</style>
<style lang="scss">
.dialog-wrapper {
.contact-prompt-modal {
.el-dialog__body {
padding: 16px 24px 24px;
}
}
}
</style>

View file

@ -38,7 +38,7 @@ export default mixins(copyPaste, showMessage).extend({
this.copyToClipboard(this.$props.copyContent);
this.$showMessage({
title: 'Copied',
title: this.$locale.baseText('credentialEdit.credentialEdit.showMessage.title'),
message: this.$props.successMessage,
type: 'success',
});
@ -53,6 +53,7 @@ export default mixins(copyPaste, showMessage).extend({
span {
font-family: Monaco, Consolas;
line-height: 1.5;
font-size: var(--font-size-s);
}
padding: var(--spacing-xs);

View file

@ -3,17 +3,17 @@
<banner
v-show="showValidationWarning"
theme="danger"
message="Please check the errors below"
:message="$locale.baseText('credentialEdit.credentialConfig.pleaseCheckTheErrorsBelow')"
/>
<banner
v-if="authError && !showValidationWarning"
theme="danger"
message="Couldnt connect with these settings"
:message="$locale.baseText('credentialEdit.credentialConfig.couldntConnectWithTheseSettings')"
:details="authError"
buttonLabel="Retry"
:buttonLabel="$locale.baseText('credentialEdit.credentialConfig.retry')"
buttonLoadingLabel="Retrying"
buttonTitle="Retry credentials test"
:buttonTitle="$locale.baseText('credentialEdit.credentialConfig.retryCredentialTest')"
:buttonLoading="isRetesting"
@click="$emit('retest')"
/>
@ -21,35 +21,37 @@
<banner
v-show="showOAuthSuccessBanner && !showValidationWarning"
theme="success"
message="Account connected"
buttonLabel="Reconnect"
buttonTitle="Reconnect OAuth Credentials"
:message="$locale.baseText('credentialEdit.credentialConfig.accountConnected')"
:buttonLabel="$locale.baseText('credentialEdit.credentialConfig.reconnect')"
:buttonTitle="$locale.baseText('credentialEdit.credentialConfig.reconnectOAuth2Credential')"
@click="$emit('oauth')"
/>
<banner
v-show="testedSuccessfully && !showValidationWarning"
theme="success"
message="Connection tested successfully"
buttonLabel="Retry"
buttonLoadingLabel="Retrying"
buttonTitle="Retry credentials test"
:message="$locale.baseText('credentialEdit.credentialConfig.connectionTestedSuccessfully')"
:buttonLabel="$locale.baseText('credentialEdit.credentialConfig.retry')"
:buttonLoadingLabel="$locale.baseText('credentialEdit.credentialConfig.retrying')"
:buttonTitle="$locale.baseText('credentialEdit.credentialConfig.retryCredentialTest')"
:buttonLoading="isRetesting"
@click="$emit('retest')"
/>
<n8n-info-tip v-if="documentationUrl && credentialProperties.length">
Need help filling out these fields?
<a :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open docs</a>
{{ $locale.baseText('credentialEdit.credentialConfig.needHelpFillingOutTheseFields') }}
<a :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">
{{ $locale.baseText('credentialEdit.credentialConfig.openDocs') }}
</a>
</n8n-info-tip>
<CopyInput
v-if="isOAuthType && credentialProperties.length"
label="OAuth Redirect URL"
:label="$locale.baseText('credentialEdit.credentialConfig.oAuthRedirectUrl')"
:copyContent="oAuthCallbackUrl"
copyButtonText="Click to copy"
:subtitle="`In ${appName}, use the URL above when prompted to enter an OAuth callback or redirect URL`"
successMessage="Redirect URL copied to clipboard"
:copyButtonText="$locale.baseText('credentialEdit.credentialConfig.clickToCopy')"
:subtitle="$locale.baseText('credentialEdit.credentialConfig.subtitle', { interpolate: { appName } })"
:successMessage="$locale.baseText('credentialEdit.credentialConfig.redirectUrlCopiedToClipboard')"
/>
<CredentialInputs
@ -70,7 +72,7 @@
</template>
<script lang="ts">
import { ICredentialType } from 'n8n-workflow';
import { ICredentialType, INodeTypeDescription } from 'n8n-workflow';
import { getAppNameFromCredType } from '../helpers';
import Vue from 'vue';
@ -78,8 +80,11 @@ import Banner from '../Banner.vue';
import CopyInput from '../CopyInput.vue';
import CredentialInputs from './CredentialInputs.vue';
import OauthButton from './OauthButton.vue';
import { restApi } from '@/components/mixins/restApi';
import { addCredentialTranslation } from '@/plugins/i18n';
import mixins from 'vue-typed-mixins';
export default Vue.extend({
export default mixins(restApi).extend({
name: 'CredentialConfig',
components: {
Banner,
@ -89,6 +94,7 @@ export default Vue.extend({
},
props: {
credentialType: {
type: Object,
},
credentialProperties: {
type: Array,
@ -121,17 +127,35 @@ export default Vue.extend({
type: Boolean,
},
},
async beforeMount() {
if (this.$store.getters.defaultLocale === 'en') return;
this.$store.commit('setActiveCredentialType', this.credentialType.name);
const key = `n8n-nodes-base.credentials.${this.credentialType.name}`;
if (this.$locale.exists(key)) return;
const credTranslation = await this.restApi().getCredentialTranslation(this.credentialType.name);
addCredentialTranslation(
{ [this.credentialType.name]: credTranslation },
this.$store.getters.defaultLocale,
);
},
computed: {
appName(): string {
if (!this.credentialType) {
return '';
}
const appName = getAppNameFromCredType(
(this.credentialType as ICredentialType).displayName,
);
return appName || "the service you're connecting to";
return appName || this.$locale.baseText('credentialEdit.credentialConfig.theServiceYouReConnectingTo');
},
credentialTypeName(): string {
return (this.credentialType as ICredentialType).name;
@ -165,6 +189,16 @@ export default Vue.extend({
},
},
methods: {
/**
* Get the current version for a node type.
*/
async getCurrentNodeVersion(targetNodeType: string) {
const { allNodeTypes }: { allNodeTypes: INodeTypeDescription[] } = this.$store.getters;
const found = allNodeTypes.find(nodeType => nodeType.name === targetNodeType);
return found ? found.version : 1;
},
onDataChange (event: { name: string; value: string | number | boolean | Date | null }): void {
this.$emit('change', event);
},

View file

@ -24,8 +24,8 @@
<div :class="$style.credActions">
<n8n-icon-button
v-if="currentCredential"
size="medium"
title="Delete"
size="small"
:title="$locale.baseText('credentialEdit.credentialEdit.delete')"
icon="trash"
type="text"
:disabled="isSaving"
@ -36,7 +36,9 @@
v-if="hasUnsavedChanges || credentialId"
:saved="!hasUnsavedChanges && !isTesting"
:isSaving="isSaving || isTesting"
:savingLabel="isTesting ? 'Testing' : 'Saving'"
:savingLabel="isTesting
? $locale.baseText('credentialEdit.credentialEdit.testing')
: $locale.baseText('credentialEdit.credentialEdit.saving')"
@click="saveCredential"
/>
</div>
@ -53,10 +55,10 @@
:light="true"
>
<n8n-menu-item index="connection" :class="$style.credTab"
><span slot="title">Connection</span></n8n-menu-item
><span slot="title">{{ $locale.baseText('credentialEdit.credentialEdit.connection') }}</span></n8n-menu-item
>
<n8n-menu-item index="details" :class="$style.credTab"
><span slot="title">Details</span></n8n-menu-item
><span slot="title">{{ $locale.baseText('credentialEdit.credentialEdit.details') }}</span></n8n-menu-item
>
</n8n-menu>
</div>
@ -349,20 +351,20 @@ export default mixins(showMessage, nodeHelpers).extend({
if (this.hasUnsavedChanges) {
const displayName = this.credentialType ? this.credentialType.displayName : '';
keepEditing = await this.confirmMessage(
`Are you sure you want to throw away the changes you made to the ${displayName} credential?`,
'Close without saving?',
this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose1.message', { interpolate: { credentialDisplayName: displayName } }),
this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose1.headline'),
null,
'Keep editing',
'Close',
this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose1.cancelButtonText'),
this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose1.confirmButtonText'),
);
}
else if (this.isOAuthType && !this.isOAuthConnected) {
keepEditing = await this.confirmMessage(
`You need to connect your credential for it to work`,
'Close without connecting?',
this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.message'),
this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.headline'),
null,
'Keep editing',
'Close',
this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.cancelButtonText'),
this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.beforeClose2.confirmButtonText'),
);
}
@ -400,7 +402,9 @@ export default mixins(showMessage, nodeHelpers).extend({
this.$store.getters['credentials/getCredentialTypeByName'](name);
if (!credentialsData) {
throw new Error(`Could not find credentials of type: ${name}`);
throw new Error(
this.$locale.baseText('credentialEdit.credentialEdit.couldNotFindCredentialOfType') + ':' + name,
);
}
if (credentialsData.extends === undefined) {
@ -436,7 +440,7 @@ export default mixins(showMessage, nodeHelpers).extend({
});
if (!currentCredentials) {
throw new Error(
`Could not find the credentials with the id: ${this.credentialId}`,
this.$locale.baseText('credentialEdit.credentialEdit.couldNotFindCredentialWithId') + ':' + this.credentialId,
);
}
@ -448,11 +452,11 @@ export default mixins(showMessage, nodeHelpers).extend({
this.nodeAccess[access.nodeType] = access;
},
);
} catch (e) {
} catch (error) {
this.$showError(
e,
'Problem loading credentials',
'There was a problem loading the credentials:',
error,
this.$locale.baseText('credentialEdit.credentialEdit.showError.loadCredential.title'),
this.$locale.baseText('credentialEdit.credentialEdit.showError.loadCredential.message'),
);
this.closeDialog();
@ -657,8 +661,8 @@ export default mixins(showMessage, nodeHelpers).extend({
} catch (error) {
this.$showError(
error,
'Problem creating credentials',
'There was a problem creating the credentials:',
this.$locale.baseText('credentialEdit.credentialEdit.showError.createCredential.title'),
this.$locale.baseText('credentialEdit.credentialEdit.showError.createCredential.message'),
);
return null;
@ -686,8 +690,8 @@ export default mixins(showMessage, nodeHelpers).extend({
} catch (error) {
this.$showError(
error,
'Problem updating credentials',
'There was a problem updating the credentials:',
this.$locale.baseText('credentialEdit.credentialEdit.showError.updateCredential.title'),
this.$locale.baseText('credentialEdit.credentialEdit.showError.updateCredential.message'),
);
return null;
@ -708,10 +712,10 @@ export default mixins(showMessage, nodeHelpers).extend({
const savedCredentialName = this.currentCredential.name;
const deleteConfirmed = await this.confirmMessage(
`Are you sure you want to delete "${savedCredentialName}" credentials?`,
'Delete Credentials?',
this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.message', { interpolate: { savedCredentialName } }),
this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.headline'),
null,
'Yes, delete!',
this.$locale.baseText('credentialEdit.credentialEdit.confirmMessage.deleteCredential.confirmButtonText'),
);
if (deleteConfirmed === false) {
@ -727,8 +731,8 @@ export default mixins(showMessage, nodeHelpers).extend({
} catch (error) {
this.$showError(
error,
'Problem deleting credentials',
'There was a problem deleting the credentials:',
this.$locale.baseText('credentialEdit.credentialEdit.showError.deleteCredential.title'),
this.$locale.baseText('credentialEdit.credentialEdit.showError.deleteCredential.message'),
);
this.isDeleting = false;
@ -740,8 +744,11 @@ export default mixins(showMessage, nodeHelpers).extend({
this.updateNodesCredentialsIssues();
this.$showMessage({
title: 'Credentials deleted',
message: `The credential "${savedCredentialName}" was deleted!`,
title: this.$locale.baseText('credentialEdit.credentialEdit.showMessage.title'),
message: this.$locale.baseText(
'credentialEdit.credentialEdit.showMessage.message',
{ interpolate: { savedCredentialName } },
),
type: 'success',
});
this.closeDialog();
@ -778,8 +785,8 @@ export default mixins(showMessage, nodeHelpers).extend({
} catch (error) {
this.$showError(
error,
'OAuth Authorization Error',
'Error generating authorization URL:',
this.$locale.baseText('credentialEdit.credentialEdit.showError.generateAuthorizationUrl.title'),
this.$locale.baseText('credentialEdit.credentialEdit.showError.generateAuthorizationUrl.message'),
);
return;

View file

@ -2,7 +2,9 @@
<div :class="$style.container">
<el-row>
<el-col :span="8" :class="$style.accessLabel">
<span>Allow use by</span>
<n8n-text :compact="true" :bold="true">
{{ $locale.baseText('credentialEdit.credentialInfo.allowUseBy') }}
</n8n-text>
</el-col>
<el-col :span="16">
<div
@ -11,7 +13,10 @@
:class="$style.valueLabel"
>
<el-checkbox
:label="node.displayName"
:label="$locale.headerText({
key: `headers.${shortNodeType(node)}.displayName`,
fallback: node.displayName,
})"
:value="!!nodeAccess[node.name]"
@change="(val) => onNodeAccessChange(node.name, val)"
/>
@ -20,26 +25,32 @@
</el-row>
<el-row v-if="currentCredential">
<el-col :span="8" :class="$style.label">
<span>Created</span>
<n8n-text :compact="true" :bold="true">
{{ $locale.baseText('credentialEdit.credentialInfo.created') }}
</n8n-text>
</el-col>
<el-col :span="16" :class="$style.valueLabel">
<TimeAgo :date="currentCredential.createdAt" :capitalize="true" />
<n8n-text :compact="true"><TimeAgo :date="currentCredential.createdAt" :capitalize="true" /></n8n-text>
</el-col>
</el-row>
<el-row v-if="currentCredential">
<el-col :span="8" :class="$style.label">
<span>Last modified</span>
<n8n-text :compact="true" :bold="true">
{{ $locale.baseText('credentialEdit.credentialInfo.lastModified') }}
</n8n-text>
</el-col>
<el-col :span="16" :class="$style.valueLabel">
<TimeAgo :date="currentCredential.updatedAt" :capitalize="true" />
<n8n-text :compact="true"><TimeAgo :date="currentCredential.updatedAt" :capitalize="true" /></n8n-text>
</el-col>
</el-row>
<el-row v-if="currentCredential">
<el-col :span="8" :class="$style.label">
<span>ID</span>
<n8n-text :compact="true" :bold="true">
{{ $locale.baseText('credentialEdit.credentialInfo.id') }}
</n8n-text>
</el-col>
<el-col :span="16" :class="$style.valueLabel">
<span>{{currentCredential.id}}</span>
<n8n-text :compact="true">{{ currentCredential.id }}</n8n-text>
</el-col>
</el-row>
</div>
@ -49,6 +60,7 @@
import Vue from 'vue';
import TimeAgo from '../TimeAgo.vue';
import { INodeTypeDescription } from 'n8n-workflow';
export default Vue.extend({
name: 'CredentialInfo',
@ -63,6 +75,9 @@ export default Vue.extend({
value,
});
},
shortNodeType(nodeType: INodeTypeDescription) {
return this.$locale.shortNodeType(nodeType.name);
},
},
});
</script>

View file

@ -4,12 +4,12 @@
v-if="isGoogleOAuthType"
:src="basePath + 'google-signin-light.png'"
:class="$style.googleIcon"
alt="Sign in with Google"
:alt="$locale.baseText('credentialEdit.oAuthButton.signInWithGoogle')"
@click.stop="$emit('click')"
/>
<n8n-button
v-else
label="Connect my account"
:label="$locale.baseText('credentialEdit.oAuthButton.connectMyAccount')"
size="large"
@click.stop="$emit('click')"
/>
@ -18,6 +18,7 @@
<script lang="ts">
import Vue from 'vue';
import mixins from 'vue-typed-mixins';
export default Vue.extend({
props: {

View file

@ -2,32 +2,32 @@
<Modal
:name="CREDENTIAL_LIST_MODAL_KEY"
width="80%"
title="Credentials"
:title="$locale.baseText('credentialsList.credentials')"
>
<template v-slot:content>
<n8n-heading tag="h3" size="small" color="text-light">Your saved credentials:</n8n-heading>
<n8n-heading tag="h3" size="small" color="text-light">{{ $locale.baseText('credentialsList.yourSavedCredentials') + ':' }}</n8n-heading>
<div class="new-credentials-button">
<n8n-button
title="Create New Credentials"
:title="$locale.baseText('credentialsList.createNewCredential')"
icon="plus"
label="Add New"
:label="$locale.baseText('credentialsList.addNew')"
size="large"
@click="createCredential()"
/>
</div>
<el-table :data="credentialsToDisplay" :default-sort = "{prop: 'name', order: 'ascending'}" stripe max-height="450" @row-click="editCredential">
<el-table-column property="name" label="Name" class-name="clickable" sortable></el-table-column>
<el-table-column property="type" label="Type" class-name="clickable" sortable></el-table-column>
<el-table-column property="createdAt" label="Created" class-name="clickable" sortable></el-table-column>
<el-table-column property="updatedAt" label="Updated" class-name="clickable" sortable></el-table-column>
<el-table-column property="name" :label="$locale.baseText('credentialsList.name')" class-name="clickable" sortable></el-table-column>
<el-table-column property="type" :label="$locale.baseText('credentialsList.type')" class-name="clickable" sortable></el-table-column>
<el-table-column property="createdAt" :label="$locale.baseText('credentialsList.created')" class-name="clickable" sortable></el-table-column>
<el-table-column property="updatedAt" :label="$locale.baseText('credentialsList.updated')" class-name="clickable" sortable></el-table-column>
<el-table-column
label="Operations"
:label="$locale.baseText('credentialsList.operations')"
width="120">
<template slot-scope="scope">
<div class="cred-operations">
<n8n-icon-button title="Edit Credentials" @click.stop="editCredential(scope.row)" icon="pen" />
<n8n-icon-button title="Delete Credentials" @click.stop="deleteCredential(scope.row)" icon="trash" />
<n8n-icon-button :title="$locale.baseText('credentialsList.editCredential')" @click.stop="editCredential(scope.row)" size="small" icon="pen" />
<n8n-icon-button :title="$locale.baseText('credentialsList.deleteCredential')" @click.stop="deleteCredential(scope.row)" size="small" icon="trash" />
</div>
</template>
</el-table-column>
@ -103,7 +103,16 @@ export default mixins(
},
async deleteCredential (credential: ICredentialsResponse) {
const deleteConfirmed = await this.confirmMessage(`Are you sure you want to delete "${credential.name}" credentials?`, 'Delete Credentials?', null, 'Yes, delete!');
const deleteConfirmed = await this.confirmMessage(
this.$locale.baseText(
'credentialsList.confirmMessage.message',
{ interpolate: { credentialName: credential.name }},
),
this.$locale.baseText('credentialsList.confirmMessage.headline'),
null,
this.$locale.baseText('credentialsList.confirmMessage.confirmButtonText'),
this.$locale.baseText('credentialsList.confirmMessage.cancelButtonText'),
);
if (deleteConfirmed === false) {
return;
@ -112,7 +121,11 @@ export default mixins(
try {
await this.$store.dispatch('credentials/deleteCredential', {id: credential.id});
} catch (error) {
this.$showError(error, 'Problem deleting credentials', 'There was a problem deleting the credentials:');
this.$showError(
error,
this.$locale.baseText('credentialsList.showError.deleteCredential.title'),
this.$locale.baseText('credentialsList.showError.deleteCredential.message'),
);
return;
}
@ -121,8 +134,11 @@ export default mixins(
this.updateNodesCredentialsIssues();
this.$showMessage({
title: 'Credentials deleted',
message: `The credential "${credential.name}" was deleted!`,
title: this.$locale.baseText('credentialsList.showMessage.title'),
message: this.$locale.baseText(
'credentialsList.showMessage.message',
{ interpolate: { credentialName: credential.name }},
),
type: 'success',
});
},

View file

@ -7,15 +7,15 @@
maxWidth="460px"
>
<template slot="header">
<h2 :class="$style.title">Add new credential</h2>
<h2 :class="$style.title">{{ $locale.baseText('credentialSelectModal.addNewCredential') }}</h2>
</template>
<template slot="content">
<div>
<div :class="$style.subtitle">Select an app or service to connect to</div>
<div :class="$style.subtitle">{{ $locale.baseText('credentialSelectModal.selectAnAppOrServiceToConnectTo') }}</div>
<n8n-select
filterable
defaultFirstOption
placeholder="Search for app..."
:placeholder="$locale.baseText('credentialSelectModal.searchForApp')"
size="xlarge"
ref="select"
:value="selected"
@ -35,7 +35,7 @@
<template slot="footer">
<div :class="$style.footer">
<n8n-button
label="Continue"
:label="$locale.baseText('credentialSelectModal.continue')"
float="right"
size="large"
:disabled="!selected"

View file

@ -3,7 +3,7 @@
:visible="!!node"
:before-close="close"
:custom-class="`classic data-display-wrapper`"
width="80%"
width="85%"
append-to-body
@opened="showDocumentHelp = true"
>
@ -15,7 +15,7 @@
<transition name="fade">
<div v-if="nodeType && showDocumentHelp" class="doc-help-wrapper">
<svg id="help-logo" :href="documentationUrl" target="_blank" width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>Node Documentation</title>
<title>{{ $locale.baseText('dataDisplay.nodeDocumentation') }}</title>
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-1127.000000, -836.000000)" fill-rule="nonzero">
<g transform="translate(1117.000000, 825.000000)">
@ -31,7 +31,7 @@
</svg>
<div class="text">
Need help? <a id="doc-hyperlink" :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">Open {{nodeType.displayName}} documentation</a>
{{ $locale.baseText('dataDisplay.needHelp') }} <a id="doc-hyperlink" :href="documentationUrl" target="_blank" @click="onDocumentationUrlClick">{{ $locale.baseText('dataDisplay.openDocumentationFor', { interpolate: { nodeTypeDisplayName: nodeType.displayName } }) }}</a>
</div>
</div>
</transition>

View file

@ -1,12 +1,12 @@
<template >
<template>
<span class="static-text-wrapper">
<span v-show="!editActive" title="Click to change">
<span v-show="!editActive" :title="$locale.baseText('displayWithChange.clickToChange')">
<span class="static-text" @mousedown="startEdit">{{currentValue}}</span>
</span>
<span v-show="editActive">
<input class="edit-field" ref="inputField" type="text" v-model="newValue" @keydown.enter.stop.prevent="setValue" @keydown.escape.stop.prevent="cancelEdit" @keydown.stop="noOp" @blur="cancelEdit" />
<font-awesome-icon icon="times" @mousedown="cancelEdit" class="icons clickable" title="Cancel Edit" />
<font-awesome-icon icon="check" @mousedown="setValue" class="icons clickable" title="Set Value" />
<font-awesome-icon icon="times" @mousedown="cancelEdit" class="icons clickable" :title="$locale.baseText('displayWithChange.cancelEdit')" />
<font-awesome-icon icon="check" @mousedown="setValue" class="icons clickable" :title="$locale.baseText('displayWithChange.setValue')" />
</span>
</span>
</template>
@ -33,6 +33,15 @@ export default mixins(genericHelpers).extend({
return path.split('.').reduce((acc, part) => acc && acc[part], obj);
};
if (this.keyName === 'name' && this.node.type.startsWith('n8n-nodes-base.')) {
const shortNodeType = this.$locale.shortNodeType(this.node.type);
return this.$locale.headerText({
key: `headers.${shortNodeType}.displayName`,
fallback: getDescendantProp(this.node, this.keyName),
});
}
return getDescendantProp(this.node, this.keyName);
},
},

View file

@ -3,7 +3,7 @@
:name="modalName"
:eventBus="modalBus"
@enter="save"
title="Duplicate Workflow"
:title="$locale.baseText('duplicateWorkflowDialog.duplicateWorkflow')"
:center="true"
minWidth="420px"
maxWidth="420px"
@ -13,7 +13,7 @@
<n8n-input
v-model="name"
ref="nameInput"
placeholder="Enter workflow name"
:placeholder="$locale.baseText('duplicateWorkflowDialog.enterWorkflowName')"
:maxlength="MAX_WORKFLOW_NAME_LENGTH"
/>
<TagsDropdown
@ -23,15 +23,15 @@
@blur="onTagsBlur"
@esc="onTagsEsc"
@update="onTagsUpdate"
placeholder="Choose or create a tag"
:placeholder="$locale.baseText('duplicateWorkflowDialog.chooseOrCreateATag')"
ref="dropdown"
/>
</div>
</template>
<template v-slot:footer="{ close }">
<div :class="$style.footer">
<n8n-button @click="save" :loading="isSaving" label="Save" float="right" />
<n8n-button type="outline" @click="close" :disabled="isSaving" label="Cancel" float="right" />
<n8n-button @click="save" :loading="isSaving" :label="$locale.baseText('duplicateWorkflowDialog.save')" float="right" />
<n8n-button type="outline" @click="close" :disabled="isSaving" :label="$locale.baseText('duplicateWorkflowDialog.cancel')" float="right" />
</div>
</template>
</Modal>
@ -101,8 +101,8 @@ export default mixins(showMessage, workflowHelpers).extend({
const name = this.name.trim();
if (!name) {
this.$showMessage({
title: "Name missing",
message: `Please enter a name.`,
title: this.$locale.baseText('duplicateWorkflowDialog.showMessage.title'),
message: this.$locale.baseText('duplicateWorkflowDialog.showMessage.message'),
type: "error",
});

View file

@ -1,18 +1,18 @@
<template>
<div>
<div class="error-header">
<div class="error-message">ERROR: {{error.message}}</div>
<div class="error-message">{{ $locale.baseText('nodeErrorView.error') + ': ' + error.message }}</div>
<div class="error-description" v-if="error.description">{{error.description}}</div>
</div>
<details>
<summary class="error-details__summary">
<font-awesome-icon class="error-details__icon" icon="angle-right" /> Details
<font-awesome-icon class="error-details__icon" icon="angle-right" /> {{ $locale.baseText('nodeErrorView.details') }}
</summary>
<div class="error-details__content">
<div v-if="error.timestamp">
<el-card class="box-card" shadow="never">
<div slot="header" class="clearfix box-card__title">
<span>Time</span>
<span>{{ $locale.baseText('nodeErrorView.time') }}</span>
</div>
<div>
{{new Date(error.timestamp).toLocaleString()}}
@ -22,7 +22,7 @@
<div v-if="error.httpCode">
<el-card class="box-card" shadow="never">
<div slot="header" class="clearfix box-card__title">
<span>HTTP-Code</span>
<span>{{ $locale.baseText('nodeErrorView.httpCode') }}</span>
</div>
<div>
{{error.httpCode}}
@ -32,13 +32,13 @@
<div v-if="error.cause">
<el-card class="box-card" shadow="never">
<div slot="header" class="clearfix box-card__title">
<span>Cause</span>
<span>{{ $locale.baseText('nodeErrorView.cause') }}</span>
<br>
<span class="box-card__subtitle">Data below may contain sensitive information. Proceed with caution when sharing.</span>
<span class="box-card__subtitle">{{ $locale.baseText('nodeErrorView.dataBelowMayContain') }}</span>
</div>
<div>
<div class="copy-button" v-if="displayCause">
<n8n-icon-button @click="copyCause" title="Copy to Clipboard" icon="copy" />
<n8n-icon-button @click="copyCause" :title="$locale.baseText('nodeErrorView.copyToClipboard')" icon="copy" />
</div>
<vue-json-pretty
v-if="displayCause"
@ -50,7 +50,7 @@
class="json-data"
/>
<span v-else>
<font-awesome-icon icon="info-circle" /> The error cause is too large to be displayed.
<font-awesome-icon icon="info-circle" />{{ $locale.baseText('nodeErrorView.theErrorCauseIsTooLargeToBeDisplayed') }}
</span>
</div>
</el-card>
@ -58,7 +58,7 @@
<div v-if="error.stack">
<el-card class="box-card" shadow="never">
<div slot="header" class="clearfix box-card__title">
<span>Stack</span>
<span>{{ $locale.baseText('nodeErrorView.stack') }}</span>
</div>
<div>
<pre><code>{{error.stack}}</code></pre>
@ -103,8 +103,8 @@ export default mixins(
},
copySuccess() {
this.$showMessage({
title: 'Copied to clipboard',
message: '',
title: this.$locale.baseText('nodeErrorView.showMessage.title'),
message: this.$locale.baseText('nodeErrorView.showMessage.message'),
type: 'info',
});
},

View file

@ -1,13 +1,13 @@
<template>
<span>
<el-dialog :visible="dialogVisible" append-to-body width="80%" :title="`Workflow Executions ${combinedExecutions.length}/${finishedExecutionsCountEstimated === true ? '~' : ''}${combinedExecutionsCount}`" :before-close="closeDialog">
<el-dialog :visible="dialogVisible" append-to-body width="80%" :title="`${$locale.baseText('executionsList.workflowExecutions')} ${combinedExecutions.length}/${finishedExecutionsCountEstimated === true ? '~' : ''}${combinedExecutionsCount}`" :before-close="closeDialog">
<div class="filters">
<el-row>
<el-col :span="2" class="filter-headline">
Filters:
{{ $locale.baseText('executionsList.filters') }}:
</el-col>
<el-col :span="7">
<n8n-select v-model="filter.workflowId" placeholder="Select Workflow" size="medium" filterable @change="handleFilterChanged">
<n8n-select v-model="filter.workflowId" :placeholder="$locale.baseText('executionsList.selectWorkflow')" size="medium" filterable @change="handleFilterChanged">
<n8n-option
v-for="item in workflows"
:key="item.id"
@ -17,7 +17,7 @@
</n8n-select>
</el-col>
<el-col :span="5" :offset="1">
<n8n-select v-model="filter.status" placeholder="Select Status" size="medium" filterable @change="handleFilterChanged">
<n8n-select v-model="filter.status" :placeholder="$locale.baseText('executionsList.selectStatus')" size="medium" filterable @change="handleFilterChanged">
<n8n-option
v-for="item in statuses"
:key="item.id"
@ -27,15 +27,15 @@
</n8n-select>
</el-col>
<el-col :span="4" :offset="5" class="autorefresh">
<el-checkbox v-model="autoRefresh" @change="handleAutoRefreshToggle">Auto refresh</el-checkbox>
<el-checkbox v-model="autoRefresh" @change="handleAutoRefreshToggle">{{ $locale.baseText('executionsList.autoRefresh') }}</el-checkbox>
</el-col>
</el-row>
</div>
<div class="selection-options">
<span v-if="checkAll === true || isIndeterminate === true">
Selected: {{numSelected}} / <span v-if="finishedExecutionsCountEstimated === true">~</span>{{finishedExecutionsCount}}
<n8n-icon-button title="Delete Selected" icon="trash" size="small" @click="handleDeleteSelected" />
{{ $locale.baseText('executionsList.selected') }}: {{numSelected}} / <span v-if="finishedExecutionsCountEstimated === true">~</span>{{finishedExecutionsCount}}
<n8n-icon-button :title="$locale.baseText('executionsList.deleteSelected')" icon="trash" size="mini" @click="handleDeleteSelected" />
</span>
</div>
@ -49,49 +49,47 @@
<el-checkbox v-if="scope.row.stoppedAt !== undefined && scope.row.id" :value="selectedItems[scope.row.id.toString()] || checkAll" @change="handleCheckboxChanged(scope.row.id)" label=" "></el-checkbox>
</template>
</el-table-column>
<el-table-column property="startedAt" label="Started At / ID" width="205">
<el-table-column property="startedAt" :label="$locale.baseText('executionsList.startedAtId')" width="205">
<template slot-scope="scope">
{{convertToDisplayDate(scope.row.startedAt)}}<br />
<small v-if="scope.row.id">ID: {{scope.row.id}}</small>
</template>
</el-table-column>
<el-table-column property="workflowName" label="Name">
<el-table-column property="workflowName" :label="$locale.baseText('executionsList.name')">
<template slot-scope="scope">
<span class="workflow-name">
{{scope.row.workflowName || '[UNSAVED WORKFLOW]'}}
{{ scope.row.workflowName || $locale.baseText('executionsList.unsavedWorkflow') }}
</span>
<span v-if="scope.row.stoppedAt === undefined">
(running)
({{ $locale.baseText('executionsList.running') }})
</span>
<span v-if="scope.row.retryOf !== undefined">
<br /><small>Retry of "{{scope.row.retryOf}}"</small>
<br /><small>{{ $locale.baseText('executionsList.retryOf') }} "{{scope.row.retryOf}}"</small>
</span>
<span v-else-if="scope.row.retrySuccessId !== undefined">
<br /><small>Success retry "{{scope.row.retrySuccessId}}"</small>
<br /><small>{{ $locale.baseText('executionsList.successRetry') }} "{{scope.row.retrySuccessId}}"</small>
</span>
</template>
</el-table-column>
<el-table-column label="Status" width="122" align="center">
<el-table-column :label="$locale.baseText('executionsList.status')" width="122" align="center">
<template slot-scope="scope" align="center">
<n8n-tooltip placement="top" >
<div slot="content" v-html="statusTooltipText(scope.row)"></div>
<span class="status-badge running" v-if="scope.row.waitTill">
Waiting
{{ $locale.baseText('executionsList.waiting') }}
</span>
<span class="status-badge running" v-else-if="scope.row.stoppedAt === undefined">
Running
{{ $locale.baseText('executionsList.running') }}
</span>
<span class="status-badge success" v-else-if="scope.row.finished">
Success
{{ $locale.baseText('executionsList.success') }}
</span>
<span class="status-badge error" v-else-if="scope.row.stoppedAt !== null">
Error
{{ $locale.baseText('executionsList.error') }}
</span>
<span class="status-badge warning" v-else>
Unknown
{{ $locale.baseText('executionsList.unknown') }}
</span>
</n8n-tooltip>
@ -101,21 +99,29 @@
v-if="scope.row.stoppedAt !== undefined && !scope.row.finished && scope.row.retryOf === undefined && scope.row.retrySuccessId === undefined && !scope.row.waitTill"
type="light"
:theme="scope.row.stoppedAt === null ? 'warning': 'danger'"
size="small"
title="Retry execution"
size="mini"
:title="$locale.baseText('executionsList.retryExecution')"
icon="redo"
/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item :command="{command: 'currentlySaved', row: scope.row}">Retry with currently saved workflow</el-dropdown-item>
<el-dropdown-item :command="{command: 'original', row: scope.row}">Retry with original workflow</el-dropdown-item>
<el-dropdown-item :command="{command: 'currentlySaved', row: scope.row}">
{{ $locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
</el-dropdown-item>
<el-dropdown-item :command="{command: 'original', row: scope.row}">
{{ $locale.baseText('executionsList.retryWithOriginalworkflow') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template>
</el-table-column>
<el-table-column property="mode" label="Mode" width="100" align="center"></el-table-column>
<el-table-column label="Running Time" width="150" align="center">
<el-table-column property="mode" :label="$locale.baseText('executionsList.mode')" width="100" align="center">
<template slot-scope="scope">
{{ $locale.baseText(`executionsList.modes.${scope.row.mode}`) }}
</template>
</el-table-column>
<el-table-column :label="$locale.baseText('executionsList.runningTime')" width="150" align="center">
<template slot-scope="scope">
<span v-if="scope.row.stoppedAt === undefined">
<font-awesome-icon icon="spinner" spin />
@ -134,10 +140,10 @@
<template slot-scope="scope">
<div class="actions-container">
<span v-if="scope.row.stoppedAt === undefined || scope.row.waitTill">
<n8n-icon-button icon="stop" title="Stop Execution" @click.stop="stopExecution(scope.row.id)" :loading="stoppingExecutions.includes(scope.row.id)" />
<n8n-icon-button icon="stop" size="small" :title="$locale.baseText('executionsList.stopExecution')" @click.stop="stopExecution(scope.row.id)" :loading="stoppingExecutions.includes(scope.row.id)" />
</span>
<span v-if="scope.row.stoppedAt !== undefined && scope.row.id" >
<n8n-icon-button icon="folder-open" title="Open Past Execution" @click.stop="displayExecution(scope.row)" />
<n8n-icon-button icon="folder-open" size="small" :title="$locale.baseText('executionsList.openPastExecution')" @click.stop="(e) => displayExecution(scope.row, e)" />
</span>
</div>
</template>
@ -145,7 +151,7 @@
</el-table>
<div class="load-more" v-if="finishedExecutionsCount > finishedExecutions.length || finishedExecutionsCountEstimated === true">
<n8n-button icon="sync" title="Load More" label="Load More" @click="loadMore()" :loading="isDataLoading" />
<n8n-button icon="sync" :title="$locale.baseText('executionsList.loadMore')" :label="$locale.baseText('executionsList.loadMore')" @click="loadMore()" :loading="isDataLoading" />
</div>
</el-dialog>
@ -224,32 +230,33 @@ export default mixins(
stoppingExecutions: [] as string[],
workflows: [] as IWorkflowShortResponse[],
statuses: [
{
id: 'ALL',
name: 'Any Status',
},
{
id: 'error',
name: 'Error',
},
{
id: 'running',
name: 'Running',
},
{
id: 'success',
name: 'Success',
},
{
id: 'waiting',
name: 'Waiting',
},
],
};
},
computed: {
statuses () {
return [
{
id: 'ALL',
name: this.$locale.baseText('executionsList.anyStatus'),
},
{
id: 'error',
name: this.$locale.baseText('executionsList.error'),
},
{
id: 'running',
name: this.$locale.baseText('executionsList.running'),
},
{
id: 'success',
name: this.$locale.baseText('executionsList.success'),
},
{
id: 'waiting',
name: this.$locale.baseText('executionsList.waiting'),
},
];
},
activeExecutions (): IExecutionsCurrentSummaryExtended[] {
return this.$store.getters.getActiveExecutions;
},
@ -324,7 +331,14 @@ export default mixins(
return false;
},
convertToDisplayDate,
displayExecution (execution: IExecutionShortResponse) {
displayExecution (execution: IExecutionShortResponse, e: PointerEvent) {
if (e.metaKey || e.ctrlKey) {
const route = this.$router.resolve({name: 'ExecutionById', params: {id: execution.id}});
window.open(route.href, '_blank');
return;
}
this.$router.push({
name: 'ExecutionById',
params: { id: execution.id },
@ -356,7 +370,16 @@ export default mixins(
}
},
async handleDeleteSelected () {
const deleteExecutions = await this.confirmMessage(`Are you sure that you want to delete the ${this.numSelected} selected executions?`, 'Delete Executions?', 'warning', 'Yes, delete!');
const deleteExecutions = await this.confirmMessage(
this.$locale.baseText(
'executionsList.confirmMessage.message',
{ interpolate: { numSelected: this.numSelected.toString() }},
),
this.$locale.baseText('executionsList.confirmMessage.headline'),
'warning',
this.$locale.baseText('executionsList.confirmMessage.confirmButtonText'),
this.$locale.baseText('executionsList.confirmMessage.cancelButtonText'),
);
if (deleteExecutions === false) {
return;
@ -377,15 +400,19 @@ export default mixins(
await this.restApi().deleteExecutions(sendData);
} catch (error) {
this.isDataLoading = false;
this.$showError(error, 'Problem deleting executions', 'There was a problem deleting the executions:');
this.$showError(
error,
this.$locale.baseText('executionsList.showError.handleDeleteSelected.title'),
this.$locale.baseText('executionsList.showError.handleDeleteSelected.message'),
);
return;
}
this.isDataLoading = false;
this.$showMessage({
title: 'Execution deleted',
message: 'The executions were deleted!',
title: this.$locale.baseText('executionsList.showMessage.handleDeleteSelected.title'),
message: this.$locale.baseText('executionsList.showMessage.handleDeleteSelected.message'),
type: 'success',
});
@ -536,10 +563,19 @@ export default mixins(
data = await this.restApi().getPastExecutions(filter, this.requestItemsPerRequest, lastId);
} catch (error) {
this.isDataLoading = false;
this.$showError(error, 'Problem loading workflows', 'There was a problem loading the workflows:');
this.$showError(
error,
this.$locale.baseText('executionsList.showError.loadMore.title'),
this.$locale.baseText('executionsList.showError.loadMore.message') + ':',
);
return;
}
data.results = data.results.map((execution) => {
// @ts-ignore
return { ...execution, mode: execution.mode };
});
this.finishedExecutions.push.apply(this.finishedExecutions, data.results);
this.finishedExecutionsCount = data.count;
this.finishedExecutionsCountEstimated = data.estimated;
@ -562,12 +598,16 @@ export default mixins(
// @ts-ignore
workflows.unshift({
id: 'ALL',
name: 'All Workflows',
name: this.$locale.baseText('executionsList.allWorkflows'),
});
Vue.set(this, 'workflows', workflows);
} catch (error) {
this.$showError(error, 'Problem loading workflows', 'There was a problem loading the workflows:');
this.$showError(
error,
this.$locale.baseText('executionsList.showError.loadWorkflows.title'),
this.$locale.baseText('executionsList.showError.loadWorkflows.message') + ':',
);
}
},
async openDialog () {
@ -590,21 +630,25 @@ export default mixins(
if (retrySuccessful === true) {
this.$showMessage({
title: 'Retry successful',
message: 'The retry was successful!',
title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulTrue.title'),
message: this.$locale.baseText('executionsList.showMessage.retrySuccessfulTrue.message'),
type: 'success',
});
} else {
this.$showMessage({
title: 'Retry unsuccessful',
message: 'The retry was not successful!',
title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulFalse.title'),
message: this.$locale.baseText('executionsList.showMessage.retrySuccessfulFalse.message'),
type: 'error',
});
}
this.isDataLoading = false;
} catch (error) {
this.$showError(error, 'Problem with retry', 'There was a problem with the retry:');
this.$showError(
error,
this.$locale.baseText('executionsList.showError.retryExecution.title'),
this.$locale.baseText('executionsList.showError.retryExecution.message'),
);
this.isDataLoading = false;
}
@ -617,7 +661,11 @@ export default mixins(
const finishedExecutionsPromise = this.loadFinishedExecutions();
await Promise.all([activeExecutionsPromise, finishedExecutionsPromise]);
} catch (error) {
this.$showError(error, 'Problem loading', 'There was a problem loading the data:');
this.$showError(
error,
this.$locale.baseText('executionsList.showError.refreshData.title'),
this.$locale.baseText('executionsList.showError.refreshData.message') + ':',
);
}
this.isDataLoading = false;
@ -626,23 +674,41 @@ export default mixins(
if (entry.waitTill) {
const waitDate = new Date(entry.waitTill);
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) {
return 'The workflow is waiting indefinitely for an incoming webhook call.';
return this.$locale.baseText('executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely');
}
return `The worklow is waiting till ${waitDate.toLocaleDateString()} ${waitDate.toLocaleTimeString()}.`;
return this.$locale.baseText(
'executionsList.statusTooltipText.theWorkflowIsWaitingTill',
{
interpolate: {
waitDateDate: waitDate.toLocaleDateString(),
waitDateTime: waitDate.toLocaleTimeString(),
},
},
);
} else if (entry.stoppedAt === undefined) {
return 'The worklow is currently executing.';
return this.$locale.baseText('executionsList.statusTooltipText.theWorkflowIsCurrentlyExecuting');
} else if (entry.finished === true && entry.retryOf !== undefined) {
return `The workflow execution was a retry of "${entry.retryOf}" and it was successful.`;
return this.$locale.baseText(
'executionsList.statusTooltipText.theWorkflowExecutionWasARetryOfAndItWasSuccessful',
{ interpolate: { entryRetryOf: entry.retryOf }},
);
} else if (entry.finished === true) {
return 'The worklow execution was successful.';
return this.$locale.baseText('executionsList.statusTooltipText.theWorkflowExecutionWasSuccessful');
} else if (entry.retryOf !== undefined) {
return `The workflow execution was a retry of "${entry.retryOf}" and failed.<br />New retries have to be started from the original execution.`;
return this.$locale.baseText(
'executionsList.statusTooltipText.theWorkflowExecutionWasARetryOfAndFailed',
{ interpolate: { entryRetryOf: entry.retryOf }},
);
} else if (entry.retrySuccessId !== undefined) {
return `The workflow execution failed but the retry "${entry.retrySuccessId}" was successful.`;
return this.$locale.baseText(
'executionsList.statusTooltipText.theWorkflowExecutionFailedButTheRetryWasSuccessful',
{ interpolate: { entryRetrySuccessId: entry.retrySuccessId }},
);
} else if (entry.stoppedAt === null) {
return 'The workflow execution is probably still running but it may have crashed and n8n cannot safely tell. ';
return this.$locale.baseText('executionsList.statusTooltipText.theWorkflowExecutionIsProbablyStillRunning');
} else {
return 'The workflow execution failed.';
return this.$locale.baseText('executionsList.statusTooltipText.theWorkflowExecutionFailed');
}
},
async stopExecution (activeExecutionId: string) {
@ -658,14 +724,21 @@ export default mixins(
this.stoppingExecutions.splice(index, 1);
this.$showMessage({
title: 'Execution stopped',
message: `The execution with the id "${activeExecutionId}" got stopped!`,
title: this.$locale.baseText('executionsList.showMessage.stopExecution.title'),
message: this.$locale.baseText(
'executionsList.showMessage.stopExecution.message',
{ interpolate: { activeExecutionId } },
),
type: 'success',
});
this.refreshData();
} catch (error) {
this.$showError(error, 'Problem stopping execution', 'There was a problem stopping the execuction:');
this.$showError(
error,
this.$locale.baseText('executionsList.showError.stopExecution.title'),
this.$locale.baseText('executionsList.showError.stopExecution.message'),
);
}
},
},
@ -711,12 +784,10 @@ export default mixins(
position: relative;
display: inline-block;
padding: 0 10px;
height: 22.6px;
line-height: 22.6px;
border-radius: 15px;
text-align: center;
font-weight: 400;
font-size: 12px;
font-size: var(--font-size-s);
&.error {
background-color: var(--color-danger-tint-1);

View file

@ -1,14 +1,14 @@
<template>
<div v-if="dialogVisible" @keydown.stop>
<el-dialog :visible="dialogVisible" custom-class="expression-dialog classic" append-to-body width="80%" title="Edit Expression" :before-close="closeDialog">
<el-dialog :visible="dialogVisible" custom-class="expression-dialog classic" append-to-body width="80%" :title="$locale.baseText('expressionEdit.editExpression')" :before-close="closeDialog">
<el-row>
<el-col :span="8">
<div class="header-side-menu">
<div class="headline">
Edit Expression
{{ $locale.baseText('expressionEdit.editExpression') }}
</div>
<div class="sub-headline">
Variable Selector
{{ $locale.baseText('expressionEdit.variableSelector') }}
</div>
</div>
@ -19,7 +19,7 @@
<el-col :span="16" class="right-side">
<div class="expression-editor-wrapper">
<div class="editor-description">
Expression
{{ $locale.baseText('expressionEdit.expression') }}
</div>
<div class="expression-editor">
<expression-input :parameter="parameter" ref="inputFieldExpression" rows="8" :value="value" :path="path" @change="valueChanged" @keydown.stop="noOp"></expression-input>
@ -28,7 +28,7 @@
<div class="expression-result-wrapper">
<div class="editor-description">
Result
{{ $locale.baseText('expressionEdit.result') }}
</div>
<expression-input :parameter="parameter" resolvedValue="true" ref="expressionResult" rows="8" :value="displayValue" :path="path"></expression-input>
</div>
@ -143,6 +143,7 @@ export default mixins(
.el-dialog__body {
padding: 0;
font-size: var(--font-size-s);
}
.right-side {

View file

@ -1,35 +1,39 @@
<template>
<div @keydown.stop class="fixed-collection-parameter">
<div v-if="getProperties.length === 0" class="no-items-exist">
Currently no items exist
<n8n-text size="small">{{ $locale.baseText('fixedCollectionParameter.currentlyNoItemsExist') }}</n8n-text>
</div>
<div v-for="property in getProperties" :key="property.name" class="fixed-collection-parameter-property">
<div v-if="property.displayName === '' || parameter.options.length === 1"></div>
<div v-else class="parameter-name" :title="property.displayName">{{property.displayName}}:</div>
<div v-if="multipleValues === true">
<div v-for="(value, index) in values[property.name]" :key="property.name + index" class="parameter-item">
<n8n-input-label
:label="property.displayName === '' || parameter.options.length === 1 ? '' : $locale.nodeText().inputLabelDisplayName(property, path)"
:underline="true"
:labelHoverableOnly="true"
size="small"
>
<div v-if="multipleValues === true">
<div v-for="(value, index) in values[property.name]" :key="property.name + index" class="parameter-item">
<div class="parameter-item-wrapper">
<div class="delete-option" v-if="!isReadOnly">
<font-awesome-icon icon="trash" class="reset-icon clickable" :title="$locale.baseText('fixedCollectionParameter.deleteItem')" @click="deleteOption(property.name, index)" />
<div v-if="sortable" class="sort-icon">
<font-awesome-icon v-if="index !== 0" icon="angle-up" class="clickable" :title="$locale.baseText('fixedCollectionParameter.moveUp')" @click="moveOptionUp(property.name, index)" />
<font-awesome-icon v-if="index !== (values[property.name].length -1)" icon="angle-down" class="clickable" :title="$locale.baseText('fixedCollectionParameter.moveDown')" @click="moveOptionDown(property.name, index)" />
</div>
</div>
<parameter-input-list :parameters="property.values" :nodeValues="nodeValues" :path="getPropertyPath(property.name, index)" :hideDelete="true" @valueChanged="valueChanged" />
</div>
</div>
</div>
<div v-else class="parameter-item">
<div class="parameter-item-wrapper">
<div class="delete-option" v-if="!isReadOnly">
<font-awesome-icon icon="trash" class="reset-icon clickable" title="Delete Item" @click="deleteOption(property.name, index)" />
<div v-if="sortable" class="sort-icon">
<font-awesome-icon v-if="index !== 0" icon="angle-up" class="clickable" title="Move up" @click="moveOptionUp(property.name, index)" />
<font-awesome-icon v-if="index !== (values[property.name].length -1)" icon="angle-down" class="clickable" title="Move down" @click="moveOptionDown(property.name, index)" />
</div>
<font-awesome-icon icon="trash" class="reset-icon clickable" :title="$locale.baseText('fixedCollectionParameter.deleteItem')" @click="deleteOption(property.name)" />
</div>
<parameter-input-list :parameters="property.values" :nodeValues="nodeValues" :path="getPropertyPath(property.name, index)" :hideDelete="true" @valueChanged="valueChanged" />
<parameter-input-list :parameters="property.values" :nodeValues="nodeValues" :path="getPropertyPath(property.name)" class="parameter-item" @valueChanged="valueChanged" :hideDelete="true" />
</div>
</div>
</div>
<div v-else class="parameter-item">
<div class="parameter-item-wrapper">
<div class="delete-option" v-if="!isReadOnly">
<font-awesome-icon icon="trash" class="reset-icon clickable" title="Delete Item" @click="deleteOption(property.name)" />
</div>
<parameter-input-list :parameters="property.values" :nodeValues="nodeValues" :path="getPropertyPath(property.name)" class="parameter-item" @valueChanged="valueChanged" :hideDelete="true" />
</div>
</div>
</n8n-input-label>
</div>
<div v-if="parameterOptions.length > 0 && !isReadOnly">
@ -39,7 +43,7 @@
<n8n-option
v-for="item in parameterOptions"
:key="item.name"
:label="item.displayName"
:label="$locale.nodeText().collectionOptionDisplayName(parameter, item, path)"
:value="item.name">
</n8n-option>
</n8n-select>
@ -81,7 +85,8 @@ export default mixins(genericHelpers)
},
computed: {
getPlaceholderText (): string {
return this.parameter.placeholder ? this.parameter.placeholder : 'Choose Option To Add';
const placeholder = this.$locale.nodeText().placeholder(this.parameter, this.path);
return placeholder ? placeholder : this.$locale.baseText('fixedCollectionParameter.choose');
},
getProperties (): INodePropertyCollection[] {
const returnProperties = [];
@ -221,16 +226,11 @@ export default mixins(genericHelpers)
<style scoped lang="scss">
.fixed-collection-parameter {
padding: 0 0 0 1em;
padding-left: var(--spacing-s);
}
.fixed-collection-parameter-property {
margin: 0.5em 0;
padding: 0.5em 0;
.parameter-name {
border-bottom: 1px solid #999;
}
margin: var(--spacing-xs) 0;
}
.delete-option {
@ -244,28 +244,33 @@ export default mixins(genericHelpers)
height: 100%;
}
.parameter-item-wrapper:hover > .delete-option {
.parameter-item:hover > .parameter-item-wrapper > .delete-option {
display: block;
}
.parameter-item {
position: relative;
padding: 0 0 0 1em;
margin: 0.6em 0 0.5em 0.1em;
+ .parameter-item {
.parameter-item-wrapper {
padding-top: 0.5em;
border-top: 1px dashed #999;
.delete-option {
top: 14px;
}
}
}
}
.no-items-exist {
margin: 0.8em 0;
margin: var(--spacing-xs) 0;
}
.sort-icon {
display: flex;
flex-direction: column;
margin-left: 1px;
margin-top: .5em;
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<div class="container">
<span class="title">
Execution Id:
{{ $locale.baseText('executionDetails.executionId') + ':' }}
<span>
<strong>{{ executionId }}</strong
>&nbsp;
@ -9,23 +9,23 @@
icon="check"
class="execution-icon success"
v-if="executionFinished"
title="Execution was successful"
:title="$locale.baseText('executionDetails.executionWasSuccessful')"
/>
<font-awesome-icon
icon="clock"
class="execution-icon warning"
v-else-if="executionWaiting"
title="Execution waiting"
:title="$locale.baseText('executionDetails.executionWaiting')"
/>
<font-awesome-icon
icon="times"
class="execution-icon error"
v-else
title="Execution failed"
:title="$locale.baseText('executionDetails.executionFailed')"
/>
</span>
of
<span class="primary-color clickable" title="Open Workflow">
{{ $locale.baseText('executionDetails.of') }}
<span class="primary-color clickable" :title="$locale.baseText('executionDetails.openWorkflow')">
<WorkflowNameShort :name="workflowName">
<template v-slot="{ shortenedName }">
<span @click="openWorkflow(workflowExecution.workflowId)">
@ -34,7 +34,7 @@
</template>
</WorkflowNameShort>
</span>
workflow
{{ $locale.baseText('executionDetails.workflow') }}
</span>
<ReadOnly class="read-only" />
</div>
@ -117,4 +117,8 @@ export default mixins(titleChange).extend({
.read-only {
align-self: flex-end;
}
.el-tooltip.read-only div {
max-width: 400px;
}
</style>

View file

@ -1,13 +1,25 @@
<template>
<n8n-tooltip class="primary-color" placement="bottom-end" >
<div slot="content">
You're viewing the log of a previous execution. You cannot<br />
make changes since this execution already occured. Make changes<br />
to this workflow by clicking on its name on the left.
<span v-html="$locale.baseText('executionDetails.readOnly.youreViewingTheLogOf')"></span>
</div>
<span>
<div>
<font-awesome-icon icon="exclamation-triangle" />
Read only
</span>
<span v-html="$locale.baseText('executionDetails.readOnly.readOnly')"></span>
</div>
</n8n-tooltip>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: "ReadOnly",
});
</script>
<style scoped>
svg {
margin-right: 6px;
}
</style>

View file

@ -33,7 +33,7 @@
@blur="onTagsBlur"
@update="onTagsUpdate"
@esc="onTagsEditEsc"
placeholder="Choose or create a tag"
:placeholder="$locale.baseText('workflowDetails.chooseOrCreateATag')"
ref="dropdown"
class="tags-edit"
/>
@ -46,7 +46,7 @@
class="add-tag clickable"
@click="onTagsEditEnable"
>
+ Add tag
+ {{ $locale.baseText('workflowDetails.addTag') }}
</span>
</div>
<TagsContainer
@ -62,7 +62,7 @@
<PushConnectionTracker class="actions">
<template>
<span class="activator">
<span>Active:</span>
<span>{{ $locale.baseText('workflowDetails.active') + ':' }}</span>
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" :disabled="!currentWorkflowId"/>
</span>
<SaveButton
@ -140,8 +140,9 @@ export default mixins(workflowHelpers).extend({
},
},
methods: {
onSaveButtonClick () {
this.saveCurrentWorkflow(undefined);
async onSaveButtonClick () {
const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
},
onTagsEditEnable() {
this.$data.appliedTagIds = this.currentWorkflowTagIds;
@ -172,7 +173,7 @@ export default mixins(workflowHelpers).extend({
const saved = await this.saveCurrentWorkflow({ tags });
this.$telemetry.track('User edited workflow tags', { workflow_id: this.currentWorkflowId as string, new_tag_count: tags.length });
this.$data.tagsSaving = false;
if (saved) {
this.$data.isTagsEditEnabled = false;
@ -196,8 +197,8 @@ export default mixins(workflowHelpers).extend({
const newName = name.trim();
if (!newName) {
this.$showMessage({
title: "Name missing",
message: `Please enter a name, or press 'esc' to go back to the old one.`,
title: this.$locale.baseText('workflowDetails.showMessage.title'),
message: this.$locale.baseText('workflowDetails.showMessage.message'),
type: "error",
});

View file

@ -22,94 +22,94 @@
<el-submenu index="workflow" title="Workflow" popperClass="sidebar-popper">
<template slot="title">
<font-awesome-icon icon="network-wired"/>&nbsp;
<span slot="title" class="item-title-root">Workflows</span>
<span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.workflows') }}</span>
</template>
<n8n-menu-item index="workflow-new">
<template slot="title">
<font-awesome-icon icon="file"/>&nbsp;
<span slot="title" class="item-title">New</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.new') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-open">
<template slot="title">
<font-awesome-icon icon="folder-open"/>&nbsp;
<span slot="title" class="item-title">Open</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.open') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-save">
<template slot="title">
<font-awesome-icon icon="save"/>
<span slot="title" class="item-title">Save</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.save') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-duplicate" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="copy"/>
<span slot="title" class="item-title">Duplicate</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.duplicate') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-delete" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="trash"/>
<span slot="title" class="item-title">Delete</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.delete') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-download">
<template slot="title">
<font-awesome-icon icon="file-download"/>
<span slot="title" class="item-title">Download</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.download') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-import-url">
<template slot="title">
<font-awesome-icon icon="cloud"/>
<span slot="title" class="item-title">Import from URL</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.importFromUrl') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-import-file">
<template slot="title">
<font-awesome-icon icon="hdd"/>
<span slot="title" class="item-title">Import from File</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.importFromFile') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="workflow-settings" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="cog"/>
<span slot="title" class="item-title">Settings</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.settings') }}</span>
</template>
</n8n-menu-item>
</el-submenu>
<el-submenu index="credentials" title="Credentials" popperClass="sidebar-popper">
<el-submenu index="credentials" :title="$locale.baseText('mainSidebar.credentials')" popperClass="sidebar-popper">
<template slot="title">
<font-awesome-icon icon="key"/>&nbsp;
<span slot="title" class="item-title-root">Credentials</span>
<span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.credentials') }}</span>
</template>
<n8n-menu-item index="credentials-new">
<template slot="title">
<font-awesome-icon icon="file"/>
<span slot="title" class="item-title">New</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.new') }}</span>
</template>
</n8n-menu-item>
<n8n-menu-item index="credentials-open">
<template slot="title">
<font-awesome-icon icon="folder-open"/>
<span slot="title" class="item-title">Open</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.open') }}</span>
</template>
</n8n-menu-item>
</el-submenu>
<n8n-menu-item index="executions">
<font-awesome-icon icon="tasks"/>&nbsp;
<span slot="title" class="item-title-root">Executions</span>
<span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.executions') }}</span>
</n8n-menu-item>
<el-submenu index="help" class="help-menu" title="Help" popperClass="sidebar-popper">
<template slot="title">
<font-awesome-icon icon="question"/>&nbsp;
<span slot="title" class="item-title-root">Help</span>
<span slot="title" class="item-title-root">{{ $locale.baseText('mainSidebar.help') }}</span>
</template>
<MenuItemsIterator :items="helpMenuItems" :afterItemClick="trackHelpItemClick" />
@ -117,7 +117,7 @@
<n8n-menu-item index="help-about">
<template slot="title">
<font-awesome-icon class="about-icon" icon="info"/>
<span slot="title" class="item-title">About n8n</span>
<span slot="title" class="item-title">{{ $locale.baseText('mainSidebar.aboutN8n') }}</span>
</template>
</n8n-menu-item>
</el-submenu>
@ -168,39 +168,6 @@ import { mapGetters } from 'vuex';
import MenuItemsIterator from './MainSidebarMenuItemsIterator.vue';
import { CREDENTIAL_LIST_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY } from '@/constants';
const helpMenuItems: IMenuItem[] = [
{
id: 'docs',
type: 'link',
properties: {
href: 'https://docs.n8n.io',
title: 'Documentation',
icon: 'book',
newWindow: true,
},
},
{
id: 'forum',
type: 'link',
properties: {
href: 'https://community.n8n.io',
title: 'Forum',
icon: 'users',
newWindow: true,
},
},
{
id: 'examples',
type: 'link',
properties: {
href: 'https://n8n.io/workflows',
title: 'Workflows',
icon: 'network-wired',
newWindow: true,
},
},
];
export default mixins(
genericHelpers,
restApi,
@ -225,7 +192,6 @@ export default mixins(
basePath: this.$store.getters.getBaseUrl,
executionsListDialogVisible: false,
stopExecutionInProgress: false,
helpMenuItems,
};
},
computed: {
@ -236,6 +202,40 @@ export default mixins(
'hasVersionUpdates',
'nextVersions',
]),
helpMenuItems (): object[] {
return [
{
id: 'docs',
type: 'link',
properties: {
href: 'https://docs.n8n.io',
title: this.$locale.baseText('mainSidebar.helpMenuItems.documentation'),
icon: 'book',
newWindow: true,
},
},
{
id: 'forum',
type: 'link',
properties: {
href: 'https://community.n8n.io',
title: this.$locale.baseText('mainSidebar.helpMenuItems.forum'),
icon: 'users',
newWindow: true,
},
},
{
id: 'examples',
type: 'link',
properties: {
href: 'https://n8n.io/workflows',
title: this.$locale.baseText('mainSidebar.helpMenuItems.workflows'),
icon: 'network-wired',
newWindow: true,
},
},
];
},
exeuctionId (): string | undefined {
return this.$route.params.id;
},
@ -322,12 +322,19 @@ export default mixins(
this.stopExecutionInProgress = true;
await this.restApi().stopCurrentExecution(executionId);
this.$showMessage({
title: 'Execution stopped',
message: `The execution with the id "${executionId}" got stopped!`,
title: this.$locale.baseText('mainSidebar.showMessage.stopExecution.title'),
message: this.$locale.baseText(
'mainSidebar.showMessage.stopExecution.message',
{ interpolate: { executionId }},
),
type: 'success',
});
} catch (error) {
this.$showError(error, 'Problem stopping execution', 'There was a problem stopping the execuction:');
this.$showError(
error,
this.$locale.baseText('mainSidebar.showError.stopExecution.title'),
this.$locale.baseText('mainSidebar.showError.stopExecution.message') + ':',
);
}
this.stopExecutionInProgress = false;
},
@ -351,8 +358,8 @@ export default mixins(
worflowData = JSON.parse(data as string);
} catch (error) {
this.$showMessage({
title: 'Could not import file',
message: `The file does not contain valid JSON data.`,
title: this.$locale.baseText('mainSidebar.showMessage.handleFileImport.title'),
message: this.$locale.baseText('mainSidebar.showMessage.handleFileImport.message'),
type: 'error',
});
return;
@ -374,17 +381,30 @@ export default mixins(
(this.$refs.importFile as HTMLInputElement).click();
} else if (key === 'workflow-import-url') {
try {
const promptResponse = await this.$prompt(`Workflow URL:`, 'Import Workflow from URL:', {
confirmButtonText: 'Import',
cancelButtonText: 'Cancel',
inputErrorMessage: 'Invalid URL',
inputPattern: /^http[s]?:\/\/.*\.json$/i,
}) as MessageBoxInputData;
const promptResponse = await this.$prompt(
this.$locale.baseText('mainSidebar.prompt.workflowUrl') + ':',
this.$locale.baseText('mainSidebar.prompt.importWorkflowFromUrl') + ':',
{
confirmButtonText: this.$locale.baseText('mainSidebar.prompt.import'),
cancelButtonText: this.$locale.baseText('mainSidebar.prompt.cancel'),
inputErrorMessage: this.$locale.baseText('mainSidebar.prompt.invalidUrl'),
inputPattern: /^http[s]?:\/\/.*\.json$/i,
},
) as MessageBoxInputData;
this.$root.$emit('importWorkflowUrl', { url: promptResponse.value });
} catch (e) {}
} else if (key === 'workflow-delete') {
const deleteConfirmed = await this.confirmMessage(`Are you sure that you want to delete the workflow "${this.workflowName}"?`, 'Delete Workflow?', 'warning', 'Yes, delete!');
const deleteConfirmed = await this.confirmMessage(
this.$locale.baseText(
'mainSidebar.confirmMessage.workflowDelete.message',
{ interpolate: { workflowName: this.workflowName } },
),
this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.headline'),
'warning',
this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.confirmButtonText'),
this.$locale.baseText('mainSidebar.confirmMessage.workflowDelete.cancelButtonText'),
);
if (deleteConfirmed === false) {
return;
@ -393,15 +413,22 @@ export default mixins(
try {
await this.restApi().deleteWorkflow(this.currentWorkflow);
} catch (error) {
this.$showError(error, 'Problem deleting the workflow', 'There was a problem deleting the workflow:');
this.$showError(
error,
this.$locale.baseText('mainSidebar.showError.stopExecution.title'),
this.$locale.baseText('mainSidebar.showError.stopExecution.message') + ':',
);
return;
}
this.$store.commit('setStateDirty', false);
// Reset tab title since workflow is deleted.
this.$titleReset();
this.$showMessage({
title: 'Workflow was deleted',
message: `The workflow "${this.workflowName}" was deleted!`,
title: this.$locale.baseText('mainSidebar.showMessage.handleSelect1.title'),
message: this.$locale.baseText(
'mainSidebar.showMessage.handleSelect1.message',
{ interpolate: { workflowName: this.workflowName }},
),
type: 'success',
});
@ -425,7 +452,8 @@ export default mixins(
saveAs(blob, workflowName + '.json');
} else if (key === 'workflow-save') {
this.saveCurrentWorkflow(undefined);
const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
} else if (key === 'workflow-duplicate') {
this.$store.dispatch('ui/openModal', DUPLICATE_MODAL_KEY);
} else if (key === 'help-about') {
@ -436,7 +464,13 @@ export default mixins(
} else if (key === 'workflow-new') {
const result = this.$store.getters.getStateIsDirty;
if(result) {
const importConfirm = await this.confirmMessage(`When you switch workflows your current workflow changes will be lost.`, 'Save your Changes?', 'warning', 'Yes, switch workflows and forget changes');
const importConfirm = await this.confirmMessage(
this.$locale.baseText('mainSidebar.confirmMessage.workflowNew.message'),
this.$locale.baseText('mainSidebar.confirmMessage.workflowNew.headline'),
'warning',
this.$locale.baseText('mainSidebar.confirmMessage.workflowNew.confirmButtonText'),
this.$locale.baseText('mainSidebar.confirmMessage.workflowNew.cancelButtonText'),
);
if (importConfirm === true) {
this.$store.commit('setStateDirty', false);
if (this.$router.currentRoute.name === 'NodeViewNew') {
@ -446,8 +480,8 @@ export default mixins(
}
this.$showMessage({
title: 'Workflow created',
message: 'A new workflow got created!',
title: this.$locale.baseText('mainSidebar.showMessage.handleSelect2.title'),
message: this.$locale.baseText('mainSidebar.showMessage.handleSelect2.message'),
type: 'success',
});
}
@ -457,8 +491,8 @@ export default mixins(
}
this.$showMessage({
title: 'Workflow created',
message: 'A new workflow got created!',
title: this.$locale.baseText('mainSidebar.showMessage.handleSelect3.title'),
message: this.$locale.baseText('mainSidebar.showMessage.handleSelect3.message'),
type: 'success',
});
}

View file

@ -4,12 +4,16 @@
:visible="visible"
:size="width"
:before-close="close"
:modal="modal"
:wrapperClosable="wrapperClosable"
>
<template v-slot:title>
<slot name="header" />
</template>
<template>
<slot name="content"/>
<span @keydown.stop>
<slot name="content"/>
</span>
</template>
</el-drawer>
</template>
@ -23,15 +27,26 @@ export default Vue.extend({
name: {
type: String,
},
beforeClose: {
type: Function,
},
eventBus: {
type: Vue,
},
direction: {
type: String,
},
modal: {
type: Boolean,
default: true,
},
width: {
type: String,
},
wrapperClosable: {
type: Boolean,
default: true,
},
},
mounted() {
window.addEventListener('keydown', this.onWindowKeydown);
@ -66,6 +81,10 @@ export default Vue.extend({
}
},
close() {
if (this.beforeClose) {
this.beforeClose();
return;
}
this.$store.commit('ui/closeTopModal');
},
},

View file

@ -1,5 +1,13 @@
<template>
<div>
<ModalRoot :name="CONTACT_PROMPT_MODAL_KEY">
<template v-slot:default="{ modalName }">
<ContactPromptModal
:modalName="modalName"
/>
</template>
</ModalRoot>
<ModalRoot :name="CREDENTIAL_EDIT_MODAL_KEY">
<template v-slot="{ modalName, activeId, mode }">
<CredentialEdit
@ -39,6 +47,12 @@
<UpdatesPanel />
</ModalRoot>
<ModalRoot :name="VALUE_SURVEY_MODAL_KEY" :keepAlive="true">
<template v-slot:default="{ active }">
<ValueSurvey :isActive="active"/>
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_OPEN_MODAL_KEY">
<WorkflowOpen />
</ModalRoot>
@ -51,8 +65,9 @@
<script lang="ts">
import Vue from "vue";
import { CREDENTIAL_LIST_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, PERSONALIZATION_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import { CONTACT_PROMPT_MODAL_KEY, CREDENTIAL_LIST_MODAL_KEY, DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, PERSONALIZATION_MODAL_KEY, WORKFLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, VALUE_SURVEY_MODAL_KEY } from '@/constants';
import ContactPromptModal from './ContactPromptModal.vue';
import CredentialEdit from "./CredentialEdit/CredentialEdit.vue";
import CredentialsList from "./CredentialsList.vue";
import CredentialsSelectModal from "./CredentialsSelectModal.vue";
@ -61,12 +76,14 @@ import ModalRoot from "./ModalRoot.vue";
import PersonalizationModal from "./PersonalizationModal.vue";
import TagsManager from "./TagsManager/TagsManager.vue";
import UpdatesPanel from "./UpdatesPanel.vue";
import ValueSurvey from "./ValueSurvey.vue";
import WorkflowSettings from "./WorkflowSettings.vue";
import WorkflowOpen from "./WorkflowOpen.vue";
export default Vue.extend({
name: "Modals",
components: {
ContactPromptModal,
CredentialEdit,
CredentialsList,
CredentialsSelectModal,
@ -75,10 +92,12 @@ export default Vue.extend({
PersonalizationModal,
TagsManager,
UpdatesPanel,
ValueSurvey,
WorkflowSettings,
WorkflowOpen,
},
data: () => ({
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_LIST_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
@ -88,6 +107,7 @@ export default Vue.extend({
VERSIONS_MODAL_KEY,
WORKFLOW_OPEN_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
VALUE_SURVEY_MODAL_KEY,
}),
});
</script>

Some files were not shown because too many files have changed in this diff Show more