1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-08-17 00:50:42 +03:00

Initial commit to release

This commit is contained in:
Jan Oberhauser 2019-06-23 12:35:23 +02:00
commit 9cb9804eee
257 changed files with 42436 additions and 0 deletions

8
.editorconfig Normal file
View File

@ -0,0 +1,8 @@
root = true
[*]
charset = utf-8
indent_style = tab
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
node_modules
.DS_Store
.tmp
tmp
dist
npm-debug.log*
lerna-debug.log
package-lock.json
yarn.lock
google-generated-credentials.json
_START_PACKAGE

230
LICENSE Normal file
View File

@ -0,0 +1,230 @@
“Commons Clause” License Condition v1.0
The Software is provided to you by the Licensor under the
License, as defined below, subject to the following condition.
Without limiting other conditions in the License, the grant
of rights under the License will not include, and the License
does not grant to you, the right to Sell the Software.
For purposes of the foregoing, “Sell” means practicing any or
all of the rights granted to you under the License to provide
to third parties, for a fee or other consideration (including
without limitation fees for hosting or consulting/ support
services related to the Software), a product or service whose
value derives, entirely or substantially, from the functionality
of the Software. Any license notice or attribution required by
the License must also include this Commons Clause License
Condition notice.
Software: n8n
License: Apache 2.0
Licensor: Jan Oberhauser
---------------------------------------------------------------------
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

33
README.md Normal file
View File

