mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
Initial commit to release
This commit is contained in:
8
.editorconfig
Normal file
8
.editorconfig
Normal 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
11
.gitignore
vendored
Normal 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
230
LICENSE
Normal 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
33
README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# n8n - Workflow Automation Tool
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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)
|
||||||
6
docker/compose/withMongo/.env
Normal file
6
docker/compose/withMongo/.env
Normal 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
|
||||||
26
docker/compose/withMongo/README.md
Normal file
26
docker/compose/withMongo/README.md
Normal 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.
|
||||||
28
docker/compose/withMongo/docker-compose.yml
Normal file
28
docker/compose/withMongo/docker-compose.yml
Normal 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}\"}}}'"
|
||||||
17
docker/compose/withMongo/init-data.sh
Executable file
17
docker/compose/withMongo/init-data.sh
Executable 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
|
||||||
18
docker/images/n8n/Dockerfile
Normal file
18
docker/images/n8n/Dockerfile
Normal 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"
|
||||||
84
docker/images/n8n/README.md
Normal file
84
docker/images/n8n/README.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
## n8n
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
6
lerna.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"packages": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
|
"version": "independent"
|
||||||
|
}
|
||||||
14
package.json
Normal file
14
package.json
Normal 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
230
packages/cli/LICENSE
Normal 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
47
packages/cli/README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# n8n - Workflow Automation Tool
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
151
packages/cli/commands/run.ts
Normal file
151
packages/cli/commands/run.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
182
packages/cli/commands/start.ts
Normal file
182
packages/cli/commands/start.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
30
packages/cli/config/default.ts
Normal file
30
packages/cli/config/default.ts
Normal 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
58
packages/cli/index.ts
Normal 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
93
packages/cli/package.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
314
packages/cli/src/ActiveWorkflowRunner.ts
Normal file
314
packages/cli/src/ActiveWorkflowRunner.ts
Normal 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;
|
||||||
|
}
|
||||||
37
packages/cli/src/CredentialTypes.ts
Normal file
37
packages/cli/src/CredentialTypes.ts
Normal 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
69
packages/cli/src/Db.ts
Normal 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;
|
||||||
|
}
|
||||||
48
packages/cli/src/GenericHelpers.ts
Normal file
48
packages/cli/src/GenericHelpers.ts
Normal 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;
|
||||||
|
}
|
||||||
248
packages/cli/src/Interfaces.ts
Normal file
248
packages/cli/src/Interfaces.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
261
packages/cli/src/LoadNodesAndCredentials.ts
Normal file
261
packages/cli/src/LoadNodesAndCredentials.ts
Normal 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;
|
||||||
|
}
|
||||||
37
packages/cli/src/NodeTypes.ts
Normal file
37
packages/cli/src/NodeTypes.ts
Normal 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
86
packages/cli/src/Push.ts
Normal 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;
|
||||||
|
}
|
||||||
176
packages/cli/src/ResponseHelper.ts
Normal file
176
packages/cli/src/ResponseHelper.ts
Normal 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
997
packages/cli/src/Server.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
208
packages/cli/src/TestWebhooks.ts
Normal file
208
packages/cli/src/TestWebhooks.ts
Normal 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;
|
||||||
|
}
|
||||||
334
packages/cli/src/WebhookHelpers.ts
Normal file
334
packages/cli/src/WebhookHelpers.ts
Normal 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;
|
||||||
|
}
|
||||||
38
packages/cli/src/WorkflowCredentials.ts
Normal file
38
packages/cli/src/WorkflowCredentials.ts
Normal 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;
|
||||||
|
}
|
||||||
210
packages/cli/src/WorkflowExecuteAdditionalData.ts
Normal file
210
packages/cli/src/WorkflowExecuteAdditionalData.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
152
packages/cli/src/WorkflowHelpers.ts
Normal file
152
packages/cli/src/WorkflowHelpers.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/cli/src/db/index.ts
Normal file
7
packages/cli/src/db/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import * as MongoDb from './mongodb';
|
||||||
|
import * as SQLite from './sqlite';
|
||||||
|
|
||||||
|
export {
|
||||||
|
MongoDb,
|
||||||
|
SQLite,
|
||||||
|
};
|
||||||
41
packages/cli/src/db/mongodb/CredentialsEntity.ts
Normal file
41
packages/cli/src/db/mongodb/CredentialsEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
51
packages/cli/src/db/mongodb/ExecutionEntity.ts
Normal file
51
packages/cli/src/db/mongodb/ExecutionEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
48
packages/cli/src/db/mongodb/WorkflowEntity.ts
Normal file
48
packages/cli/src/db/mongodb/WorkflowEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
3
packages/cli/src/db/mongodb/index.ts
Normal file
3
packages/cli/src/db/mongodb/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './CredentialsEntity';
|
||||||
|
export * from './ExecutionEntity';
|
||||||
|
export * from './WorkflowEntity';
|
||||||
44
packages/cli/src/db/sqlite/CredentialsEntity.ts
Normal file
44
packages/cli/src/db/sqlite/CredentialsEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
53
packages/cli/src/db/sqlite/ExecutionEntity.ts
Normal file
53
packages/cli/src/db/sqlite/ExecutionEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
55
packages/cli/src/db/sqlite/WorkflowEntity.ts
Normal file
55
packages/cli/src/db/sqlite/WorkflowEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
3
packages/cli/src/db/sqlite/index.ts
Normal file
3
packages/cli/src/db/sqlite/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './CredentialsEntity';
|
||||||
|
export * from './ExecutionEntity';
|
||||||
|
export * from './WorkflowEntity';
|
||||||
29
packages/cli/src/index.ts
Normal file
29
packages/cli/src/index.ts
Normal 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,
|
||||||
|
};
|
||||||
39
packages/cli/tsconfig.json
Normal file
39
packages/cli/tsconfig.json
Normal 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
103
packages/cli/tslint.json
Normal 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
230
packages/core/LICENSE
Normal 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
13
packages/core/README.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# n8n-core
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Core components for n8n
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install n8n-core
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[Apache 2.0 with Commons Clause](LICENSE)
|
||||||
60
packages/core/package.json
Normal file
60
packages/core/package.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
172
packages/core/src/ActiveExecutions.ts
Normal file
172
packages/core/src/ActiveExecutions.ts
Normal 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;
|
||||||
|
}
|
||||||
175
packages/core/src/ActiveWebhooks.ts
Normal file
175
packages/core/src/ActiveWebhooks.ts
Normal 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;
|
||||||
|
// }
|
||||||
|
|
||||||
|
}
|
||||||
112
packages/core/src/ActiveWorkflows.ts
Normal file
112
packages/core/src/ActiveWorkflows.ts
Normal 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
7
packages/core/src/Constants.ts
Normal file
7
packages/core/src/Constants.ts
Normal 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';
|
||||||
120
packages/core/src/Credentials.ts
Normal file
120
packages/core/src/Credentials.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/core/src/DeferredPromise.ts
Normal file
14
packages/core/src/DeferredPromise.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
115
packages/core/src/Interfaces.ts
Normal file
115
packages/core/src/Interfaces.ts
Normal 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[][];
|
||||||
|
}
|
||||||
97
packages/core/src/LoadNodeParameterOptions.ts
Normal file
97
packages/core/src/LoadNodeParameterOptions.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
639
packages/core/src/NodeExecuteFunctions.ts
Normal file
639
packages/core/src/NodeExecuteFunctions.ts
Normal 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);
|
||||||
|
|
||||||
|
}
|
||||||
234
packages/core/src/UserSettings.ts
Normal file
234
packages/core/src/UserSettings.ts
Normal 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;
|
||||||
|
}
|
||||||
579
packages/core/src/WorkflowExecute.ts
Normal file
579
packages/core/src/WorkflowExecute.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/core/src/index.ts
Normal file
24
packages/core/src/index.ts
Normal 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,
|
||||||
|
};
|
||||||
88
packages/core/test/Credentials.test.ts
Normal file
88
packages/core/test/Credentials.test.ts
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
34
packages/core/tsconfig.json
Normal file
34
packages/core/tsconfig.json
Normal 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
103
packages/core/tslint.json
Normal 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": []
|
||||||
|
}
|
||||||
3
packages/editor-ui/.browserslistrc
Normal file
3
packages/editor-ui/.browserslistrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
> 1%
|
||||||
|
last 2 versions
|
||||||
|
not ie <= 8
|
||||||
8
packages/editor-ui/.editorconfig
Normal file
8
packages/editor-ui/.editorconfig
Normal 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
|
||||||
23
packages/editor-ui/.eslintrc.js
Normal file
23
packages/editor-ui/.eslintrc.js
Normal 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
24
packages/editor-ui/.gitignore
vendored
Normal 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*
|
||||||
33
packages/editor-ui/.npmignore
Normal file
33
packages/editor-ui/.npmignore
Normal 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
230
packages/editor-ui/LICENSE
Normal 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.
|
||||||
52
packages/editor-ui/README.md
Normal file
52
packages/editor-ui/README.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# n8n-editor-ui
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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)
|
||||||
10
packages/editor-ui/babel.config.js
Normal file
10
packages/editor-ui/babel.config.js
Normal 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
|
||||||
3
packages/editor-ui/cypress.json
Normal file
3
packages/editor-ui/cypress.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"pluginsFile": "tests/e2e/plugins/index.js"
|
||||||
|
}
|
||||||
25
packages/editor-ui/jest.config.js
Normal file
25
packages/editor-ui/jest.config.js
Normal 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/',
|
||||||
|
};
|
||||||
70
packages/editor-ui/package.json
Normal file
70
packages/editor-ui/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/editor-ui/postcss.config.js
Normal file
5
packages/editor-ui/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
packages/editor-ui/public/favicon-16x16.png
Normal file
BIN
packages/editor-ui/public/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 342 B |
BIN
packages/editor-ui/public/favicon-32x32.png
Normal file
BIN
packages/editor-ui/public/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 806 B |
BIN
packages/editor-ui/public/favicon.ico
Normal file
BIN
packages/editor-ui/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
17
packages/editor-ui/public/index.html
Normal file
17
packages/editor-ui/public/index.html
Normal 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>
|
||||||
BIN
packages/editor-ui/public/n8n-icon-small.png
Normal file
BIN
packages/editor-ui/public/n8n-icon-small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
47
packages/editor-ui/src/App.vue
Normal file
47
packages/editor-ui/src/App.vue
Normal 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>
|
||||||
365
packages/editor-ui/src/Interface.ts
Normal file
365
packages/editor-ui/src/Interface.ts
Normal 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;
|
||||||
|
}
|
||||||
BIN
packages/editor-ui/src/assets/logo.png
Normal file
BIN
packages/editor-ui/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
135
packages/editor-ui/src/components/BinaryDataDisplay.vue
Normal file
135
packages/editor-ui/src/components/BinaryDataDisplay.vue
Normal 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>
|
||||||
193
packages/editor-ui/src/components/CollectionParameter.vue
Normal file
193
packages/editor-ui/src/components/CollectionParameter.vue
Normal 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>
|
||||||
217
packages/editor-ui/src/components/CredentialsEdit.vue
Normal file
217
packages/editor-ui/src/components/CredentialsEdit.vue
Normal 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>
|
||||||
270
packages/editor-ui/src/components/CredentialsInput.vue
Normal file
270
packages/editor-ui/src/components/CredentialsInput.vue
Normal 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>
|
||||||
184
packages/editor-ui/src/components/CredentialsList.vue
Normal file
184
packages/editor-ui/src/components/CredentialsList.vue
Normal 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>
|
||||||
103
packages/editor-ui/src/components/DataDisplay.vue
Normal file
103
packages/editor-ui/src/components/DataDisplay.vue
Normal 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>
|
||||||
121
packages/editor-ui/src/components/DisplayWithChange.vue
Normal file
121
packages/editor-ui/src/components/DisplayWithChange.vue
Normal 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>
|
||||||
624
packages/editor-ui/src/components/ExecutionsList.vue
Normal file
624
packages/editor-ui/src/components/ExecutionsList.vue
Normal 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">
|
||||||
|
</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>
|
||||||
147
packages/editor-ui/src/components/ExpressionEdit.vue
Normal file
147
packages/editor-ui/src/components/ExpressionEdit.vue
Normal 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>
|
||||||
360
packages/editor-ui/src/components/ExpressionInput.vue
Normal file
360
packages/editor-ui/src/components/ExpressionInput.vue
Normal 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>
|
||||||
235
packages/editor-ui/src/components/FixedCollectionParameter.vue
Normal file
235
packages/editor-ui/src/components/FixedCollectionParameter.vue
Normal 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>
|
||||||
314
packages/editor-ui/src/components/MainHeader.vue
Normal file
314
packages/editor-ui/src/components/MainHeader.vue
Normal 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>
|
||||||
|
<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" />
|
||||||
|
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>
|
||||||
459
packages/editor-ui/src/components/MainSidebar.vue
Normal file
459
packages/editor-ui/src/components/MainSidebar.vue
Normal 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"/>
|
||||||
|
<span slot="title" class="item-title-root">Workflows</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-menu-item index="workflow-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="workflow-open">
|
||||||
|
<template slot="title">
|
||||||
|
<font-awesome-icon icon="folder-open"/>
|
||||||
|
<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"/>
|
||||||
|
<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"/>
|
||||||
|
<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>
|
||||||
171
packages/editor-ui/src/components/MultipleParameter.vue
Normal file
171
packages/editor-ui/src/components/MultipleParameter.vue
Normal 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>
|
||||||
406
packages/editor-ui/src/components/Node.vue
Normal file
406
packages/editor-ui/src/components/Node.vue
Normal 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 /> - ' + nodeIssues.join('<br /> - ');
|
||||||
|
},
|
||||||
|
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>
|
||||||
79
packages/editor-ui/src/components/NodeCreateItem.vue
Normal file
79
packages/editor-ui/src/components/NodeCreateItem.vue
Normal 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
Reference in New Issue
Block a user