@ -0,0 +1,33 @@
# n8n - Workflow Automation Tool
![n8n.io - Workflow Automation](https://n8n.io/n8n-logo.png)
n8n is a tool which allows to easily and fast automate different taks.
Is still in beta so can not guarantee that everything works perfectly. Also
is there currently not much documentation. That will hopefully change soon.
## Usage
Information about how to install and use it can be found in the cli package [here](packages/cli/README)
And information about how to run it in Docker [here](docker/images/n8n/README.md)
## Development Setup
1. Clone the repository
2. Go into repository folder
3. Run: `npm install`
4. Run: `npx lerna bootstrap --hoist`
5. Run: `npm run build` or `npx lerna exec npm run build` (if lerna is not installed)
## Start
Execute: `npm run start`
## License
[Apache 2.0 with Commons Clause](LICENSE)

View File

@ -0,0 +1,6 @@
MONGO_INITDB_ROOT_USERNAME=adminuser
MONGO_INITDB_ROOT_PASSWORD=JvsjjAYg12FJ90sdCBdsh322V
MONGO_INITDB_DATABASE=n8n
MONGO_NON_ROOT_USERNAME=n8nuser
MONGO_NON_ROOT_PASSWORD=PLsQ8vHGShwDFdmSssb

View File

@ -0,0 +1,26 @@
# n8n with MongoDB
Starts n8n with MongoDB as database.
## Start
To start n8n with MongoDB simply start docker-compose by executing the following
command in the current folder.
**IMPORTANT:** But before you do that change the default users and passwords in the `.env` file!
```
docker-compose up -d
```
To stop it execute:
```
docker-compose stop
```
## Configuration
The default name of the database, user and password for MongoDB can be changed in the `.env` file in the current directory.

View File

@ -0,0 +1,28 @@
version: '3.1'
services:
mongo:
image: mongo:4.0
restart: always
environment:
- MONGO_INITDB_ROOT_USERNAME
- MONGO_INITDB_ROOT_PASSWORD
- MONGO_INITDB_DATABASE
- MONGO_NON_ROOT_USERNAME
- MONGO_NON_ROOT_PASSWORD
volumes:
- ./init-data.sh:/docker-entrypoint-initdb.d/init-data.sh
n8n:
image: n8n
restart: always
ports:
- 5678:5678
links:
- mongo
volumes:
- ~/.n8n:/root/.n8n
# Wait 5 seconds to start n8n to make sure that MongoDB is ready
# when n8n tries to connect to it
command: /bin/sh -c "sleep 5; n8n start --NODE_CONFIG='{\"database\":{\"type\":\"mongodb\", \"mongodbConfig\":{\"url\":\"mongodb://n8nuser:${MONGO_NON_ROOT_PASSWORD}@mongo:27017/${MONGO_INITDB_DATABASE}\"}}}'"

View File

@ -0,0 +1,17 @@
#!/bin/bash
set -e;
# Create a default non-root role
MONGO_NON_ROOT_ROLE="${MONGO_NON_ROOT_ROLE:-readWrite}"
if [ -n "${MONGO_NON_ROOT_USERNAME:-}" ] && [ -n "${MONGO_NON_ROOT_PASSWORD:-}" ]; then
"${mongo[@]}" "$MONGO_INITDB_DATABASE" <<-EOJS
db.createUser({
user: $(_js_escape "$MONGO_NON_ROOT_USERNAME"),
pwd: $(_js_escape "$MONGO_NON_ROOT_PASSWORD"),
roles: [ { role: $(_js_escape "$MONGO_NON_ROOT_ROLE"), db: $(_js_escape "$MONGO_INITDB_DATABASE") } ]
})
EOJS
else
echo "SETUP INFO: No Environment variables given!"
fi

View File

@ -0,0 +1,18 @@
FROM mhart/alpine-node:10
# Update everything and install needed dependencies
RUN apk add --update \
graphicsmagick
# # Set a custom user to not have n8n run as root
USER root
# Install n8n and the also temporary all the packages
# it needs to build it correctly.
RUN apk --update add --virtual build-dependencies python build-base && \
npm_config_user=root npm install -g n8n && \
apk del build-dependencies
WORKDIR /data
CMD "n8n"

View File

@ -0,0 +1,84 @@
## n8n
![n8n.io - Workflow Automation](https://n8n.io/n8n-logo.png)
Run n8n in Docker.
```
docker run -it --rm \
--name n8n \
-p 5678:5678 \
n8nio/n8n
```
You can then access n8n by opening:
[http://localhost:5678](http://localhost:5678)
## Start with tunnel
To be able to use webhooks which all triggers of external services like Github
rely on n8n has to be reachable from the web. To make that easy n8n has a
special tunnel service which redirects requests from our servers to your local
n8n instance.
To use it simply start n8n with `--tunnel`
```
docker run -it --rm \
--name n8n \
--init \
-p 5678:5678 \
-v ~/.n8n:/root/.n8n \
n8nio/n8n \
n8n start --tunnel
```
## Persist data
The workflow data gets by default saved in an SQLite database in the user
folder (`/root/.n8n`). That folder also additionally contains the
settings like webhook URL and encryption key.
```
docker run -it --rm \
--name n8n \
-p 5678:5678 \
-v ~/.n8n:/root/.n8n \
n8nio/n8n
```
## Use with MongoDB
Instead of SQLite, it is also possible to run n8n with MongoDB.
It is important to still persist the data in the `/root/.n8` folder. The reason
is that it contains n8n user data. That is the name of the webhook
(in case) the n8n tunnel gets used and even more important the encryption key
for the credentials. If none gets found n8n creates automatically one on
startup. In case credentials are already saved with a different encryption key
it can not be used anymore as encrypting it is not possible anymore.
Replace the following placeholders with the actual data:
- MONGO_DATABASE
- MONGO_HOST
- MONGO_PORT
- MONGO_USER
- MONGO_PASSWORD
```
docker run -it --rm \
--name n8n \
-p 5678:5678 \
-v ~/.n8n:/root/.n8n \
n8nio/n8n \
n8n start \
--NODE_CONFIG='{\"database\":{\"type\":\"mongodb\", \"mongodbConfig\":{\"url\":\"mongodb://MONGO_USER:MONGO_PASSWORD@MONGO_SERVER:MONGO_PORT/MONGO_DATABASE\"}}}'"
```
A full working setup with docker-compose can be found [here](../../compose/withMongo/README.md)
## License
n8n is licensed under **Apache 2.0 with Commons Clause**

6
lerna.json Normal file
View File

@ -0,0 +1,6 @@
{
"packages": [
"packages/*"
],
"version": "independent"
}

14
package.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "n8n",
"private": true,
"scripts": {
"bootstrap": "lerna bootstrap --hoist --no-ci",
"build": "lerna exec npm run build",
"start": "cd packages/cli && node dist/index.js start",
"watch": "lerna run --parallel watch"
},
"devDependencies": {
"lerna": "^3.13.1"
},
"postcss": {}
}

230
packages/cli/LICENSE Normal file
View File

@ -0,0 +1,230 @@
“Commons Clause” License Condition v1.0
The Software is provided to you by the Licensor under the
License, as defined below, subject to the following condition.
Without limiting other conditions in the License, the grant
of rights under the License will not include, and the License
does not grant to you, the right to Sell the Software.
For purposes of the foregoing, “Sell” means practicing any or
all of the rights granted to you under the License to provide
to third parties, for a fee or other consideration (including
without limitation fees for hosting or consulting/ support
services related to the Software), a product or service whose
value derives, entirely or substantially, from the functionality
of the Software. Any license notice or attribution required by
the License must also include this Commons Clause License
Condition notice.
Software: n8n
License: Apache 2.0
Licensor: Jan Oberhauser
---------------------------------------------------------------------
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

47
packages/cli/README.md Normal file
View File

@ -0,0 +1,47 @@
# n8n - Workflow Automation Tool
![n8n.io - Workflow Automation](https://n8n.io/n8n-logo.png)
n8n is a tool which allows to easily and fast automate different taks.
Is still in beta so can not guarantee that everything works perfectly. Also
is there currently not much documentation. That will hopefully change soon.
## Give n8n a spin
To simply spin up n8n to have a look and give it spin you can simply run:
```
npx n8n
```
It will then download everything which is needed and start n8n.
You can then access n8n by opening:
[http://localhost:5678](http://localhost:5678)
## Installation
To fully install n8n globally execute:
```
npm install n8n -g
```
After the installation n8n can be started by simply typing in:
```
n8n
```
## License
[Apache 2.0 with Commons Clause](LICENSE)
## Development
When developing n8n can be started with `npm run start:dev`.
It will then automatically restart n8n every time a file changes.

View File

@ -0,0 +1,151 @@
import Vorpal = require('vorpal');
import { Args } from 'vorpal';
import { promises as fs } from 'fs';
import {
CredentialTypes,
Db,
IWorkflowBase,
LoadNodesAndCredentials,
NodeTypes,
GenericHelpers,
WorkflowHelpers,
WorkflowExecuteAdditionalData,
} from "../src";
import {
ActiveExecutions,
UserSettings,
WorkflowExecute,
} from "n8n-core";
import {
INode,
Workflow,
} from "n8n-workflow";
module.exports = (vorpal: Vorpal) => {
return vorpal
.command('run')
// @ts-ignore
.description('Executes a given workflow')
.option('--file <workflow-file>',
'The path to a workflow file to execute')
.option('--id <workflow-id>',
'The id of the workflow to execute')
.option('\n')
// TODO: Add validation
// .validate((args: Args) => {
// })
.action(async (args: Args) => {
// Start directly with the init of the database to improve startup time
const startDbInitPromise = Db.init();
// Load all node and credential types
const loadNodesAndCredentials = LoadNodesAndCredentials();
const loadNodesAndCredentialsPromise = loadNodesAndCredentials.init();
if (!args.options.id && !args.options.file) {
GenericHelpers.logOutput(`Either option "--id" or "--file" have to be set!`);
return Promise.resolve();
}
if (args.options.id && args.options.file) {
GenericHelpers.logOutput(`Either "id" or "file" can be set never both!`);
return Promise.resolve();
}
let workflowId: string | undefined;
let workflowData: IWorkflowBase | undefined = undefined;
if (args.options.file) {
// Path to workflow is given
try {
workflowData = JSON.parse(await fs.readFile(args.options.file, 'utf8'));
} catch (error) {
if (error.code === 'ENOENT') {
GenericHelpers.logOutput(`The file "${args.options.file}" could not be found.`);
return;
}
throw error;
}
// Do a basic check if the data in the file looks right
// TODO: Later check with the help of TypeScript data if it is valid or not
if (workflowData === undefined || workflowData.nodes === undefined || workflowData.connections === undefined) {
GenericHelpers.logOutput(`The file "${args.options.file}" does not contain valid workflow data.`);
return;
}
workflowId = workflowData.id!.toString();
}
// Wait till the database is ready
await startDbInitPromise;
if (args.options.id) {
// Id of workflow is given
workflowId = args.options.id;
workflowData = await Db.collections!.Workflow!.findOne(workflowId);
if (workflowData === undefined) {
GenericHelpers.logOutput(`The workflow with the id "${workflowId}" does not exist.`);
return;
}
}
// Make sure the settings exist
await UserSettings.prepareUserSettings();
// Wait till the n8n-packages have been read
await loadNodesAndCredentialsPromise;
// Add the found types to an instance other parts of the application can use
const nodeTypes = NodeTypes();
await nodeTypes.init(loadNodesAndCredentials.nodeTypes);
const credentialTypes = CredentialTypes();
await credentialTypes.init(loadNodesAndCredentials.credentialTypes);
if (!WorkflowHelpers.isWorkflowIdValid(workflowId)) {
workflowId = undefined;
}
const workflowInstance = new Workflow(workflowId, workflowData!.nodes, workflowData!.connections, true, nodeTypes, workflowData!.staticData);
// Check if the workflow contains the required "Start" node
// "requiredNodeTypes" are also defined in editor-ui/views/NodeView.vue
const requiredNodeTypes = ['n8n-nodes-base.start'];
let startNodeFound = false;
let node: INode;
for (const nodeName of Object.keys(workflowInstance.nodes)) {
node = workflowInstance.nodes[nodeName];
if (requiredNodeTypes.includes(node.type)) {
startNodeFound = true;
}
}
if (startNodeFound === false) {
// If the workflow does not contain a start-node we can not know what
// should be executed and with which data to start.
GenericHelpers.logOutput(`The workflow does not contain a "Start" node. So it can not be executed.`);
return Promise.resolve();
}
const mode = 'cli';
const additionalData = await WorkflowExecuteAdditionalData.get(mode, workflowData!, workflowInstance);
const workflowExecute = new WorkflowExecute(additionalData, mode);
try {
const executionId = await workflowExecute.run(workflowInstance);
const activeExecutions = ActiveExecutions.getInstance();
const data = activeExecutions.getPostExecutePromise(executionId);
console.log('Execution was successfull:');
console.log('====================================');
console.log(JSON.stringify(data, null, 2));
} catch (e) {
console.error('GOT ERROR');
console.log('====================================');
console.error(e);
return;
}
});
};

View File

@ -0,0 +1,182 @@
import Vorpal = require('vorpal');
import { Args } from 'vorpal';
import { randomBytes } from 'crypto';
import * as config from 'config';
const open = require('open');
import * as localtunnel from 'localtunnel';
import {
ActiveWorkflowRunner,
CredentialTypes,
Db,
GenericHelpers,
LoadNodesAndCredentials,
NodeTypes,
TestWebhooks,
Server,
} from "../src";
import {
UserSettings,
} from "n8n-core";
import { promisify } from "util";
const tunnel = promisify(localtunnel);
let activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner | undefined;
/**
* Opens the UI in browser
*
*/
function openBrowser() {
const editorUrl = GenericHelpers.getBaseUrl();
open(editorUrl, { wait: true })
.catch((error: Error) => {
console.log(`\nWas not able to open URL in browser. Please open manually by visiting:\n${editorUrl}\n`);
});
}
module.exports = (vorpal: Vorpal) => {
return vorpal
.command('start')
// @ts-ignore
.description('Starts n8n. Makes Web-UI available and starts active workflows')
.option('-o --open',
'Opens the UI automatically in browser')
.option('--tunnel',
'Runs the webhooks via a hooks.n8n.cloud tunnel server')
.option('\n')
// TODO: Add validation
// .validate((args: Args) => {
// })
.action((args: Args) => {
if (process.pid === 1) {
console.error(`The n8n node process should not run as process with ID 1 because that will cause
problems with shutting everything down correctly. If started with docker use the
flag "--init" to fix this problem!`);
return;
}
// TODO: Start here the the script in a subprocess which can get restarted when new nodes get added and so new packages have to get installed
// npm install / rm (in other process)
// restart process depending on exit code (lets say 50 means restart)
// Wrap that the process does not close but we can still use async
(async () => {
// Start directly with the init of the database to improve startup time
const startDbInitPromise = Db.init();
// Make sure the settings exist
const userSettings = await UserSettings.prepareUserSettings();
// Load all node and credential types
const loadNodesAndCredentials = LoadNodesAndCredentials();
await loadNodesAndCredentials.init();
// Add the found types to an instance other parts of the application can use
const nodeTypes = NodeTypes();
await nodeTypes.init(loadNodesAndCredentials.nodeTypes);
const credentialTypes = CredentialTypes();
await credentialTypes.init(loadNodesAndCredentials.credentialTypes);
// Wait till the database is ready
await startDbInitPromise;
if (args.options.tunnel !== undefined) {
console.log('\nWaiting for tunnel ...');
if (userSettings.tunnelSubdomain === undefined) {
// When no tunnel subdomain did exist yet create a new random one
const availableCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789';
userSettings.tunnelSubdomain = Array.from({ length: 24 }).map(() => {
return availableCharacters.charAt(Math.floor(Math.random() * availableCharacters.length));
}).join('');
await UserSettings.writeUserSettings(userSettings);
}
const tunnelSettings: localtunnel.TunnelConfig = {
host: 'https://hooks.n8n.cloud',
subdomain: userSettings.tunnelSubdomain,
};
const port = config.get('urls.port') as number;
// @ts-ignore
const webhookTunnel = await tunnel(port, tunnelSettings);
process.env.WEBHOOK_TUNNEL_URL = webhookTunnel.url + '/';
console.log(`Tunnel URL: ${process.env.WEBHOOK_TUNNEL_URL}\n`);
}
Server.start();
// Start to get active workflows and run their triggers
activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
await activeWorkflowRunner.init();
const editorUrl = GenericHelpers.getBaseUrl();
console.log(`\nEditor is now accessible via:\n${editorUrl}`);
// Allow to open n8n editor by pressing "o"
if (Boolean(process.stdout.isTTY) && process.stdin.setRawMode) {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
let inputText = '';
if (args.options.browser !== undefined) {
openBrowser();
}
console.log(`\nPress "o" to open in Browser.`);
process.stdin.on("data", (key) => {
if (key === 'o') {
openBrowser();
inputText = '';
} else {
// When anything else got pressed, record it and send it on enter into the child process
if (key.charCodeAt(0) === 13) {
// send to child process and print in terminal
process.stdout.write('\n');
inputText = '';
} else {
// record it and write into terminal
inputText += key;
process.stdout.write(key);
}
}
});
}
})();
vorpal.sigint(async () => {
console.log(`\nStopping n8n...`);
setTimeout(() => {
// In case that something goes wrong with shutdown we
// kill after max. 30 seconds no matter what
process.exit();
}, 30000);
const removePromises = [];
if (activeWorkflowRunner !== undefined) {
removePromises.push(activeWorkflowRunner.removeAll());
}
// Remove all test webhooks
const testWebhooks = TestWebhooks.getInstance();
removePromises.push(testWebhooks.removeAll());
await Promise.all(removePromises);
process.exit();
});
});
};

View File

@ -0,0 +1,30 @@
module.exports = {
urls: {
endpointRest: 'rest',
endpointWebhook: 'webhook',
endpointWebhookTest: 'webhook-test',
host: 'localhost',
port: 5678,
protocol: 'http',
},
database: {
type: 'sqlite', // Available types: sqlite, mongodb
// MongoDB specific settings
mongodbConfig: {
url: 'mongodb://user:password@localhost:27017/database',
},
},
executions: {
saveManualRuns: false,
},
nodes: {
// Nodes not to load even if found
// exclude: [],
errorTriggerType: 'n8n-nodes-base.errorTrigger',
},
timezone: 'America/New_York',
};

58
packages/cli/index.ts Normal file
View File

@ -0,0 +1,58 @@
#!/usr/bin/env node
import { join as pathJoin } from 'path';
// Make sure that it also find the config folder when it
// did get started from another folder that the root one.
process.env.NODE_CONFIG_DIR = process.env.NODE_CONFIG_DIR || pathJoin(__dirname, 'config');
import Vorpal = require('vorpal');
import { GenericHelpers } from './src';
// Check if version should be displayed
const versionFlags = [
'-v',
'-V',
'--version'
];
if (versionFlags.includes(process.argv.slice(-1)[0])) {
console.log(require('../package').version);
process.exit(0);
}
if (process.argv.length === 2) {
// When no command is given choose by default start
process.argv.push('start');
}
const command = process.argv[2];
// Check if the command the user did enter is supported else stop
const supportedCommands = [
'help',
'run',
'start',
];
if (!supportedCommands.includes(command)) {
GenericHelpers.logOutput(`The command "${command}" is not known!`);
process.argv.push('help');
}
const vorpal = new Vorpal();
vorpal
.use(require('./commands/run.js'))
.use(require('./commands/start.js'))
.delimiter('')
.show()
.parse(process.argv);
process
.on('unhandledRejection', (reason, p) => {
console.error(reason, 'Unhandled Rejection at Promise', p);
})
.on('uncaughtException', err => {
console.error(err, 'Uncaught Exception thrown');
process.exit(1);
});

93
packages/cli/package.json Normal file
View File

@ -0,0 +1,93 @@
{
"name": "n8n",
"version": "0.1.2",
"description": "n8n Workflow Automation Tool",
"license": "SEE LICENSE IN LICENSE",
"author": {
"name": "Jan Oberhauser",
"email": "jan@n8n.io"
},
"main": "dist/index",
"types": "dist/src/index.d.ts",
"scripts": {
"build": "tsc",
"start": "node dist/index.js start",
"start:dev": "nodemon",
"test": "jest",
"tslint": "tslint -p tsconfig.json -c tslint.json",
"watch": "tsc --watch"
},
"bin": {
"n8n": "./dist/index.js"
},
"keywords": [
"automate",
"automation",
"IaaS",
"iPaaS",
"n8n",
"workflow"
],
"engines": {
"node": ">=8.0.0"
},
"files": [
"dist"
],
"devDependencies": {
"@types/config": "0.0.34",
"@types/connect-history-api-fallback": "^1.3.1",
"@types/express": "^4.16.1",
"@types/jest": "^23.3.2",
"@types/localtunnel": "^1.9.0",
"@types/node": "^10.10.1",
"@types/open": "^6.1.0",
"@types/parseurl": "^1.3.1",
"@types/request-promise-native": "^1.0.15",
"@types/vorpal": "^1.11.0",
"jest": "^23.6.0",
"nodemon": "^1.19.1",
"sails-disk": "^1.0.1",
"ts-jest": "^23.10.1",
"tslint": "^5.11.0",
"typescript": "~3.3.0"
},
"dependencies": {
"body-parser": "^1.18.3",
"config": "^3.0.1",
"connect-history-api-fallback": "^1.6.0",
"express": "^4.16.4",
"flatted": "^2.0.0",
"glob-promise": "^3.4.0",
"google-timezones-json": "^1.0.2",
"localtunnel": "^1.9.1",
"mongodb": "^3.2.3",
"n8n-core": "^0.1.0",
"n8n-editor-ui": "^0.1.0",
"n8n-nodes-base": "^0.1.0",
"n8n-workflow": "^0.1.0",
"open": "^6.1.0",
"request-promise-native": "^1.0.7",
"sqlite3": "^4.0.6",
"sse-channel": "^3.1.1",
"typeorm": "^0.2.16",
"vorpal": "^1.12.0"
},
"jest": {
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"testURL": "http://localhost/",
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
"testPathIgnorePatterns": [
"/dist/",
"/node_modules/"
],
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"json"
]
}
}

View File

@ -0,0 +1,314 @@
import {
IActivationError,
Db,
NodeTypes,
IResponseCallbackData,
IWorkflowDb,
ResponseHelper,
WebhookHelpers,
WorkflowHelpers,
WorkflowExecuteAdditionalData,
} from './';
import {
ActiveWorkflows,
ActiveWebhooks,
} from 'n8n-core';
import {
IWebhookData,
IWorkflowExecuteAdditionalData as IWorkflowExecuteAdditionalDataWorkflow,
WebhookHttpMethod,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import * as express from 'express';
export class ActiveWorkflowRunner {
private activeWorkflows: ActiveWorkflows | null = null;
private activeWebhooks: ActiveWebhooks | null = null;
private activationErrors: {
[key: string]: IActivationError;
} = {};
async init() {
// Get the active workflows from database
const workflowsData: IWorkflowDb[] = await Db.collections.Workflow!.find({ active: true }) as IWorkflowDb[];
this.activeWebhooks = new ActiveWebhooks();
// Add them as active workflows
this.activeWorkflows = new ActiveWorkflows();
if (workflowsData.length !== 0) {
console.log('\n ================================');
console.log(' Start Active Workflows:');
console.log(' ================================');
for (const workflowData of workflowsData) {
console.log(` - ${workflowData.name}`);
try {
await this.add(workflowData.id.toString(), workflowData);
console.log(` => Started`);
} catch (error) {
console.log(` => ERROR: Workflow could not be activated:`);
console.log(` ${error.message}`);
}
}
}
}
/**
* Removes all the currently active workflows
*
* @returns {Promise<void>}
* @memberof ActiveWorkflowRunner
*/
async removeAll(): Promise<void> {
if (this.activeWorkflows === null) {
return;
}
const activeWorkflows = this.activeWorkflows.allActiveWorkflows();
const removePromises = [];
for (const workflowId of activeWorkflows) {
removePromises.push(this.remove(workflowId));
}
await Promise.all(removePromises);
return;
}
/**
* Checks if a webhook for the given method and path exists and executes the workflow.
*
* @param {WebhookHttpMethod} httpMethod
* @param {string} path
* @param {express.Request} req
* @param {express.Response} res
* @returns {Promise<object>}
* @memberof ActiveWorkflowRunner
*/
async executeWebhook(httpMethod: WebhookHttpMethod, path: string, req: express.Request, res: express.Response): Promise<IResponseCallbackData> {
if (this.activeWorkflows === null) {
throw new ResponseHelper.ReponseError('The "activeWorkflows" instance did not get initialized yet.', 404, 404);
}
const webhookData: IWebhookData | undefined = this.activeWebhooks!.get(httpMethod, path);
if (webhookData === undefined) {
// The requested webhook is not registred
throw new ResponseHelper.ReponseError('The requested webhook is not registred.', 404, 404);
}
// Get the node which has the webhook defined to know where to start from and to
// get additional data
const workflowStartNode = webhookData.workflow.getNode(webhookData.node);
if (workflowStartNode === null) {
throw new ResponseHelper.ReponseError('Could not find node to process webhook.', 404, 404);
}
const executionMode = 'webhook';
const workflowData = await Db.collections.Workflow!.findOne(webhookData.workflow.id!);
if (workflowData === undefined) {
throw new ResponseHelper.ReponseError(`Could not find workflow with id "${webhookData.workflow.id}"`, 404, 404);
}
return new Promise((resolve, reject) => {
WebhookHelpers.executeWebhook(webhookData, workflowData, workflowStartNode, executionMode, undefined, req, res, (error: Error | null, data: object) => {
if (error !== null) {
return reject(error);
}
resolve(data);
});
});
}
/**
* Returns the ids of the currently active workflows
*
* @returns {string[]}
* @memberof ActiveWorkflowRunner
*/
getActiveWorkflows(): string[] {
if (this.activeWorkflows === null) {
return [];
}
return this.activeWorkflows.allActiveWorkflows();
}
/**
* Returns if the workflow is active
*
* @param {string} id The id of the workflow to check
* @returns {boolean}
* @memberof ActiveWorkflowRunner
*/
isActive(id: string): boolean {
if (this.activeWorkflows !== null) {
return this.activeWorkflows.isActive(id);
}
return false;
}
/**
* Return error if there was a problem activating the workflow
*
* @param {string} id The id of the workflow to return the error of
* @returns {(IActivationError | undefined)}
* @memberof ActiveWorkflowRunner
*/
getActivationError(id: string): IActivationError | undefined {
if (this.activationErrors[id] === undefined) {
return undefined;
}
return this.activationErrors[id];
}
/**
* Adds all the webhooks of the workflow
*
* @param {Workflow} workflow
* @param {IWorkflowExecuteAdditionalDataWorkflow} additionalData
* @param {WorkflowExecuteMode} mode
* @returns {Promise<void>}
* @memberof ActiveWorkflowRunner
*/
async addWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflowExecuteAdditionalDataWorkflow, mode: WorkflowExecuteMode): Promise<void> {
const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData);
for (const webhookData of webhooks) {
await this.activeWebhooks!.add(webhookData, mode);
}
}
/**
* Remove all the webhooks of the workflow
*
* @param {string} workflowId
* @returns
* @memberof ActiveWorkflowRunner
*/
removeWorkflowWebhooks(workflowId: string): Promise<boolean> {
return this.activeWebhooks!.removeByWorkflowId(workflowId);
}
/**
* Makes a workflow active
*
* @param {string} workflowId The id of the workflow to activate
* @param {IWorkflowDb} [workflowData] If workflowData is given it saves the DB query
* @returns {Promise<void>}
* @memberof ActiveWorkflowRunner
*/
async add(workflowId: string, workflowData?: IWorkflowDb): Promise<void> {
if (this.activeWorkflows === null) {
throw new Error(`The "activeWorkflows" instance did not get initialized yet.`);
}
let workflowInstance: Workflow;
try {
if (workflowData === undefined) {
workflowData = await Db.collections.Workflow!.findOne(workflowId) as IWorkflowDb;
}
if (!workflowData) {
throw new Error(`Could not find workflow with id "${workflowId}".`);
}
const nodeTypes = NodeTypes();
workflowInstance = new Workflow(workflowId, workflowData.nodes, workflowData.connections, workflowData.active, nodeTypes, workflowData.staticData, workflowData.settings);
const canBeActivated = workflowInstance.checkIfWorkflowCanBeActivated(['n8n-nodes-base.start']);
if (canBeActivated === false) {
throw new Error(`The workflow can not be activated because it does not contain any nodes which could start the workflow. Only workflows which have trigger or webhook nodes can be activated.`);
}
const mode = 'trigger';
const additionalData = await WorkflowExecuteAdditionalData.get(mode, workflowData, workflowInstance);
// Add the workflows which have webhooks defined
await this.addWorkflowWebhooks(workflowInstance, additionalData, mode);
await this.activeWorkflows.add(workflowId, workflowInstance, additionalData);
if (this.activationErrors[workflowId] !== undefined) {
// If there were any activation errors delete them
delete this.activationErrors[workflowId];
}
} catch (error) {
// There was a problem activating the workflow
// Save the error
this.activationErrors[workflowId] = {
time: new Date().getTime(),
error: {
message: error.message,
},
};
throw error;
}
await WorkflowHelpers.saveStaticData(workflowInstance!);
}
/**
* Makes a workflow inactive
*
* @param {string} workflowId The id of the workflow to deactivate
* @returns {Promise<void>}
* @memberof ActiveWorkflowRunner
*/
async remove(workflowId: string): Promise<void> {
if (this.activeWorkflows !== null) {
const workflowData = this.activeWorkflows.get(workflowId);
// Remove all the webhooks of the workflow
await this.removeWorkflowWebhooks(workflowId);
if (workflowData) {
// Save the static workflow data if needed
await WorkflowHelpers.saveStaticData(workflowData.workflow);
}
if (this.activationErrors[workflowId] !== undefined) {
// If there were any activation errors delete them
delete this.activationErrors[workflowId];
}
// Remove the workflow from the "list" of active workflows
return this.activeWorkflows.remove(workflowId);
}
throw new Error(`The "activeWorkflows" instance did not get initialized yet.`);
}
}
let workflowRunnerInstance: ActiveWorkflowRunner | undefined;
export function getInstance(): ActiveWorkflowRunner {
if (workflowRunnerInstance === undefined) {
workflowRunnerInstance = new ActiveWorkflowRunner();
}
return workflowRunnerInstance;
}

View File

@ -0,0 +1,37 @@
import {
ICredentialType,
ICredentialTypes as ICredentialTypesInterface,
} from 'n8n-workflow';
class CredentialTypesClass implements ICredentialTypesInterface {
credentialTypes: {
[key: string]: ICredentialType
} = {};
async init(credentialTypes: { [key: string]: ICredentialType }): Promise<void> {
this.credentialTypes = credentialTypes;
}
getAll(): ICredentialType[] {
return Object.values(this.credentialTypes);
}
getByName(credentialType: string): ICredentialType {
return this.credentialTypes[credentialType];
}
}
let credentialTypesInstance: CredentialTypesClass | undefined;
export function CredentialTypes(): CredentialTypesClass {
if (credentialTypesInstance === undefined) {
credentialTypesInstance = new CredentialTypesClass();
}
return credentialTypesInstance;
}

69
packages/cli/src/Db.ts Normal file
View File

@ -0,0 +1,69 @@
import {
IDatabaseCollections,
DatabaseType,
} from './';
import {
UserSettings,
} from "n8n-core";
import {
ConnectionOptions,
createConnection,
getRepository,
} from "typeorm";
import * as config from 'config';
import {
MongoDb,
SQLite,
} from './db';
export let collections: IDatabaseCollections = {
Credentials: null,
Execution: null,
Workflow: null,
};
import * as path from 'path';
export async function init(): Promise<IDatabaseCollections> {
const dbType = config.get('database.type') as DatabaseType;
const n8nFolder = UserSettings.getUserN8nFolderPath();
let entities;
let connectionOptions: ConnectionOptions;
if (dbType === 'mongodb') {
entities = MongoDb;
connectionOptions = {
type: 'mongodb',
url: config.get('database.mongodbConfig.url') as string,
useNewUrlParser: true,
};
} else if (dbType === 'sqlite') {
entities = SQLite;
connectionOptions = {
type: 'sqlite',
database: path.join(n8nFolder, 'database.sqlite'),
};
} else {
throw new Error(`The database "${dbType}" is currently not supported!`);
}
Object.assign(connectionOptions, {
entities: Object.values(entities),
synchronize: true,
logging: false
});
await createConnection(connectionOptions);
collections.Credentials = getRepository(entities.CredentialsEntity);
collections.Execution = getRepository(entities.ExecutionEntity);
collections.Workflow = getRepository(entities.WorkflowEntity);
return collections;
}

View File

@ -0,0 +1,48 @@
import * as config from 'config';
import * as express from 'express';
/**
* Displays a message to the user
*
* @export
* @param {string} message The message to display
* @param {string} [level='log']
*/
export function logOutput(message: string, level = 'log'): void {
if (level === 'log') {
console.log(message);
} else if (level === 'error') {
console.error(message);
}
}
/**
* Returns the base URL n8n is reachable from
*
* @export
* @returns {string}
*/
export function getBaseUrl(): string {
const protocol = config.get('urls.protocol') as string;
const host = config.get('urls.host') as string;
const port = config.get('urls.port') as number;
if (protocol === 'http' && port === 80 || protocol === 'https' && port === 443) {
return `${protocol}://${host}/`;
}
return `${protocol}://${host}:${port}/`;
}
/**
* Returns the session id if one is set
*
* @export
* @param {express.Request} req
* @returns {(string | undefined)}
*/
export function getSessionId(req: express.Request): string | undefined {
return req.headers.sessionid as string | undefined;
}

View File

@ -0,0 +1,248 @@
import {
IConnections,
ICredentialsDecrypted,
ICredentialsEncrypted,
IDataObject,
IExecutionError,
INode,
IRun,
IRunExecutionData,
ITaskData,
IWorkflowSettings,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { ObjectID, Repository } from "typeorm";
import { Url } from 'url';
import { Request } from 'express';
export interface IActivationError {
time: number;
error: {
message: string;
};
}
export interface ICustomRequest extends Request {
parsedUrl: Url | undefined;
}
export interface IDatabaseCollections {
Credentials: Repository<ICredentialsDb> | null;
Execution: Repository<IExecutionFlattedDb> | null;
Workflow: Repository<IWorkflowDb> | null;
}
export interface IWorkflowBase {
id?: number | string | ObjectID;
name: string;
active: boolean;
createdAt: number | string;
updatedAt: number | string;
nodes: INode[];
connections: IConnections;
settings?: IWorkflowSettings;
staticData?: IDataObject;
}
// Almost identical to editor-ui.Interfaces.ts
export interface IWorkflowDb extends IWorkflowBase {
id: number | string | ObjectID;
}
export interface IWorkflowResponse extends IWorkflowBase {
id: string;
}
export interface IWorkflowShortResponse {
id: string;
name: string;
active: boolean;
createdAt: number | string;
updatedAt: number | string;
}
export interface ICredentialsBase {
createdAt: number | string;
updatedAt: number | string;
}
export interface ICredentialsDb extends ICredentialsBase, ICredentialsEncrypted{
id: number | string | ObjectID;
}
export interface ICredentialsResponse extends ICredentialsDb {
id: string;
}
export interface ICredentialsDecryptedDb extends ICredentialsBase, ICredentialsDecrypted {
id: number | string | ObjectID;
}
export interface ICredentialsDecryptedResponse extends ICredentialsDecryptedDb {
id: string;
}
export type DatabaseType = 'mongodb' | 'sqlite';
export interface IExecutionBase {
id?: number | string | ObjectID;
mode: WorkflowExecuteMode;
startedAt: number;
stoppedAt: number;
workflowId?: string; // To be able to filter executions easily //
finished: boolean;
retryOf?: number | string | ObjectID; // If it is a retry, the id of the execution it is a retry of.
retrySuccessId?: number | string | ObjectID; // If it failed and a retry did succeed. The id of the successful retry.
}
// Data in regular format with references
export interface IExecutionDb extends IExecutionBase {
data: IRunExecutionData;
workflowData?: IWorkflowBase;
}
export interface IExecutionPushResponse {
executionId?: string;
waitingForWebhook?: boolean;
}
export interface IExecutionResponse extends IExecutionBase {
id: string;
data: IRunExecutionData;
retryOf?: string;
retrySuccessId?: string;
workflowData: IWorkflowBase;
}
// Flatted data to save memory when saving in database or transfering
// via REST API
export interface IExecutionFlatted extends IExecutionBase {
data: string;
workflowData: IWorkflowBase;
}
export interface IExecutionFlattedDb extends IExecutionBase {
id: number | string | ObjectID;
data: string;
workflowData: IWorkflowBase;
}
export interface IExecutionFlattedResponse extends IExecutionFlatted {
id: string;
retryOf?: string;
}
export interface IExecutionsListResponse {
count: number;
// results: IExecutionShortResponse[];
results: IExecutionsSummary[];
}
export interface IExecutionsStopData {
finished?: boolean;
mode: WorkflowExecuteMode;
startedAt: number | string;
stoppedAt: number | string;
}
export interface IExecutionsSummary {
id: string;
mode: WorkflowExecuteMode;
finished?: boolean;
retryOf?: string;
retrySuccessId?: string;
startedAt: number | string;
stoppedAt?: number | string;
workflowId: string;
workflowName?: string;
}
export interface IExecutionDeleteFilter {
deleteBefore?: number;
filters?: IDataObject;
ids?: string[];
}
export interface IN8nConfig {
database: IN8nConfigDatabase;
nodes?: IN8nConfigNodes;
}
export interface IN8nConfigDatabase {
type: DatabaseType;
mongodbConfig?: {
url: string;
};
}
export interface IN8nConfigNodes {
exclude?: string[];
}
export interface IN8nUISettings {
endpointWebhook: string;
endpointWebhookTest: string;
saveManualRuns: boolean;
timezone: string;
urlBaseWebhook: string;
}
export interface IPushData {
data: IPushDataExecutionFinished | IPushDataNodeExecuteAfter | IPushDataNodeExecuteBefore | IPushDataTestWebhook;
type: IPushDataType;
}
export type IPushDataType = 'executionFinished' | 'nodeExecuteAfter' | 'nodeExecuteBefore' | 'testWebhookDeleted' | 'testWebhookReceived';
export interface IPushDataExecutionFinished {
data: IRun;
executionId: string;
}
export interface IPushDataNodeExecuteAfter {
data: ITaskData;
executionId: string;
nodeName: string;
}
export interface IPushDataNodeExecuteBefore {
executionId: string;
nodeName: string;
}
export interface IPushDataTestWebhook {
workflowId: string;
}
export interface IResponseCallbackData {
data?: IDataObject | IDataObject[];
noWebhookResponse?: boolean;
}
export interface IWorkflowErrorData {
[key: string]: IDataObject | string | number | IExecutionError;
execution: {
id?: string;
error: IExecutionError;
lastNodeExecuted: string;
mode: WorkflowExecuteMode;
};
workflow: {
id?: string;
name: string;
};
}

View File

@ -0,0 +1,261 @@
import {
CUSTOM_EXTENSION_ENV,
UserSettings,
} from 'n8n-core';
import {
ICredentialType,
INodeType,
} from 'n8n-workflow';
import {
IN8nConfigNodes,
} from './';
import { promises as fs } from 'fs';
import * as path from 'path';
import * as glob from 'glob-promise';
import * as config from 'config';
class LoadNodesAndCredentialsClass {
nodeTypes: {
[key: string]: INodeType
} = {};
credentialTypes: {
[key: string]: ICredentialType
} = {};
excludeNodes: string[] | undefined = undefined;
nodeModulesPath = '';
async init(directory?: string) {
// Get the path to the node-modules folder to be later able
// to load the credentials and nodes
const checkPaths = [
// In case "n8n" package is in same node_modules folder.
path.join(__dirname, '..', '..', '..', 'n8n-workflow'),
// In case "n8n" package is the root and the packages are
// in the "node_modules" folder underneath it.
path.join(__dirname, '..', '..', 'node_modules', 'n8n-workflow'),
];
for (const checkPath of checkPaths) {
try {
await fs.access(checkPath);
// Folder exists, so use it.
this.nodeModulesPath = path.dirname(checkPath);
break;
} catch (error) {
// Folder does not exist so get next one
continue;
}
}
if (this.nodeModulesPath === '') {
throw new Error('Could not find "node_modules" folder!');
}
const nodeSettings = config.get('nodes') as IN8nConfigNodes | undefined;
if (nodeSettings !== undefined && nodeSettings.exclude !== undefined) {
this.excludeNodes = nodeSettings.exclude;
}
// Get all the installed packages which contain n8n nodes
const packages = await this.getN8nNodePackages();
for (const packageName of packages) {
await this.loadDataFromPackage(packageName);
}
// Read nodes and credentials from custom directories
const customDirectories = [];
// Add "custom" folder in user-n8n folder
customDirectories.push(UserSettings.getUserN8nFolderCustomExtensionPath());
// Add folders from special environment variable
if (process.env[CUSTOM_EXTENSION_ENV] !== undefined) {
const customExtensionFolders = process.env[CUSTOM_EXTENSION_ENV]!.split(';');
customDirectories.push.apply(customDirectories, customExtensionFolders);
}
for (const directory of customDirectories) {
await this.loadDataFromDirectory('CUSTOM', directory);
}
}
/**
* Returns all the names of the packages which could
* contain n8n nodes
*
* @returns {Promise<string[]>}
* @memberof LoadNodesAndCredentialsClass
*/
async getN8nNodePackages(): Promise<string[]> {
const packages: string[] = [];
for (const file of await fs.readdir(this.nodeModulesPath)) {
if (file.indexOf('n8n-nodes-') !== 0) {
continue;
}
// Check if it is really a folder
if (!(await fs.stat(path.join(this.nodeModulesPath, file))).isDirectory()) {
continue;
}
packages.push(file);
}
return packages;
}
/**
* Loads credentials from a file
*
* @param {string} credentialName The name of the credentials
* @param {string} filePath The file to read credentials from
* @returns {Promise<void>}
* @memberof N8nPackagesInformationClass
*/
async loadCredentialsFromFile(credentialName: string, filePath: string): Promise<void> {
const tempModule = require(filePath);
let tempCredential: ICredentialType;
try {
tempCredential = new tempModule[credentialName]() as ICredentialType;
} catch (e) {
if (e instanceof TypeError) {
throw new Error(`Class with name "${credentialName}" could not be found. Please check if the class is named correctly!`);
} else {
throw e;
}
}
this.credentialTypes[credentialName] = tempCredential;
}
/**
* Loads a node from a file
*
* @param {string} packageName The package name to set for the found nodes
* @param {string} nodeName Tha name of the node
* @param {string} filePath The file to read node from
* @returns {Promise<void>}
* @memberof N8nPackagesInformationClass
*/
async loadNodeFromFile(packageName: string, nodeName: string, filePath: string): Promise<void> {
let tempNode: INodeType;
let fullNodeName: string;
const tempModule = require(filePath);
try {
tempNode = new tempModule[nodeName]() as INodeType;
} catch (error) {
console.error(`Error loading node "${nodeName}" from: "${filePath}"`);
throw error;
}
fullNodeName = packageName + '.' + tempNode.description.name;
tempNode.description.name = fullNodeName;
if (tempNode.description.icon !== undefined &&
tempNode.description.icon.startsWith('file:')) {
// If a file icon gets used add the full path
tempNode.description.icon = 'file:' + path.join(path.dirname(filePath), tempNode.description.icon.substr(5));
}
// Check if the node should be skipped
if (this.excludeNodes !== undefined && this.excludeNodes.includes(fullNodeName)) {
return;
}
this.nodeTypes[fullNodeName] = tempNode;
}
/**
* Loads nodes and credentials from the given directory
*
* @param {string} setPackageName The package name to set for the found nodes
* @param {string} directory The directory to look in
* @returns {Promise<void>}
* @memberof N8nPackagesInformationClass
*/
async loadDataFromDirectory(setPackageName: string, directory: string): Promise<void> {
const files = await glob(path.join(directory, '*\.@(node|credentials)\.js'));
let fileName: string;
let type: string;
const loadPromises = [];
for (const filePath of files) {
[fileName, type] = path.parse(filePath).name.split('.');
if (type === 'node') {
loadPromises.push(this.loadNodeFromFile(setPackageName, fileName, filePath));
} else if (type === 'credentials') {
loadPromises.push(this.loadCredentialsFromFile(fileName, filePath));
}
}
await Promise.all(loadPromises);
}
/**
* Loads nodes and credentials from the package with the given name
*
* @param {string} packageName The name to read data from
* @returns {Promise<void>}
* @memberof N8nPackagesInformationClass
*/
async loadDataFromPackage(packageName: string): Promise<void> {
// Get the absolute path of the package
const packagePath = path.join(this.nodeModulesPath, packageName);
// Read the data from the package.json file to see if any n8n data is defiend
const packageFileString = await fs.readFile(path.join(packagePath, 'package.json'), 'utf8');
const packageFile = JSON.parse(packageFileString);
if (!packageFile.hasOwnProperty('n8n')) {
return;
}
let tempPath: string, filePath: string;
// Read all node types
let fileName: string, type: string;
if (packageFile.n8n.hasOwnProperty('nodes') && Array.isArray(packageFile.n8n.nodes)) {
for (filePath of packageFile.n8n.nodes) {
tempPath = path.join(packagePath, filePath);
[fileName, type] = path.parse(filePath).name.split('.');
await this.loadNodeFromFile(packageName, fileName, tempPath);
}
}
// Read all credential types
if (packageFile.n8n.hasOwnProperty('credentials') && Array.isArray(packageFile.n8n.credentials)) {
for (filePath of packageFile.n8n.credentials) {
tempPath = path.join(packagePath, filePath);
[fileName, type] = path.parse(filePath).name.split('.');
this.loadCredentialsFromFile(fileName, tempPath);
}
}
}
}
let packagesInformationInstance: LoadNodesAndCredentialsClass | undefined;
export function LoadNodesAndCredentials(): LoadNodesAndCredentialsClass {
if (packagesInformationInstance === undefined) {
packagesInformationInstance = new LoadNodesAndCredentialsClass();
}
return packagesInformationInstance;
}

View File

@ -0,0 +1,37 @@
import {
INodeType,
INodeTypes,
} from 'n8n-workflow';
class NodeTypesClass implements INodeTypes {
nodeTypes: {
[key: string]: INodeType
} = {};
async init(nodeTypes: {[key: string]: INodeType }): Promise<void> {
this.nodeTypes = nodeTypes;
}
getAll(): INodeType[] {
return Object.values(this.nodeTypes);
}
getByName(nodeType: string): INodeType | undefined {
return this.nodeTypes[nodeType];
}
}
let nodeTypesInstance: NodeTypesClass | undefined;
export function NodeTypes(): NodeTypesClass {
if (nodeTypesInstance === undefined) {
nodeTypesInstance = new NodeTypesClass();
}
return nodeTypesInstance;
}

86
packages/cli/src/Push.ts Normal file
View File

@ -0,0 +1,86 @@
// @ts-ignore
import * as sseChannel from 'sse-channel';
import * as express from 'express';
import {
IPushData,
IPushDataType,
} from '.';
export class Push {
private channel: sseChannel;
private connections: {
[key: string]: express.Response;
} = {};
constructor() {
this.channel = new sseChannel({
cors: {
// Allow access also from frontend when developing
origins: ['http://localhost:8080'],
},
});
this.channel.on('disconnect', (channel: string, res: express.Response) => {
if (res.req !== undefined) {
delete this.connections[res.req.query.sessionId];
}
});
}
/**
* Adds a new push connection
*
* @param {string} sessionId The id of the session
* @param {express.Request} req The request
* @param {express.Response} res The response
* @memberof Push
*/
add(sessionId: string, req: express.Request, res: express.Response) {
if (this.connections[sessionId] !== undefined) {
// Make sure to remove existing connection with the same session
// id if one exists already
this.connections[sessionId].end();
this.channel.removeClient(this.connections[sessionId]);
}
this.connections[sessionId] = res;
this.channel.addClient(req, res);
}
/**
* Sends data to the client which is connected via a specific session
*
* @param {string} sessionId The session id of client to send data to
* @param {string} type Type of data to send
* @param {*} data
* @memberof Push
*/
send(sessionId: string, type: IPushDataType, data: any) { // tslint:disable-line:no-any
if (this.connections[sessionId] === undefined) {
// TODO: Log that properly!
console.error(`The session "${sessionId}" is not registred.`);
return;
}
const sendData: IPushData = {
type,
data,
};
this.channel.send(JSON.stringify(sendData));
}
}
let activePushInstance: Push | undefined;
export function getInstance(): Push {
if (activePushInstance === undefined) {
activePushInstance = new Push();
}
return activePushInstance;
}

View File

@ -0,0 +1,176 @@
import { Request, Response } from 'express';
import { parse, stringify } from 'flatted';
import {
IExecutionDb,
IExecutionFlatted,
IExecutionFlattedDb,
IExecutionResponse,
IWorkflowDb,
} from './';
/**
* Special Error which allows to return also an error code and http status code
*
* @export
* @class ReponseError
* @extends {Error}
*/
export class ReponseError extends Error {
// The HTTP status code of response
httpStatusCode?: number;
// The error code in the resonse
errorCode?: number;
/**
* Creates an instance of ReponseError.
* @param {string} message The error message
* @param {number} [errorCode] The error code which can be used by frontend to identify the actual error
* @param {number} [httpStatusCode] The HTTP status code the response should have
* @memberof ReponseError
*/
constructor(message: string, errorCode?: number, httpStatusCode?: number) {
super(message);
this.name = 'ReponseError';
if (errorCode) {
this.errorCode = errorCode;
}
if (httpStatusCode) {
this.httpStatusCode = httpStatusCode;
}
}
}
export function sendSuccessResponse(res: Response, data: any, raw?: boolean) { // tslint:disable-line:no-any
res.setHeader('Content-Type', 'application/json');
if (raw === true) {
res.send(JSON.stringify(data));
return;
} else {
res.send(JSON.stringify({
data
}));
}
}
export function sendErrorResponse(res: Response, error: ReponseError) {
let httpStatusCode = 500;
if (error.httpStatusCode) {
httpStatusCode = error.httpStatusCode;
}
if (process.env.NODE_ENV !== 'production') {
console.error('ERROR RESPONSE');
console.error(error);
}
const response = {
code: 0,
message: 'Unknown error',
};
if (error.errorCode) {
response.code = error.errorCode;
}
if (error.message) {
response.message = error.message;
}
if (error.stack && process.env.NODE_ENV !== 'production') {
// @ts-ignore
response.stack = error.stack;
}
res.status(httpStatusCode).send(JSON.stringify(response));
}
/**
* A helper function which does not just allow to return Promises it also makes sure that
* all the responses have the same format
*
*
* @export
* @param {(req: Request, res: Response) => Promise<any>} processFunction The actual function to process the request
* @returns
*/
export function send(processFunction: (req: Request, res: Response) => Promise<any>) { // tslint:disable-line:no-any
return async (req: Request, res: Response) => {
try {
const data = await processFunction(req, res);
// Success response
sendSuccessResponse(res, data);
} catch (error) {
// Error response
sendErrorResponse(res, error);
}
};
}
/**
* Flattens the Execution data.
* As it contains a lot of references which normally would be saved as duplicate data
* with regular JSON.stringify it gets flattened which keeps the references in place.
*
* @export
* @param {IExecutionDb} fullExecutionData The data to flatten
* @returns {IExecutionFlatted}
*/
export function flattenExecutionData(fullExecutionData: IExecutionDb): IExecutionFlatted {
// Flatten the data
const returnData: IExecutionFlatted = Object.assign({}, {
data: stringify(fullExecutionData.data),
mode: fullExecutionData.mode,
startedAt: fullExecutionData.startedAt,
stoppedAt: fullExecutionData.stoppedAt,
finished: fullExecutionData.finished ? fullExecutionData.finished : false,
workflowId: fullExecutionData.workflowId,
workflowData: fullExecutionData.workflowData!,
});
if (fullExecutionData.id !== undefined) {
returnData.id = fullExecutionData.id!.toString();
}
if (fullExecutionData.retryOf !== undefined) {
returnData.retryOf = fullExecutionData.retryOf!.toString();
}
if (fullExecutionData.retrySuccessId !== undefined) {
returnData.retrySuccessId = fullExecutionData.retrySuccessId!.toString();
}
return returnData;
}
/**
* Unflattens the Execution data.
*
* @export
* @param {IExecutionFlattedDb} fullExecutionData The data to unflatten
* @returns {IExecutionResponse}
*/
export function unflattenExecutionData(fullExecutionData: IExecutionFlattedDb): IExecutionResponse {
const returnData: IExecutionResponse = Object.assign({}, {
id: fullExecutionData.id.toString(),
workflowData: fullExecutionData.workflowData as IWorkflowDb,
data: parse(fullExecutionData.data),
mode: fullExecutionData.mode,
startedAt: fullExecutionData.startedAt,
stoppedAt: fullExecutionData.stoppedAt,
finished: fullExecutionData.finished ? fullExecutionData.finished : false
});
return returnData;
}

997
packages/cli/src/Server.ts Normal file
View File

@ -0,0 +1,997 @@
import * as express from 'express';
import * as bodyParser from 'body-parser';
import * as history from 'connect-history-api-fallback';
import * as requestPromise from 'request-promise-native';
import {
IActivationError,
ActiveWorkflowRunner,
ICustomRequest,
ICredentialsDb,
ICredentialsDecryptedDb,
ICredentialsDecryptedResponse,
ICredentialsResponse,
CredentialTypes,
Db,
IExecutionDeleteFilter,
IExecutionFlatted,
IExecutionFlattedDb,
IExecutionFlattedResponse,
IExecutionPushResponse,
IExecutionsListResponse,
IExecutionsStopData,
IExecutionsSummary,
IN8nUISettings,
IWorkflowBase,
IWorkflowShortResponse,
IWorkflowResponse,
NodeTypes,
Push,
ResponseHelper,
TestWebhooks,
WebhookHelpers,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
GenericHelpers,
} from './';
import {
ActiveExecutions,
Credentials,
LoadNodeParameterOptions,
UserSettings,
WorkflowExecute,
} from 'n8n-core';
import {
ICredentialType,
IDataObject,
INodeCredentials,
INodeTypeDescription,
INodePropertyOptions,
IRunData,
Workflow,
} from 'n8n-workflow';
import {
FindManyOptions,
LessThan,
LessThanOrEqual,
} from 'typeorm';
import * as parseUrl from 'parseurl';
import * as config from 'config';
// @ts-ignore
import * as timezones from 'google-timezones-json';
class App {
app: express.Application;
activeWorkflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner;
testWebhooks: TestWebhooks.TestWebhooks;
endpointWebhook: string;
endpointWebhookTest: string;
saveManualRuns: boolean;
timezone: string;
activeExecutionsInstance: ActiveExecutions.ActiveExecutions;
push: Push.Push;
constructor() {
this.app = express();
this.endpointWebhook = config.get('urls.endpointWebhook') as string;
this.endpointWebhookTest = config.get('urls.endpointWebhookTest') as string;
this.saveManualRuns = config.get('executions.saveManualRuns') as boolean;
this.timezone = config.get('timezone') as string;
this.config();
this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
this.testWebhooks = TestWebhooks.getInstance();
this.push = Push.getInstance();
this.activeExecutionsInstance = ActiveExecutions.getInstance();
}
/**
* Returns the current epoch time
*
* @returns {number}
* @memberof App
*/
getCurrentDate(): number {
return Math.floor(new Date().getTime());
}
private config(): void {
// Get push connections
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (req.url.indexOf('/rest/push') === 0) {
// TODO: Later also has to add some kind of authentication token
if (req.query.sessionId === undefined) {
next(new Error('The query parameter "sessionId" is missing!'));
return;
}
this.push.add(req.query.sessionId, req, res);
return;
}
next();
});
// Make sure that each request has the "parsedUrl" parameter
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
(req as ICustomRequest).parsedUrl = parseUrl(req);
next();
});
// Support application/json type post data
this.app.use(bodyParser.json({ limit: "16mb" }));
// Make sure that Vue history mode works properly
this.app.use(history({
rewrites: [
{
from: new RegExp(`^\/(rest|${this.endpointWebhook}|${this.endpointWebhookTest})\/.*$`),
to: (context) => {
return context.parsedUrl!.pathname!.toString();
}
}
]
}));
//support application/x-www-form-urlencoded post data
this.app.use(bodyParser.urlencoded({ extended: false }));
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
// Allow access also from frontend when developing
res.header('Access-Control-Allow-Origin', 'http://localhost:8080');
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, sessionid');
next();
});
this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
if (Db.collections.Workflow === null) {
const error = new ResponseHelper.ReponseError('Database is not ready!', undefined, 503);
return ResponseHelper.sendErrorResponse(res, error);
}
next();
});
// ----------------------------------------
// Workflow
// ----------------------------------------
// Creates a new workflow
this.app.post('/rest/workflows', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IWorkflowResponse> => {
const newWorkflowData = req.body;
newWorkflowData.createdAt = this.getCurrentDate();
newWorkflowData.updatedAt = this.getCurrentDate();
newWorkflowData.id = undefined;
// Save the workflow in DB
const result = await Db.collections.Workflow!.save(newWorkflowData);
// Convert to response format in which the id is a string
(result as IWorkflowBase as IWorkflowResponse).id = result.id.toString();
return result as IWorkflowBase as IWorkflowResponse;
}));
// Reads and returns workflow data from an URL
this.app.get('/rest/workflows/from-url', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IWorkflowResponse> => {
if (req.query.url === undefined) {
throw new ResponseHelper.ReponseError(`The parameter "url" is missing!`, undefined, 400);
}
if (!req.query.url.match(/^http[s]?:\/\/.*\.json$/i)) {
throw new ResponseHelper.ReponseError(`The parameter "url" is not valid! It does not seem to be a URL pointing to a n8n workflow JSON file.`, undefined, 400);
}
const data = await requestPromise.get(req.query.url);
let workflowData: IWorkflowResponse | undefined;
try {
workflowData = JSON.parse(data);
} catch (error) {
throw new ResponseHelper.ReponseError(`The URL does not point to valid JSON file!`, undefined, 400);
}
// Do a very basic check if it is really a n8n-workflow-json
if (workflowData === undefined || workflowData.nodes === undefined || !Array.isArray(workflowData.nodes) ||
workflowData.connections === undefined || typeof workflowData.connections !== 'object' ||
Array.isArray(workflowData.connections)) {
throw new ResponseHelper.ReponseError(`The data in the file does not seem to be a n8n workflow JSON file!`, undefined, 400);
}
return workflowData;
}));
// Returns workflows
this.app.get('/rest/workflows', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IWorkflowShortResponse[]> => {
const findQuery = {} as FindManyOptions;
if (req.query.filter) {
findQuery.where = JSON.parse(req.query.filter);
}
// Return only the fields we need
findQuery.select = ['id', 'name', 'active', 'createdAt', 'updatedAt'];
const results = await Db.collections.Workflow!.find(findQuery);
for (const entry of results) {
(entry as unknown as IWorkflowShortResponse).id = entry.id.toString();
}
return results as unknown as IWorkflowShortResponse[];
}));
// Returns a specific workflow
this.app.get('/rest/workflows/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IWorkflowResponse | undefined> => {
const result = await Db.collections.Workflow!.findOne(req.params.id);
if (result === undefined) {
return undefined;
}
// Convert to response format in which the id is a string
(result as IWorkflowBase as IWorkflowResponse).id = result.id.toString();
return result as IWorkflowBase as IWorkflowResponse;
}));
// Updates an existing workflow
this.app.patch('/rest/workflows/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IWorkflowResponse> => {
const newWorkflowData = req.body;
const id = req.params.id;
if (this.activeWorkflowRunner.isActive(id)) {
// When workflow gets saved always remove it as the triggers could have been
// changed and so the changes would not take effect
await this.activeWorkflowRunner.remove(id);
}
if (newWorkflowData.settings) {
if (newWorkflowData.settings.timezone === 'DEFAULT') {
// Do not save the default timezone
delete newWorkflowData.settings.timezone;
}
if (newWorkflowData.settings.saveManualRuns === 'DEFAULT') {
// Do not save when default got set
delete newWorkflowData.settings.saveManualRuns;
}
}
newWorkflowData.updatedAt = this.getCurrentDate();
await Db.collections.Workflow!.update(id, newWorkflowData);
// We sadly get nothing back from "update". Neither if it updated a record
// nor the new value. So query now the hopefully updated entry.
const reponseData = await Db.collections.Workflow!.findOne(id);
if (reponseData === undefined) {
throw new ResponseHelper.ReponseError(`Workflow with id "${id}" could not be found to be updated.`, undefined, 400);
}
if (reponseData.active === true) {
// When the workflow is supposed to be active add it again
try {
await this.activeWorkflowRunner.add(id);
} catch (error) {
// If workflow could not be activated set it again to inactive
newWorkflowData.active = false;
await Db.collections.Workflow!.update(id, newWorkflowData);
// Also set it in the returned data
reponseData.active = false;
// Now return the original error for UI to display
throw error;
}
}
// Convert to response format in which the id is a string
(reponseData as IWorkflowBase as IWorkflowResponse).id = reponseData.id.toString();
return reponseData as IWorkflowBase as IWorkflowResponse;
}));
// Deletes a specific workflow
this.app.delete('/rest/workflows/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<boolean> => {
const id = req.params.id;
if (this.activeWorkflowRunner.isActive(id)) {
// Before deleting a workflow deactivate it
await this.activeWorkflowRunner.remove(id);
}
await Db.collections.Workflow!.delete(id);
return true;
}));
this.app.post('/rest/workflows/run', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IExecutionPushResponse> => {
const workflowData = req.body.workflowData;
const runData: IRunData | undefined = req.body.runData;
const startNodes: string[] | undefined = req.body.startNodes;
const destinationNode: string | undefined = req.body.destinationNode;
const nodeTypes = NodeTypes();
const executionMode = 'manual';
const sessionId = GenericHelpers.getSessionId(req);
// Do not supply the saved static data! Tests always run with initially empty static data.
// The reason is that it contains information like webhook-ids. If a workflow is currently
// active it would see its id and would so not create an own test-webhook. Additionally would
// it also delete the webhook at the service in the end. So that the active workflow would end
// up without still being active but not receiving and webhook requests anymore as it does
// not exist anymore.
const workflowInstance = new Workflow(workflowData.id, workflowData.nodes, workflowData.connections, false, nodeTypes, undefined, workflowData.settings);
const additionalData = await WorkflowExecuteAdditionalData.get(executionMode, workflowData, workflowInstance, sessionId);
const workflowExecute = new WorkflowExecute(additionalData, executionMode);
let executionId: string;
if (runData === undefined || startNodes === undefined || startNodes.length === 0 || destinationNode === undefined) {
// Execute all nodes
if (WorkflowHelpers.isWorkflowIdValid(workflowData.id) === true) {
// Webhooks can only be tested with saved workflows
const needsWebhook = await this.testWebhooks.needsWebhookData(workflowData, workflowInstance, additionalData, executionMode, sessionId, destinationNode);
if (needsWebhook === true) {
return {
waitingForWebhook: true,
};
}
}
// Can execute without webhook so go on
executionId = await workflowExecute.run(workflowInstance, undefined, destinationNode);
} else {
// Execute only the nodes between start and destination nodes
executionId = await workflowExecute.runPartialWorkflow(workflowInstance, runData, startNodes, destinationNode);
}
return {
executionId,
};
}));
// Returns parameter values which normally get loaded from an external API or
// get generated dynamically
this.app.get('/rest/node-parameter-options', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<INodePropertyOptions[]> => {
const nodeType = req.query.nodeType;
let credentials: INodeCredentials | undefined = undefined;
if (req.query.credentials !== undefined) {
credentials = JSON.parse(req.query.credentials);
}
const methodName = req.query.methodName;
const nodeTypes = NodeTypes();
const executionMode = 'manual';
const sessionId = GenericHelpers.getSessionId(req);
const loadDataInstance = new LoadNodeParameterOptions(nodeType, nodeTypes, credentials);
const workflowData = loadDataInstance.getWorkflowData() as IWorkflowBase;
const additionalData = await WorkflowExecuteAdditionalData.get(executionMode, workflowData, loadDataInstance.workflow, sessionId);
return loadDataInstance.getOptions(methodName, additionalData);
}));
// Returns all the node-types
this.app.get('/rest/node-types', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<INodeTypeDescription[]> => {
const returnData: INodeTypeDescription[] = [];
const nodeTypes = NodeTypes();
const allNodes = nodeTypes.getAll();
allNodes.forEach((nodeData) => {
returnData.push(nodeData.description);
});
return returnData;
}));
// ----------------------------------------
// Node-Types
// ----------------------------------------
// Returns the node icon
this.app.get('/rest/node-icon/:nodeType', async (req: express.Request, res: express.Response): Promise<void> => {
const nodeTypeName = req.params.nodeType;
const nodeTypes = NodeTypes();
const nodeType = nodeTypes.getByName(nodeTypeName);
if (nodeType === undefined) {
res.status(404).send('The nodeType is not known.');
return;
}
if (nodeType.description.icon === undefined) {
res.status(404).send('No icon found for node.');
return;
}
if (!nodeType.description.icon.startsWith('file:')) {
res.status(404).send('Node does not have a file icon.');
return;
}
const filepath = nodeType.description.icon.substr(5);
res.sendFile(filepath);
});
// ----------------------------------------
// Active Workflows
// ----------------------------------------
// Returns the active workflow ids
this.app.get('/rest/active', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<string[]> => {
return this.activeWorkflowRunner.getActiveWorkflows();
}));
// Returns if the workflow with the given id had any activation errors
this.app.get('/rest/active/error/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IActivationError | undefined> => {
const id = req.params.id;
return this.activeWorkflowRunner.getActivationError(id);
}));
// ----------------------------------------
// Credentials
// ----------------------------------------
// Deletes a specific credential
this.app.delete('/rest/credentials/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<boolean> => {
const id = req.params.id;
await Db.collections.Credentials!.delete({ id });
return true;
}));
// Creates new credentials
this.app.post('/rest/credentials', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<ICredentialsResponse> => {
const incomingData = req.body;
// Add the added date for node access permissions
for (const nodeAccess of incomingData.nodesAccess) {
nodeAccess.date = this.getCurrentDate();
}
const encryptionKey = await UserSettings.getEncryptionKey();
if (encryptionKey === undefined) {
throw new Error('No encryption key got found to encrypt the credentials!');
}
// Encrypt the data
const credentials = new Credentials(incomingData.name, incomingData.type, incomingData.nodesAccess);
credentials.setData(incomingData.data, encryptionKey);
const newCredentialsData = credentials.getDataToSave() as ICredentialsDb;
// Add special database related data
newCredentialsData.createdAt = this.getCurrentDate();
newCredentialsData.updatedAt = this.getCurrentDate();
// TODO: also add user automatically depending on who is logged in, if anybody is logged in
// Save the credentials in DB
const result = await Db.collections.Credentials!.save(newCredentialsData);
// Convert to response format in which the id is a string
(result as unknown as ICredentialsResponse).id = result.id.toString();
return result as unknown as ICredentialsResponse;
}));
// Updates existing credentials
this.app.patch('/rest/credentials/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<ICredentialsResponse> => {
const incomingData = req.body;
const id = req.params.id;
// Add the date for newly added node access permissions
for (const nodeAccess of incomingData.nodesAccess) {
if (!nodeAccess.date) {
nodeAccess.date = this.getCurrentDate();
}
}
const encryptionKey = await UserSettings.getEncryptionKey();
if (encryptionKey === undefined) {
throw new Error('No encryption key got found to encrypt the credentials!');
}
// Encrypt the data
const credentials = new Credentials(incomingData.name, incomingData.type, incomingData.nodesAccess);
credentials.setData(incomingData.data, encryptionKey);
const newCredentialsData = credentials.getDataToSave() as unknown as ICredentialsDb;
// Add special database related data
newCredentialsData.updatedAt = this.getCurrentDate();
// Update the credentials in DB
await Db.collections.Credentials!.update(id, newCredentialsData);
// We sadly get nothing back from "update". Neither if it updated a record
// nor the new value. So query now the hopefully updated entry.
const reponseData = await Db.collections.Credentials!.findOne(id);
if (reponseData === undefined) {
throw new ResponseHelper.ReponseError(`Credentials with id "${id}" could not be found to be updated.`, undefined, 400);
}
// Remove the encrypted data as it is not needed in the frontend
reponseData.data = '';
// Convert to response format in which the id is a string
(reponseData as unknown as ICredentialsResponse).id = reponseData.id.toString();
return reponseData as unknown as ICredentialsResponse;
}));
// Returns specific credentials
this.app.get('/rest/credentials/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<ICredentialsDecryptedResponse | ICredentialsResponse | undefined> => {
const findQuery = {} as FindManyOptions;
// Make sure the variable has an expected value
if (req.query.includeData === 'true') {
req.query.includeData = true;
} else {
req.query.includeData = false;
}
if (req.query.includeData !== true) {
// Return only the fields we need
findQuery.select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'];
}
const result = await Db.collections.Credentials!.findOne(req.params.id);
if (result === undefined) {
return result;
}
let encryptionKey = undefined;
if (req.query.includeData === true) {
encryptionKey = await UserSettings.getEncryptionKey();
if (encryptionKey === undefined) {
throw new Error('No encryption key got found to decrypt the credentials!');
}
const credentials = new Credentials(result.name, result.type, result.nodesAccess, result.data);
(result as ICredentialsDecryptedDb).data = credentials.getData(encryptionKey!);
}
(result as ICredentialsDecryptedResponse).id = result.id.toString();
return result as ICredentialsDecryptedResponse;
}));
// Returns all the saved credentials
this.app.get('/rest/credentials', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<ICredentialsResponse[]> => {
const findQuery = {} as FindManyOptions;
if (req.query.filter) {
findQuery.where = JSON.parse(req.query.filter);
if ((findQuery.where! as IDataObject).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 };
}
}
findQuery.select = ['id', 'name', 'type', 'nodesAccess', 'createdAt', 'updatedAt'];
const results = await Db.collections.Credentials!.find(findQuery) as unknown as ICredentialsResponse[];
let encryptionKey = undefined;
if (req.query.includeData === true) {
encryptionKey = await UserSettings.getEncryptionKey();
if (encryptionKey === undefined) {
throw new Error('No encryption key got found to decrypt the credentials!');
}
}
let result;
for (result of results) {
(result as ICredentialsDecryptedResponse).id = result.id.toString();
}
return results;
}));
// ----------------------------------------
// Credential-Types
// ----------------------------------------
// Returns all the credential types which are defined in the loaded n8n-modules
this.app.get('/rest/credential-types', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<ICredentialType[]> => {
const returnData: ICredentialType[] = [];
const credentialTypes = CredentialTypes();
credentialTypes.getAll().forEach((credentialData) => {
returnData.push(credentialData);
});
return returnData;
}));
// ----------------------------------------
// Executions
// ----------------------------------------
// Returns all finished executions
this.app.get('/rest/executions', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IExecutionsListResponse> => {
let filter: any = {}; // tslint:disable-line:no-any
if (req.query.filter) {
filter = JSON.parse(req.query.filter);
}
let limit = 20;
if (req.query.limit) {
limit = parseInt(req.query.limit, 10);
}
const countFilter = JSON.parse(JSON.stringify(filter));
if (req.query.lastStartedAt) {
filter.startedAt = LessThan(req.query.lastStartedAt);
}
const resultsPromise = Db.collections.Execution!.find({
where: filter,
order: {
startedAt: "DESC",
},
take: limit,
});
const countPromise = Db.collections.Execution!.count(countFilter);
const results: IExecutionFlattedDb[] = await resultsPromise;
const count = await countPromise;
const returnResults: IExecutionsSummary[] = [];
for (const result of results) {
returnResults.push({
id: result.id!.toString(),
finished: result.finished,
mode: result.mode,
retryOf: result.retryOf ? result.retryOf.toString() : undefined,
retrySuccessId: result.retrySuccessId ? result.retrySuccessId.toString() : undefined,
startedAt: result.startedAt,
stoppedAt: result.stoppedAt,
workflowId: result.workflowData!.id!.toString(),
workflowName: result.workflowData!.name,
});
}
return {
count,
results: returnResults,
};
}));
// Returns a specific execution
this.app.get('/rest/executions/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IExecutionFlattedResponse | undefined> => {
const result = await Db.collections.Execution!.findOne(req.params.id);
if (result === undefined) {
return undefined;
}
// Convert to response format in which the id is a string
(result as IExecutionFlatted as IExecutionFlattedResponse).id = result.id.toString();
return result as IExecutionFlatted as IExecutionFlattedResponse;
}));
// Retries a failed execution
this.app.post('/rest/executions/:id/retry', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<string> => {
// Get the data to execute
const fullExecutionDataFlatted = await Db.collections.Execution!.findOne(req.params.id);
if (fullExecutionDataFlatted === undefined) {
throw new ResponseHelper.ReponseError(`The execution with the id "${req.params.id}" does not exist.`, 404, 404);
}
const fullExecutionData = ResponseHelper.unflattenExecutionData(fullExecutionDataFlatted);
if (fullExecutionData.finished === true) {
throw new Error('The execution did succeed and can so not be retried.');
}
const executionMode = 'retry';
const nodeTypes = NodeTypes();
const workflowInstance = new Workflow(req.params.id, fullExecutionData.workflowData.nodes, fullExecutionData.workflowData.connections, false, nodeTypes, fullExecutionData.workflowData.staticData, fullExecutionData.workflowData.settings);
const additionalData = await WorkflowExecuteAdditionalData.get(executionMode, fullExecutionData.workflowData, workflowInstance, undefined, req.params.id);
const workflowExecute = new WorkflowExecute(additionalData, executionMode);
return workflowExecute.runExecutionData(workflowInstance, fullExecutionData.data);
}));
// Delete Executions
// INFORMATION: We use POST instead of DELETE to not run into any issues
// with the query data getting to long
this.app.post('/rest/executions/delete', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<void> => {
const deleteData = req.body as IExecutionDeleteFilter;
if (deleteData.deleteBefore !== undefined) {
const filters = {
startedAt: LessThanOrEqual(deleteData.deleteBefore),
};
if (deleteData.filters !== undefined) {
Object.assign(filters, deleteData.filters);
}
await Db.collections.Execution!.delete(filters);
} else if (deleteData.ids !== undefined) {
// Deletes all executions with the given ids
await Db.collections.Execution!.delete(deleteData.ids);
} else {
throw new Error('Required body-data "ids" or "deleteBefore" is missing!');
}
}));
// ----------------------------------------
// Executing Workflows
// ----------------------------------------
// Returns all the currently working executions
// this.app.get('/rest/executions-current', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IExecutionsCurrentSummaryExtended[]> => {
this.app.get('/rest/executions-current', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IExecutionsSummary[]> => {
const executingWorkflows = this.activeExecutionsInstance.getActiveExecutions();
const returnData: IExecutionsSummary[] = [];
let filter: any = {}; // tslint:disable-line:no-any
if (req.query.filter) {
filter = JSON.parse(req.query.filter);
}
for (const data of executingWorkflows) {
if (filter.workflowId !== undefined && filter.workflowId !== data.workflowId) {
continue;
}
returnData.push(
{
id: data.id.toString(),
workflowId: data.workflowId,
mode:data.mode,
startedAt: data.startedAt,
}
);
}
return returnData;
}));
// Forces the execution to stop
this.app.post('/rest/executions-current/:id/stop', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IExecutionsStopData> => {
const executionId = req.params.id;
// Stopt he execution and wait till it is done and we got the data
const result = await this.activeExecutionsInstance.stopExecution(executionId);
if (result === undefined) {
throw new Error(`The execution id "${executionId}" could not be found.`);
}
const returnData: IExecutionsStopData = {
mode: result.mode,
startedAt: result.startedAt,
stoppedAt: result.stoppedAt,
finished: result.finished,
};
return returnData;
}));
// Removes a test webhook
this.app.delete('/rest/test-webhook/:id', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<boolean> => {
const workflowId = req.params.id;
return this.testWebhooks.cancelTestWebhook(workflowId);
}));
// ----------------------------------------
// Options
// ----------------------------------------
// Returns all the available timezones
this.app.get('/rest/options/timezones', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<object> => {
return timezones;
}));
// ----------------------------------------
// Settings
// ----------------------------------------
// Returns the settings which are needed in the UI
this.app.get('/rest/settings', ResponseHelper.send(async (req: express.Request, res: express.Response): Promise<IN8nUISettings> => {
return {
endpointWebhook: this.endpointWebhook,
endpointWebhookTest: this.endpointWebhookTest,
saveManualRuns: this.saveManualRuns,
timezone: this.timezone,
urlBaseWebhook: WebhookHelpers.getWebhookBaseUrl(),
};
}));
// ----------------------------------------
// Webhooks
// ----------------------------------------
// GET webhook requests
this.app.get(`/${this.endpointWebhook}/*`, async (req: express.Request, res: express.Response) => {
console.log('\n*** WEBHOOK CALLED (GET) ***');
// Cut away the "/webhook/" to get the registred part of the url
const requestUrl = (req as ICustomRequest).parsedUrl!.pathname!.slice(this.endpointWebhook.length + 2);
let response;
try {
response = await this.activeWorkflowRunner.executeWebhook('GET', requestUrl, req, res);
} catch (error) {
ResponseHelper.sendErrorResponse(res, error);
return ;
}
if (response.noWebhookResponse === true) {
// Nothing else to do as the response got already sent
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true);
});
// POST webhook requests
this.app.post(`/${this.endpointWebhook}/*`, async (req: express.Request, res: express.Response) => {
console.log('\n*** WEBHOOK CALLED (POST) ***');
// Cut away the "/webhook/" to get the registred part of the url
const requestUrl = (req as ICustomRequest).parsedUrl!.pathname!.slice(this.endpointWebhook.length + 2);
let response;
try {
response = await this.activeWorkflowRunner.executeWebhook('POST', requestUrl, req, res);
} catch (error) {
ResponseHelper.sendErrorResponse(res, error);
return;
}
if (response.noWebhookResponse === true) {
// Nothing else to do as the response got already sent
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true);
});
// GET webhook requests (test for UI)
this.app.get(`/${this.endpointWebhookTest}/*`, async (req: express.Request, res: express.Response) => {
console.log('\n*** WEBHOOK-TEST CALLED (GET) ***');
// Cut away the "/webhook-test/" to get the registred part of the url
const requestUrl = (req as ICustomRequest).parsedUrl!.pathname!.slice(this.endpointWebhookTest.length + 2);
let response;
try {
response = await this.testWebhooks.callTestWebhook('GET', requestUrl, req, res);
} catch (error) {
ResponseHelper.sendErrorResponse(res, error);
return;
}
if (response.noWebhookResponse === true) {
// Nothing else to do as the response got already sent
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true);
});
// POST webhook requests (test for UI)
this.app.post(`/${this.endpointWebhookTest}/*`, async (req: express.Request, res: express.Response) => {
console.log('\n*** WEBHOOK-TEST CALLED (POST) ***');
// Cut away the "/webhook-test/" to get the registred part of the url
const requestUrl = (req as ICustomRequest).parsedUrl!.pathname!.slice(this.endpointWebhookTest.length + 2);
let response;
try {
response = await this.testWebhooks.callTestWebhook('POST', requestUrl, req, res);
} catch (error) {
ResponseHelper.sendErrorResponse(res, error);
return;
}
if (response.noWebhookResponse === true) {
// Nothing else to do as the response got already sent
return;
}
ResponseHelper.sendSuccessResponse(res, response.data, true);
});
// Serve the website
this.app.use('/', express.static(__dirname + '/../../node_modules/n8n-editor-ui/dist', { index: 'index.html' }));
}
}
export function start() {
const PORT = config.get('urls.port');
const app = new App().app;
app.listen(PORT, () => {
console.log('n8n ready on port ' + PORT);
});
}

View File

@ -0,0 +1,208 @@
import * as express from 'express';
import {
IResponseCallbackData,
Push,
ResponseHelper,
WebhookHelpers,
IWorkflowDb,
} from './';
import {
ActiveWebhooks,
} from 'n8n-core';
import {
IWebhookData,
IWorkflowExecuteAdditionalData,
WebhookHttpMethod,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
const pushInstance = Push.getInstance();
export class TestWebhooks {
private testWebhookData: {
[key: string]: {
sessionId?: string;
timeout: NodeJS.Timeout,
workflowData: IWorkflowDb;
};
} = {};
private activeWebhooks: ActiveWebhooks | null = null;
constructor() {
this.activeWebhooks = new ActiveWebhooks();
this.activeWebhooks.testWebhooks = true;
}
/**
* Executes a test-webhook and returns the data. It also makes sure that the
* data gets additionally send to the UI. After the request got handled it
* automatically remove the test-webhook.
*
* @param {WebhookHttpMethod} httpMethod
* @param {string} path
* @param {express.Request} request
* @param {express.Response} response
* @returns {Promise<object>}
* @memberof TestWebhooks
*/
async callTestWebhook(httpMethod: WebhookHttpMethod, path: string, request: express.Request, response: express.Response): Promise<IResponseCallbackData> {
const webhookData: IWebhookData | undefined = this.activeWebhooks!.get(httpMethod, path);
if (webhookData === undefined) {
// The requested webhook is not registred
throw new ResponseHelper.ReponseError('The requested webhook is not registred.', 404, 404);
}
// Get the node which has the webhook defined to know where to start from and to
// get additional data
const workflowStartNode = webhookData.workflow.getNode(webhookData.node);
if (workflowStartNode === null) {
throw new ResponseHelper.ReponseError('Could not find node to process webhook.', 404, 404);
}
const webhookKey = this.activeWebhooks!.getWebhookKey(webhookData.httpMethod, webhookData.path);
return new Promise(async (resolve, reject) => {
try {
const executionMode = 'manual';
const executionId = await WebhookHelpers.executeWebhook(webhookData, this.testWebhookData[webhookKey].workflowData, workflowStartNode, executionMode, this.testWebhookData[webhookKey].sessionId, request, response, (error: Error | null, data: IResponseCallbackData) => {
if (error !== null) {
return reject(error);
}
resolve(data);
});
if (executionId === undefined) {
// The workflow did not run as the request was probably setup related
// or a ping so do not resolve the promise and wait for the real webhook
// request instead.
return;
}
// Inform editor-ui that webhook got received
if (this.testWebhookData[webhookKey].sessionId !== undefined) {
pushInstance.send(this.testWebhookData[webhookKey].sessionId!, 'testWebhookReceived', { workflowId: webhookData.workflow.id });
}
} catch (error) {
// Delete webhook also if an error is thrown
}
// Remove the webhook
clearTimeout(this.testWebhookData[webhookKey].timeout);
delete this.testWebhookData[webhookKey];
this.activeWebhooks!.removeByWorkflowId(webhookData.workflow.id!.toString());
});
}
/**
* Checks if it has to wait for webhook data to execute the workflow. If yes it waits
* for it and resolves with the result of the workflow if not it simply resolves
* with undefined
*
* @param {IWorkflowDb} workflowData
* @param {Workflow} workflow
* @returns {(Promise<IExecutionDb | undefined>)}
* @memberof TestWebhooks
*/
async needsWebhookData(workflowData: IWorkflowDb, workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, sessionId?: string, destinationNode?: string): Promise<boolean> {
const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, destinationNode);
if (webhooks.length === 0) {
// No Webhooks found
return false;
}
// Remove test-webhooks automatically if they do not get called (after 120 seconds)
const timeout = setTimeout(() => {
this.cancelTestWebhook(workflowData.id.toString());
}, 120000);
let key: string;
for (const webhookData of webhooks) {
await this.activeWebhooks!.add(webhookData, mode);
key = this.activeWebhooks!.getWebhookKey(webhookData.httpMethod, webhookData.path);
this.testWebhookData[key] = {
sessionId,
timeout,
workflowData,
};
}
return true;
}
/**
* Removes a test webhook of the workflow with the given id
*
* @param {string} workflowId
* @returns {boolean}
* @memberof TestWebhooks
*/
cancelTestWebhook(workflowId: string): boolean {
let foundWebhook = false;
for (const webhookKey of Object.keys(this.testWebhookData)) {
const webhookData = this.testWebhookData[webhookKey];
if (webhookData.workflowData.id.toString() !== workflowId) {
continue;
}
foundWebhook = true;
clearTimeout(this.testWebhookData[webhookKey].timeout);
// Inform editor-ui that webhook got received
if (this.testWebhookData[webhookKey].sessionId !== undefined) {
try {
pushInstance.send(this.testWebhookData[webhookKey].sessionId!, 'testWebhookDeleted', { workflowId });
} catch (error) {
// Could not inform editor, probably is not connected anymore. So sipmly go on.
}
}
// Remove the webhook
delete this.testWebhookData[webhookKey];
this.activeWebhooks!.removeByWorkflowId(workflowId);
}
return foundWebhook;
}
/**
* Removes all the currently active test webhooks
*/
async removeAll(): Promise<void> {
if (this.activeWebhooks === null) {
return;
}
return this.activeWebhooks.removeAll();
}
}
let testWebhooksInstance: TestWebhooks | undefined;
export function getInstance(): TestWebhooks {
if (testWebhooksInstance === undefined) {
testWebhooksInstance = new TestWebhooks();
}
return testWebhooksInstance;
}

View File

@ -0,0 +1,334 @@
import * as express from 'express';
import {
GenericHelpers,
IExecutionDb,
IResponseCallbackData,
IWorkflowDb,
ResponseHelper,
WorkflowExecuteAdditionalData,
} from './';
import {
BINARY_ENCODING,
ActiveExecutions,
NodeExecuteFunctions,
WorkflowExecute,
} from 'n8n-core';
import {
IBinaryKeyData,
IDataObject,
IExecuteData,
INode,
IRun,
IRunExecutionData,
ITaskData,
IWebhookData,
IWorkflowExecuteAdditionalData,
NodeHelpers,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
const activeExecutions = ActiveExecutions.getInstance();
/**
* Returns the data of the last executed node
*
* @export
* @param {IRun} inputData
* @returns {(ITaskData | undefined)}
*/
export function getDataLastExecutedNodeData(inputData: IRun): ITaskData | undefined {
const runData = inputData.data.resultData.runData;
const lastNodeExecuted = inputData.data.resultData.lastNodeExecuted;
if (lastNodeExecuted === undefined) {
return undefined;
}
if (runData[lastNodeExecuted] === undefined) {
return undefined;
}
return runData[lastNodeExecuted][runData[lastNodeExecuted].length - 1];
}
/**
* Returns all the webhooks which should be created for the give workflow
*
* @export
* @param {string} workflowId
* @param {Workflow} workflow
* @returns {IWebhookData[]}
*/
export function getWorkflowWebhooks(workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData, destinationNode?: string): IWebhookData[] {
// Check all the nodes in the workflow if they have webhooks
const returnData: IWebhookData[] = [];
let parentNodes: string[] | undefined;
if (destinationNode !== undefined) {
parentNodes = workflow.getParentNodes(destinationNode);
}
for (const node of Object.values(workflow.nodes)) {
if (parentNodes !== undefined && !parentNodes.includes(node.name)) {
// If parentNodes are given check only them if they have webhooks
// and no other ones
continue;
}
returnData.push.apply(returnData, NodeHelpers.getNodeWebhooks(workflow, node, additionalData));
}
return returnData;
}
/**
* Executes a webhook
*
* @export
* @param {IWebhookData} webhookData
* @param {IWorkflowDb} workflowData
* @param {INode} workflowStartNode
* @param {WorkflowExecuteMode} executionMode
* @param {(string | undefined)} sessionId
* @param {express.Request} req
* @param {express.Response} res
* @param {((error: Error | null, data: IResponseCallbackData) => void)} responseCallback
* @returns {(Promise<string | undefined>)}
*/
export async function executeWebhook(webhookData: IWebhookData, workflowData: IWorkflowDb, workflowStartNode: INode, executionMode: WorkflowExecuteMode, sessionId: string | undefined, req: express.Request, res: express.Response, responseCallback: (error: Error | null, data: IResponseCallbackData) => void): Promise<string | undefined> {
// Get the nodeType to know which responseMode is set
const nodeType = webhookData.workflow.nodeTypes.getByName(workflowStartNode.type);
if (nodeType === undefined) {
const errorMessage = `The type of the webhook node "${workflowStartNode.name}" is not known.`;
responseCallback(new Error(errorMessage), {});
throw new ResponseHelper.ReponseError(errorMessage, 500, 500);
}
// Get the responseMode
const reponseMode = webhookData.workflow.getWebhookParameterValue(workflowStartNode, webhookData.webhookDescription, 'reponseMode', 'onReceived');
if (!['onReceived', 'lastNode'].includes(reponseMode 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.
const errorMessage = `The response mode ${reponseMode} is not valid!.`;
responseCallback(new Error(errorMessage), {});
throw new ResponseHelper.ReponseError(errorMessage, 500, 500);
}
// Prepare everything that is needed to run the workflow
const additionalData = await WorkflowExecuteAdditionalData.get(executionMode, workflowData, webhookData.workflow, sessionId);
const workflowExecute = new WorkflowExecute(additionalData, executionMode);
// Add the Response and Request so that this data can be accessed in the node
additionalData.httpRequest = req;
additionalData.httpResponse = res;
let didSendResponse = false;
try {
// Run the webhook function to see what should be returned and if
// the workflow should be executed or not
const webhookResultData = await webhookData.workflow.runWebhook(workflowStartNode, additionalData, NodeExecuteFunctions, executionMode);
if (webhookResultData.noWebhookResponse === true) {
// The response got already send
responseCallback(null, {
noWebhookResponse: true,
});
didSendResponse = true;
}
if (webhookResultData.workflowData === undefined) {
// Workflow should not run
if (webhookResultData.webhookResponse !== undefined) {
// Data to respond with is given
responseCallback(null, {
data: webhookResultData.webhookResponse
});
} else {
// Send default response
responseCallback(null, {
data: {
message: 'Webhook call got received.',
},
});
}
return;
}
// Now that we know that the workflow should run we can return the default respons
// directly if responseMode it set to "onReceived" and a respone should be sent
if (reponseMode === 'onReceived' && didSendResponse === false) {
// Return response directly and do not wait for the workflow to finish
if (webhookResultData.webhookResponse !== undefined) {
// Data to respond with is given
responseCallback(null, {
data: webhookResultData.webhookResponse,
});
} else {
responseCallback(null, {
data: {
message: 'Workflow got started.',
}
});
}
didSendResponse = true;
}
// Initialize the data of the webhook node
const nodeExecutionStack: IExecuteData[] = [];
nodeExecutionStack.push(
{
node: workflowStartNode,
data: {
main: webhookResultData.workflowData,
},
},
);
const runExecutionData: IRunExecutionData = {
startData: {
},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack,
waitingExecution: {},
},
};
// Start now to run the workflow
const executionId = await workflowExecute.runExecutionData(webhookData.workflow, runExecutionData);
// Get a promise which resolves when the workflow did execute and send then response
const executePromise = activeExecutions.getPostExecutePromise(executionId) as Promise<IExecutionDb | undefined>;
executePromise.then((data) => {
if (data === undefined) {
if (didSendResponse === false) {
responseCallback(null, {
data: {
message: 'Workflow did execute sucessfully but no data got returned.',
}
});
didSendResponse = true;
}
return undefined;
}
const returnData = getDataLastExecutedNodeData(data);
if (returnData === undefined) {
if (didSendResponse === false) {
responseCallback(null, {
data: {
message: 'Workflow did execute sucessfully but the last node did not return any data.',
}
});
}
didSendResponse = true;
return data;
} else if (returnData.error !== undefined) {
if (didSendResponse === false) {
responseCallback(null, {
data: {
message: 'Workflow did error.',
}
});
}
didSendResponse = true;
return data;
}
const reponseData = webhookData.workflow.getWebhookParameterValue(workflowStartNode, webhookData.webhookDescription, 'reponseData', 'firstEntryJson');
if (didSendResponse === false) {
let data: IDataObject | IDataObject[];
if (reponseData === 'firstEntryJson') {
// Return the JSON data of the first entry
data = returnData.data!.main[0]![0].json;
} else if (reponseData === 'firstEntryBinary') {
// Return the binary data of the first entry
data = returnData.data!.main[0]![0];
if (data.binary === undefined) {
responseCallback(new Error('No binary data to return got found.'), {});
}
const responseBinaryPropertyName = webhookData.workflow.getWebhookParameterValue(workflowStartNode, webhookData.webhookDescription, 'responseBinaryPropertyName', 'data');
if (responseBinaryPropertyName === undefined) {
responseCallback(new Error('No "responseBinaryPropertyName" is set.'), {});
}
const binaryData = (data.binary as IBinaryKeyData)[responseBinaryPropertyName as string];
if (binaryData === undefined) {
responseCallback(new Error(`The binary property "${responseBinaryPropertyName}" which should be returned does not exist.`), {});
}
// Send the webhook response manually
res.setHeader('Content-Type', binaryData.mimeType);
res.end(Buffer.from(binaryData.data, BINARY_ENCODING));
responseCallback(null, {
noWebhookResponse: true,
});
} else {
// Return the JSON data of all the entries
data = [];
for (const entry of returnData.data!.main[0]!) {
data.push(entry.json);
}
}
responseCallback(null, {
data,
});
}
didSendResponse = true;
return data;
})
.catch((e) => {
if (didSendResponse === false) {
responseCallback(new Error('There was a problem executing the workflow.'), {});
}
throw new ResponseHelper.ReponseError(e.message, 500, 500);
});
return executionId;
} catch (e) {
if (didSendResponse === false) {
responseCallback(new Error('There was a problem executing the workflow.'), {});
}
throw new ResponseHelper.ReponseError(e.message, 500, 500);
}
}
/**
* Returns the base URL of the webhooks
*
* @export
* @returns
*/
export function getWebhookBaseUrl() {
let urlBaseWebhook = GenericHelpers.getBaseUrl();
if (process.env.WEBHOOK_TUNNEL_URL !== undefined) {
urlBaseWebhook = process.env.WEBHOOK_TUNNEL_URL;
}
return urlBaseWebhook;
}

View File

@ -0,0 +1,38 @@
import {
Db,
} from './';
import {
INode,
IWorkflowCredentials
} from 'n8n-workflow';
export async function WorkflowCredentials(nodes: INode[]): Promise<IWorkflowCredentials> {
// Go through all nodes to find which credentials are needed to execute the workflow
const returnCredentials: IWorkflowCredentials = {};
let node, type, name, foundCredentials;
for (node of nodes) {
if (!node.credentials) {
continue;
}
for (type of Object.keys(node.credentials)) {
if (!returnCredentials.hasOwnProperty(type)) {
returnCredentials[type] = {};
}
name = node.credentials[type];
if (!returnCredentials[type].hasOwnProperty(name)) {
foundCredentials = await Db.collections.Credentials!.find({ name, type });
if (!foundCredentials.length) {
throw new Error(`Could not find credentials for type "${type}" with name "${name}".`);
}
returnCredentials[type][name] = foundCredentials[0];
}
}
}
return returnCredentials;
}

View File

@ -0,0 +1,210 @@
import {
Db,
IExecutionDb,
IExecutionFlattedDb,
IPushDataExecutionFinished,
IPushDataNodeExecuteAfter,
IPushDataNodeExecuteBefore,
IWorkflowBase,
Push,
ResponseHelper,
WebhookHelpers,
WorkflowCredentials,
WorkflowHelpers,
} from './';
import {
UserSettings,
} from "n8n-core";
import {
IRun,
ITaskData,
IWorkflowExecuteAdditionalData,
WorkflowExecuteMode,
Workflow,
} from 'n8n-workflow';
import * as config from 'config';
const pushInstance = Push.getInstance();
/**
* Checks if there was an error and if errorWorkflow is defined. If so it collects
* all the data and executes it
*
* @param {IWorkflowBase} workflowData The workflow which got executed
* @param {IRun} fullRunData The run which produced the error
* @param {WorkflowExecuteMode} mode The mode in which the workflow which did error got started in
* @param {string} [executionId] The id the execution got saved as
*/
function executeErrorWorkflow(workflowData: IWorkflowBase, fullRunData: IRun, mode: WorkflowExecuteMode, executionId?: string): void {
// Check if there was an error and if so if an errorWorkflow is set
if (fullRunData.data.resultData.error !== undefined && workflowData.settings !== undefined && workflowData.settings.errorWorkflow) {
const workflowErrorData = {
execution: {
id: executionId,
error: fullRunData.data.resultData.error,
lastNodeExecuted: fullRunData.data.resultData.lastNodeExecuted!,
mode,
},
workflow: {
id: workflowData.id !== undefined ? workflowData.id.toString() as string : undefined,
name: workflowData.name,
}
};
// Run the error workflow
WorkflowHelpers.executeErrorWorkflow(workflowData.settings.errorWorkflow as string, workflowErrorData);
}
}
const hooks = (mode: WorkflowExecuteMode, workflowData: IWorkflowBase, workflowInstance: Workflow, sessionId?: string, retryOf?: string) => {
return {
nodeExecuteBefore: [
async (executionId: string, nodeName: string): Promise<void> => {
if (sessionId === undefined) {
return;
}
const sendData: IPushDataNodeExecuteBefore = {
executionId,
nodeName,
};
pushInstance.send(sessionId, 'nodeExecuteBefore', sendData);
},
],
nodeExecuteAfter: [
async (executionId: string, nodeName: string, data: ITaskData): Promise<void> => {
if (sessionId === undefined) {
return;
}
const sendData: IPushDataNodeExecuteAfter = {
executionId,
nodeName,
data,
};
pushInstance.send(sessionId, 'nodeExecuteAfter', sendData);
},
],
workflowExecuteAfter: [
async (fullRunData: IRun, executionId: string): Promise<void> => {
try {
if (sessionId !== undefined) {
// Clone the object except the runData. That one is not supposed
// to be send. Because that data got send piece by piece after
// each node which finished executing
const pushRunData = {
...fullRunData,
data: {
...fullRunData.data,
resultData: {
...fullRunData.data.resultData,
runData: {},
},
},
};
// Push data to editor-ui once workflow finished
const sendData: IPushDataExecutionFinished = {
executionId,
data: pushRunData,
};
pushInstance.send(sessionId, 'executionFinished', sendData);
}
const workflowSavePromise = WorkflowHelpers.saveStaticData(workflowInstance);
let saveManualRuns = config.get('executions.saveManualRuns') as boolean;
if (workflowInstance.settings !== undefined && workflowInstance.settings.saveManualRuns !== undefined) {
// Apply to workflow override
saveManualRuns = workflowInstance.settings.saveManualRuns as boolean;
}
if (mode === 'manual' && saveManualRuns === false) {
if (workflowSavePromise !== undefined) {
// If workflow had to be saved wait till it is done
await workflowSavePromise;
}
// For now do not save manual executions
// TODO: Later that should be configurable. Think about what to do
// with the workflow.id when not saved yet or currently differes from saved version (save diff?!?!)
executeErrorWorkflow(workflowData, fullRunData, mode);
return;
}
// TODO: Should maybe have different log-modes like
// to save all data, only first input, only last node output, ....
// or depending on success to only save all on error to be
// able to start it again where it ended (but would then also have to save active data)
const fullExecutionData: IExecutionDb = {
data: fullRunData.data,
mode: fullRunData.mode,
finished: fullRunData.finished ? fullRunData.finished : false,
startedAt: fullRunData.startedAt,
stoppedAt: fullRunData.stoppedAt,
workflowData,
};
if (retryOf !== undefined) {
fullExecutionData.retryOf = retryOf.toString();
}
if (workflowData.id !== undefined && WorkflowHelpers.isWorkflowIdValid(workflowData.id.toString()) === true) {
fullExecutionData.workflowId = workflowData.id.toString();
}
const executionData = ResponseHelper.flattenExecutionData(fullExecutionData);
// Save the Execution in DB
const executionResult = await Db.collections.Execution!.save(executionData as IExecutionFlattedDb);
if (fullRunData.finished === true && retryOf !== undefined) {
// If the retry was successful save the reference it on the original execution
// await Db.collections.Execution!.save(executionData as IExecutionFlattedDb);
await Db.collections.Execution!.update(retryOf, { retrySuccessId: executionResult.id });
}
if (workflowSavePromise !== undefined) {
// If workflow had to be saved wait till it is done
await workflowSavePromise;
}
executeErrorWorkflow(workflowData, fullRunData, mode, executionResult ? executionResult.id as string : undefined);
} catch (error) {
executeErrorWorkflow(workflowData, fullRunData, mode);
}
},
]
};
};
export async function get(mode: WorkflowExecuteMode, workflowData: IWorkflowBase, workflowInstance: Workflow, sessionId?: string, retryOf?: string): Promise<IWorkflowExecuteAdditionalData> {
const urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl();
const timezone = config.get('timezone') as string;
const webhookBaseUrl = urlBaseWebhook + config.get('urls.endpointWebhook') as string;
const webhookTestBaseUrl = urlBaseWebhook + config.get('urls.endpointWebhookTest') as string;
const encryptionKey = await UserSettings.getEncryptionKey();
if (encryptionKey === undefined) {
throw new Error('No encryption key got found to decrypt the credentials!');
}
return {
credentials: await WorkflowCredentials(workflowData.nodes),
hooks: hooks(mode, workflowData, workflowInstance, sessionId, retryOf),
encryptionKey,
timezone,
webhookBaseUrl,
webhookTestBaseUrl,
};
}

View File

@ -0,0 +1,152 @@
import {
Db,
IWorkflowErrorData,
NodeTypes,
WorkflowExecuteAdditionalData,
} from './';
import {
WorkflowExecute,
} from 'n8n-core';
import {
IExecuteData,
INode,
IRunExecutionData,
Workflow,
} from 'n8n-workflow';
import * as config from 'config';
const ERROR_TRIGGER_TYPE = config.get('nodes.errorTriggerType') as string;
/**
* Returns if the given id is a valid workflow id
*
* @param {(string | null | undefined)} id The id to check
* @returns {boolean}
* @memberof App
*/
export function isWorkflowIdValid (id: string | null | undefined | number): boolean {
if (typeof id === 'string') {
id = parseInt(id, 10);
}
if (isNaN(id as number)) {
return false;
}
return true;
}
/**
* Executes the error workflow
*
* @export
* @param {string} workflowId The id of the error workflow
* @param {IWorkflowErrorData} workflowErrorData The error data
* @returns {Promise<void>}
*/
export async function executeErrorWorkflow(workflowId: string, workflowErrorData: IWorkflowErrorData): Promise<void> {
// Wrap everything in try/catch to make sure that no errors bubble up and all get caught here
try {
const workflowData = await Db.collections.Workflow!.findOne({ id: workflowId });
if (workflowData === undefined) {
// The error workflow could not be found
console.error(`ERROR: Calling Error Workflow for "${workflowErrorData.workflow.id}". Could not find error workflow "${workflowId}"`);
return;
}
const executionMode = 'error';
const nodeTypes = NodeTypes();
const workflowInstance = new Workflow(workflowId, workflowData.nodes, workflowData.connections, workflowData.active, nodeTypes, undefined, workflowData.settings);
let node: INode;
let workflowStartNode: INode | undefined;
for (const nodeName of Object.keys(workflowInstance.nodes)) {
node = workflowInstance.nodes[nodeName];
if (node.type === ERROR_TRIGGER_TYPE) {
workflowStartNode = node;
}
}
if (workflowStartNode === undefined) {
console.error(`ERROR: Calling Error Workflow for "${workflowErrorData.workflow.id}". Could not find "${ERROR_TRIGGER_TYPE}" in workflow "${workflowId}"`);
return;
}
const additionalData = await WorkflowExecuteAdditionalData.get(executionMode, workflowData, workflowInstance);
// Can execute without webhook so go on
const workflowExecute = new WorkflowExecute(additionalData, executionMode);
// Initialize the data of the webhook node
const nodeExecutionStack: IExecuteData[] = [];
nodeExecutionStack.push(
{
node: workflowStartNode,
data: {
main: [
[
{
json: workflowErrorData
}
]
],
},
},
);
const runExecutionData: IRunExecutionData = {
startData: {
},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack,
waitingExecution: {},
},
};
// Start now to run the workflow
await workflowExecute.runExecutionData(workflowInstance, runExecutionData);
} catch (error) {
console.error(`ERROR: Calling Error Workflow for "${workflowErrorData.workflow.id}": ${error.message}`);
}
}
/**
* Saves the static data if it changed
*
* @export
* @param {Workflow} workflow
* @returns {Promise <void>}
*/
export async function saveStaticData(workflow: Workflow): Promise <void> {
if (workflow.staticData.__dataChanged === true) {
// Static data of workflow changed and so has to be saved
if (isWorkflowIdValid(workflow.id) === true) {
// Workflow is saved so update in database
try {
await Db.collections.Workflow!
.update(workflow.id!, {
staticData: workflow.staticData,
});
workflow.staticData.__dataChanged = false;
} catch (e) {
// TODO: Add proper logging!
console.error(`There was a problem saving the workflow with id "${workflow.id}" to save changed staticData: ${e.message}`);
}
}
}
}

View File

@ -0,0 +1,7 @@
import * as MongoDb from './mongodb';
import * as SQLite from './sqlite';
export {
MongoDb,
SQLite,
};

View File

@ -0,0 +1,41 @@
import {
ICredentialNodeAccess,
} from 'n8n-workflow';
import {
ICredentialsDb,
} from '../../';
import {
Column,
Entity,
Index,
ObjectID,
ObjectIdColumn,
} from "typeorm";
@Entity()
export class CredentialsEntity implements ICredentialsDb {
@ObjectIdColumn()
id: ObjectID;
@Column()
name: string;
@Column()
data: string;
@Index()
@Column()
type: string;
@Column('json')
nodesAccess: ICredentialNodeAccess[];
@Column()
createdAt: number;
@Column()
updatedAt: number;
}

View File

@ -0,0 +1,51 @@
import {
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
IExecutionFlattedDb,
IWorkflowDb,
} from '../../';
import {
Column,
Entity,
Index,
ObjectID,
ObjectIdColumn,
} from "typeorm";
@Entity()
export class ExecutionEntity implements IExecutionFlattedDb {
@ObjectIdColumn()
id: ObjectID;
@Column()
data: string;
@Column()
finished: boolean;
@Column()
mode: WorkflowExecuteMode;
@Column()
retryOf: string;
@Column()
retrySuccessId: string;
@Column()
startedAt: number;
@Column()
stoppedAt: number;
@Column('json')
workflowData: IWorkflowDb;
@Index()
@Column()
workflowId: string;
}

View File

@ -0,0 +1,48 @@
import {
IConnections,
IDataObject,
INode,
IWorkflowSettings,
} from 'n8n-workflow';
import {
IWorkflowDb,
} from '../../';
import {
Column,
Entity,
ObjectID,
ObjectIdColumn,
} from "typeorm";
@Entity()
export class WorkflowEntity implements IWorkflowDb {
@ObjectIdColumn()
id: ObjectID;
@Column()
name: string;
@Column()
active: boolean;
@Column('json')
nodes: INode[];
@Column('json')
connections: IConnections;
@Column()
createdAt: number;
@Column()
updatedAt: number;
@Column('json')
settings?: IWorkflowSettings;
@Column('json')
staticData?: IDataObject;
}

View File

@ -0,0 +1,3 @@
export * from './CredentialsEntity';
export * from './ExecutionEntity';
export * from './WorkflowEntity';

View File

@ -0,0 +1,44 @@
import {
ICredentialNodeAccess,
} from 'n8n-workflow';
import {
ICredentialsDb,
} from '../../';
import {
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from "typeorm";
@Entity()
export class CredentialsEntity implements ICredentialsDb {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 128
})
name: string;
@Column('text')
data: string;
@Index()
@Column({
length: 32
})
type: string;
@Column('simple-json')
nodesAccess: ICredentialNodeAccess[];
@Column()
createdAt: number;
@Column()
updatedAt: number;
}

View File

@ -0,0 +1,53 @@
import {
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
IExecutionFlattedDb,
IWorkflowDb,
} from '../../';
import {
Column,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
} from "typeorm";
import { WorkflowEntity } from './WorkflowEntity';
@Entity()
export class ExecutionEntity implements IExecutionFlattedDb {
@PrimaryGeneratedColumn()
id: number;
@Column('text')
data: string;
@Column()
finished: boolean;
@Column()
mode: WorkflowExecuteMode;
@Column({ nullable: true })
retryOf: string;
@Column({ nullable: true })
retrySuccessId: string;
@Column()
startedAt: number;
@Column()
stoppedAt: number;
@Column('simple-json')
workflowData: IWorkflowDb;
@Index()
@Column({ nullable: true })
workflowId: string;
}

View File

@ -0,0 +1,55 @@
import {
IConnections,
IDataObject,
INode,
IWorkflowSettings,
} from 'n8n-workflow';
import {
IWorkflowDb,
} from '../../';
import {
Column,
Entity,
PrimaryGeneratedColumn,
} from "typeorm";
@Entity()
export class WorkflowEntity implements IWorkflowDb {
@PrimaryGeneratedColumn()
id: number;
@Column({
length: 128
})
name: string;
@Column()
active: boolean;
@Column('simple-json')
nodes: INode[];
@Column('simple-json')
connections: IConnections;
@Column()
createdAt: number;
@Column()
updatedAt: number;
@Column({
type: 'simple-json',
nullable: true,
})
settings?: IWorkflowSettings;
@Column({
type: 'simple-json',
nullable: true,
})
staticData?: IDataObject;
}

View File

@ -0,0 +1,3 @@
export * from './CredentialsEntity';
export * from './ExecutionEntity';
export * from './WorkflowEntity';

29
packages/cli/src/index.ts Normal file
View File

@ -0,0 +1,29 @@
export * from './CredentialTypes';
export * from './Interfaces';
export * from './LoadNodesAndCredentials';
export * from './NodeTypes';
export * from './WorkflowCredentials';
import * as ActiveWorkflowRunner from './ActiveWorkflowRunner';
import * as Db from './Db';
import * as GenericHelpers from './GenericHelpers';
import * as Push from './Push';
import * as ResponseHelper from './ResponseHelper';
import * as Server from './Server';
import * as TestWebhooks from './TestWebhooks';
import * as WebhookHelpers from './WebhookHelpers';
import * as WorkflowExecuteAdditionalData from './WorkflowExecuteAdditionalData';
import * as WorkflowHelpers from './WorkflowHelpers';
export {
ActiveWorkflowRunner,
Db,
GenericHelpers,
Push,
ResponseHelper,
Server,
TestWebhooks,
WebhookHelpers,
WorkflowExecuteAdditionalData,
WorkflowHelpers,
};

View File

@ -0,0 +1,39 @@
{
"compilerOptions": {
"lib": [
"es2017"
],
"types": [
"node",
"jest"
],
"module": "commonjs",
"noImplicitAny": true,
"removeComments": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
// Have to deactivate for TypeORM
// "strict": true,
"preserveConstEnums": true,
"declaration": true,
"outDir": "./dist/",
"target": "es2017",
"sourceMap": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true
},
"include": [
"**/*.d.ts",
"commands/**/*",
"index.ts",
"config/**/*",
"src/**/*",
"test/**/*",
],
"exclude": [
"dist/**/*",
"node_modules/**/*",
"**/*.spec.ts"
]
}

103
packages/cli/tslint.json Normal file
View File

@ -0,0 +1,103 @@
{
"linterOptions": {
"exclude": [
"node_modules/**/*"
]
},
"defaultSeverity": "error",
"jsRules": {},
"rules": {
"array-type": [
true,
"array-simple"
],
"arrow-return-shorthand": true,
"ban": [
true,
{
"name": "Array",
"message": "tsstyle#array-constructor"
}
],
"ban-types": [
true,
[
"Object",
"Use {} instead."
],
[
"String",
"Use 'string' instead."
],
[
"Number",
"Use 'number' instead."
],
[
"Boolean",
"Use 'boolean' instead."
]
],
"class-name": true,
"curly": [
true,
"ignore-same-line"
],
"forin": true,
"jsdoc-format": true,
"label-position": true,
"member-access": [
true,
"no-public"
],
"new-parens": true,
"no-angle-bracket-type-assertion": true,
"no-any": true,
"no-arg": true,
"no-conditional-assignment": true,
"no-construct": true,
"no-debugger": true,
"no-default-export": true,
"no-duplicate-variable": true,
"no-inferrable-types": true,
"no-namespace": [
true,
"allow-declarations"
],
"no-reference": true,
"no-string-throw": true,
"no-unused-expression": true,
"no-var-keyword": true,
"object-literal-shorthand": true,
"only-arrow-functions": [
true,
"allow-declarations",
"allow-named-functions"
],
"prefer-const": true,
"radix": true,
"semicolon": [
true,
"always",
"ignore-bound-class-methods"
],
"switch-default": true,
"triple-equals": [
true,
"allow-null-check"
],
"use-isnan": true,
"quotes": [
"error",
"single"
],
"variable-name": [
true,
"check-format",
"ban-keywords",
"allow-leading-underscore",
"allow-trailing-underscore"
]
},
"rulesDirectory": []
}

230
packages/core/LICENSE Normal file
View File

@ -0,0 +1,230 @@
“Commons Clause” License Condition v1.0
The Software is provided to you by the Licensor under the
License, as defined below, subject to the following condition.
Without limiting other conditions in the License, the grant
of rights under the License will not include, and the License
does not grant to you, the right to Sell the Software.
For purposes of the foregoing, “Sell” means practicing any or
all of the rights granted to you under the License to provide
to third parties, for a fee or other consideration (including
without limitation fees for hosting or consulting/ support
services related to the Software), a product or service whose
value derives, entirely or substantially, from the functionality
of the Software. Any license notice or attribution required by
the License must also include this Commons Clause License
Condition notice.
Software: n8n
License: Apache 2.0
Licensor: Jan Oberhauser
---------------------------------------------------------------------
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

13
packages/core/README.md Normal file
View File

@ -0,0 +1,13 @@
# n8n-core
![n8n.io - Workflow Automation](https://n8n.io/n8n-logo.png)
Core components for n8n
```
npm install n8n-core
```
## License
[Apache 2.0 with Commons Clause](LICENSE)

View File

@ -0,0 +1,60 @@
{
"name": "n8n-core",
"version": "0.1.0",
"description": "Core functionality of n8n",
"license": "SEE LICENSE IN LICENSE",
"author": {
"name": "Jan Oberhauser",
"email": "jan@n8n.io"
},
"main": "dist/src/index",
"types": "dist/src/index.d.ts",
"scripts": {
"build": "tsc",
"tslint": "tslint -p tsconfig.json -c tslint.json",
"watch": "tsc --watch",
"test": "jest"
},
"files": [
"dist"
],
"devDependencies": {
"@types/crypto-js": "^3.1.43",
"@types/express": "^4.16.1",
"@types/jest": "^23.3.2",
"@types/lodash.get": "^4.4.5",
"@types/mmmagic": "^0.4.29",
"@types/node": "^10.10.1",
"@types/request-promise-native": "^1.0.15",
"jest": "^23.6.0",
"source-map-support": "^0.5.9",
"ts-jest": "^23.10.1",
"tslint": "^5.11.0",
"typescript": "~3.3.0"
},
"dependencies": {
"crypto-js": "^3.1.9-1",
"lodash.get": "^4.4.2",
"mmmagic": "^0.5.2",
"n8n-workflow": "^0.1.0",
"request-promise-native": "^1.0.7"
},
"jest": {
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"testURL": "http://localhost/",
"testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
"testPathIgnorePatterns": [
"/dist/",
"/node_modules/"
],
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"json",
"node"
]
}
}

View File

@ -0,0 +1,172 @@
import {
IRun,
IRunExecutionData,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
createDeferredPromise,
IExecutingWorkflowData,
IExecutionsCurrentSummary,
} from '.';
export class ActiveExecutions {
private nextId = 1;
private activeExecutions: {
[index: string]: IExecutingWorkflowData;
} = {};
private stopExecutions: string[] = [];
/**
* Add a new active execution
*
* @param {Workflow} workflow
* @param {IRunExecutionData} runExecutionData
* @param {WorkflowExecuteMode} mode
* @returns {string}
* @memberof ActiveExecutions
*/
add(workflow: Workflow, runExecutionData: IRunExecutionData, mode: WorkflowExecuteMode): string {
const executionId = this.nextId++;
this.activeExecutions[executionId] = {
runExecutionData,
startedAt: new Date().getTime(),
mode,
workflow,
postExecutePromises: [],
};
return executionId.toString();
}
/**
* Remove an active execution
*
* @param {string} executionId
* @param {IRun} fullRunData
* @returns {void}
* @memberof ActiveExecutions
*/
remove(executionId: string, fullRunData: IRun): void {
if (this.activeExecutions[executionId] === undefined) {
return;
}
// Resolve all the waiting promises
for (const promise of this.activeExecutions[executionId].postExecutePromises) {
promise.resolve(fullRunData);
}
// Remove from the list of active executions
delete this.activeExecutions[executionId];
const stopExecutionIndex = this.stopExecutions.indexOf(executionId);
if (stopExecutionIndex !== -1) {
// If it was on the stop-execution list remove it
this.stopExecutions.splice(stopExecutionIndex, 1);
}
}
/**
* Forces an execution to stop
*
* @param {string} executionId The id of the execution to stop
* @returns {(Promise<IRun | undefined>)}
* @memberof ActiveExecutions
*/
async stopExecution(executionId: string): Promise<IRun | undefined> {
if (this.activeExecutions[executionId] === undefined) {
// There is no execution running with that id
return;
}
if (!this.stopExecutions.includes(executionId)) {
// Add the execution to the stop list if it is not already on it
this.stopExecutions.push(executionId);
}
return this.getPostExecutePromise(executionId);
}
/**
* Returns a promise which will resolve with the data of the execution
* with the given id
*
* @param {string} executionId The id of the execution to wait for
* @returns {Promise<IRun>}
* @memberof ActiveExecutions
*/
async getPostExecutePromise(executionId: string): Promise<IRun> {
// Create the promise which will be resolved when the execution finished
const waitPromise = await createDeferredPromise<IRun>();
if (this.activeExecutions[executionId] === undefined) {
throw new Error(`There is no active execution with id "${executionId}".`);
}
this.activeExecutions[executionId].postExecutePromises.push(waitPromise);
return waitPromise.promise();
}
/**
* Returns if the execution should be stopped
*
* @param {string} executionId The execution id to check
* @returns {boolean}
* @memberof ActiveExecutions
*/
shouldBeStopped(executionId: string): boolean {
return this.stopExecutions.includes(executionId);
}
/**
* Returns all the currently active executions
*
* @returns {IExecutionsCurrentSummary[]}
* @memberof ActiveExecutions
*/
getActiveExecutions(): IExecutionsCurrentSummary[] {
const returnData: IExecutionsCurrentSummary[] = [];
let executionData;
for (const id of Object.keys(this.activeExecutions)) {
executionData = this.activeExecutions[id];
returnData.push(
{
id,
startedAt: executionData.startedAt,
mode: executionData.mode,
workflowId: executionData.workflow.id!,
}
);
}
return returnData;
}
}
let activeExecutionsInstance: ActiveExecutions | undefined;
export function getInstance(): ActiveExecutions {
if (activeExecutionsInstance === undefined) {
activeExecutionsInstance = new ActiveExecutions();
}
return activeExecutionsInstance;
}

View File

@ -0,0 +1,175 @@
import {
IWebhookData,
WebhookHttpMethod,
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
NodeExecuteFunctions,
} from './';
export class ActiveWebhooks {
private workflowWebhooks: {
[key: string]: IWebhookData[];
} = {};
private webhookUrls: {
[key: string]: IWebhookData;
} = {};
testWebhooks = false;
/**
* Adds a new webhook
*
* @param {IWebhookData} webhookData
* @param {WorkflowExecuteMode} mode
* @returns {Promise<void>}
* @memberof ActiveWebhooks
*/
async add(webhookData: IWebhookData, mode: WorkflowExecuteMode): Promise<void> {
if (webhookData.workflow.id === undefined) {
throw new Error('Webhooks can only be added for saved workflows as an id is needed!');
}
if (this.workflowWebhooks[webhookData.workflow.id] === undefined) {
this.workflowWebhooks[webhookData.workflow.id] = [];
}
// Make the webhook available directly because sometimes to create it successfully
// it gets called
this.webhookUrls[this.getWebhookKey(webhookData.httpMethod, webhookData.path)] = webhookData;
const webhookExists = await webhookData.workflow.runWebhookMethod('checkExists', webhookData, NodeExecuteFunctions, mode, this.testWebhooks);
if (webhookExists === false) {
// If webhook does not exist yet create it
await webhookData.workflow.runWebhookMethod('create', webhookData, NodeExecuteFunctions, mode, this.testWebhooks);
}
// Run the "activate" hooks on the nodes
await webhookData.workflow.runNodeHooks('activate', webhookData, NodeExecuteFunctions, mode);
this.workflowWebhooks[webhookData.workflow.id].push(webhookData);
}
/**
* Returns webhookData if a webhook with matches is currently registered
*
* @param {WebhookHttpMethod} httpMethod
* @param {string} path
* @returns {(IWebhookData | undefined)}
* @memberof ActiveWebhooks
*/
get(httpMethod: WebhookHttpMethod, path: string): IWebhookData | undefined {
const webhookKey = this.getWebhookKey(httpMethod, path);
if (this.webhookUrls[webhookKey] === undefined) {
return undefined;
}
return this.webhookUrls[webhookKey];
}
/**
* Returns key to uniquely identify a webhook
*
* @param {WebhookHttpMethod} httpMethod
* @param {string} path
* @returns {string}
* @memberof ActiveWebhooks
*/
getWebhookKey(httpMethod: WebhookHttpMethod, path: string): string {
return `${httpMethod}|${path}`;
}
/**
* Removes all webhooks of a workflow
*
* @param {string} workflowId
* @returns {boolean}
* @memberof ActiveWebhooks
*/
async removeByWorkflowId(workflowId: string): Promise<boolean> {
if (this.workflowWebhooks[workflowId] === undefined) {
// If it did not exist then there is nothing to remove
return false;
}
const webhooks = this.workflowWebhooks[workflowId];
const mode = 'internal';
// Go through all the registered webhooks of the workflow and remove them
for (const webhookData of webhooks) {
await webhookData.workflow.runWebhookMethod('delete', webhookData, NodeExecuteFunctions, mode, this.testWebhooks);
// Run the "deactivate" hooks on the nodes
await webhookData.workflow.runNodeHooks('deactivate', webhookData, NodeExecuteFunctions, mode);
delete this.webhookUrls[this.getWebhookKey(webhookData.httpMethod, webhookData.path)];
}
// Remove also the workflow-webhook entry
delete this.workflowWebhooks[workflowId];
return true;
}
/**
* Removes all the currently active webhooks
*/
async removeAll(): Promise<void> {
const workflowIds = Object.keys(this.workflowWebhooks);
const removePromises = [];
for (const workflowId of workflowIds) {
removePromises.push(this.removeByWorkflowId(workflowId));
}
await Promise.all(removePromises);
return;
}
// /**
// * Removes a single webhook by its key.
// * Currently not used, runNodeHooks for "deactivate" is missing
// *
// * @param {string} webhookKey
// * @returns {boolean}
// * @memberof ActiveWebhooks
// */
// removeByWebhookKey(webhookKey: string): boolean {
// if (this.webhookUrls[webhookKey] === undefined) {
// // If it did not exist then there is nothing to remove
// return false;
// }
// const webhookData = this.webhookUrls[webhookKey];
// // Remove from workflow-webhooks
// const workflowWebhooks = this.workflowWebhooks[webhookData.workflowId];
// for (let index = 0; index < workflowWebhooks.length; index++) {
// if (workflowWebhooks[index].path === webhookData.path) {
// workflowWebhooks.splice(index, 1);
// break;
// }
// }
// if (workflowWebhooks.length === 0) {
// // When there are no webhooks left for any workflow remove it totally
// delete this.workflowWebhooks[webhookData.workflowId];
// }
// // Remove from webhook urls
// delete this.webhookUrls[webhookKey];
// return true;
// }
}

View File

@ -0,0 +1,112 @@
import {
ITriggerResponse,
IWorkflowExecuteAdditionalData,
Workflow,
} from 'n8n-workflow';
import {
NodeExecuteFunctions,
} from './';
export interface WorkflowData {
workflow: Workflow;
triggerResponse?: ITriggerResponse;
}
export class ActiveWorkflows {
private workflowData: {
[key: string]: WorkflowData;
} = {};
/**
* Returns if the workflow is active
*
* @param {string} id The id of the workflow to check
* @returns {boolean}
* @memberof ActiveWorkflows
*/
isActive(id: string): boolean {
return this.workflowData.hasOwnProperty(id);
}
/**
* Returns the ids of the currently active workflows
*
* @returns {string[]}
* @memberof ActiveWorkflows
*/
allActiveWorkflows(): string[] {
return Object.keys(this.workflowData);
}
/**
* Returns the Workflow data for the workflow with
* the given id if it is currently active
*
* @param {string} id
* @returns {(WorkflowData | undefined)}
* @memberof ActiveWorkflows
*/
get(id: string): WorkflowData | undefined {
return this.workflowData[id];
}
/**
* Makes a workflow active
*
* @param {string} id The id of the workflow to activate
* @param {Workflow} workflow The workflow to activate
* @param {IWorkflowExecuteAdditionalData} additionalData The additional data which is needed to run workflows
* @returns {Promise<void>}
* @memberof ActiveWorkflows
*/
async add(id: string, workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData): Promise<void> {
console.log('ADD ID (active): ' + id);
this.workflowData[id] = {
workflow
};
const triggerNodes = workflow.getTriggerNodes();
let triggerResponse: ITriggerResponse | undefined;
for (const triggerNode of triggerNodes) {
triggerResponse = await workflow.runTrigger(triggerNode, NodeExecuteFunctions, additionalData, 'trigger');
if (triggerResponse !== undefined) {
// If a response was given save it
this.workflowData[id].triggerResponse = triggerResponse;
}
}
}
/**
* Makes a workflow inactive
*
* @param {string} id The id of the workflow to deactivate
* @returns {Promise<void>}
* @memberof ActiveWorkflows
*/
async remove(id: string): Promise<void> {
console.log('REMOVE ID (active): ' + id);
if (!this.isActive(id)) {
// Workflow is currently not registered
throw new Error(`The workflow with the id "${id}" is currently not active and can so not be removed`);
}
const workflowData = this.workflowData[id];
if (workflowData.triggerResponse && workflowData.triggerResponse.closeFunction) {
await workflowData.triggerResponse.closeFunction();
}
delete this.workflowData[id];
}
}

View File

@ -0,0 +1,7 @@
export const BINARY_ENCODING = 'base64';
export const CUSTOM_EXTENSION_ENV = 'N8N_CUSTOM_EXTENSIONS';
export const ENCRYPTION_KEY_ENV_OVERWRITE = 'N8N_ENCRYPTION_KEY';
export const EXTENSIONS_SUBDIRECTORY = 'custom';
export const USER_FOLDER_ENV_OVERWRITE = 'N8N_USER_FOLDER';
export const USER_SETTINGS_FILE_NAME = 'config';
export const USER_SETTINGS_SUBFOLDER = '.n8n';

View File

@ -0,0 +1,120 @@
import {
ICredentialDataDecryptedObject,
CredentialInformation,
ICredentialsEncrypted,
ICredentialNodeAccess,
} from 'n8n-workflow';
import { enc, AES } from 'crypto-js';
export class Credentials implements ICredentialsEncrypted {
name: string;
type: string;
data: string | undefined;
nodesAccess: ICredentialNodeAccess[];
constructor(name: string, type: string, nodesAccess: ICredentialNodeAccess[], data?: string) {
this.name = name;
this.type = type;
this.nodesAccess = nodesAccess;
this.data = data;
}
/**
* Returns if the given nodeType has access to data
*/
hasNodeAccess(nodeType: string): boolean {
for (const accessData of this.nodesAccess) {
if (accessData.nodeType === nodeType) {
return true;
}
}
return false;
}
/**
* Sets new credential object
*/
setData(data: ICredentialDataDecryptedObject, encryptionKey: string): void {
this.data = AES.encrypt(JSON.stringify(data), encryptionKey).toString();
}
/**
* Sets new credentials for given key
*/
setDataKey(key: string, data: CredentialInformation, encryptionKey: string): void {
let fullData;
try {
fullData = this.getData(encryptionKey);
} catch (e) {
fullData = {};
}
fullData[key] = data;
return this.setData(fullData, encryptionKey);
}
/**
* Returns the decrypted credential object
*/
getData(encryptionKey: string, nodeType?: string): ICredentialDataDecryptedObject {
if (nodeType && !this.hasNodeAccess(nodeType)) {
throw new Error(`The node of type "${nodeType}" does not have access to credentials "${this.name}" of type "${this.type}".`);
}
if (this.data === undefined) {
throw new Error('No data is set so nothing can be returned.');
}
const decryptedData = AES.decrypt(this.data, encryptionKey);
try {
return JSON.parse(decryptedData.toString(enc.Utf8));
} catch (e) {
throw new Error('Credentials could not be decrypted. The reason is that probably a different "encryptionKey" got used to encrypt the data than now to decrypt it.');
}
}
/**
* Returns the decrypted credentials for given key
*/
getDataKey(key: string, encryptionKey: string, nodeType?: string): CredentialInformation {
const fullData = this.getData(encryptionKey, nodeType);
if (fullData === null) {
throw new Error(`No data got set.`);
}
if (!fullData.hasOwnProperty(key)) {
throw new Error(`No data for key "${key}" exists.`);
}
return fullData[key];
}
/**
* Returns the encrypted credentials to be saved
*/
getDataToSave(): ICredentialsEncrypted {
if (this.data === undefined) {
throw new Error(`No credentials got set to save.`);
}
return {
name: this.name,
type: this.type,
data: this.data,
nodesAccess: this.nodesAccess,
};
}
}

View File

@ -0,0 +1,14 @@
// From: https://gist.github.com/compulim/8b49b0a744a3eeb2205e2b9506201e50
export interface IDeferredPromise<T> {
promise: () => Promise<T>;
reject: (error: Error) => void;
resolve: (result: T) => void;
}
export function createDeferredPromise<T>(): Promise<IDeferredPromise<T>> {
return new Promise<IDeferredPromise<T>>(resolveCreate => {
const promise = new Promise<T>((resolve, reject) => {
resolveCreate({ promise: () => promise, resolve, reject });
});
});
}

View File

@ -0,0 +1,115 @@
import {
IBinaryData,
ICredentialType,
IDataObject,
IExecuteFunctions as IExecuteFunctionsBase,
IExecuteSingleFunctions as IExecuteSingleFunctionsBase,
IHookFunctions as IHookFunctionsBase,
ILoadOptionsFunctions as ILoadOptionsFunctionsBase,
INodeExecutionData,
INodeType,
IRun,
IRunExecutionData,
ITriggerFunctions as ITriggerFunctionsBase,
IWebhookFunctions as IWebhookFunctionsBase,
IWorkflowSettings as IWorkflowSettingsWorkflow,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
IDeferredPromise
} from '.';
import * as request from 'request';
import * as requestPromise from 'request-promise-native';
interface Constructable<T> {
new(): T;
}
export interface IExecuteFunctions extends IExecuteFunctionsBase {
helpers: {
prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise<IBinaryData>;
request: request.RequestAPI<requestPromise.RequestPromise, requestPromise.RequestPromiseOptions, request.RequiredUriUrl>,
returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[];
};
}
export interface IExecuteSingleFunctions extends IExecuteSingleFunctionsBase {
helpers: {
prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise<IBinaryData>;
request: request.RequestAPI < requestPromise.RequestPromise, requestPromise.RequestPromiseOptions, request.RequiredUriUrl >,
};
}
export interface IExecutingWorkflowData {
runExecutionData: IRunExecutionData;
startedAt: number;
mode: WorkflowExecuteMode;
workflow: Workflow;
postExecutePromises: Array<IDeferredPromise<IRun>>;
}
export interface IExecutionsCurrentSummary {
id: string;
startedAt: number;
mode: WorkflowExecuteMode;
workflowId: string;
}
export interface ITriggerFunctions extends ITriggerFunctionsBase {
helpers: {
prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise<IBinaryData>;
request: request.RequestAPI<requestPromise.RequestPromise, requestPromise.RequestPromiseOptions, request.RequiredUriUrl>,
returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[];
};
}
export interface IUserSettings {
encryptionKey?: string;
tunnelSubdomain?: string;
}
export interface ILoadOptionsFunctions extends ILoadOptionsFunctionsBase {
helpers: {
request?: request.RequestAPI<requestPromise.RequestPromise, requestPromise.RequestPromiseOptions, request.RequiredUriUrl>,
};
}
export interface IHookFunctions extends IHookFunctionsBase {
helpers: {
request: request.RequestAPI<requestPromise.RequestPromise, requestPromise.RequestPromiseOptions, request.RequiredUriUrl>,
};
}
export interface IWebhookFunctions extends IWebhookFunctionsBase {
helpers: {
prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise<IBinaryData>;
request: request.RequestAPI<requestPromise.RequestPromise, requestPromise.RequestPromiseOptions, request.RequiredUriUrl>,
returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[];
};
}
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
errorWorkflow?: string;
timezone?: string;
saveManualRuns?: boolean;
}
// New node definition in file
export interface INodeDefinitionFile {
[key: string]: Constructable<INodeType | ICredentialType>;
}
// Is identical to TaskDataConnections but does not allow null value to be used as input for nodes
export interface INodeInputDataConnections {
[key: string]: INodeExecutionData[][];
}

View File

@ -0,0 +1,97 @@
import {
INode,
INodeCredentials,
INodePropertyOptions,
INodeTypes,
IWorkflowExecuteAdditionalData,
Workflow,
} from 'n8n-workflow';
import {
NodeExecuteFunctions,
} from './';
const TEMP_NODE_NAME = 'Temp-Node';
const TEMP_WORKFLOW_NAME = 'Temp-Workflow';
export class LoadNodeParameterOptions {
workflow: Workflow;
constructor(nodeTypeName: string, nodeTypes: INodeTypes, credentials?: INodeCredentials) {
const nodeType = nodeTypes.getByName(nodeTypeName);
if (nodeType === undefined) {
throw new Error(`The node-type "${nodeTypeName}" is not known!`);
}
const nodeData: INode = {
parameters: {
},
name: TEMP_NODE_NAME,
type: nodeTypeName,
typeVersion: 1,
position: [
0,
0,
]
};
if (credentials) {
nodeData.credentials = credentials;
}
const workflowData = {
nodes: [
nodeData,
],
connections: {},
};
this.workflow = new Workflow(undefined, workflowData.nodes, workflowData.connections, false, nodeTypes, undefined);
}
/**
* Returns data of a fake workflow
*
* @returns
* @memberof LoadNodeParameterOptions
*/
getWorkflowData() {
return {
name: TEMP_WORKFLOW_NAME,
active: false,
connections: {},
nodes: Object.values(this.workflow.nodes),
createdAt: 0,
updatedAt: 0,
};
}
/**
* Returns the available options
*
* @param {string} methodName The name of the method of which to get the data from
* @param {IWorkflowExecuteAdditionalData} additionalData
* @returns {Promise<INodePropertyOptions[]>}
* @memberof LoadNodeParameterOptions
*/
getOptions(methodName: string, additionalData: IWorkflowExecuteAdditionalData): Promise<INodePropertyOptions[]> {
const node = this.workflow.getNode(TEMP_NODE_NAME);
const nodeType = this.workflow.nodeTypes.getByName(node!.type);
if (nodeType!.methods === undefined || nodeType!.methods.loadOptions === undefined || nodeType!.methods.loadOptions[methodName] === undefined) {
throw new Error(`The node-type "${node!.type}" does not have the method "${methodName}" defined!`);
}
const thisArgs = NodeExecuteFunctions.getLoadOptionsFunctions(this.workflow, node!, additionalData);
return nodeType!.methods.loadOptions[methodName].call(thisArgs);
}
}

View File

@ -0,0 +1,639 @@
import {
Credentials,
IHookFunctions,
ILoadOptionsFunctions,
IWorkflowSettings,
WorkflowExecute,
BINARY_ENCODING,
} from './';
import {
IBinaryData,
IContextObject,
ICredentialDataDecryptedObject,
IDataObject,
IExecuteData,
IExecuteFunctions,
IExecuteSingleFunctions,
INode,
INodeExecutionData,
INodeParameters,
INodeType,
IRunExecutionData,
ITaskDataConnections,
ITriggerFunctions,
IWebhookDescription,
IWebhookFunctions,
IWorkflowExecuteAdditionalData,
NodeHelpers,
NodeParameterValue,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { get } from 'lodash';
import * as express from "express";
import * as path from 'path';
import * as requestPromise from 'request-promise-native';
import { Magic, MAGIC_MIME_TYPE } from 'mmmagic';
const magic = new Magic(MAGIC_MIME_TYPE);
/**
* Takes a buffer and converts it into the format n8n uses. It encodes the binary data as
* base64 and adds metadata.
*
* @export
* @param {Buffer} binaryData
* @param {string} [filePath]
* @param {string} [mimeType]
* @returns {Promise<IBinaryData>}
*/
export async function prepareBinaryData(binaryData: Buffer, filePath?: string, mimeType?: string): Promise<IBinaryData> {
if (!mimeType) {
// If not mime type is given figure it out
mimeType = await new Promise<string>(
(resolve, reject) => {
magic.detect(binaryData, (err: Error, mimeType: string) => {
if (err) {
return reject(err);
}
return resolve(mimeType);
});
}
);
}
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)
};
if (filePath) {
if (filePath.includes('?')) {
// Remove maybe present query parameters
filePath = filePath.split('?').shift();
}
const filePathParts = path.parse(filePath as string);
returnData.fileName = filePathParts.base;
// Remove the dot
const fileExtension = filePathParts.ext.slice(1);
if (fileExtension) {
returnData.fileExtension = fileExtension;
}
}
return returnData;
}
/**
* Takes generic input data and brings it into the json format n8n uses.
*
* @export
* @param {(IDataObject | IDataObject[])} jsonData
* @returns {INodeExecutionData[]}
*/
export function returnJsonArray(jsonData: IDataObject | IDataObject[]): INodeExecutionData[] {
const returnData: INodeExecutionData[] = [];
if (!Array.isArray(jsonData)) {
jsonData = [jsonData];
}
jsonData.forEach((data) => {
returnData.push({ json: data });
});
return returnData;
}
/**
* Returns the requested decrypted credentials if the node has access to them.
*
* @export
* @param {Workflow} workflow Workflow which requests the data
* @param {INode} node Node which request the data
* @param {string} type The credential type to return
* @param {IWorkflowExecuteAdditionalData} additionalData
* @returns {(ICredentialDataDecryptedObject | undefined)}
*/
export function getCredentials(workflow: Workflow, node: INode, type: string, additionalData: IWorkflowExecuteAdditionalData): ICredentialDataDecryptedObject | undefined {
// Get the NodeType as it has the information if the credentials are required
const nodeType = workflow.nodeTypes.getByName(node.type);
if (nodeType === undefined) {
throw new Error(`Node type "${node.type}" is not known so can not get credentials!`);
}
if (nodeType.description.credentials === undefined) {
throw new Error(`Node type "${node.type}" does not have any credentials defined!`);
}
const nodeCredentialDescription = nodeType.description.credentials.find((credentialTypeDescription) => credentialTypeDescription.name === type);
if (nodeCredentialDescription === undefined) {
throw new Error(`Node type "${node.type}" does not have any credentials of type "${type}" defined!`);
}
if (NodeHelpers.displayParameter(node.parameters, nodeCredentialDescription, node.parameters) === false) {
// Credentials should not be displayed so return undefined even if they would be defined
return undefined;
}
// Check if node has any credentials defined
if (!node.credentials || !node.credentials[type]) {
// If none are defined check if the credentials are required or not
if (nodeCredentialDescription.required === true) {
// Credentials are required so error
if (!node.credentials) {
throw new Error('Node does not have any credentials set!');
}
if (!node.credentials[type]) {
throw new Error(`Node does not have any credentials set for "${type}"!`);
}
} else {
// Credentials are not required so resolve with undefined
return undefined;
}
}
const name = node.credentials[type];
if (!additionalData.credentials[type]) {
throw new Error(`No credentials of type "${type}" exist.`);
}
if (!additionalData.credentials[type][name]) {
throw new Error(`No credentials with name "${name}" exist for type "${type}".`);
}
const credentialData = additionalData.credentials[type][name];
const credentials = new Credentials(name, type, credentialData.nodesAccess, credentialData.data);
const decryptedDataObject = credentials.getData(additionalData.encryptionKey, node.type);
if (decryptedDataObject === null) {
throw new Error('Could not get the credentials');
}
return decryptedDataObject;
}
/**
* Returns the requested resolved (all expressions replaced) node parameters.
*
* @export
* @param {Workflow} workflow
* @param {(IRunExecutionData | null)} runExecutionData
* @param {number} runIndex
* @param {INodeExecutionData[]} connectionInputData
* @param {INode} node
* @param {string} parameterName
* @param {number} itemIndex
* @param {*} [fallbackValue]
* @returns {(NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object)}
*/
export function getNodeParameter(workflow: Workflow, runExecutionData: IRunExecutionData | null, runIndex: number, connectionInputData: INodeExecutionData[], node: INode, parameterName: string, itemIndex: number, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object { //tslint:disable-line:no-any
const nodeType = workflow.nodeTypes.getByName(node.type);
if (nodeType === undefined) {
throw new Error(`Node type "${node.type}" is not known so can not return paramter value!`);
}
const value = get(node.parameters, parameterName, fallbackValue);
if (value === undefined) {
throw new Error(`Could not get parameter "${parameterName}"!`);
}
const returnData = workflow.getParameterValue(value, runExecutionData, runIndex, itemIndex, node.name, connectionInputData);
return returnData;
}
/**
* Returns the timezone for the workflow
*
* @export
* @param {Workflow} workflow
* @param {IWorkflowExecuteAdditionalData} additionalData
* @returns {string}
*/
export function getTimezone(workflow: Workflow, additionalData: IWorkflowExecuteAdditionalData):string {
if (workflow.settings !== undefined && workflow.settings.timezone !== undefined) {
return (workflow.settings as IWorkflowSettings).timezone as string;
}
return additionalData.timezone;
}
/**
* Returns the execute functions the trigger nodes have access to.
*
* @export
* @param {Workflow} workflow
* @param {INode} node
* @param {IWorkflowExecuteAdditionalData} additionalData
* @param {WorkflowExecuteMode} mode
* @returns {ITriggerFunctions}
*/
export function getExecuteTriggerFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode): ITriggerFunctions {
return ((workflow: Workflow, node: INode) => {
return {
emit: (data: INodeExecutionData[][]): void => {
const workflowExecute = new WorkflowExecute(additionalData, mode);
const nodeExecutionStack: IExecuteData[] = [
{
node,
data: {
main: data,
}
}
];
const runExecutionData: IRunExecutionData = {
startData: {},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack,
waitingExecution: {},
},
};
workflowExecute.runExecutionData(workflow, runExecutionData);
},
getCredentials(type: string): ICredentialDataDecryptedObject | undefined {
return getCredentials(workflow, node, type, additionalData);
},
getMode: (): WorkflowExecuteMode => {
return mode;
},
getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any
const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0;
const runIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
return getNodeParameter(workflow, runExecutionData, runIndex, connectionInputData, node, parameterName, itemIndex, fallbackValue);
},
getTimezone: (): string => {
return getTimezone(workflow, additionalData);
},
getWorkflowStaticData(type: string): IDataObject {
return workflow.getStaticData(type, node);
},
helpers: {
prepareBinaryData,
request: requestPromise,
returnJsonArray,
},
};
}) (workflow, node);
}
/**
* Returns the execute functions regular nodes have access to.
*
* @export
* @param {Workflow} workflow
* @param {IRunExecutionData} runExecutionData
* @param {number} runIndex
* @param {INodeExecutionData[]} connectionInputData
* @param {ITaskDataConnections} inputData
* @param {INode} node
* @param {IWorkflowExecuteAdditionalData} additionalData
* @param {WorkflowExecuteMode} mode
* @returns {IExecuteFunctions}
*/
export function getExecuteFunctions(workflow: Workflow, runExecutionData: IRunExecutionData, runIndex: number, connectionInputData: INodeExecutionData[], inputData: ITaskDataConnections, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode): IExecuteFunctions {
return ((workflow, runExecutionData, connectionInputData, inputData, node) => {
return {
getContext(type: string): IContextObject {
return NodeHelpers.getContext(runExecutionData, type, node);
},
getCredentials(type: string): ICredentialDataDecryptedObject | undefined {
return getCredentials(workflow, node, type, additionalData);
},
getInputData: (inputIndex = 0, inputName = 'main') => {
if (!inputData.hasOwnProperty(inputName)) {
// Return empty array because else it would throw error when nothing is connected to input
return [];
}
// TODO: Check if nodeType has input with that index defined
if (inputData[inputName].length < inputIndex) {
throw new Error(`Could not get input index "${inputIndex}" of input "${inputName}"!`);
}
if (inputData[inputName][inputIndex] === null) {
// return [];
throw new Error(`Value "${inputIndex}" of input "${inputName}" did not get set!`);
}
// TODO: Maybe do clone of data only here so it only clones the data that is really needed
return inputData[inputName][inputIndex] as INodeExecutionData[];
},
getNodeParameter: (parameterName: string, itemIndex: number, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any
return getNodeParameter(workflow, runExecutionData, runIndex, connectionInputData, node, parameterName, itemIndex, fallbackValue);
},
getMode: (): WorkflowExecuteMode => {
return mode;
},
getTimezone: (): string => {
return getTimezone(workflow, additionalData);
},
getWorkflowStaticData(type: string): IDataObject {
return workflow.getStaticData(type, node);
},
prepareOutputData: NodeHelpers.prepareOutputData,
helpers: {
prepareBinaryData,
request: requestPromise,
returnJsonArray,
},
};
})(workflow, runExecutionData, connectionInputData, inputData, node);
}
/**
* Returns the execute functions regular nodes have access to when single-function is defined.
*
* @export
* @param {Workflow} workflow
* @param {IRunExecutionData} runExecutionData
* @param {number} runIndex
* @param {INodeExecutionData[]} connectionInputData
* @param {ITaskDataConnections} inputData
* @param {INode} node
* @param {number} itemIndex
* @param {IWorkflowExecuteAdditionalData} additionalData
* @param {WorkflowExecuteMode} mode
* @returns {IExecuteSingleFunctions}
*/
export function getExecuteSingleFunctions(workflow: Workflow, runExecutionData: IRunExecutionData, runIndex: number, connectionInputData: INodeExecutionData[], inputData: ITaskDataConnections, node: INode, itemIndex: number, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode): IExecuteSingleFunctions {
return ((workflow, runExecutionData, connectionInputData, inputData, node, itemIndex) => {
return {
getContext(type: string): IContextObject {
return NodeHelpers.getContext(runExecutionData, type, node);
},
getCredentials(type: string): ICredentialDataDecryptedObject | undefined {
return getCredentials(workflow, node, type, additionalData);
},
getInputData: (inputIndex = 0, inputName = 'main') => {
if (!inputData.hasOwnProperty(inputName)) {
// Return empty array because else it would throw error when nothing is connected to input
return {json: {}};
}
// TODO: Check if nodeType has input with that index defined
if (inputData[inputName].length < inputIndex) {
throw new Error(`Could not get input index "${inputIndex}" of input "${inputName}"!`);
}
const allItems = inputData[inputName][inputIndex];
if (allItems === null) {
// return [];
throw new Error(`Value "${inputIndex}" of input "${inputName}" did not get set!`);
}
if (allItems[itemIndex] === null) {
// return [];
throw new Error(`Value "${inputIndex}" of input "${inputName}" with itemIndex "${itemIndex}" did not get set!`);
}
return allItems[itemIndex] as INodeExecutionData;
},
getMode: (): WorkflowExecuteMode => {
return mode;
},
getTimezone: (): string => {
return getTimezone(workflow, additionalData);
},
getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any
return getNodeParameter(workflow, runExecutionData, runIndex, connectionInputData, node, parameterName, itemIndex, fallbackValue);
},
getWorkflowStaticData(type: string): IDataObject {
return workflow.getStaticData(type, node);
},
helpers: {
prepareBinaryData,
request: requestPromise,
},
};
})(workflow, runExecutionData, connectionInputData, inputData, node, itemIndex);
}
/**
* Returns the execute functions regular nodes have access to in load-options-function.
*
* @export
* @param {Workflow} workflow
* @param {INode} node
* @param {IWorkflowExecuteAdditionalData} additionalData
* @returns {ILoadOptionsFunctions}
*/
export function getLoadOptionsFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData): ILoadOptionsFunctions {
return ((workflow: Workflow, node: INode) => {
const that = {
getCredentials(type: string): ICredentialDataDecryptedObject | undefined {
return getCredentials(workflow, node, type, additionalData);
},
getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any
const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0;
const runIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
return getNodeParameter(workflow, runExecutionData, runIndex, connectionInputData, node, parameterName, itemIndex, fallbackValue);
},
getTimezone: (): string => {
return getTimezone(workflow, additionalData);
},
helpers: {
request: requestPromise,
},
};
return that;
})(workflow, node);
}
/**
* Returns the execute functions regular nodes have access to in hook-function.
*
* @export
* @param {Workflow} workflow
* @param {INode} node
* @param {IWorkflowExecuteAdditionalData} additionalData
* @param {WorkflowExecuteMode} mode
* @returns {IHookFunctions}
*/
export function getExecuteHookFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, isTest?: boolean): IHookFunctions {
return ((workflow: Workflow, node: INode) => {
const that = {
getCredentials(type: string): ICredentialDataDecryptedObject | undefined {
return getCredentials(workflow, node, type, additionalData);
},
getMode: (): WorkflowExecuteMode => {
return mode;
},
getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any
const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0;
const runIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
return getNodeParameter(workflow, runExecutionData, runIndex, connectionInputData, node, parameterName, itemIndex, fallbackValue);
},
getNodeWebhookUrl: (name: string): string | undefined => {
let baseUrl = additionalData.webhookBaseUrl;
if (isTest === true) {
baseUrl = additionalData.webhookTestBaseUrl;
}
const webhookDescription = that.getWebhookDescription(name);
if (webhookDescription === undefined) {
return undefined;
}
const path = workflow.getWebhookParameterValue(node, webhookDescription, 'path');
if (path === undefined) {
return undefined;
}
return NodeHelpers.getNodeWebhookUrl(baseUrl, workflow.id!, node, path);
},
getTimezone: (): string => {
return getTimezone(workflow, additionalData);
},
getWebhookDescription(name: string): IWebhookDescription | undefined {
const nodeType = workflow.nodeTypes.getByName(node.type) as INodeType;
if (nodeType.description.webhooks === undefined) {
// Node does not have any webhooks so return
return undefined;
}
for (const webhookDescription of nodeType.description.webhooks) {
if (webhookDescription.name === name) {
return webhookDescription;
}
}
return undefined;
},
getWorkflowStaticData(type: string): IDataObject {
return workflow.getStaticData(type, node);
},
helpers: {
request: requestPromise,
},
};
return that;
})(workflow, node);
}
/**
* Returns the execute functions regular nodes have access to when webhook-function is defined.
*
* @export
* @param {Workflow} workflow
* @param {IRunExecutionData} runExecutionData
* @param {INode} node
* @param {IWorkflowExecuteAdditionalData} additionalData
* @param {WorkflowExecuteMode} mode
* @returns {IWebhookFunctions}
*/
export function getExecuteWebhookFunctions(workflow: Workflow, node: INode, additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode): IWebhookFunctions {
return ((workflow: Workflow, node: INode) => {
return {
getBodyData(): IDataObject {
if (additionalData.httpRequest === undefined) {
throw new Error('Request is missing!');
}
return additionalData.httpRequest.body;
},
getCredentials(type: string): ICredentialDataDecryptedObject | undefined {
return getCredentials(workflow, node, type, additionalData);
},
getHeaderData(): object {
if (additionalData.httpRequest === undefined) {
throw new Error('Request is missing!');
}
return additionalData.httpRequest.headers;
},
getMode: (): WorkflowExecuteMode => {
return mode;
},
getNodeParameter: (parameterName: string, fallbackValue?: any): NodeParameterValue | INodeParameters | NodeParameterValue[] | INodeParameters[] | object => { //tslint:disable-line:no-any
const runExecutionData: IRunExecutionData | null = null;
const itemIndex = 0;
const runIndex = 0;
const connectionInputData: INodeExecutionData[] = [];
return getNodeParameter(workflow, runExecutionData, runIndex, connectionInputData, node, parameterName, itemIndex, fallbackValue);
},
getQueryData(): object {
if (additionalData.httpRequest === undefined) {
throw new Error('Request is missing!');
}
return additionalData.httpRequest.query;
},
getRequestObject(): express.Request {
if (additionalData.httpRequest === undefined) {
throw new Error('Request is missing!');
}
return additionalData.httpRequest;
},
getResponseObject(): express.Response {
if (additionalData.httpResponse === undefined) {
throw new Error('Response is missing!');
}
return additionalData.httpResponse;
},
getTimezone: (): string => {
return getTimezone(workflow, additionalData);
},
getWorkflowStaticData(type: string): IDataObject {
return workflow.getStaticData(type, node);
},
prepareOutputData: NodeHelpers.prepareOutputData,
helpers: {
prepareBinaryData,
request: requestPromise,
returnJsonArray,
},
};
})(workflow, node);
}

View File

@ -0,0 +1,234 @@
import {
ENCRYPTION_KEY_ENV_OVERWRITE,
EXTENSIONS_SUBDIRECTORY,
USER_FOLDER_ENV_OVERWRITE,
USER_SETTINGS_FILE_NAME,
USER_SETTINGS_SUBFOLDER,
IUserSettings,
} from '.';
import * as fs from 'fs';
import * as path from 'path';
import { randomBytes } from 'crypto';
const { promisify } = require('util');
const fsAccess = promisify(fs.access);
const fsReadFile = promisify(fs.readFile);
const fsMkdir = promisify(fs.mkdir);
const fsWriteFile = promisify(fs.writeFile);
let settingsCache: IUserSettings | undefined = undefined;
/**
* Creates the user settings if they do not exist yet
*
* @export
*/
export async function prepareUserSettings(): Promise<IUserSettings> {
const settingsPath = getUserSettingsPath();
let userSettings = await getUserSettings(settingsPath);
if (userSettings !== undefined) {
// Settings already exist, check if they contain the encryptionKey
if (userSettings.encryptionKey !== undefined) {
// Key already exists so return
return userSettings;
}
} else {
userSettings = {};
}
// Settings and/or key do not exist. So generate a new encryption key
userSettings.encryptionKey = randomBytes(24).toString('base64');
console.log(`UserSettings got generated and saved to: ${settingsPath}`);
return writeUserSettings(userSettings, settingsPath);
}
/**
* Returns the encryption key which is used to encrypt
* the credentials.
*
* @export
* @returns
*/
export async function getEncryptionKey() {
if (process.env[ENCRYPTION_KEY_ENV_OVERWRITE] !== undefined) {
return process.env[ENCRYPTION_KEY_ENV_OVERWRITE];
}
const userSettings = await getUserSettings();
if (userSettings === undefined) {
return undefined;
}
if (userSettings.encryptionKey === undefined) {
return undefined;
}
return userSettings.encryptionKey;
}
/**
* Adds/Overwrite the given settings in the currently
* saved user settings
*
* @export
* @param {IUserSettings} addSettings The settings to add/overwrite
* @param {string} [settingsPath] Optional settings file path
* @returns {Promise<IUserSettings>}
*/
export async function addToUserSettings(addSettings: IUserSettings, settingsPath?: string): Promise<IUserSettings> {
if (settingsPath === undefined) {
settingsPath = getUserSettingsPath();
}
let userSettings = await getUserSettings(settingsPath);
if (userSettings === undefined) {
userSettings = {};
}
// Add the settings
Object.assign(userSettings, addSettings);
return writeUserSettings(userSettings, settingsPath);
}
/**
* Writes a user settings file
*
* @export
* @param {IUserSettings} userSettings The settings to write
* @param {string} [settingsPath] Optional settings file path
* @returns {Promise<IUserSettings>}
*/
export async function writeUserSettings(userSettings: IUserSettings, settingsPath?: string): Promise<IUserSettings> {
if (settingsPath === undefined) {
settingsPath = getUserSettingsPath();
}
if (userSettings === undefined) {
userSettings = {};
}
// Check if parent folder exists if not create it.
try {
await fsAccess(path.dirname(settingsPath));
} catch (error) {
// Parent folder does not exist so create
await fsMkdir(path.dirname(settingsPath));
}
await fsWriteFile(settingsPath, JSON.stringify(userSettings, null, '\t'));
settingsCache = JSON.parse(JSON.stringify(userSettings));
return userSettings;
}
/**
* Returns the content of the user settings
*
* @export
* @returns {UserSettings}
*/
export async function getUserSettings(settingsPath?: string, ignoreCache?: boolean): Promise<IUserSettings | undefined> {
if (settingsCache !== undefined && ignoreCache !== true) {
return settingsCache;
}
if (settingsPath === undefined) {
settingsPath = getUserSettingsPath();
}
try {
await fsAccess(settingsPath);
} catch (error) {
// The file does not exist
return undefined;
}
const settingsFile = await fsReadFile(settingsPath, 'utf8');
settingsCache = JSON.parse(settingsFile);
return JSON.parse(settingsFile) as IUserSettings;
}
/**
* Returns the path to the user settings
*
* @export
* @returns {string}
*/
export function getUserSettingsPath(): string {
const n8nFolder = getUserN8nFolderPath();
return path.join(n8nFolder, USER_SETTINGS_FILE_NAME);
}
/**
* Retruns the path to the n8n folder in which all n8n
* related data gets saved
*
* @export
* @returns {string}
*/
export function getUserN8nFolderPath(): string {
let userFolder;
if (process.env[USER_FOLDER_ENV_OVERWRITE] !== undefined) {
userFolder = process.env[USER_FOLDER_ENV_OVERWRITE] as string;
} else {
userFolder = getUserHome();
}
return path.join(userFolder, USER_SETTINGS_SUBFOLDER);
}
/**
* Returns the path to the n8n user folder with the custom
* extensions like nodes and credentials
*
* @export
* @returns {string}
*/
export function getUserN8nFolderCustomExtensionPath(): string {
return path.join(getUserN8nFolderPath(), EXTENSIONS_SUBDIRECTORY);
}
/**
* Returns the home folder path of the user if
* none can be found it falls back to the current
* working directory
*
* @export
* @returns {string}
*/
export function getUserHome(): string {
let variableName = 'HOME';
if (process.platform === 'win32') {
variableName = 'USERPROFILE';
}
if (process.env[variableName] === undefined) {
// If for some reason the variable does not exist
// fall back to current folder
return process.cwd();
}
return process.env[variableName] as string;
}

View File

@ -0,0 +1,579 @@
import {
IConnection,
IExecuteData,
IExecutionError,
INode,
INodeConnections,
INodeExecutionData,
IRun,
IRunData,
IRunExecutionData,
ITaskData,
ITaskDataConnections,
IWaitingForExecution,
IWorkflowExecuteAdditionalData,
WorkflowExecuteMode,
Workflow,
} from 'n8n-workflow';
import {
ActiveExecutions,
NodeExecuteFunctions,
} from './';
export class WorkflowExecute {
private additionalData: IWorkflowExecuteAdditionalData;
private mode: WorkflowExecuteMode;
private activeExecutions: ActiveExecutions.ActiveExecutions;
private executionId: string | null = null;
constructor(additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode) {
this.additionalData = additionalData;
this.activeExecutions = ActiveExecutions.getInstance();
this.mode = mode;
}
/**
* Executes the given workflow.
*
* @param {Workflow} workflow The workflow to execute
* @param {INode[]} [startNodes] Node to start execution from
* @param {string} [destinationNode] Node to stop execution at
* @returns {(Promise<string>)}
* @memberof WorkflowExecute
*/
async run(workflow: Workflow, startNodes?: INode[], destinationNode?: string): Promise<string> {
// Get the nodes to start workflow execution from
startNodes = startNodes || workflow.getStartNodes(destinationNode);
// If a destination node is given we only run the direct parent nodes and no others
let runNodeFilter: string[] | undefined = undefined;
if (destinationNode) {
// TODO: Combine that later with getStartNodes which does more or less the same tree iteration
runNodeFilter = workflow.getParentNodes(destinationNode);
runNodeFilter.push(destinationNode);
}
// Initialize the data of the start nodes
const nodeExecutionStack: IExecuteData[] = [];
startNodes.forEach((node) => {
nodeExecutionStack.push(
{
node,
data: {
main: [
[
{
json: {},
},
],
],
},
},
);
});
const runExecutionData: IRunExecutionData = {
startData: {
destinationNode,
runNodeFilter,
},
resultData: {
runData: {},
},
executionData: {
contextData: {},
nodeExecutionStack,
waitingExecution: {},
},
};
return this.runExecutionData(workflow, runExecutionData);
}
/**
* Executes the given workflow but only
*
* @param {Workflow} workflow The workflow to execute
* @param {IRunData} runData
* @param {string[]} startNodes Nodes to start execution from
* @param {string} destinationNode Node to stop execution at
* @returns {(Promise<string>)}
* @memberof WorkflowExecute
*/
async runPartialWorkflow(workflow: Workflow, runData: IRunData, startNodes: string[], destinationNode: string): Promise<string> {
let incomingNodeConnections: INodeConnections | undefined;
let connection: IConnection;
const runIndex = 0;
// Initialize the nodeExecutionStack and waitingExecution with
// the data from runData
const nodeExecutionStack: IExecuteData[] = [];
const waitingExecution: IWaitingForExecution = {};
for (const startNode of startNodes) {
incomingNodeConnections = workflow.connectionsByDestinationNode[startNode];
const incomingData: INodeExecutionData[][] = [];
if (incomingNodeConnections === undefined) {
// If it has no incoming data add the default empty data
incomingData.push([
{
json: {}
}
]);
} else {
// Get the data of the incoming connections
for (const connections of incomingNodeConnections.main) {
for (let inputIndex = 0; inputIndex < connections.length; inputIndex++) {
connection = connections[inputIndex];
incomingData.push(
runData[connection.node!][runIndex].data![connection.type][connection.index]!,
);
}
}
}
const executeData: IExecuteData = {
node: workflow.getNode(startNode) as INode,
data: {
main: incomingData,
}
};
nodeExecutionStack.push(executeData);
// Check if the destinationNode has to be added as waiting
// because some input data is already fully available
incomingNodeConnections = workflow.connectionsByDestinationNode[destinationNode];
if (incomingNodeConnections !== undefined) {
for (const connections of incomingNodeConnections.main) {
for (let inputIndex = 0; inputIndex < connections.length; inputIndex++) {
connection = connections[inputIndex];
if (waitingExecution[destinationNode] === undefined) {
waitingExecution[destinationNode] = {};
}
if (waitingExecution[destinationNode][runIndex] === undefined) {
waitingExecution[destinationNode][runIndex] = {};
}
if (waitingExecution[destinationNode][runIndex][connection.type] === undefined) {
waitingExecution[destinationNode][runIndex][connection.type] = [];
}
if (runData[connection.node!] !== undefined) {
// Input data exists so add as waiting
// incomingDataDestination.push(runData[connection.node!][runIndex].data![connection.type][connection.index]);
waitingExecution[destinationNode][runIndex][connection.type].push(runData[connection.node!][runIndex].data![connection.type][connection.index]);
} else {
waitingExecution[destinationNode][runIndex][connection.type].push(null);
}
}
}
}
}
// Only run the parent nodes and no others
let runNodeFilter: string[] | undefined = undefined;
runNodeFilter = workflow.getParentNodes(destinationNode);
runNodeFilter.push(destinationNode);
const runExecutionData: IRunExecutionData = {
startData: {
destinationNode,
runNodeFilter,
},
resultData: {
runData,
},
executionData: {
contextData: {},
nodeExecutionStack,
waitingExecution,
},
};
return await this.runExecutionData(workflow, runExecutionData);
}
/**
* Executes the hook with the given name
*
* @param {string} hookName
* @param {any[]} parameters
* @returns {Promise<IRun>}
* @memberof WorkflowExecute
*/
async executeHook(hookName: string, parameters: any[]): Promise<void> { // tslint:disable-line:no-any
if (this.additionalData.hooks === undefined) {
return parameters[0];
}
if (this.additionalData.hooks[hookName] === undefined || this.additionalData.hooks[hookName]!.length === 0) {
return parameters[0];
}
for (const hookFunction of this.additionalData.hooks[hookName]!) {
await hookFunction.apply(this, parameters as [IRun, IWaitingForExecution])
.catch((error) => {
// Catch all errors here because when "executeHook" gets called
// we have the most time no "await" and so the errors would so
// not be uncaught by anything.
// TODO: Add proper logging
console.error(`There was a problem executing hook: "${hookName}"`);
console.error('Parameters:');
console.error(parameters);
console.error('Error:');
console.error(error);
});
}
}
/**
* Runs the given execution data.
*
* @param {Workflow} workflow
* @param {IRunExecutionData} runExecutionData
* @returns {Promise<string>}
* @memberof WorkflowExecute
*/
async runExecutionData(workflow: Workflow, runExecutionData: IRunExecutionData): Promise<string> {
const startedAt = new Date().getTime();
const workflowIssues = workflow.checkReadyForExecution();
if (workflowIssues !== null) {
throw new Error('The workflow has issues and can for that reason not be executed. Please fix them first.');
}
// Variables which hold temporary data for each node-execution
let executionData: IExecuteData;
let executionError: IExecutionError | undefined;
let executionNode: INode;
let nodeSuccessData: INodeExecutionData[][] | null;
let runIndex: number;
let startTime: number;
let taskData: ITaskData;
if (runExecutionData.startData === undefined) {
runExecutionData.startData = {};
}
this.executionId = this.activeExecutions.add(workflow, runExecutionData, this.mode);
this.executeHook('workflowExecuteBefore', [this.executionId]);
let currentExecutionTry = '';
let lastExecutionTry = '';
// Wait for the next tick so that the executionId gets already returned.
// So it can directly be send to the editor-ui and is so aware of the
// executionId when the first push messages arrive.
process.nextTick(() => (async () => {
executionLoop:
while (runExecutionData.executionData!.nodeExecutionStack.length !== 0) {
if (this.activeExecutions.shouldBeStopped(this.executionId!) === true) {
// The execution should be stopped
break;
}
nodeSuccessData = null;
executionError = undefined;
executionData = runExecutionData.executionData!.nodeExecutionStack.shift() as IExecuteData;
executionNode = executionData.node;
this.executeHook('nodeExecuteBefore', [this.executionId, executionNode.name]);
// Get the index of the current run
runIndex = 0;
if (runExecutionData.resultData.runData.hasOwnProperty(executionNode.name)) {
runIndex = runExecutionData.resultData.runData[executionNode.name].length;
}
currentExecutionTry = `${executionNode.name}:${runIndex}`;
if (currentExecutionTry === lastExecutionTry) {
throw new Error('Did stop execution because execution seems to be in endless loop.');
}
if (runExecutionData.startData!.runNodeFilter !== undefined && runExecutionData.startData!.runNodeFilter!.indexOf(executionNode.name) === -1) {
// If filter is set and node is not on filter skip it, that avoids the problem that it executes
// leafs that are parallel to a selected destinationNode. Normally it would execute them because
// they have the same parent and it executes all child nodes.
continue;
}
// Check if all the data which is needed to run the node is available
if (workflow.connectionsByDestinationNode.hasOwnProperty(executionNode.name)) {
// Check if the node has incoming connections
if (workflow.connectionsByDestinationNode[executionNode.name].hasOwnProperty('main')) {
let inputConnections: IConnection[][];
let connectionIndex: number;
inputConnections = workflow.connectionsByDestinationNode[executionNode.name]['main'];
for (connectionIndex = 0; connectionIndex < inputConnections.length; connectionIndex++) {
if (workflow.getHighestNode(executionNode.name, 'main', connectionIndex).length === 0) {
// If there is no valid incoming node (if all are disabled)
// then ignore that it has inputs and simply execute it as it is without
// any data
continue;
}
if (!executionData.data!.hasOwnProperty('main')) {
// ExecutionData does not even have the connection set up so can
// not have that data, so add it again to be executed later
runExecutionData.executionData!.nodeExecutionStack.push(executionData);
lastExecutionTry = currentExecutionTry;
continue executionLoop;
}
// Check if it has the data for all the inputs
// The most nodes just have one but merge node for example has two and data
// of both inputs has to be available to be able to process the node.
if (executionData.data!.main!.length < connectionIndex || executionData.data!.main![connectionIndex] === null) {
// Does not have the data of the connections so add back to stack
runExecutionData.executionData!.nodeExecutionStack.push(executionData);
lastExecutionTry = currentExecutionTry;
continue executionLoop;
}
}
}
}
// TODO Has to check if node is disabled
// Clone input data that nodes can not mess up data of parallel nodes which receive the same data
// TODO: Should only clone if multiple nodes get the same data or when it gets returned to frontned
// is very slow so only do if needed
startTime = new Date().getTime();
try {
runExecutionData.resultData.lastNodeExecuted = executionData.node.name;
nodeSuccessData = await workflow.runNode(executionData.node, JSON.parse(JSON.stringify(executionData.data)), runExecutionData, runIndex, this.additionalData, NodeExecuteFunctions, this.mode);
if (nodeSuccessData === null) {
// If null gets returned it means that the node did succeed
// but did not have any data. So the branch should end
// (meaning the nodes afterwards should not be processed)
continue;
}
} catch (error) {
executionError = {
message: error.message,
stack: error.stack,
};
}
// Add the data to return to the user
// (currently does not get cloned as data does not get changed, maybe later we should do that?!?!)
if (!runExecutionData.resultData.runData.hasOwnProperty(executionNode.name)) {
runExecutionData.resultData.runData[executionNode.name] = [];
}
taskData = {
startTime,
executionTime: (new Date().getTime()) - startTime
};
if (executionError !== undefined) {
taskData.error = executionError;
if (executionData.node.continueOnFail === true) {
// Workflow should continue running even if node errors
if (executionData.data.hasOwnProperty('main') && executionData.data.main.length > 0) {
// Simply get the input data of the node if it has any and pass it through
// to the next node
if (executionData.data.main[0] !== null) {
nodeSuccessData = [(JSON.parse(JSON.stringify(executionData.data.main[0])) as INodeExecutionData[])];
}
}
} else {
// Node execution did fail so add error and stop execution
runExecutionData.resultData.runData[executionNode.name].push(taskData);
// Add the execution data again so that it can get restarted
runExecutionData.executionData!.nodeExecutionStack.unshift(executionData);
this.executeHook('nodeExecuteAfter', [this.executionId, executionNode.name, taskData]);
break;
}
}
// Node executed successfully. So add data and go on.
taskData.data = ({
'main': nodeSuccessData
} as ITaskDataConnections);
this.executeHook('nodeExecuteAfter', [this.executionId, executionNode.name, taskData]);
runExecutionData.resultData.runData[executionNode.name].push(taskData);
if (runExecutionData.startData && runExecutionData.startData.destinationNode && runExecutionData.startData.destinationNode === executionNode.name) {
// If destination node is defined and got executed stop execution
continue;
}
// Add the nodes to which the current node has an output connection to that they can
// be executed next
if (workflow.connectionsBySourceNode.hasOwnProperty(executionNode.name)) {
if (workflow.connectionsBySourceNode[executionNode.name].hasOwnProperty('main')) {
let outputIndex: string, connectionData: IConnection;
// Go over all the different
for (outputIndex in workflow.connectionsBySourceNode[executionNode.name]['main']) {
if (!workflow.connectionsBySourceNode[executionNode.name]['main'].hasOwnProperty(outputIndex)) {
continue;
}
// Go through all the different outputs of this connection
for (connectionData of workflow.connectionsBySourceNode[executionNode.name]['main'][outputIndex]) {
if (!workflow.nodes.hasOwnProperty(connectionData.node)) {
return Promise.reject(new Error(`The node "${executionNode.name}" connects to not found node "${connectionData.node}"`));
}
let stillDataMissing = false;
// Check if node has multiple inputs as then we have to wait for all input data
// to be present before we can add it to the node-execution-stack
if (workflow.connectionsByDestinationNode[connectionData.node]['main'].length > 1) {
// Node has multiple inputs
// Check if there is already data for the node
if (runExecutionData.executionData!.waitingExecution.hasOwnProperty(connectionData.node) && runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex] !== undefined) {
// There is already data for the node and the current run so
// add the new data
if (nodeSuccessData === null) {
runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex].main[connectionData.index] = null;
} else {
runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex].main[connectionData.index] = nodeSuccessData[outputIndex];
}
// Check if all data exists now
let thisExecutionData: INodeExecutionData[] | null;
let allDataFound = true;
for (let i = 0; i < runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex].main.length; i++) {
thisExecutionData = runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex].main[i];
if (thisExecutionData === null) {
allDataFound = false;
break;
}
}
if (allDataFound === true) {
// All data exists for node to be executed
// So add it to the execution stack
runExecutionData.executionData!.nodeExecutionStack.push({
node: workflow.nodes[connectionData.node],
data: runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex]
});
// Remove the data from waiting
delete runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex];
if (Object.keys(runExecutionData.executionData!.waitingExecution[connectionData.node]).length === 0) {
// No more data left for the node so also delete that one
delete runExecutionData.executionData!.waitingExecution[connectionData.node];
}
continue;
} else {
stillDataMissing = true;
}
} else {
stillDataMissing = true;
}
}
// Make sure the array has all the values
const connectionDataArray: Array<INodeExecutionData[] | null> = [];
for (let i: number = connectionData.index; i >= 0; i--) {
connectionDataArray[i] = null;
}
// Add the data of the current execution
if (nodeSuccessData === null) {
connectionDataArray[connectionData.index] = null;
} else {
connectionDataArray[connectionData.index] = nodeSuccessData[outputIndex];
}
if (stillDataMissing === true) {
// Additional data is needed to run node so add it to waiting
if (!runExecutionData.executionData!.waitingExecution.hasOwnProperty(connectionData.node)) {
runExecutionData.executionData!.waitingExecution[connectionData.node] = {};
}
runExecutionData.executionData!.waitingExecution[connectionData.node][runIndex] = {
main: connectionDataArray
};
} else {
// All data is there so add it directly to stack
runExecutionData.executionData!.nodeExecutionStack.push({
node: workflow.nodes[connectionData.node],
data: {
main: connectionDataArray
}
});
}
}
}
}
}
}
return Promise.resolve();
})()
.then(async () => {
const fullRunData: IRun = {
data: runExecutionData,
mode: this.mode,
startedAt,
stoppedAt: new Date().getTime(),
};
if (executionError !== undefined) {
fullRunData.data.resultData.error = executionError;
} else {
fullRunData.finished = true;
}
this.activeExecutions.remove(this.executionId!, fullRunData);
await this.executeHook('workflowExecuteAfter', [fullRunData, this.executionId!]);
return fullRunData;
})
.catch(async (error) => {
const fullRunData: IRun = {
data: runExecutionData,
mode: this.mode,
startedAt,
stoppedAt: new Date().getTime(),
};
fullRunData.data.resultData.error = {
message: error.message,
stack: error.stack,
};
this.activeExecutions.remove(this.executionId!, fullRunData);
await this.executeHook('workflowExecuteAfter', [fullRunData, this.executionId!]);
return fullRunData;
}));
return this.executionId;
}
}

View File

@ -0,0 +1,24 @@
try {
require('source-map-support').install();
} catch (error) {
}
export * from './ActiveWorkflows';
export * from './ActiveWebhooks';
export * from './Constants';
export * from './Credentials';
export * from './DeferredPromise';
export * from './Interfaces';
export * from './LoadNodeParameterOptions';
export * from './NodeExecuteFunctions';
export * from './WorkflowExecute';
import * as ActiveExecutions from './ActiveExecutions';
import * as NodeExecuteFunctions from './NodeExecuteFunctions';
import * as UserSettings from './UserSettings';
export {
ActiveExecutions,
NodeExecuteFunctions,
UserSettings,
};

View File

@ -0,0 +1,88 @@
import { Credentials } from '../src';
describe('Credentials', () => {
describe('without nodeType set', () => {
test('should be able to set and read key data without initial data set', () => {
const credentials = new Credentials('testName', 'testType', []);
const key = 'key1';
const password = 'password';
// const nodeType = 'base.noOp';
const newData = 1234;
credentials.setDataKey(key, newData, password);
expect(credentials.getDataKey(key, password)).toEqual(newData);
});
test('should be able to set and read key data with initial data set', () => {
const key = 'key2';
const password = 'password';
// Saved under "key1"
const initialData = 4321;
const initialDataEncoded = 'U2FsdGVkX1+0baznXt+Ag/ub8A2kHLyoLxn/rR9h4XQ=';
const credentials = new Credentials('testName', 'testType', [], initialDataEncoded);
const newData = 1234;
// Set and read new data
credentials.setDataKey(key, newData, password);
expect(credentials.getDataKey(key, password)).toEqual(newData);
// Read the data which got provided encrypted on init
expect(credentials.getDataKey('key1', password)).toEqual(initialData);
});
});
describe('with nodeType set', () => {
test('should be able to set and read key data without initial data set', () => {
const nodeAccess = [
{
nodeType: 'base.noOp',
user: 'userName',
date: 1234,
}
];
const credentials = new Credentials('testName', 'testType', nodeAccess);
const key = 'key1';
const password = 'password';
const nodeType = 'base.noOp';
const newData = 1234;
credentials.setDataKey(key, newData, password);
// Should be able to read with nodeType which has access
expect(credentials.getDataKey(key, password, nodeType)).toEqual(newData);
// Should not be able to read with nodeType which does NOT have access
// expect(credentials.getDataKey(key, password, 'base.otherNode')).toThrowError(Error);
try {
credentials.getDataKey(key, password, 'base.otherNode');
expect(true).toBe(false);
} catch (e) {
expect(e.message).toBe('The node of type "base.otherNode" does not have access to credentials "testName" of type "testType".');
}
// Get the data which will be saved in database
const dbData = credentials.getDataToSave();
expect(dbData.name).toEqual('testName');
expect(dbData.type).toEqual('testType');
expect(dbData.nodesAccess).toEqual(nodeAccess);
// Compare only the first 6 characters as the rest seems to change with each execution
expect(dbData.data!.slice(0, 6)).toEqual('U2FsdGVkX1+wpQWkj+YTzaPSNTFATjnlmFKIsUTZdhk='.slice(0, 6));
});
});
});

View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"lib": [
"es2017"
],
"types": [
"node",
"jest"
],
"module": "commonjs",
"noImplicitAny": true,
"removeComments": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitReturns": true,
"strict": true,
"noUnusedLocals": true,
"preserveConstEnums": true,
"declaration": true,
"outDir": "./dist/",
"target": "es2017",
"sourceMap": true
},
"include": [
"**/*.d.ts",
"src/**/*",
"test/**/*",
],
"exclude": [
"dist/**/*",
"node_modules/**/*",
"**/*.spec.ts"
]
}

103
packages/core/tslint.json Normal file
View File

@ -0,0 +1,103 @@
{
"linterOptions": {
"exclude": [
"node_modules/**/*"
]
},
"defaultSeverity": "error",
"jsRules": {},
"rules": {
"array-type": [
true,
"array-simple"
],
"arrow-return-shorthand": true,
"ban": [
true,
{
"name": "Array",
"message": "tsstyle#array-constructor"
}
],
"ban-types": [
true,
[
"Object",
"Use {} instead."
],
[
"String",
"Use 'string' instead."
],
[
"Number",
"Use 'number' instead."
],
[
"Boolean",
"Use 'boolean' instead."
]
],
"class-name": true,
"curly": [
true,
"ignore-same-line"
],
"forin": true,
"jsdoc-format": true,
"label-position": true,
"member-access": [
true,
"no-public"
],
"new-parens": true,
"no-angle-bracket-type-assertion": true,
"no-any": true,
"no-arg": true,
"no-conditional-assignment": true,
"no-construct": true,
"no-debugger": true,
"no-default-export": true,
"no-duplicate-variable": true,
"no-inferrable-types": true,
"no-namespace": [
true,
"allow-declarations"
],
"no-reference": true,
"no-string-throw": true,
"no-unused-expression": true,
"no-var-keyword": true,
"object-literal-shorthand": true,
"only-arrow-functions": [
true,
"allow-declarations",
"allow-named-functions"
],
"prefer-const": true,
"radix": true,
"semicolon": [
true,
"always",
"ignore-bound-class-methods"
],
"switch-default": true,
"triple-equals": [
true,
"allow-null-check"
],
"use-isnan": true,
"quotes": [
"error",
"single"
],
"variable-name": [
true,
"check-format",
"ban-keywords",
"allow-leading-underscore",
"allow-trailing-underscore"
]
},
"rulesDirectory": []
}

View File

@ -0,0 +1,3 @@
> 1%
last 2 versions
not ie <= 8

View File

@ -0,0 +1,8 @@
root = true
[*]
charset = utf-8
indent_style = tab
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

View File

@ -0,0 +1,23 @@
module.exports = {
root: true,
env: {
node: true,
},
'extends': [
'plugin:vue/essential',
'@vue/standard',
'@vue/typescript',
],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'semi': [2, 'always'],
'indent': ['error', 'tab'],
'comma-dangle': ['error', 'always-multiline'],
'no-tabs': 0,
'no-labels': 0,
},
parserOptions: {
parser: 'typescript-eslint-parser',
},
};

24
packages/editor-ui/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
.DS_Store
node_modules
/dist
/tests/e2e/videos/
/tests/e2e/screenshots/
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*

View File

@ -0,0 +1,33 @@
/tests/
/src
# local env files
.env.local
.env.*.local
# Log files
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
.browserslistrc
.editorconfig
.eslintrc.js
babel.config.js
cypress.json
jest.config.js
postcss.config.js
tsconfig.json
tslint.json
vue.config.js
dist/report.html
dist/**/*.map
public/

230
packages/editor-ui/LICENSE Normal file
View File

@ -0,0 +1,230 @@
“Commons Clause” License Condition v1.0
The Software is provided to you by the Licensor under the
License, as defined below, subject to the following condition.
Without limiting other conditions in the License, the grant
of rights under the License will not include, and the License
does not grant to you, the right to Sell the Software.
For purposes of the foregoing, “Sell” means practicing any or
all of the rights granted to you under the License to provide
to third parties, for a fee or other consideration (including
without limitation fees for hosting or consulting/ support
services related to the Software), a product or service whose
value derives, entirely or substantially, from the functionality
of the Software. Any license notice or attribution required by
the License must also include this Commons Clause License
Condition notice.
Software: n8n
License: Apache 2.0
Licensor: Jan Oberhauser
---------------------------------------------------------------------
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,52 @@
# n8n-editor-ui
![n8n.io - Workflow Automation](https://n8n.io/n8n-logo.png)
The UI to create and update n8n workflows
```
npm install n8n -g
```
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Run your tests
```
npm run test
```
### Lints and fixes files
```
npm run lint
```
### Run your end-to-end tests
```
npm run test:e2e
```
### Run your unit tests
```
npm run test:unit
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
## License
[Apache 2.0 with Commons Clause](LICENSE)

View File

@ -0,0 +1,10 @@
module.exports = {
// TODO: Find proper solution. Deactivated as it causes problems with quill. Error occurs when clicking in property field which has expression.
// presets: [
// '@vue/app'
// ]
// transpileDependencies: [
// /\/node_modules\/quill/
// ]
};
// // https://stackoverflow.com/questions/44625868/es6-babel-class-constructor-cannot-be-invoked-without-new

View File

@ -0,0 +1,3 @@
{
"pluginsFile": "tests/e2e/plugins/index.js"
}

View File

@ -0,0 +1,25 @@
module.exports = {
moduleFileExtensions: [
'js',
'jsx',
'json',
'vue',
'ts',
'tsx',
],
transform: {
'^.+\\.vue$': 'vue-jest',
'.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
'^.+\\.tsx?$': 'ts-jest',
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
snapshotSerializers: [
'jest-serializer-vue',
],
testMatch: [
'**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)',
],
testURL: 'http://localhost/',
};

View File

@ -0,0 +1,70 @@
{
"name": "n8n-editor-ui",
"version": "0.1.1",
"description": "Workflow Editor UI for n8n",
"license": "SEE LICENSE IN LICENSE",
"author": {
"name": "Jan Oberhauser",
"email": "jan@n8n.io"
},
"scripts": {
"serve": "VUE_APP_URL_BASE_API=http://localhost:5678/ vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"tslint": "tslint -p tsconfig.json -c tslint.json",
"test:e2e": "vue-cli-service test:e2e",
"test:unit": "vue-cli-service test:unit"
},
"dependencies": {
},
"devDependencies": {
"@beyonk/google-fonts-webpack-plugin": "^1.2.3",
"@fortawesome/fontawesome-svg-core": "^1.2.19",
"@fortawesome/free-solid-svg-icons": "^5.9.0",
"@fortawesome/vue-fontawesome": "^0.1.6",
"@types/dateformat": "^3.0.0",
"@types/file-saver": "^2.0.1",
"@types/jest": "^23.3.2",
"@types/lodash.get": "^4.4.5",
"@types/quill": "^2.0.1",
"@vue/cli-plugin-babel": "^3.8.0",
"@vue/cli-plugin-e2e-cypress": "^3.8.0",
"@vue/cli-plugin-eslint": "^3.8.0",
"@vue/cli-plugin-typescript": "~3.2.0",
"@vue/cli-plugin-unit-jest": "^3.8.0",
"@vue/cli-service": "^3.8.0",
"@vue/eslint-config-standard": "^4.0.0",
"@vue/eslint-config-typescript": "~3.2.0",
"@vue/test-utils": "^1.0.0-beta.20",
"axios": "^0.18.1",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.1",
"dateformat": "^3.0.3",
"element-ui": "~2.4.11",
"eslint": "^5.8.0",
"eslint-plugin-vue": "^5.0.0-0",
"file-saver": "^2.0.2",
"flatted": "^2.0.0",
"jquery": "^3.4.1",
"jshint": "^2.9.7",
"jsplumb": "^2.10.0",
"lodash.debounce": "^4.0.8",
"lodash.get": "^4.4.2",
"n8n-workflow": "^0.1.0",
"node-sass": "^4.12.0",
"quill": "^2.0.0-dev.3",
"quill-autoformat": "^0.1.1",
"sass-loader": "^7.0.1",
"string-template-parser": "^1.2.6",
"ts-jest": "^23.10.1",
"tslint": "^5.17.0",
"typescript": "~3.3.0",
"vue": "^2.6.9",
"vue-cli-plugin-webpack-bundle-analyzer": "^1.3.0",
"vue-json-pretty": "^1.4.1",
"vue-router": "^3.0.6",
"vue-typed-mixins": "^0.1.0",
"vuex": "^3.1.1",
"vue-template-compiler": "^2.5.17"
}
}

View File

@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>n8n.io - Workflow Automation</title>
</head>
<body>
<noscript>
<strong>We're sorry but editor-ui-ts-default-lint doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,47 @@
<template>
<div id="app">
<div id="header">
<router-view name="header"></router-view>
</div>
<div id="sidebar">
<router-view name="sidebar"></router-view>
</div>
<div id="content">
<router-view />
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'App',
};
</script>
<style lang="scss">
#app {
padding: 0;
margin: 0 auto;
}
#content {
position: relative;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#header {
z-index: 10;
position: fixed;
width: 100%;
}
#sidebar {
z-index: 15;
position: fixed;
}
</style>

View File

@ -0,0 +1,365 @@
import {
IConnections,
ICredentialsDecrypted,
ICredentialsEncrypted,
ICredentialType,
IDataObject,
GenericValue,
IWorkflowSettings as IWorkflowSettingsWorkflow,
INode,
INodeCredentials,
INodeIssues,
INodePropertyOptions,
INodeTypeDescription,
IRunExecutionData,
IRun,
IRunData,
ITaskData,
WorkflowExecuteMode,
} from 'n8n-workflow';
import {
PaintStyle,
} from 'jsplumb';
declare module 'jsplumb' {
interface Anchor {
lastReturnValue: number[];
}
interface Connection {
// bind(event: string, (connection: Connection): void;): void; // tslint:disable-line:no-any
bind(event: string, callback: Function): void; // tslint:disable-line:no-any
removeOverlay(name: string): 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
}
interface Overlay {
setVisible(visible: boolean): void;
}
}
// EndpointOptions from jsplumb seems incomplete and wrong so we define an own one
export interface IEndpointOptions {
anchor?: any; // tslint:disable-line:no-any
createEndpoint?: boolean;
dragAllowedWhenFull?: boolean;
dropOptions?: any; // tslint:disable-line:no-any
dragProxy?: any; // tslint:disable-line:no-any
endpoint?: string;
endpointStyle?: 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;
};
}
export interface IUpdateInformation {
name: string;
key: string;
value: string | number; // with null makes problems in NodeSettings.vue
node?: string;
oldValue?: string | number;
}
export interface INodeUpdatePropertiesInformation {
name: string; // Node-Name
properties: {
[key: string]: IDataObject;
};
}
export type XYPositon = [number, number];
export type MessageType = 'success' | 'warning' | 'info' | 'error';
export interface INodeUi extends INode {
position: XYPositon;
color?: string;
notes?: string;
issues?: INodeIssues;
_jsPlumb?: {
endpoints?: {
[key: string]: IEndpointOptions[];
};
};
}
export interface INodeTypesMaxCount {
[key: string]: {
exist: number;
max: number;
nodeNames: string[];
};
}
export interface IRestApi {
getActiveWorkflows(): Promise<string[]>;
getActivationError(id: string): Promise<IActivationError | undefined >;
getCurrentExecutions(filter: object): Promise<IExecutionsCurrentSummaryExtended[]>;
getPastExecutions(filter: object, limit: number, lastStartedAt?: number): Promise<IExecutionsListResponse>;
stopCurrentExecution(executionId: string): Promise<IExecutionsStopData>;
makeRestApiRequest(method: string, endpoint: string, data?: any): Promise<any>; // tslint:disable-line:no-any
getSettings(): Promise<IN8nUISettings>;
getNodeTypes(): Promise<INodeTypeDescription[]>;
getNodeParameterOptions(nodeType: string, methodName: string, credentials?: INodeCredentials): Promise<INodePropertyOptions[]>;
removeTestWebhook(workflowId: string): Promise<boolean>;
runWorkflow(runData: IStartRunData): Promise<IExecutionPushResponse>;
createNewWorkflow(sendData: IWorkflowData): Promise<IWorkflowDb>;
updateWorkflow(id: string, data: IWorkflowDataUpdate): Promise<IWorkflowDb>;
deleteWorkflow(name: string): Promise<void>;
getWorkflow(id: string): Promise<IWorkflowDb>;
getWorkflows(filter?: object): Promise<IWorkflowShortResponse[]>;
getWorkflowFromUrl(url: string): Promise<IWorkflowDb>;
createNewCredentials(sendData: ICredentialsDecrypted): Promise<ICredentialsResponse>;
deleteCredentials(id: string): Promise<void>;
updateCredentials(id: string, data: ICredentialsDecrypted): Promise<ICredentialsResponse>;
getAllCredentials(filter?: object): Promise<ICredentialsResponse[]>;
getCredentials(id: string, includeData?: boolean): Promise<ICredentialsDecryptedResponse | ICredentialsResponse | undefined>;
getCredentialTypes(): Promise<ICredentialType[]>;
getExecution(id: string): Promise<IExecutionResponse>;
deleteExecutions(sendData: IExecutionDeleteFilter): Promise<void>;
retryExecution(id: string): Promise<IExecutionResponse>;
getTimezones(): Promise<IDataObject>;
}
export interface IBinaryDisplayData {
index: number;
key: string;
node: string;
outputIndex: number;
runIndex: number;
}
export interface IStartRunData {
workflowData: IWorkflowData;
startNodes?: string[];
destinationNode?: string;
runData?: IRunData;
}
export interface IRunDataUi {
node?: string;
workflowData: IWorkflowData;
}
export interface ITableData {
columns: string[];
data: GenericValue[][];
}
export interface IVariableItemSelected {
variable: string;
}
export interface IVariableSelectorOption {
name: string;
key?: string;
value?: string;
options?: IVariableSelectorOption[] | null;
allowParentSelect?: boolean;
dataType?: string;
}
// Simple version of n8n-workflow.Workflow
export interface IWorkflowData {
id?: string;
name?: string;
active?: boolean;
nodes: INode[];
connections: IConnections;
settings?: IWorkflowSettings;
}
export interface IWorkflowDataUpdate {
name?: string;
nodes?: INode[];
connections?: IConnections;
settings?: IWorkflowSettings;
active?: boolean;
}
// Almost identical to cli.Interfaces.ts
export interface IWorkflowDb {
id: string;
name: string;
active: boolean;
createdAt: number | string;
updatedAt: number | string;
nodes: INodeUi[];
connections: IConnections;
settings?: IWorkflowSettings;
}
// Identical to cli.Interfaces.ts
export interface IWorkflowShortResponse {
id: string;
name: string;
active: boolean;
createdAt: number | string;
updatedAt: number | string;
}
// Identical or almost identical to cli.Interfaces.ts
export interface IActivationError {
time: number;
error: {
message: string;
};
}
export interface ICredentialsResponse extends ICredentialsEncrypted {
id?: string;
createdAt: number | string;
updatedAt: number | string;
}
export interface ICredentialsBase {
createdAt: number | string;
updatedAt: number | string;
}
export interface ICredentialsDecryptedResponse extends ICredentialsBase, ICredentialsDecrypted{
id: string;
}
export interface IExecutionBase {
id?: number | string;
finished: boolean;
mode: WorkflowExecuteMode;
retryOf?: string;
retrySuccessId?: string;
startedAt: number;
stoppedAt: number;
workflowId?: string; // To be able to filter executions easily //
}
export interface IExecutionFlatted extends IExecutionBase {
data: string;
workflowData: IWorkflowDb;
}
export interface IExecutionFlattedResponse extends IExecutionFlatted {
id: string;
}
export interface IExecutionPushResponse {
executionId?: string;
waitingForWebhook?: boolean;
}
export interface IExecutionResponse extends IExecutionBase {
id: string;
data: IRunExecutionData;
workflowData: IWorkflowDb;
}
export interface IExecutionShortResponse {
id: string;
workflowData: {
id: string;
name: string;
};
mode: WorkflowExecuteMode;
finished: boolean;
startedAt: number | string;
stoppedAt: number | string;
executionTime?: number;
}
export interface IExecutionsListResponse {
count: number;
results: IExecutionsSummary[];
}
export interface IExecutionsCurrentSummaryExtended {
id: string;
finished?: boolean;
mode: WorkflowExecuteMode;
startedAt: number | string;
stoppedAt?: number | string;
workflowId: string;
workflowName?: string;
}
export interface IExecutionsStopData {
finished?: boolean;
mode: WorkflowExecuteMode;
startedAt: number | string;
stoppedAt: number | string;
}
export interface IExecutionsSummary {
id: string;
mode: WorkflowExecuteMode;
finished?: boolean;
retryOf?: string;
retrySuccessId?: string;
startedAt: number | string;
stoppedAt?: number | string;
workflowId: string;
workflowName?: string;
}
export interface IExecutionDeleteFilter {
deleteBefore?: number;
filters?: IDataObject;
ids?: string[];
}
export interface IPushData {
data: IPushDataExecutionFinished | IPushDataNodeExecuteAfter | IPushDataNodeExecuteBefore | IPushDataTestWebhook;
type: IPushDataType;
}
export type IPushDataType = 'executionFinished' | 'nodeExecuteAfter' | 'nodeExecuteBefore' | 'testWebhookDeleted' | 'testWebhookReceived';
export interface IPushDataExecutionFinished {
data: IRun;
executionId: string;
}
export interface IPushDataNodeExecuteAfter {
data: ITaskData;
executionId: string;
nodeName: string;
}
export interface IPushDataNodeExecuteBefore {
executionId: string;
nodeName: string;
}
export interface IPushDataTestWebhook {
workflowId: string;
}
export interface IN8nUISettings {
endpointWebhook: string;
endpointWebhookTest: string;
saveManualRuns: boolean;
timezone: string;
urlBaseWebhook: string;
}
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
errorWorkflow?: string;
saveManualRuns?: boolean;
timezone?: string;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,135 @@
<template>
<div v-if="windowVisible" class="binary-data-window">
<el-button
@click.stop="closeWindow"
size="small"
class="binary-data-window-back"
title="Back to overview page"
icon="el-icon-arrow-left"
>
Back to list
</el-button>
<div class="binary-data-window-wrapper">
<div v-if="binaryData === null">
Data to display did not get found
</div>
<embed :src="'data:' + binaryData.mimeType + ';base64,' + binaryData.data" class="binary-data" :class="embedClass"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IBinaryData,
IBinaryKeyData,
IRunData,
IRunExecutionData,
Workflow,
} from 'n8n-workflow';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(
nodeHelpers,
)
.extend({
name: 'BinaryDataDisplay',
props: [
'displayData', // IBinaryDisplayData
'windowVisible', // boolean
],
computed: {
binaryData (): IBinaryData | null {
const binaryData = this.getBinaryData(this.workflowRunData, this.displayData.node, this.displayData.runIndex, this.displayData.outputIndex);
if (binaryData.length === 0) {
return null;
}
if (this.displayData.index >= binaryData.length || binaryData[this.displayData.index][this.displayData.key] === undefined) {
return null;
}
return binaryData[this.displayData.index][this.displayData.key];
},
embedClass (): string[] {
if (this.binaryData !== null &&
this.binaryData.mimeType !== undefined &&
(this.binaryData.mimeType as string).startsWith('image')
) {
return ['image'];
}
return ['other'];
},
workflowRunData (): IRunData | null {
const workflowExecution = this.$store.getters.getWorkflowExecution;
if (workflowExecution === null) {
return null;
}
const executionData: IRunExecutionData = workflowExecution.data;
return executionData.resultData.runData;
},
},
methods: {
closeWindow () {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('close');
return false;
},
},
});
</script>
<style lang="scss">
.binary-data-window {
position: absolute;
top: 50px;
left: 0;
z-index: 10;
width: 100%;
height: calc(100% - 50px);
background-color: #f9f9f9;
overflow: hidden;
text-align: center;
.binary-data-window-wrapper {
padding: 0 1em;
height: calc(100% - 50px);
.el-row,
.el-col {
height: 100%;
}
}
.binary-data-window-back {
margin: 0 0 0.5em 0;
}
.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

@ -0,0 +1,193 @@
<template>
<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
</div>
<parameter-input-list :parameters="getProperties" :nodeValues="nodeValues" :path="path" :hideDelete="hideDelete" @valueChanged="valueChanged" />
<div v-if="parameterOptions.length > 0 && !isReadOnly">
<el-button v-if="parameter.options.length === 1" size="small" class="add-option" @click="optionSelected(parameter.options[0].name)">{{ getPlaceholderText }}</el-button>
<el-select v-else v-model="selectedOption" :placeholder="getPlaceholderText" size="small" class="add-option" @change="optionSelected" filterable>
<el-option
v-for="item in parameterOptions"
:key="item.name"
:label="item.displayName"
:value="item.name">
</el-option>
</el-select>
</div>
</div>
</div>
</template>
<script lang="ts">
import {
IUpdateInformation,
} from '@/Interface';
import {
INodeProperties,
INodePropertyOptions,
} from 'n8n-workflow';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { get } from 'lodash';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
nodeHelpers,
)
.extend({
name: 'CollectionParameter',
props: [
'hideDelete', // boolean
'nodeValues', // NodeParameters
'parameter', // INodeProperties
'path', // string
'values', // NodeParameters
],
data () {
return {
selectedOption: undefined,
};
},
computed: {
getPlaceholderText (): string {
return this.parameter.placeholder ? this.parameter.placeholder : 'Choose option to add';
},
getProperties (): INodeProperties[] {
const returnProperties = [];
let tempProperties;
for (const name of this.propertyNames) {
tempProperties = this.getOptionProperties(name);
if (tempProperties !== undefined) {
returnProperties.push(tempProperties);
}
}
return returnProperties;
},
// Returns all the options which should be displayed
filteredOptions (): Array<INodePropertyOptions | INodeProperties> {
return (this.parameter.options as Array<INodePropertyOptions | INodeProperties>).filter((option) => {
return this.displayNodeParameter(option as INodeProperties);
});
},
// Returns all the options which did not get added already
parameterOptions (): Array<INodePropertyOptions | INodeProperties> {
return (this.filteredOptions as Array<INodePropertyOptions | INodeProperties>).filter((option) => {
return !this.propertyNames.includes(option.name);
});
},
propertyNames (): string[] {
if (this.values) {
return Object.keys(this.values);
}
return [];
},
},
methods: {
getArgument (argumentName: string): string | number | boolean | undefined {
if (this.parameter.typeOptions === undefined) {
return undefined;
}
if (this.parameter.typeOptions[argumentName] === undefined) {
return undefined;
}
return this.parameter.typeOptions[argumentName];
},
getOptionProperties (optionName: string): INodeProperties | undefined {
for (const option of this.parameter.options) {
if (option.name === optionName) {
return option;
}
}
return undefined;
},
displayNodeParameter (parameter: INodeProperties) {
if (parameter.displayOptions === undefined) {
// If it is not defined no need to do a proper check
return true;
}
return this.displayParameter(this.nodeValues, parameter, this.path);
},
optionSelected (optionName: string) {
const option = this.getOptionProperties(optionName);
if (option === undefined) {
return;
}
const name = `${this.path}.${option.name}`;
let parameterData;
if (option.typeOptions !== undefined && option.typeOptions.multipleValues === true) {
// Multiple values are allowed
let newValue;
if (option.type === 'fixedCollection') {
// The "fixedCollection" entries are different as they save values
// in an object and then underneath there is an array. So initialize
// them differently.
newValue = get(this.nodeValues, `${this.path}.${optionName}`, {});
} else {
// Everything else saves them directly as an array.
newValue = get(this.nodeValues, `${this.path}.${optionName}`, []);
newValue.push(JSON.parse(JSON.stringify(option.default)));
}
parameterData = {
name,
value: newValue,
};
} else {
// Add a new option
parameterData = {
name,
value: JSON.parse(JSON.stringify(option.default)),
};
}
this.$emit('valueChanged', parameterData);
this.selectedOption = undefined;
},
valueChanged (parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
},
beforeCreate: function () { // tslint:disable-line
// Because we have a circular dependency on ParameterInputList import it here
// to not break Vue.
this.$options!.components!.ParameterInputList = require('./ParameterInputList.vue').default;
},
});
</script>
<style lang="scss">
.collection-parameter {
padding: 0em 0 0em 2em;
.add-option {
margin-top: 0.5em;
width: 100%;
}
.no-items-exist {
margin: 0.8em 0 0.4em 0;
}
.option {
position: relative;
padding: 0.25em 0 0.25em 1em;
}
}
</style>

View File

@ -0,0 +1,217 @@
<template>
<div v-if="dialogVisible" @keydown.stop>
<el-dialog :visible="dialogVisible" append-to-body width="55%" :title="title" :before-close="closeDialog">
<div class="credential-type-item">
<el-row v-if="!setCredentialType">
<el-col :span="6">
Credential type:
</el-col>
<el-col :span="18">
<el-select v-model="credentialType" placeholder="Select Type" size="small">
<el-option
v-for="item in credentialTypes"
:key="item.name"
:label="item.displayName"
:value="item.name">
</el-option>
</el-select>
</el-col>
</el-row>
</div>
<credentials-input v-if="credentialType" @credentialsCreated="credentialsCreated" @credentialsUpdated="credentialsUpdated" :credentialTypeData="getCredentialTypeData(credentialType)" :credentialData="credentialData" :nodesInit="nodesInit"></credentials-input>
</el-dialog>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import CredentialsInput from '@/components/CredentialsInput.vue';
import { ICredentialsDecryptedResponse } from '@/Interface';
import {
ICredentialType,
} from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
export default mixins(
restApi,
showMessage,
).extend({
name: 'CredentialsEdit',
props: [
'dialogVisible', // Boolean
'editCredentials',
'setCredentialType', // String
'nodesInit', // Array
],
components: {
CredentialsInput,
},
data () {
return {
credentialData: null as ICredentialsDecryptedResponse | null,
credentialType: null as string | null,
};
},
computed: {
credentialTypes (): ICredentialType[] {
const credentialTypes = this.$store.getters.allCredentialTypes;
if (credentialTypes === null) {
return [];
}
return credentialTypes;
},
title (): string {
if (this.editCredentials) {
const credentialType = this.$store.getters.credentialType(this.editCredentials.type);
return `Edit Credentials: "${credentialType.displayName}"`;
} else {
if (this.credentialType) {
const credentialType = this.$store.getters.credentialType(this.credentialType);
return `Create New Credentials: "${credentialType.displayName}"`;
} else {
return `Create New Credentials`;
}
}
},
},
watch: {
async dialogVisible (newValue, oldValue): Promise<void> {
if (newValue) {
if (this.editCredentials) {
// Credentials which should be edited are given
const credentialType = this.$store.getters.credentialType(this.editCredentials.type);
if (credentialType === null) {
this.$showMessage({
title: 'Credential type not known',
message: `Credentials of type "${this.editCredentials.type}" are not known.`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
if (this.editCredentials.id === undefined) {
this.$showMessage({
title: 'Credential ID missing',
message: 'The ID of the credentials which should be edited is missing!',
type: 'error',
});
this.closeDialog();
return;
}
let currentCredentials: ICredentialsDecryptedResponse | undefined;
try {
currentCredentials = await this.restApi().getCredentials(this.editCredentials.id as string, true) as ICredentialsDecryptedResponse | undefined;
} catch (error) {
this.$showError(error, 'Problem loading credentials', 'There was a problem loading the credentials:');
this.closeDialog();
return;
}
if (currentCredentials === undefined) {
this.$showMessage({
title: 'Credentials not found',
message: `Could not find the credentials with the id: ${this.editCredentials.id}`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
if (currentCredentials === undefined) {
this.$showMessage({
title: 'Problem loading credentials',
message: 'No credentials could be loaded!',
type: 'error',
});
return;
}
this.credentialData = currentCredentials;
} else {
if (this.credentialType || this.setCredentialType) {
const credentialType = this.$store.getters.credentialType(this.credentialType || this.setCredentialType);
if (credentialType === null) {
this.$showMessage({
title: 'Credential type not known',
message: `Credentials of type "${this.credentialType || this.setCredentialType}" are not known.`,
type: 'error',
duration: 0,
});
this.closeDialog();
return;
}
}
this.credentialData = null;
}
if (this.setCredentialType || (this.credentialData && this.credentialData.type)) {
this.credentialType = this.setCredentialType || (this.credentialData && this.credentialData.type);
}
} else {
// Make sure that it gets always reset else it uses by default
// again the last selection from when it was open the previous time.
this.credentialType = null;
}
},
},
methods: {
getCredentialTypeData (name: string): ICredentialType | null {
for (const credentialData of this.credentialTypes) {
if (credentialData.name === name) {
return credentialData;
}
}
return null;
},
credentialsCreated (data: ICredentialsDecryptedResponse): void {
this.$emit('credentialsCreated', data);
this.$showMessage({
title: 'Credentials created',
message: `The credential "${data.name}" got created!`,
type: 'success',
});
this.closeDialog();
},
credentialsUpdated (data: ICredentialsDecryptedResponse): void {
this.$emit('credentialsUpdated', data);
this.$showMessage({
title: 'Credentials updated',
message: `The credential "${data.name}" got updated!`,
type: 'success',
});
this.closeDialog();
},
closeDialog (): void {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
},
},
});
</script>
<style lang="scss">
.credential-type-item {
padding-bottom: 1em;
}
</style>

View File

@ -0,0 +1,270 @@
<template>
<div @keydown.stop class="credentials-input-wrapper">
<el-row>
<el-col :span="6">
Preset Name:
</el-col>
<el-col :span="18">
<el-input size="small" type="text" v-model="name"></el-input>
</el-col>
</el-row>
<br />
<div class="headline">
Credential Data:
</div>
<el-row v-for="parameter in credentialTypeData.properties" :key="parameter.name" class="parameter-wrapper">
<el-col :span="6">
{{parameter.displayName}}:
</el-col>
<el-col :span="18">
<parameter-input :parameter="parameter" :value="propertyValue[parameter.name]" :path="parameter.name" :isCredential="true" @valueChanged="valueChanged" />
</el-col>
</el-row>
<el-row class="nodes-access-wrapper">
<el-col :span="6" class="headline">
Nodes with access:
</el-col>
<el-col :span="18">
<el-transfer
:titles="['No Access', 'Access ']"
v-model="nodesAccess"
:data="allNodesRequestingAccess">
</el-transfer>
<div v-if="nodesAccess.length === 0" class="no-nodes-access">
<strong>
Important!
</strong><br />
Add at least one node which has access to the credentials!
</div>
</el-col>
</el-row>
<div class="action-buttons">
<el-button type="success" @click="updateCredentials" v-if="credentialData">
Save
</el-button>
<el-button type="success" @click="createCredentials" v-else>
Create
</el-button>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { restApi } from '@/components/mixins/restApi';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { ICredentialsDecryptedResponse, IUpdateInformation } from '@/Interface';
import {
CredentialInformation,
ICredentialDataDecryptedObject,
ICredentialsDecrypted,
ICredentialType,
ICredentialNodeAccess,
INodeCredentialDescription,
INodeTypeDescription,
} from 'n8n-workflow';
import ParameterInput from '@/components/ParameterInput.vue';
import mixins from 'vue-typed-mixins';
export default mixins(
nodeHelpers,
restApi,
).extend({
name: 'CredentialsInput',
props: [
'credentialTypeData', // ICredentialType
'credentialData', // ICredentialsDecryptedResponse
'nodesInit', // {
// type: Array,
// default: () => { [] },
// }
],
components: {
ParameterInput,
},
data () {
return {
nodesAccess: [] as string[],
name: '',
propertyValue: {} as ICredentialDataDecryptedObject,
};
},
computed: {
allNodesRequestingAccess (): Array<{key: string, label: string}> {
const returnNodeTypes: string[] = [];
const nodeTypes: INodeTypeDescription[] = this.$store.getters.allNodeTypes;
let nodeType: INodeTypeDescription;
let credentialTypeDescription: INodeCredentialDescription;
// Find the node types which need the credentials
for (nodeType of nodeTypes) {
if (!nodeType.credentials) {
continue;
}
for (credentialTypeDescription of nodeType.credentials) {
if (credentialTypeDescription.name === (this.credentialTypeData as ICredentialType).name && !returnNodeTypes.includes(credentialTypeDescription.name)) {
returnNodeTypes.push(nodeType.name);
break;
}
}
}
// Return the data in the correct format el-transfer expects
return returnNodeTypes.map((nodeTypeName: string) => {
return {
key: nodeTypeName,
label: this.$store.getters.nodeType(nodeTypeName).displayName as string,
};
});
},
},
methods: {
valueChanged (parameterData: IUpdateInformation) {
const name = parameterData.name.split('.').pop();
// @ts-ignore
this.propertyValue[name] = parameterData.value;
},
async createCredentials () {
const nodesAccess = this.nodesAccess.map((nodeType) => {
return {
nodeType,
};
});
const newCredentials = {
name: this.name,
type: (this.credentialTypeData as ICredentialType).name,
nodesAccess,
data: this.propertyValue,
} as ICredentialsDecrypted;
const result = await this.restApi().createNewCredentials(newCredentials);
// Add also to local store
this.$store.commit('addCredentials', result);
this.$emit('credentialsCreated', result);
},
async updateCredentials () {
const nodesAccess: ICredentialNodeAccess[] = [];
const addedNodeTypes: string[] = [];
// Add Node-type which already had access to keep the original added date
let nodeAccessData: ICredentialNodeAccess;
for (nodeAccessData of (this.credentialData as ICredentialsDecryptedResponse).nodesAccess) {
if (this.nodesAccess.includes((nodeAccessData.nodeType))) {
nodesAccess.push(nodeAccessData);
addedNodeTypes.push(nodeAccessData.nodeType);
}
}
// Add Node-type which did not have access before
for (const nodeType of this.nodesAccess) {
if (!addedNodeTypes.includes(nodeType)) {
nodesAccess.push({
nodeType,
});
}
}
const newCredentials = {
name: this.name,
type: (this.credentialTypeData as ICredentialType).name,
nodesAccess,
data: this.propertyValue,
} as ICredentialsDecrypted;
const result = await this.restApi().updateCredentials((this.credentialData as ICredentialsDecryptedResponse).id as string, newCredentials);
// Update also in local store
this.$store.commit('updateCredentials', result);
// Now that the credentials changed check if any nodes use credentials
// which have now a different name
this.updateNodesCredentialsIssues();
this.$emit('credentialsUpdated', result);
},
init () {
if (this.credentialData) {
// Initialize with the given data
this.name = (this.credentialData as ICredentialsDecryptedResponse).name;
this.propertyValue = (this.credentialData as ICredentialsDecryptedResponse).data as ICredentialDataDecryptedObject;
const nodesAccess = (this.credentialData as ICredentialsDecryptedResponse).nodesAccess.map((nodeAccess) => {
return nodeAccess.nodeType;
});
Vue.set(this, 'nodesAccess', nodesAccess);
} else {
// No data supplied so init empty
this.name = '';
this.propertyValue = {} as ICredentialDataDecryptedObject;
const nodesAccess = [] as string[];
nodesAccess.push.apply(nodesAccess, this.nodesInit);
Vue.set(this, 'nodesAccess', nodesAccess);
}
// Set default values
for (const property of (this.credentialTypeData as ICredentialType).properties) {
if (!this.propertyValue.hasOwnProperty(property.name)) {
this.propertyValue[property.name] = property.default as CredentialInformation;
}
}
},
},
watch: {
credentialData () {
this.init();
},
credentialTypeData () {
this.init();
},
},
mounted () {
this.init();
},
});
</script>
<style lang="scss">
.credentials-input-wrapper {
.action-buttons {
margin-top: 2em;
text-align: right;
}
.headline {
font-weight: 600;
color: $--color-primary;
margin-bottom: 1em;
}
.nodes-access-wrapper {
margin-top: 1em;
}
.no-nodes-access {
margin: 1em 0;
color: $--color-primary;
line-height: 1.75em;
}
.parameter-wrapper {
line-height: 3em;
}
}
</style>

View File

@ -0,0 +1,184 @@
<template>
<div v-if="dialogVisible">
<credentials-edit :dialogVisible="credentialEditDialogVisible" @closeDialog="closeCredentialEditDialog" @credentialsUpdated="reloadCredentialList" @credentialsCreated="reloadCredentialList" :setCredentialType="editCredentials && editCredentials.type" :editCredentials="editCredentials"></credentials-edit>
<el-dialog :visible="dialogVisible" append-to-body width="80%" title="Credentials" :before-close="closeDialog">
<div class="text-very-light">
Your saved credentials:
</div>
<el-button title="Create New Credentials" class="new-credentials-button" @click="createCredential()">
<font-awesome-icon icon="plus" />
<div class="next-icon-text">
Add New
</div>
</el-button>
<el-table :data="credentials" :default-sort = "{prop: 'name', order: 'ascending'}" stripe @row-click="editCredential" max-height="450" v-loading="isDataLoading">
<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>
<template slot-scope="scope">
{{credentialTypeDisplayNames[scope.row.type]}}
</template>
</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
label="Operations"
width="120">
<template slot-scope="scope">
<el-button title="Edit Credentials" @click.stop="editCredential(scope.row)" icon="el-icon-edit" circle></el-button>
<el-button title="Delete Credentials" @click.stop="deleteCredential(scope.row)" type="danger" icon="el-icon-delete" circle></el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { restApi } from '@/components/mixins/restApi';
import { ICredentialsResponse } from '@/Interface';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import CredentialsEdit from '@/components/CredentialsEdit.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
nodeHelpers,
restApi,
showMessage,
).extend({
name: 'CredentialsList',
props: [
'dialogVisible',
],
components: {
CredentialsEdit,
},
data () {
return {
credentialEditDialogVisible: false,
credentialTypeDisplayNames: {} as { [key: string]: string; },
credentials: [] as ICredentialsResponse[],
displayAddCredentials: false,
editCredentials: null as ICredentialsResponse | null,
isDataLoading: false,
};
},
watch: {
dialogVisible (newValue, oldValue) {
if (newValue) {
this.loadCredentials();
this.loadCredentialTypes();
}
},
},
methods: {
closeCredentialEditDialog () {
this.credentialEditDialogVisible = false;
},
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;
},
createCredential () {
this.editCredentials = null;
this.credentialEditDialogVisible = true;
},
editCredential (credential: ICredentialsResponse) {
const editCredentials = {
id: credential.id,
name: credential.name,
type: credential.type,
} as ICredentialsResponse;
this.editCredentials = editCredentials;
this.credentialEditDialogVisible = true;
},
reloadCredentialList () {
this.loadCredentials();
},
loadCredentialTypes () {
if (Object.keys(this.credentialTypeDisplayNames).length !== 0) {
// Data is already loaded
return;
}
if (this.$store.getters.allCredentialTypes === null) {
// Data is not ready yet to be loaded
return;
}
for (const credentialType of this.$store.getters.allCredentialTypes) {
this.credentialTypeDisplayNames[credentialType.name] = credentialType.displayName;
}
},
loadCredentials () {
this.isDataLoading = true;
try {
this.credentials = JSON.parse(JSON.stringify(this.$store.getters.allCredentials));
} catch (error) {
this.$showError(error, 'Proble loading credentials', 'There was a problem loading the credentials:');
this.isDataLoading = false;
return;
}
this.credentials.forEach((credentialData: ICredentialsResponse) => {
credentialData.createdAt = this.convertToDisplayDate(credentialData.createdAt as number);
credentialData.updatedAt = this.convertToDisplayDate(credentialData.updatedAt as number);
});
this.isDataLoading = false;
},
async deleteCredential (credential: ICredentialsResponse) {
const deleteConfirmed = await this.confirmMessage(`Are you sure that you want to delete the credentials "${credential.name}"?`, 'Delete Credentials?', 'warning', 'Yes, delete!');
if (deleteConfirmed === false) {
return;
}
let result;
try {
result = await this.restApi().deleteCredentials(credential.id!);
} catch (error) {
this.$showError(error, 'Problem deleting credentials', 'There was a problem deleting the credentials:');
return;
}
// Remove also from local store
this.$store.commit('removeCredentials', credential);
// Now that the credentials got removed check if any nodes used them
this.updateNodesCredentialsIssues();
this.$showMessage({
title: 'Credentials deleted',
message: `The credential "${credential.name}" got deleted!`,
type: 'success',
});
// Refresh list
this.loadCredentials();
},
},
});
</script>
<style lang="scss">
.new-credentials-button {
float: right;
position: relative;
top: -15px;
}
</style>

View File

@ -0,0 +1,103 @@
<template>
<transition name="el-fade-in">
<div class="data-display-wrapper close-on-click" v-show="node" @click="close">
<div class="data-display" >
<NodeSettings @valueChanged="valueChanged" />
<RunData />
<div class="close-button clickable close-on-click" @click="close" title="Close">
<i class="el-icon-close close-on-click"></i>
</div>
</div>
</div>
</transition>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IRunData,
} from 'n8n-workflow';
import {
INodeUi,
IUpdateInformation,
} from '../Interface';
import NodeSettings from '@/components/NodeSettings.vue';
import RunData from '@/components/RunData.vue';
export default Vue.extend({
name: 'DataDisplay',
components: {
NodeSettings,
RunData,
},
computed: {
node (): INodeUi {
return this.$store.getters.activeNode;
},
},
methods: {
valueChanged (parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
nodeTypeSelected (nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
close (e: MouseEvent) {
// @ts-ignore
if (e.target.className && e.target.className.includes && e.target.className.includes('close-on-click')) {
this.$store.commit('setActiveNode', null);
}
},
},
});
</script>
<style lang="scss">
.data-display-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
background-color: #9d8d9dd8;
.close-button {
position: absolute;
top: 0;
right: -50px;
color: #fff;
background-color: $--custom-header-background;
border-radius: 0 18px 18px 0;
z-index: 110;
font-size: 1.7em;
text-align: center;
line-height: 50px;
height: 50px;
width: 50px;
.close-on-click {
color: #fff;
font-weight: 400;
}
.close-on-click:hover {
transform: scale(1.2);
}
}
.data-display {
position: relative;
width: 80%;
height: 80%;
margin: 8em auto;
background-color: #fff;
border-radius: 2px;
}
}
</style>

View File

@ -0,0 +1,121 @@
<template >
<span class="static-text-wrapper">
<span v-show="!editActive" title="Click to change">
<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" />
</span>
</span>
</template>
<script lang="ts">
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { INodeUi } from '@/Interface';
import mixins from 'vue-typed-mixins';
export default mixins(genericHelpers).extend({
name: 'DisplayWithChange',
props: {
keyName: String,
},
computed: {
node (): INodeUi {
return this.$store.getters.activeNode;
},
currentValue (): string {
const parameterNameParts = this.keyName.split('.');
const getDescendantProp = (obj: object, path: string): string => {
// @ts-ignore
return path.split('.').reduce((acc, part) => acc && acc[part], obj);
};
return getDescendantProp(this.node, this.keyName);
},
},
watch: {
currentValue (val) {
// Deactivate when the data to edit changes
// (like when a different node gets selected)
this.editActive = false;
},
},
data: () => {
return {
editActive: false,
newValue: '',
};
},
methods: {
noOp () {},
startEdit () {
if (this.isReadOnly === true) {
return;
}
this.editActive = true;
this.newValue = this.currentValue;
setTimeout(() => {
(this.$refs.inputField as HTMLInputElement).focus();
});
},
cancelEdit () {
this.editActive = false;
},
setValue () {
const sendData = {
value: this.newValue,
name: this.keyName,
};
this.$emit('valueChanged', sendData);
this.editActive = false;
},
},
});
</script>
<style lang="scss">
.static-text-wrapper {
line-height: 1.4em;
font-weight: 600;
.static-text {
position: relative;
top: 1px;
&:hover {
border-bottom: 1px dashed #555;
cursor: text;
}
}
input {
font-weight: 600;
&.edit-field {
background: none;
border: none;
font-size: 1em;
color: #555;
border-bottom: 1px dashed #555;
width: calc(100% - 130px);
}
&.edit-field:focus {
outline-offset: unset;
outline: none;
}
}
.icons {
margin-left: 0.6em;
}
}
</style>

View File

@ -0,0 +1,624 @@
<template>
<span>
<el-dialog :visible="dialogVisible" append-to-body width="80%" :title="`Workflow Executions (${combinedExecutions.length}/${combinedExecutionsCount})`" :before-close="closeDialog">
<div class="filters">
<el-row>
<el-col :span="4" class="filter-headline">
Filters:
</el-col>
<el-col :span="6">
<el-select v-model="filter.workflowId" placeholder="Select Workflow" size="small" filterable @change="handleFilterChanged">
<el-option
v-for="item in workflows"
:key="item.id"
:label="item.name"
:value="item.id">
</el-option>
</el-select>
</el-col>
<el-col :span="3">&nbsp;
</el-col>
<el-col :span="3" class="filter-headline">
Auto-Refresh:
</el-col>
<el-col :span="4">
<el-select v-model="autoRefresh.time" placeholder="Select Refresh Time" size="small" filterable @change="handleRefreshTimeChanged">
<el-option
v-for="item in autoRefresh.options"
:key="item.value"
:label="item.name"
:value="item.value">
</el-option>
</el-select>
</el-col>
<el-col :span="4">
<el-button title="Refresh" @click="refreshData()" :disabled="isDataLoading" size="small" type="success" class="refresh-button">
<font-awesome-icon icon="sync" /> Manual Refresh
</el-button>
</el-col>
</el-row>
</div>
<div class="selection-options">
<span v-if="checkAll === true || isIndeterminate === true">
Selected: {{numSelected}}/{{finishedExecutionsCount}}
<el-button type="danger" title="Delete Selected" icon="el-icon-delete" size="mini" @click="handleDeleteSelected" circle></el-button>
</span>
</div>
<el-table :data="combinedExecutions" stripe v-loading="isDataLoading" :row-class-name="getRowClass" @row-click="handleRowClick">
<el-table-column label="" width="30">
<!-- eslint-disable-next-line vue/no-unused-vars -->
<template slot="header" slot-scope="scope">
<el-checkbox :indeterminate="isIndeterminate" v-model="checkAll" @change="handleCheckAllChange">Check all</el-checkbox>
</template>
<template slot-scope="scope">
<el-checkbox v-if="scope.row.stoppedAt !== undefined" :value="selectedItems[scope.row.id.toString()] || checkAll" @change="handleCheckboxChanged(scope.row.id)" >Check all</el-checkbox>
</template>
</el-table-column>
<el-table-column property="startedAt" label="Started At / ID" width="205">
<template slot-scope="scope">
{{convertToDisplayDate(scope.row.startedAt)}}<br />
<small>ID: {{scope.row.id}}</small>
</template>
</el-table-column>
<el-table-column property="workflowName" label="Name">
<template slot-scope="scope">
<span class="workflow-name">
{{scope.row.workflowName}}
</span>
<span v-if="scope.row.stoppedAt === undefined">
(running)
</span>
<span v-if="scope.row.retryOf !== undefined">
<br /><small>Retry of "{{scope.row.retryOf}}"</small>
</span>
<span v-else-if="scope.row.retrySuccessId !== undefined">
<br /><small>Success retry "{{scope.row.retrySuccessId}}"</small>
</span>
</template>
</el-table-column>
<el-table-column label="Status" width="120">
<template slot-scope="scope">
<el-tooltip placement="top" effect="light">
<div slot="content" v-html="statusTooltipText(scope.row)"></div>
<span class="status-badge running" v-if="scope.row.stoppedAt === undefined">
Running
</span>
<span class="status-badge success" v-else-if="scope.row.finished">
Success
</span>
<span class="status-badge error" v-else>
Error
</span>
</el-tooltip>
<el-button class="retry-button" circle v-if="scope.row.stoppedAt !== undefined && !scope.row.finished && scope.row.retryOf === undefined && scope.row.retrySuccessId === undefined" @click.stop="retryExecution(scope.row)" type="text" size="small" title="Retry execution">
<font-awesome-icon icon="redo" />
</el-button>
</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">
<template slot-scope="scope">
<span v-if="scope.row.stoppedAt === undefined">
<font-awesome-icon icon="spinner" spin />
{{(new Date().getTime() - new Date(scope.row.startedAt).getTime())/1000}} sec.
</span>
<span v-else>
{{(scope.row.stoppedAt - scope.row.startedAt) / 1000}} sec.
</span>
</template>
</el-table-column>
<el-table-column label="" width="100" align="center">
<template slot-scope="scope">
<span v-if="scope.row.stoppedAt === undefined">
<el-button circle title="Stop Execution" @click.stop="stopExecution(scope.row.id)" :loading="stoppingExecutions.includes(scope.row.id)" size="mini">
<font-awesome-icon icon="stop" />
</el-button>
</span>
<span v-else>
<el-button circle title="Open Past Execution" @click.stop="displayExecution(scope.row)" size="mini">
<font-awesome-icon icon="folder-open" />
</el-button>
</span>
</template>
</el-table-column>
</el-table>
<div class="load-more" v-if="finishedExecutionsCount > finishedExecutions.length">
<el-button title="Load More" @click="loadMore()" size="small" :disabled="isDataLoading">
<font-awesome-icon icon="sync" /> Load More
</el-button>
</div>
</el-dialog>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { restApi } from '@/components/mixins/restApi';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { showMessage } from '@/components/mixins/showMessage';
import {
IExecutionsCurrentSummaryExtended,
IExecutionDeleteFilter,
IExecutionsListResponse,
IExecutionShortResponse,
IExecutionsStopData,
IExecutionsSummary,
IWorkflowShortResponse,
} from '@/Interface';
import {
IDataObject,
} from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
restApi,
showMessage,
).extend({
name: 'ExecutionsList',
props: [
'dialogVisible',
],
components: {
WorkflowActivator,
},
data () {
return {
activeExecutions: [] as IExecutionsCurrentSummaryExtended[],
finishedExecutions: [] as IExecutionsSummary[],
finishedExecutionsCount: 0,
checkAll: false,
autoRefresh: {
timer: undefined as NodeJS.Timeout | undefined,
time: -1,
options: [
{
name: 'Deactivated',
value: -1,
},
{
name: '5 Seconds',
value: 5,
},
{
name: '10 Seconds',
value: 10,
},
{
name: '15 Seconds',
value: 15,
},
{
name: '30 Seconds',
value: 30,
},
{
name: '1 Minute',
value: 60,
},
{
name: '5 Minutes',
value: 300,
},
],
},
filter: {
workflowId: 'ALL',
},
isDataLoading: false,
requestItemsPerRequest: 10,
selectedItems: {} as { [key: string]: boolean; },
stoppingExecutions: [] as string[],
workflows: [] as IWorkflowShortResponse[],
};
},
computed: {
combinedExecutions (): IExecutionsSummary[] {
const returnData: IExecutionsSummary[] = [];
// The active executions do not have the workflow-names yet so add them
for (const executionData of this.activeExecutions) {
executionData.workflowName = this.getWorkflowName(executionData.workflowId);
returnData.push(executionData);
}
returnData.push.apply(returnData, this.finishedExecutions);
return returnData;
},
combinedExecutionsCount (): number {
return this.activeExecutions.length + this.finishedExecutionsCount;
},
numSelected (): number {
if (this.checkAll === true) {
return this.finishedExecutionsCount;
}
return Object.keys(this.selectedItems).length;
},
isIndeterminate (): boolean {
if (this.checkAll === true) {
return false;
}
if (this.numSelected > 0) {
return true;
}
return false;
},
workflowFilter (): IDataObject {
const filter: IDataObject = {};
if (this.filter.workflowId !== 'ALL') {
filter.workflowId = this.filter.workflowId;
}
return filter;
},
},
watch: {
dialogVisible (newValue, oldValue) {
if (newValue) {
this.openDialog();
} else {
this.handleRefreshTimeChanged(-1);
}
},
},
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;
},
displayExecution (execution: IExecutionShortResponse) {
this.$router.push({
name: 'ExecutionById',
params: { id: execution.id },
});
this.closeDialog();
},
handleCheckAllChange () {
if (this.checkAll === false) {
Vue.set(this, 'selectedItems', {});
}
},
handleCheckboxChanged (executionId: string) {
if (this.selectedItems[executionId]) {
Vue.delete(this.selectedItems, executionId);
} else {
Vue.set(this.selectedItems, executionId, true);
}
},
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!');
if (deleteExecutions === false) {
return;
}
this.isDataLoading = true;
const sendData: IExecutionDeleteFilter = {};
if (this.checkAll === true) {
sendData.deleteBefore = this.finishedExecutions[0].startedAt as number;
} else {
sendData.ids = Object.keys(this.selectedItems);
}
sendData.filters = this.workflowFilter;
try {
await this.restApi().deleteExecutions(sendData);
} catch (error) {
this.isDataLoading = false;
this.$showError(error, 'Problem deleting executions', 'There was a problem deleting the executions:');
return;
}
this.isDataLoading = false;
this.$showMessage({
title: 'Execution deleted',
message: 'The executions got deleted!',
type: 'success',
});
Vue.set(this, 'selectedItems', {});
this.checkAll = false;
this.refreshData();
},
handleFilterChanged () {
this.refreshData();
},
getRowClass (data: IDataObject): string {
const classes: string[] = ['clickable'];
if ((data.row as IExecutionsSummary).stoppedAt === undefined) {
classes.push('currently-running');
}
return classes.join(' ');
},
getWorkflowName (workflowId: string): string {
const workflow = this.workflows.find((data) => data.id === workflowId);
if (workflow === undefined) {
return '<UNSAVED WORKFLOW>';
}
return workflow.name;
},
async loadActiveExecutions (): Promise<void> {
this.activeExecutions = await this.restApi().getCurrentExecutions(this.workflowFilter);
},
async loadFinishedExecutions (): Promise<void> {
const data = await this.restApi().getPastExecutions(this.workflowFilter, this.requestItemsPerRequest);
this.finishedExecutions = data.results;
this.finishedExecutionsCount = data.count;
},
async loadMore () {
// Deactivate the auto-refresh because else the newly displayed
// data would be lost with the next automatic refresh
this.autoRefresh.time = -1;
this.handleRefreshTimeChanged();
this.isDataLoading = true;
const filter = this.workflowFilter;
let lastStartedAt: number | undefined;
if (this.finishedExecutions.length !== 0) {
const lastItem = this.finishedExecutions.slice(-1)[0];
lastStartedAt = lastItem.startedAt as number;
}
let data: IExecutionsListResponse;
try {
data = await this.restApi().getPastExecutions(filter, this.requestItemsPerRequest, lastStartedAt);
} catch (error) {
this.isDataLoading = false;
this.$showError(error, 'Problem loading workflows', 'There was a problem loading the workflows:');
return;
}
this.finishedExecutions.push.apply(this.finishedExecutions, data.results);
this.finishedExecutionsCount = data.count;
this.isDataLoading = false;
},
async loadWorkflows () {
try {
const workflows = await this.restApi().getWorkflows();
workflows.sort((a, b) => {
if (a.name.toLowerCase() < b.name.toLowerCase()) {
return -1;
}
if (a.name.toLowerCase() > b.name.toLowerCase()) {
return 1;
}
return 0;
});
// @ts-ignore
workflows.unshift({
id: 'ALL',
name: 'All',
});
Vue.set(this, 'workflows', workflows);
} catch (error) {
this.$showError(error, 'Problem loading workflows', 'There was a problem loading the workflows:');
}
},
openDialog () {
Vue.set(this, 'selectedItems', {});
this.filter.workflowId = 'ALL';
this.checkAll = false;
this.loadWorkflows();
this.refreshData();
this.handleRefreshTimeChanged();
},
handleRefreshTimeChanged (manualOverwrite?: number) {
if (this.autoRefresh.timer !== undefined) {
// Make sure the old timer gets removed
clearInterval(this.autoRefresh.timer);
this.autoRefresh.timer = undefined;
}
const timerValue = manualOverwrite !== undefined ? manualOverwrite : this.autoRefresh.time;
if (timerValue === -1) {
// No timer should be set
return;
}
// Create the new interval timer
this.autoRefresh.timer = setInterval(() => {
this.refreshData();
}, timerValue * 1000);
},
async retryExecution (execution: IExecutionShortResponse) {
this.isDataLoading = true;
try {
const data = await this.restApi().retryExecution(execution.id);
if (data.finished === true) {
this.$showMessage({
title: 'Retry successful',
message: 'The retry was successful!',
type: 'success',
});
} else {
this.$showMessage({
title: 'Retry unsuccessful',
message: 'The retry was not successful!',
type: 'error',
});
}
this.refreshData();
this.isDataLoading = false;
} catch (error) {
this.$showError(error, 'Problem with retry', 'There was a problem with the retry:');
this.isDataLoading = false;
this.refreshData();
}
},
async refreshData () {
this.isDataLoading = true;
try {
const activeExecutionsPromise = this.loadActiveExecutions();
const finishedExecutionsPromise = this.loadFinishedExecutions();
await Promise.all([activeExecutionsPromise, finishedExecutionsPromise]);
} catch (error) {
this.$showError(error, 'Problem loading', 'There was a problem loading the data:');
}
this.isDataLoading = false;
},
handleRowClick (entry: IExecutionsSummary, event: Event, column: any) { // tslint:disable-line:no-any
if (column.label === '') {
// Ignore all clicks in the first and last row
return;
}
if (this.selectedItems[entry.id]) {
Vue.delete(this.selectedItems, entry.id);
} else {
Vue.set(this.selectedItems, entry.id, true);
}
},
statusTooltipText (entry: IExecutionsSummary): string {
if (entry.stoppedAt === undefined) {
return 'The worklow is currently executing.';
} else if (entry.finished === true) {
return 'The worklow execution was successful.';
} else if (entry.retryOf !== undefined) {
return `The workflow execution was a retry of "${entry.retryOf}" and did fail.<br />New retries have to be started from the original execution.`;
} else if (entry.retrySuccessId !== undefined) {
return `The workflow execution did fail but the retry "${entry.retrySuccessId}" was successful.`;
} else {
return 'The workflow execution did fail.';
}
},
async stopExecution (executionId: string) {
try {
// Add it to the list of currently stopping executions that we
// can show the user in the UI that it is in progress
this.stoppingExecutions.push(executionId);
const stopData: IExecutionsStopData = await this.restApi().stopCurrentExecution(executionId);
// Remove it from the list of currently stopping executions
const index = this.stoppingExecutions.indexOf(executionId);
this.stoppingExecutions.splice(index, 1);
this.$showMessage({
title: 'Execution stopped',
message: `The execution with the id "${executionId}" got stopped!`,
type: 'success',
});
this.refreshData();
} catch (error) {
this.$showError(error, 'Problem stopping execution', 'There was a problem stopping the execuction:');
}
},
},
});
</script>
<style scoped lang="scss">
.filters {
line-height: 2em;
.refresh-button {
position: absolute;
right: 0;
}
}
.load-more {
margin: 2em 0 0 0;
width: 100%;
text-align: center;
}
.retry-button {
color: $--custom-error-text;
background-color: $--custom-error-background;
margin-left: 5px;
}
.selection-options {
height: 2em;
}
.status-badge {
position: relative;
display: inline-block;
padding: 0 10px;
height: 30px;
line-height: 30px;
border-radius: 15px;
text-align: center;
font-weight: 400;
&.error {
background-color: $--custom-error-background;
color: $--custom-error-text;
}
&.running {
background-color: $--custom-running-background;
color: $--custom-running-text;
}
&.success {
background-color: $--custom-success-background;
color: $--custom-success-text;
}
}
.workflow-name {
font-weight: bold;
}
</style>
<style lang="scss">
.currently-running {
background-color: $--color-primary-light !important;
}
.el-table tr:hover.currently-running td {
background-color: #907070 !important;
}
</style>

View File

@ -0,0 +1,147 @@
<template>
<div v-if="dialogVisible" @keydown.stop>
<el-dialog :visible="dialogVisible" custom-class="expression-dialog" append-to-body width="80%" title="Edit Expression" :before-close="closeDialog">
<el-row>
<el-col :span="8">
<div class="header-side-menu">
<div class="headline">
Edit Expression
</div>
<div class="sub-headline">
Variable Selector
</div>
</div>
<div class="variable-selector">
<variable-selector :path="path" @itemSelected="itemSelected"></variable-selector>
</div>
</el-col>
<el-col :span="16" class="right-side">
<div class="expression-editor-wrapper">
<div class="editor-description">
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>
</div>
</div>
<div class="expression-result-wrapper">
<div class="editor-description">
Result
</div>
<expression-input :parameter="parameter" resolvedValue="true" rows="8" :value="value" :path="path"></expression-input>
</div>
</el-col>
</el-row>
</el-dialog>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import ExpressionInput from '@/components/ExpressionInput.vue';
import VariableSelector from '@/components/VariableSelector.vue';
import { IVariableItemSelected } from '@/Interface';
import {
Workflow,
} from 'n8n-workflow';
export default Vue.extend({
name: 'ExpressionEdit',
props: [
'dialogVisible',
'parameter',
'path',
'value',
],
components: {
ExpressionInput,
VariableSelector,
},
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;
},
itemSelected (eventData: IVariableItemSelected) {
(this.$refs.inputFieldExpression as any).itemSelected(eventData); // tslint:disable-line:no-any
},
},
});
</script>
<style scoped lang="scss">
.editor-description {
font-weight: bold;
padding: 0 0 0.5em 0.2em;;
}
.expression-result-wrapper,
.expression-editor-wrapper {
padding: 10px;
}
.expression-result-wrapper {
margin-top: 1em;
}
/deep/ .expression-dialog {
.el-dialog__header {
padding: 0;
}
.el-dialog__title {
display: none;
}
.el-dialog__body {
padding: 0;
}
.right-side {
background-color: #f9f9f9;
}
}
.header-side-menu {
padding: 1em 0 0.5em 1.8em;
background-color: $--custom-window-sidebar-top;
color: #555;
border-bottom: 1px solid $--color-primary;
margin-bottom: 1em;
.headline {
font-size: 1.35em;
font-weight: 600;
}
.sub-headline {
font-weight: 600;
font-size: 1.1em;
text-align: center;
padding-top: 1.5em;
color: $--color-primary;
}
}
.variable-selector {
margin: 0 1em;
}
</style>

View File

@ -0,0 +1,360 @@
<template>
<div>
<div ref="expression-editor" :style="editorStyle" @keydown.stop></div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import 'quill/dist/quill.core.css';
import Quill, { DeltaOperation } from 'quill';
// @ts-ignore
import AutoFormat, { AutoformatHelperAttribute } from 'quill-autoformat';
import {
NodeParameterValue,
Workflow,
WorkflowDataProxy,
} from 'n8n-workflow';
import {
IExecutionResponse,
IVariableItemSelected,
IVariableSelectorOption,
} from '@/Interface';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(
workflowHelpers,
)
.extend({
name: 'ExpressionInput',
props: [
'rows',
'value',
'parameter',
'path',
'resolvedValue',
],
data () {
return {
editor: null as null | Quill,
};
},
computed: {
editorStyle () {
let rows = 1;
if (this.rows) {
rows = parseInt(this.rows, 10);
}
return {
'height': Math.max((rows * 26 + 10), 40) + 'px',
};
},
workflow (): Workflow {
return this.getWorkflow();
},
},
watch: {
value () {
if (this.resolvedValue) {
// When resolved value gets displayed update the input automatically
this.initValue();
}
},
},
mounted () {
const that = this;
// tslint:disable-next-line
const Inline = Quill.import('blots/inline');
class VariableField extends Inline {
static create (value: string) {
const node = super.create(value);
node.setAttribute('data-value', value);
node.setAttribute('class', 'variable');
return node;
}
static formats (domNode: HTMLElement) {
// For the not resolved one the value can be read directly from the dom
let variableName = domNode.innerHTML.trim();
if (that.resolvedValue) {
// For the resolve done it has to get the one from creation.
// It will not update on change but because the init runs on every change it does not really matter
variableName = domNode.getAttribute('data-value') as string;
}
const newClasses = that.getPlaceholderClasses(variableName);
if (domNode.getAttribute('class') !== newClasses) {
// Only update when it changed else we get an endless loop!
domNode.setAttribute('class', newClasses);
}
return true;
}
}
VariableField.blotName = 'variable';
VariableField.className = 'variable';
VariableField.tagName = 'span';
Quill.register({
'formats/variable': VariableField,
});
AutoFormat.DEFAULTS = {
expression: {
trigger: /[\w\s]/,
find: /\{\{[^\s,;:!?}]+\}\}/i,
format: 'variable',
},
};
this.editor = new Quill(this.$refs['expression-editor'] as Element, {
readOnly: !!this.resolvedValue,
modules: {
autoformat: {},
},
});
this.editor.root.addEventListener('blur', (event: Event) => {
this.$emit('blur', event);
});
this.initValue();
if (!this.resolvedValue) {
// Only call update when not resolved value gets displayed
this.setFocus();
this.editor.on('text-change', () => this.update());
}
},
methods: {
// ------------------------------- EDITOR -------------------------------
customizeVariable (variableName: string) {
const returnData = {
classes: [] as string[],
message: variableName as string,
};
let value;
try {
value = this.resolveExpression(`=${variableName}`);
if (value !== undefined) {
returnData.classes.push('valid');
} else {
returnData.classes.push('invalid');
}
} catch (e) {
returnData.classes.push('invalid');
}
return returnData;
},
// Resolves the given variable. If it is not valid it will return
// an error-string.
resolveParameterString (variableName: string) {
let returnValue;
try {
returnValue = this.resolveExpression(`=${variableName}`);
} catch (e) {
return 'invalid';
}
if (returnValue === undefined) {
return 'not found';
}
return returnValue;
},
getPlaceholderClasses (variableName: string) {
const customizeData = this.customizeVariable(variableName);
return 'variable ' + customizeData.classes.join(' ');
},
getValue () {
if (!this.editor) {
return '';
}
const content = this.editor.getContents();
if (!content || !content.ops) {
return '';
}
let returnValue = '';
// Convert the editor operations into a string
content.ops.forEach((item: DeltaOperation) => {
if (!item.insert) {
return;
}
returnValue += item.insert;
});
// For some unknown reason does the Quill always return a "\n"
// at the end. Remove it here manually
return '=' + returnValue.replace(/\s+$/g, '');
},
setFocus () {
// TODO: There is a bug that when opening ExpressionEditor and typing directly it shows the first letter and
// then adds the second letter in from of the first on
this.editor!.focus();
},
itemSelected (eventData: IVariableItemSelected) {
// We can only get the selection if editor is in focus so make
// sure it is
this.editor!.focus();
const selection = this.editor!.getSelection();
let addIndex = null;
if (selection) {
addIndex = selection.index;
}
if (addIndex) {
// If we have a location to add variable to add it there
this.editor!.insertText(addIndex, `{{${eventData.variable}}}`, 'variable', true);
this.update();
} else {
// If no position got found add it to end
let newValue = this.value;
if (newValue !== '=') {
newValue += ` `;
}
newValue += `{{${eventData.variable}}}\n`;
this.$emit('change', newValue);
if (!this.resolvedValue) {
Vue.nextTick(() => {
this.initValue();
});
}
}
},
initValue () {
if (!this.value) {
return;
}
let currentValue = this.value;
if (currentValue.charAt(0) === '=') {
currentValue = currentValue.slice(1);
}
// Convert the expression string into a Quill Operations
const editorOperations: DeltaOperation[] = [];
currentValue.replace(/\{\{(.*?)\}\}/ig, '*^^%#_@$1*^^%#_@').split('*^^%#_@').forEach((value: string) => {
if (!value) {
} else if (value.charAt(0) === '^') {
// Is variable
let displayValue = `{{${value.slice(1)}}}` as string | number | boolean;
if (this.resolvedValue) {
displayValue = this.resolveParameterString(displayValue.toString()) as NodeParameterValue;
}
editorOperations.push({
attributes: {
variable: `{{${value.slice(1)}}}`,
},
insert: displayValue.toString(),
});
} else {
// Is text
editorOperations.push({
insert: value,
});
}
});
// @ts-ignore
this.editor!.setContents(editorOperations);
},
update () {
this.$emit('input', this.getValue());
this.$emit('change', this.getValue());
},
},
});
</script>
<style lang="scss">
.variable-wrapper {
text-decoration: none;
}
.variable-value {
font-weight: bold;
color: #000;
background-color: #c0c0c0;
padding: 3px;
border-radius: 3px;
}
.variable-delete {
position: relative;
left: -3px;
top: -8px;
display: none;
color: #fff;
font-weight: bold;
padding: 2px 4px;
}
.variable-wrapper:hover .variable-delete {
display: inline;
background-color: #AA2200;
border-radius: 5px;
}
.variable {
font-weight: bold;
color: #000;
background-color: #c0c0c0;
padding: 3px;
border-radius: 3px;
margin: 0 2px;
&:first-child {
margin-left: 0;
}
&.invalid {
background-color: #e25e5e;
}
&.valid {
background-color: #37ac37;
}
}
.ql-editor {
padding: 0.5em 1em;
}
.ql-disabled .ql-editor {
border-width: 1px;
border: 1px dashed $--custom-expression-text;
color: $--custom-expression-text;
background-color: $--custom-expression-background;
cursor: not-allowed;
}
.ql-disabled .ql-editor .variable {
color: #303030;
}
</style>

View File

@ -0,0 +1,235 @@
<template>
<div @keydown.stop class="fixed-collection-parameter">
<div v-if="getProperties.length === 0" class="no-items-exist">
Currently no items exist
</div>
<div v-for="property in getProperties" :key="property.name" class="fixed-collection-parameter-property">
<div 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">
<div class="parameter-item-wrapper">
<div class="delete-option clickable" title="Delete" v-if="!isReadOnly">
<font-awesome-icon icon="trash" class="reset-icon clickable" title="Delete Item" @click="deleteOption(property.name, index)" />
</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 clickable" title="Delete" 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>
</div>
<div v-if="parameterOptions.length > 0 && !isReadOnly">
<el-button v-if="parameter.options.length === 1" size="small" class="add-option" @click="optionSelected(parameter.options[0].name)">{{ getPlaceholderText }}</el-button>
<el-select v-else v-model="selectedOption" :placeholder="getPlaceholderText" size="small" class="add-option" @change="optionSelected" filterable>
<el-option
v-for="item in parameterOptions"
:key="item.name"
:label="item.displayName"
:value="item.name">
</el-option>
</el-select>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IUpdateInformation,
} from '@/Interface';
import {
INodeParameters,
INodePropertyCollection,
} from 'n8n-workflow';
import { get } from 'lodash';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(genericHelpers)
.extend({
name: 'FixedCollectionParameter',
props: [
'nodeValues', // INodeParameters
'parameter', // INodeProperties
'path', // string
'values', // INodeParameters
],
data () {
return {
selectedOption: undefined,
};
},
computed: {
getPlaceholderText (): string {
return this.parameter.placeholder ? this.parameter.placeholder : 'Choose option to add';
},
getProperties (): INodePropertyCollection[] {
const returnProperties = [];
let tempProperties;
for (const name of this.propertyNames) {
tempProperties = this.getOptionProperties(name);
if (tempProperties !== undefined) {
returnProperties.push(tempProperties);
}
}
return returnProperties;
},
multipleValues (): boolean {
if (this.parameter.typeOptions !== undefined && this.parameter.typeOptions.multipleValues === true) {
return true;
}
return false;
},
parameterOptions (): INodePropertyCollection[] {
if (this.multipleValues === true) {
return this.parameter.options;
}
return (this.parameter.options as INodePropertyCollection[]).filter((option) => {
return !this.propertyNames.includes(option.name);
});
},
propertyNames (): string[] {
if (this.values) {
return Object.keys(this.values);
}
return [];
},
},
methods: {
deleteOption (optionName: string, index?: number) {
const parameterData = {
name: this.getPropertyPath(optionName, index),
value: null,
};
this.$emit('valueChanged', parameterData);
},
getPropertyPath (name: string, index?: number) {
return `${this.path}.${name}` + (index !== undefined ? `[${index}]` : '');
},
getOptionProperties (optionName: string): INodePropertyCollection | undefined {
for (const option of this.parameter.options) {
if (option.name === optionName) {
return option;
}
}
return undefined;
},
optionSelected (optionName: string) {
const option = this.getOptionProperties(optionName);
if (option === undefined) {
return;
}
const name = `${this.path}.${option.name}`;
let parameterData;
const newParameterValue: INodeParameters = {};
for (const optionParameter of option.values) {
if (optionParameter.typeOptions !== undefined && optionParameter.typeOptions.multipleValues === true) {
// Multiple values are allowed so append option to array
newParameterValue[optionParameter.name] = get(this.nodeValues, `${this.path}.${optionParameter.name}`, []);
(newParameterValue[optionParameter.name] as INodeParameters[]).push(JSON.parse(JSON.stringify(optionParameter.default)));
} else {
// Add a new option
newParameterValue[optionParameter.name] = JSON.parse(JSON.stringify(optionParameter.default));
}
}
let newValue;
if (this.multipleValues === true) {
newValue = get(this.nodeValues, name, []);
newValue.push(newParameterValue);
} else {
newValue = newParameterValue;
}
parameterData = {
name,
value: newValue,
};
this.$emit('valueChanged', parameterData);
this.selectedOption = undefined;
},
valueChanged (parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
},
beforeCreate: function () { // tslint:disable-line
// Because we have a circular dependency on ParameterInputList import it here
// to not break Vue.
this.$options!.components!.ParameterInputList = require('./ParameterInputList.vue').default;
},
});
</script>
<style scoped lang="scss">
.add-option {
width: 100%;
}
.fixed-collection-parameter {
padding: 0 0 0 1em;
}
.fixed-collection-parameter-property {
margin: 0.5em 0;
padding: 0.5em 0;
.parameter-name {
border-bottom: 1px solid #999;
}
}
.delete-option {
display: none;
position: absolute;
z-index: 999;
color: #f56c6c;
left: 0;
top: 0;
}
.parameter-item-wrapper:hover > .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;
}
}
}
.no-items-exist {
margin: 0.8em 0;
}
</style>

View File

@ -0,0 +1,314 @@
<template>
<div>
<div class="main-header">
<input type="file" ref="importFile" style="display: none" v-on:change="handleFileImport()">
<div class="top-menu">
<div class="center-item">
<span v-if="isExecutionPage">
Execution Id:
<span v-if="isExecutionPage" class="execution-name">
<strong>{{executionId}}</strong>&nbsp;
<font-awesome-icon icon="check" class="execution-icon success" v-if="executionFinished" title="Execution was successful" />
<font-awesome-icon icon="times" class="execution-icon error" v-else title="Execution did fail" />
</span>
of Workflow
<span class="workflow-name clickable" title="Open Workflow">
<span @click="openWorkflow(workflowExecution.workflowId)">"{{workflowName}}"</span>
</span>
</span>
<span index="workflow-name" class="current-workflow" v-if="!isReadOnly">
<span v-if="currentWorkflow">Workflow: <span class="workflow-name">{{workflowName}}</span></span>
<span v-else class="workflow-not-saved">Workflow not saved!</span>
</span>
<span class="saving-workflow" v-if="isWorkflowSaving">
<font-awesome-icon icon="spinner" spin />
Saving...
</span>
</div>
<div class="clear-execution clickable" v-if="!isReadOnly && workflowExecution && !workflowRunning" @click="clearExecutionData()" title="Deletes the current Execution Data.">
<font-awesome-icon icon="trash" class="clear-execution-icon" />
</div>
<div class="push-connection-lost" v-if="!isPushConnectionActive">
<el-tooltip placement="bottom-end" effect="light">
<div slot="content">
Server connection could not be established.<br />
The server is down or there is a connection problem.<br />
It will reconnect automatically as soon as the backend can be reached.
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />&nbsp;
Connection lost
</span>
</el-tooltip>
</div>
<div class="workflow-active" v-else-if="!isReadOnly">
Active:
<workflow-activator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflow" :disabled="!currentWorkflow"/>
</div>
<div class="read-only" v-if="isReadOnly">
<el-tooltip placement="bottom-end" effect="light">
<div slot="content">
A past execution gets displayed. For that reason no data<br />
can be changed. To make changes or to execute it again open<br />
the workflow by clicking on it`s name on the left.
</div>
<span>
<font-awesome-icon icon="exclamation-triangle" />
Read only
</span>
</el-tooltip>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IExecutionResponse,
IExecutionsStopData,
IWorkflowDataUpdate,
} from '../Interface';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { pushConnection } from '@/components/mixins/pushConnection';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { saveAs } from 'file-saver';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
pushConnection,
restApi,
showMessage,
workflowHelpers,
)
.extend({
name: 'MainHeader',
components: {
WorkflowActivator,
},
computed: {
executionId (): string | undefined {
return this.$route.params.id;
},
executionFinished (): boolean {
if (!this.isExecutionPage) {
// We are not on an execution page so return false
return false;
}
const fullExecution = this.$store.getters.getWorkflowExecution;
if (fullExecution === null) {
// No execution loaded so return also false
return false;
}
if (fullExecution.finished === true) {
return true;
}
return false;
},
isExecutionPage (): boolean {
if (['ExecutionById'].includes(this.$route.name as string)) {
return true;
}
return false;
},
isPushConnectionActive (): boolean {
return this.$store.getters.pushConnectionActive;
},
isWorkflowActive (): boolean {
return this.$store.getters.isActive;
},
isWorkflowSaving (): boolean {
return this.$store.getters.isActionActive('workflowSaving');
},
currentWorkflow (): string {
return this.$route.params.name;
},
workflowExecution (): IExecutionResponse | null {
return this.$store.getters.getWorkflowExecution;
},
workflowName (): string {
return this.$store.getters.workflowName;
},
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
},
methods: {
clearExecutionData () {
this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues();
},
async openWorkflow (workflowId: string) {
// Change to other workflow
this.$router.push({
name: 'NodeViewExisting',
params: { name: workflowId },
});
},
},
async mounted () {
// Initialize the push connection
this.pushConnect();
},
beforeDestroy () {
this.pushDisconnect();
},
});
</script>
<style lang="scss">
.el-menu--horizontal>.el-menu-item,
.el-menu--horizontal>.el-submenu .el-submenu__title,
.el-menu-item {
height: 65px;
line-height: 65px;
}
.el-submenu .el-submenu__title,
.el-menu--horizontal>.el-menu-item,
.el-menu.el-menu--horizontal {
border: none !important;
}
.el-menu--popup-bottom-start {
margin-top: 0px;
border-top: 1px solid #464646;
border-radius: 0 0 2px 2px;
}
.main-header {
position: fixed;
top: 0;
background-color: #fff;
height: 65px;
width: 100%;
}
.top-menu {
position: relative;
font-size: 0.9em;
width: 100%;
font-weight: 400;
.center-item {
margin: 0 auto;
text-align: center;
line-height: 65px;
.saving-workflow {
display: inline-block;
margin-left: 2em;
padding: 0 15px;
color: $--color-primary;
background-color: $--color-primary-light;
line-height: 30px;
height: 30px;
border-radius: 15px;
}
}
.read-only {
position: absolute;
top: 0;
line-height: 65px;
margin-right: 5em;
right: 0;
color: $--color-primary;
}
.push-connection-lost {
position: absolute;
top: 0;
line-height: 65px;
margin-right: 5em;
right: 0;
color: $--color-primary;
}
.workflow-active {
position: absolute;
top: 0;
line-height: 65px;
margin-right: 5em;
right: 0;
}
.workflow-name {
color: $--color-primary;
}
}
</style>
<style scoped lang="scss">
.current-execution,
.current-workflow {
vertical-align: top;
}
.execution-icon.error,
.workflow-not-saved {
color: #FF2244;
}
.execution-icon.success {
color: #22FF44;
}
.menu-separator-bottom {
border-bottom: 1px solid #707070;
}
.menu-separator-top {
border-top: 1px solid #707070;
}
.clear-execution {
position: absolute;
top: calc(50% - 19px);
line-height: 65px;
right: 200px;
width: 38px;
height: 38px;
line-height: 38px;
font-size: 18px;
text-align: center;
border-radius: 19px;
background-color: $--color-primary-light;
color: $--color-primary;
&:hover {
transform: scale(1.1);
}
.clear-execution-icon {
color: #f56c6c;
}
}
</style>

View File

@ -0,0 +1,459 @@
<template>
<div id="side-menu">
<executions-list :dialogVisible="executionsListDialogVisible" @closeDialog="closeExecutionsListOpenDialog"></executions-list>
<credentials-list :dialogVisible="credentialOpenDialogVisible" @closeDialog="closeCredentialOpenDialog"></credentials-list>
<credentials-edit :dialogVisible="credentialNewDialogVisible" @closeDialog="closeCredentialNewDialog"></credentials-edit>
<workflow-open @openWorkflow="openWorkflow" :dialogVisible="workflowOpenDialogVisible" @closeDialog="closeWorkflowOpenDialog"></workflow-open>
<workflow-settings :dialogVisible="workflowSettingsDialogVisible" @closeDialog="closeWorkflowSettingsDialog"></workflow-settings>
<input type="file" ref="importFile" style="display: none" v-on:change="handleFileImport()">
<div class="side-menu-wrapper" :class="{expanded: !isCollapsed}">
<div id="collapse-change-button" class="clickable" @click="isCollapsed=!isCollapsed">
<font-awesome-icon icon="angle-right" class="icon" />
</div>
<el-menu default-active="workflow" @select="handleSelect" :collapse="isCollapsed">
<el-menu-item index="logo" class="logo-item">
<img src="/n8n-icon-small.png" class="icon" alt="n8n.io"/>
<a href="https://n8n.io" class="logo-text" target="_blank" slot="title">
n8n.io
</a>
</el-menu-item>
<el-submenu index="workflow">
<template slot="title">
<font-awesome-icon icon="network-wired"/>&nbsp;
<span slot="title" class="item-title-root">Workflows</span>
</template>
<el-menu-item index="workflow-new">
<template slot="title">
<font-awesome-icon icon="file"/>&nbsp;
<span slot="title" class="item-title">New</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-open">
<template slot="title">
<font-awesome-icon icon="folder-open"/>&nbsp;
<span slot="title" class="item-title">Open</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-save" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="save"/>
<span slot="title" class="item-title">Save</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-save-as">
<template slot="title">
<font-awesome-icon icon="copy"/>
<span slot="title" class="item-title">Save As</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-delete" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="trash"/>
<span slot="title" class="item-title">Delete</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-download">
<template slot="title">
<font-awesome-icon icon="file-download"/>
<span slot="title" class="item-title">Download</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-import-url">
<template slot="title">
<font-awesome-icon icon="cloud"/>
<span slot="title" class="item-title">Import from URL</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-import-file">
<template slot="title">
<font-awesome-icon icon="hdd"/>
<span slot="title" class="item-title">Import from File</span>
</template>
</el-menu-item>
<el-menu-item index="workflow-settings" :disabled="!currentWorkflow">
<template slot="title">
<font-awesome-icon icon="cog"/>
<span slot="title" class="item-title">Settings</span>
</template>
</el-menu-item>
</el-submenu>
<el-submenu index="credentials">
<template slot="title">
<font-awesome-icon icon="key"/>&nbsp;
<span slot="title" class="item-title-root">Credentials</span>
</template>
<el-menu-item index="credentials-new">
<template slot="title">
<font-awesome-icon icon="file"/>
<span slot="title" class="item-title">New</span>
</template>
</el-menu-item>
<el-menu-item index="credentials-open">
<template slot="title">
<font-awesome-icon icon="folder-open"/>
<span slot="title" class="item-title">Open</span>
</template>
</el-menu-item>
</el-submenu>
<el-menu-item index="executions">
<font-awesome-icon icon="tasks"/>&nbsp;
<span slot="title" class="item-title-root">Executions</span>
</el-menu-item>
</el-menu>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IExecutionResponse,
IExecutionsStopData,
IWorkflowDataUpdate,
} from '../Interface';
import CredentialsEdit from '@/components/CredentialsEdit.vue';
import CredentialsList from '@/components/CredentialsList.vue';
import ExecutionsList from '@/components/ExecutionsList.vue';
import WorkflowOpen from '@/components/WorkflowOpen.vue';
import WorkflowSettings from '@/components/WorkflowSettings.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { workflowRun } from '@/components/mixins/workflowRun';
import { saveAs } from 'file-saver';
import mixins from 'vue-typed-mixins';
export default mixins(
genericHelpers,
restApi,
showMessage,
workflowHelpers,
workflowRun,
)
.extend({
name: 'MainHeader',
components: {
CredentialsEdit,
CredentialsList,
ExecutionsList,
WorkflowOpen,
WorkflowSettings,
},
data () {
return {
isCollapsed: true,
credentialNewDialogVisible: false,
credentialOpenDialogVisible: false,
executionsListDialogVisible: false,
stopExecutionInProgress: false,
workflowOpenDialogVisible: false,
workflowSettingsDialogVisible: false,
};
},
computed: {
exeuctionId (): string | undefined {
return this.$route.params.id;
},
executionFinished (): boolean {
if (!this.isExecutionPage) {
// We are not on an exeuction page so return false
return false;
}
const fullExecution = this.$store.getters.getWorkflowExecution;
if (fullExecution === null) {
// No exeuction loaded so return also false
return false;
}
if (fullExecution.finished === true) {
return true;
}
return false;
},
executionWaitingForWebhook (): boolean {
return this.$store.getters.executionWaitingForWebhook;
},
isExecutionPage (): boolean {
if (['ExecutionById'].includes(this.$route.name as string)) {
return true;
}
return false;
},
isWorkflowActive (): boolean {
return this.$store.getters.isActive;
},
currentWorkflow (): string {
return this.$route.params.name;
},
workflowExecution (): IExecutionResponse | null {
return this.$store.getters.getWorkflowExecution;
},
workflowName (): string {
return this.$store.getters.workflowName;
},
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
},
methods: {
clearExecutionData () {
this.$store.commit('setWorkflowExecutionData', null);
this.updateNodesExecutionIssues();
},
closeWorkflowOpenDialog () {
this.workflowOpenDialogVisible = false;
},
closeWorkflowSettingsDialog () {
this.workflowSettingsDialogVisible = false;
},
closeExecutionsListOpenDialog () {
this.executionsListDialogVisible = false;
},
closeCredentialOpenDialog () {
this.credentialOpenDialogVisible = false;
},
closeCredentialNewDialog () {
this.credentialNewDialogVisible = false;
},
async stopExecution () {
const executionId = this.$store.getters.activeExecutionId;
if (executionId === null) {
return;
}
try {
this.stopExecutionInProgress = true;
const stopData: IExecutionsStopData = await this.restApi().stopCurrentExecution(executionId);
this.$showMessage({
title: 'Execution stopped',
message: `The execution with the id "${executionId}" got stopped!`,
type: 'success',
});
} catch (error) {
this.$showError(error, 'Problem stopping execution', 'There was a problem stopping the execuction:');
}
this.stopExecutionInProgress = false;
},
async openWorkflow (workflowId: string) {
// Change to other workflow
this.$router.push({
name: 'NodeViewExisting',
params: { name: workflowId },
});
this.workflowOpenDialogVisible = false;
},
async handleFileImport () {
const reader = new FileReader();
reader.onload = (event: ProgressEvent) => {
const data = (event.target as FileReader).result;
let worflowData: IWorkflowDataUpdate;
try {
worflowData = JSON.parse(data as string);
} catch (error) {
this.$showMessage({
title: 'Could not import file',
message: `The file does not contain valid JSON data.`,
type: 'error',
});
return;
}
this.$root.$emit('importWorkflowData', { data: worflowData });
};
const input = this.$refs.importFile as HTMLInputElement;
if (input !== null && input.files !== null && input.files.length !== 0) {
reader.readAsText(input!.files[0]!);
}
},
async handleSelect (key: string, keyPath: string) {
if (key === 'workflow-open') {
this.workflowOpenDialogVisible = true;
} else if (key === 'workflow-import-file') {
(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,
});
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!');
if (deleteConfirmed === false) {
return;
}
let result;
try {
result = await this.restApi().deleteWorkflow(this.currentWorkflow);
} catch (error) {
this.$showError(error, 'Problem deleting the workflow', 'There was a problem deleting the workflow:');
return;
}
this.$showMessage({
title: 'Workflow got deleted',
message: `The workflow "${this.workflowName}" got deleted!`,
type: 'success',
});
this.$router.push({ name: 'NodeViewNew' });
} else if (key === 'workflow-download') {
const workflowData = await this.getWorkflowDataToSave();
const blob = new Blob([JSON.stringify(workflowData, null, 2)], {
type: 'application/json;charset=utf-8',
});
let workflowName = this.$store.getters.workflowName || 'unsaved_workflow';
workflowName = workflowName.replace(/[^a-z0-9]/gi, '_');
saveAs(blob, workflowName + '.json');
} else if (key === 'workflow-save') {
this.saveCurrentWorkflow();
} else if (key === 'workflow-save-as') {
this.saveCurrentWorkflow(true);
} else if (key === 'workflow-settings') {
this.workflowSettingsDialogVisible = true;
} else if (key === 'workflow-new') {
this.$router.push({ name: 'NodeViewNew' });
this.$showMessage({
title: 'Workflow created',
message: 'A new workflow got created!',
type: 'success',
});
} else if (key === 'credentials-open') {
this.credentialOpenDialogVisible = true;
} else if (key === 'credentials-new') {
this.credentialNewDialogVisible = true;
} else if (key === 'execution-open-workflow') {
if (this.workflowExecution !== null) {
this.openWorkflow(this.workflowExecution.workflowId as string);
}
} else if (key === 'executions') {
this.executionsListDialogVisible = true;
}
},
},
async mounted () {
this.$root.$on('openWorkflowDialog', async () => {
this.workflowOpenDialogVisible = true;
});
},
});
</script>
<style lang="scss">
#collapse-change-button {
position: absolute;
z-index: 10;
top: 55px;
left: 25px;
text-align: right;
line-height: 24px;
height: 20px;
width: 20px;
background-color: #fff;
border: none;
border-radius: 15px;
-webkit-transition-duration: 0.5s;
-moz-transition-duration: 0.5s;
-o-transition-duration: 0.5s;
transition-duration: 0.5s;
-webkit-transition-property: -webkit-transform;
-moz-transition-property: -moz-transform;
-o-transition-property: -o-transform;
transition-property: transform;
overflow: hidden;
.icon {
position: relative;
left: -5px;
top: -2px;
}
}
#collapse-change-button:hover {
transform: scale(1.1);
}
.el-menu-item.logo-item {
background-color: $--color-primary !important;
height: 65px;
.icon {
position: relative;
height: 23px;
left: -10px;
top: -2px;
}
}
a.logo-text {
position: relative;
top: -3px;
left: 5px;
font-weight: bold;
color: #fff;
text-decoration: none;
}
.expanded #collapse-change-button {
-webkit-transform: translateX(60px) rotate(180deg);
-moz-transform: translateX(60px) rotate(180deg);
-o-transform: translateX(60px) rotate(180deg);
transform: translateX(60px) rotate(180deg);
}
#side-menu {
position: fixed;
height: 100%;
.el-menu {
height: 100%;
}
}
.side-menu-wrapper {
height: 100%;
width: 65px;
&.expanded {
width: 200px;
}
}
</style>

View File

@ -0,0 +1,171 @@
<template>
<div @keydown.stop class="duplicate-parameter">
<div class="parameter-name">
{{parameter.displayName}}:
</div>
<div v-for="(value, index) in values" :key="index" class="duplicate-parameter-item" :class="parameter.type">
<div class="delete-item clickable" v-if="!isReadOnly" title="Delete Item" @click="deleteItem(index)">
<font-awesome-icon icon="trash" />
</div>
<div v-if="parameter.type === 'collection'">
<collection-parameter :parameter="parameter" :values="value" :nodeValues="nodeValues" :path="getPath(index)" :hideDelete="hideDelete" @valueChanged="valueChanged" />
</div>
<div v-else>
<parameter-input class="duplicate-parameter-input-item" :parameter="parameter" :value="value" :displayOptions="true" :path="getPath(index)" @valueChanged="valueChanged" />
</div>
</div>
<div class="add-item-wrapper">
<div v-if="values && Object.keys(values).length === 0 || isReadOnly" class="no-items-exist">
Currently no items exist
</div>
<el-button v-if="!isReadOnly" size="small" class="add-item" @click="addItem()">{{ addButtonText }}</el-button>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import {
IUpdateInformation,
} from '@/Interface';
import CollectionParameter from '@/components/CollectionParameter.vue';
import ParameterInput from '@/components/ParameterInput.vue';
import { get } from 'lodash';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import mixins from 'vue-typed-mixins';
export default mixins(genericHelpers)
.extend({
name: 'MultipleParameter',
components: {
CollectionParameter,
ParameterInput,
},
props: [
'nodeValues', // NodeParameters
'parameter', // NodeProperties
'path', // string
'values', // NodeParameters[]
],
computed: {
addButtonText (): string {
return (this.parameter.typeOptions && this.parameter.typeOptions.multipleValueButtonText) ? this.parameter.typeOptions.multipleValueButtonText : 'Add item';
},
hideDelete (): boolean {
return this.parameter.options.length === 1;
},
},
methods: {
addItem () {
const name = this.getPath();
let currentValue = get(this.nodeValues, name);
if (currentValue === undefined) {
currentValue = [];
}
currentValue.push(JSON.parse(JSON.stringify(this.parameter.default)));
const parameterData = {
name,
value: currentValue,
};
this.$emit('valueChanged', parameterData);
},
deleteItem (index: number) {
const parameterData = {
name: this.getPath(index),
value: null,
};
this.$emit('valueChanged', parameterData);
},
getPath (index?: number): string {
return this.path + (index !== undefined ? `[${index}]` : '');
},
valueChanged (parameterData: IUpdateInformation) {
this.$emit('valueChanged', parameterData);
},
},
});
</script>
<style scoped lang="scss">
.duplicate-parameter-item ~.add-item-wrapper {
margin: 1.5em 0 0em 0em;
}
.add-item-wrapper {
margin: 0.5em 0 0em 2em;
}
.add-item {
width: 100%;
}
.delete-item {
display: none;
position: absolute;
left: 0.1em;
top: .3em;
z-index: 999;
color: #f56c6c;
:hover {
color: #ff0000;
}
}
.duplicate-parameter {
margin-top: 0.5em;
.parameter-name {
border-bottom: 1px solid #999;
}
}
/deep/ .duplicate-parameter-item {
position: relative;
margin-top: 0.5em;
padding-top: 0.5em;
.multi > .delete-item{
top: 0.1em;
}
}
/deep/ .duplicate-parameter-input-item {
margin: 0.5em 0 0.25em 1em;
}
/deep/ .duplicate-parameter-item + .duplicate-parameter-item {
.collection-parameter-wrapper {
border-top: 1px dashed #999;
padding-top: 0.5em;
}
}
.no-items-exist {
margin: 0 0 1em 0;
}
</style>
<style>
.duplicate-parameter-item:hover > .delete-item {
display: inline;
}
.duplicate-parameter-item .multi > .delete-item{
top: 0.1em;
}
</style>

View File

@ -0,0 +1,406 @@
<template>
<div class="node-default" :style="nodeStyle" :class="nodeClass" :ref="data.name" @dblclick="setNodeActive" @click.left="mouseLeftClick">
<div v-if="hasIssues" class="node-info-icon node-issues">
<el-tooltip placement="top" effect="light">
<div slot="content" v-html="nodeIssues"></div>
<font-awesome-icon icon="exclamation-triangle" />
</el-tooltip>
</div>
<el-badge v-else :hidden="workflowDataItems === 0" class="node-info-icon data-count" :value="workflowDataItems"></el-badge>
<div class="node-executing-info" title="Node is executing">
<font-awesome-icon icon="spinner" spin />
</div>
<div class="node-execute" v-if="!isReadOnly && !workflowRunning">
<font-awesome-icon class="execute-icon" @click.stop.left="executeNode" icon="play-circle" title="Execute Node"/>
</div>
<div class="node-options" v-if="!isReadOnly">
<div @click.stop.left="deleteNode" class="option indent" title="Delete Node" >
<font-awesome-icon icon="trash" />
</div>
<div @click.stop.left="duplicateNode" class="option" title="Duplicate Node" >
<font-awesome-icon icon="clone" />
</div>
<div @click.stop.left="disableNode" class="option indent" title="Activate/Deactivate Node" >
<font-awesome-icon :icon="nodeDisabledIcon" />
</div>
</div>
<NodeIcon class="node-icon" :nodeType="nodeType" :style="nodeIconStyle"/>
<div class="node-name" :title="data.name">
{{data.name}}
</div>
<div v-if="nodeOperation !== null" class="node-operation" :title="nodeOperation">
{{nodeOperation}}
</div>
<div class="node-edit" @click.left.stop="setNodeActive" title="Edit Node">
<font-awesome-icon icon="pen" />
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { nodeBase } from '@/components/mixins/nodeBase';
import {
INodeIssueObjectProperty,
INodePropertyOptions,
INodeTypeDescription,
ITaskData,
NodeHelpers,
} from 'n8n-workflow';
import NodeIcon from '@/components/NodeIcon.vue';
import mixins from 'vue-typed-mixins';
export default mixins(nodeBase).extend({
name: 'Node',
components: {
NodeIcon,
},
computed: {
workflowResultDataNode (): ITaskData[] | null {
return this.$store.getters.getWorkflowResultDataByNodeName(this.data.name);
},
workflowDataItems () {
if (this.workflowResultDataNode === null) {
return 0;
}
return this.workflowResultDataNode.length;
},
isExecuting (): boolean {
return this.$store.getters.executingNode === this.data.name;
},
nodeIconStyle (): object {
return {
color: this.data.disabled ? '#ccc' : this.data.color,
};
},
nodeType (): INodeTypeDescription | null {
return this.$store.getters.nodeType(this.data.type);
},
nodeClass () {
const classes = [];
if (this.data.disabled) {
classes.push('disabled');
}
if (this.nodeOperation) {
classes.push('has-operation');
}
if (this.isExecuting) {
classes.push('executing');
}
if (this.workflowDataItems !== 0) {
classes.push('has-data');
}
return classes;
},
nodeIssues (): string {
if (this.data.issues === undefined) {
return '';
}
const nodeIssues = NodeHelpers.nodeIssuesToString(this.data.issues, this.data);
return 'Issues:<br />&nbsp;&nbsp;- ' + nodeIssues.join('<br />&nbsp;&nbsp;- ');
},
nodeDisabledIcon (): string {
if (this.data.disabled === false) {
return 'pause';
} else {
return 'play';
}
},
nodeOperation (): string | null {
if (this.data.parameters.operation !== undefined) {
const operation = this.data.parameters.operation as string;
if (this.nodeType === null) {
return operation;
}
const operationData = this.nodeType.properties.find((property) => {
return property.name === 'operation';
});
if (operationData === undefined) {
return operation;
}
if (operationData.options === undefined) {
return operation;
}
const optionData = operationData.options.find((option) => {
return (option as INodePropertyOptions).value === this.data.parameters.operation;
});
if (optionData === undefined) {
return operation;
}
return optionData.name;
}
return null;
},
workflowRunning (): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
},
data () {
return {
};
},
methods: {
disableNode () {
// Toggle disabled flag
const updateInformation = {
name: this.data.name,
properties: {
disabled: !this.data.disabled,
},
};
this.$store.commit('updateNodeProperties', updateInformation);
},
executeNode () {
this.$emit('runWorkflow', this.data.name);
},
deleteNode () {
Vue.nextTick(() => {
// Wait a tick else vue causes problems because the data is gone
this.$emit('removeNode', this.data.name);
});
},
duplicateNode () {
Vue.nextTick(() => {
// Wait a tick else vue causes problems because the data is gone
this.$emit('duplicateNode', this.data.name);
});
},
setNodeActive () {
this.$store.commit('setActiveNode', this.data.name);
},
},
});
</script>
<style lang="scss">
.node-default {
position: absolute;
width: 160px;
height: 50px;
background-color: #fff;
border-radius: 25px;
text-align: center;
z-index: 24;
cursor: pointer;
color: #444;
line-height: 50px;
font-size: 0.8em;
font-weight: 600;
border: 1px dashed grey;
&.has-data {
border-style: solid;
}
&.has-operation {
line-height: 38px;
.node-info-icon {
top: -22px;
&.data-count {
top: -15px;
}
}
}
&.disabled {
color: #a0a0a0;
text-decoration: line-through;
border: 1px solid #eee !important;
background-color: #eee;
}
&.executing {
background-color: $--color-primary-light !important;
border-color: $--color-primary !important;
.node-executing-info {
display: initial;
}
}
&:hover {
.node-execute {
display: initial;
}
.node-options {
display: initial;
}
}
.node-edit {
position: absolute;
top: 0;
right: 0;
width: 50px;
height: 100%;
font-size: 1.1em;
color: #ccc;
border-radius: 0 25px 25px 0;
&:hover {
color: #00cc00;
}
.svg-inline--fa {
height: 100%;
}
}
.node-execute {
display: none;
position: absolute;
right: -25px;
width: 45px;
line-height: 50px;
font-size: 1.5em;
text-align: right;
z-index: 10;
color: #aaa;
.execute-icon:hover {
color: $--color-primary;
}
}
.node-executing-info {
display: none;
position: absolute;
right: -35px;
top: 8px;
z-index: 12;
width: 30px;
height: 30px;
line-height: 30px;
font-size: 18px;
text-align: center;
border-radius: 15px;
background-color: $--color-primary-light;
color: $--color-primary;
}
.node-icon {
position: absolute;
top: 0;
height: 30px;
margin: 10px;
}
.node-info-icon {
position: absolute;
top: -28px;
right: 18px;
z-index: 10;
&.data-count {
top: -22px;
}
}
.node-issues {
width: 25px;
height: 25px;
font-size: 20px;
color: #ff0000;
}
.node-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0 37px;
}
.node-operation {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: -23px 20px 0 20px;
font-weight: 400;
color: $--custom-font-light;
font-size: 0.9em;
}
.node-options {
display: none;
position: absolute;
left: -28px;
width: 45px;
top: -8px;
line-height: 1.8em;
font-size: 12px;
text-align: left;
z-index: 10;
color: #aaa;
.option {
width: 20px;
text-align: center;
&:hover {
color: $--color-primary;
}
&.indent {
margin-left: 7px;
}
}
}
}
</style>
<style>
.el-badge__content {
border-width: 2px;
background-color: #67c23a;
}
.jtk-connector {
z-index:4;
}
.jtk-endpoint {
z-index:5;
}
.jtk-overlay {
z-index:6;
}
.jtk-endpoint.dropHover {
border: 2px solid #ff2244;
}
.node-default.jtk-drag-selected {
/* https://www.cssmatic.com/box-shadow */
-webkit-box-shadow: 0px 0px 6px 2px rgba(50, 75, 216, 0.37);
-moz-box-shadow: 0px 0px 6px 2px rgba(50, 75, 216, 0.37);
box-shadow: 0px 0px 6px 2px rgba(50, 75, 216, 0.37);
}
.disabled .node-icon img {
-webkit-filter: contrast(40%) brightness(1.5) grayscale(100%);
filter: contrast(40%) brightness(1.5) grayscale(100%);
}
</style>

View File

@ -0,0 +1,79 @@
<template>
<div class="node-item clickable" :class="{active: active}" @click="nodeTypeSelected(nodeType)">
<NodeIcon class="node-icon" :nodeType="nodeType"/>
<div class="name">
{{nodeType.displayName}}
</div>
<div class="description">
{{nodeType.description}}
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { INodeTypeDescription } from 'n8n-workflow';
import NodeIcon from '@/components/NodeIcon.vue';
export default Vue.extend({
name: 'NodeCreateItem',
components: {
NodeIcon,
},
props: [
'active',
'filter',
'nodeType',
],
data () {
return {
};
},
methods: {
nodeTypeSelected (nodeType: INodeTypeDescription) {
this.$emit('nodeTypeSelected', nodeType.name);
},
},
});
</script>
<style scoped lang="scss">
.node-item {
position: relative;
border-bottom: 1px solid #eee;
background-color: #fff;
padding: 6px;
border-left: 3px solid #fff;
&:hover {
border-left: 3px solid #ccc;
}
}
.active {
border-left: 3px solid $--color-primary;
}
.node-icon {
display: inline-block;
position: absolute;
left: 12px;
top: calc(50% - 15px);
}
.name {
font-weight: bold;
font-size: 0.9em;
padding-left: 50px;
}
.description {
margin-top: 3px;
line-height: 1.7em;
font-size: 0.8em;
padding-left: 50px;
}
</style>

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