Add new version notification (#1977)

* add menu item

* implement versions modal

* fix up modal

* clean up badges

* implement key features

* fix up spacing

* add error message

* add notification icon

* fix notification

* fix bug when no updates

* address lint issues

* address multi line nodes

* add closing animation

* keep drawer open

* address design feedback

* address badge styling

* use grid for icons

* update cli to return version information

* set env variables

* add scss color variables

* address comments

* fix lint issue

* handle edge cases

* update scss variables, spacing

* update spacing

* build

* override top value for theme

* bolden version

* update config

* check endpoint exists

* handle long names

* set dates

* set title

* fix bug

* update warning

* remove unused component

* refactor components

* add fragments

* inherit styles

* fix icon size

* fix lint issues

* add cli dep

* address comments

* handle network error

* address empty case

* Revert "address comments"

480f969e07

* remove dependency

* build

* update variable names

* update variable names

* refactor verion card

* split out variables

* clean up impl

* clean up scss

* move from nodeview

* refactor out gift notification icon

* fix lint issues

* clean up variables

* update scss variables

* update info url

* Add instanceId to frontendSettings

* Use createHash from crypto module

* Add instanceId to store & send it as http header

* Fix lintings

* Fix interfaces & apply review changes

* Apply review changes

* add console message

* update text info

* update endpoint

* clean up interface

* address comments

* cleanup todo

* Update packages/cli/config/index.ts

Co-authored-by: Ben Hesseldieck <1849459+BHesseldieck@users.noreply.github.com>

* update console message

*  Display node-name on hover

*  Formatting fix

Co-authored-by: MedAliMarz <servfrdali@yahoo.fr>
Co-authored-by: Ben Hesseldieck <1849459+BHesseldieck@users.noreply.github.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
Mutasem Aldmour 2021-07-22 10:22:17 +02:00 committed by GitHub
parent 073e5e24cb
commit 98ec23544b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 793 additions and 25 deletions

View file

@ -142,6 +142,9 @@ export class Start extends Command {
LoggerProxy.init(logger);
logger.info('Initializing n8n process');
// todo remove a few versions after release
logger.info('\nn8n now checks for new versions and security updates. You can turn this off using the environment variable N8N_VERSION_NOTIFICATIONS_ENABLED to "false"\nFor more information, please refer to https://docs.n8n.io/getting-started/installation/advanced/configuration.html\n');
// Start directly with the init of the database to improve startup time
const startDbInitPromise = Db.init().catch((error: Error) => {
logger.error(`There was an error initializing DB: "${error.message}"`);

View file

@ -618,6 +618,27 @@ const config = convict({
},
},
versionNotifications: {
enabled: {
doc: 'Whether feature is enabled to request notifications about new versions and security updates.',
format: Boolean,
default: true,
env: 'N8N_VERSION_NOTIFICATIONS_ENABLED',
},
endpoint: {
doc: 'Endpoint to retrieve version information from.',
format: String,
default: 'https://api.n8n.io/versions/',
env: 'N8N_VERSION_NOTIFICATIONS_ENDPOINT',
},
infoUrl: {
doc: `Url in New Versions Panel with more information on updating one's instance.`,
format: String,
default: 'https://docs.n8n.io/getting-started/installation/updating.html',
env: 'N8N_VERSION_NOTIFICATIONS_INFO_URL',
},
},
});
// Overwrite default configuration with settings which got defined in

View file

@ -312,6 +312,11 @@ export interface IN8nConfigNodes {
exclude: string[];
}
export interface IVersionNotificationSettings {
enabled: boolean;
endpoint: string;
infoUrl: string;
}
export interface IN8nUISettings {
endpointWebhook: string;
@ -331,6 +336,8 @@ export interface IN8nUISettings {
n8nMetadata?: {
[key: string]: string | number | undefined;
};
versionNotifications: IVersionNotificationSettings;
instanceId: string;
}
export interface IPackageVersions {

View file

@ -21,7 +21,7 @@ import * as clientOAuth1 from 'oauth-1.0a';
import { RequestOptions } from 'oauth-1.0a';
import * as csrf from 'csrf';
import * as requestPromise from 'request-promise-native';
import { createHmac } from 'crypto';
import { createHash, createHmac } from 'crypto';
// IMPORTANT! Do not switch to anther bcrypt library unless really necessary and
// tested with all possible systems like Windows, Alpine on ARM, FreeBSD, ...
import { compare } from 'bcryptjs';
@ -196,6 +196,12 @@ class App {
'oauth1': urlBaseWebhook + `${this.restEndpoint}/oauth1-credential/callback`,
'oauth2': urlBaseWebhook + `${this.restEndpoint}/oauth2-credential/callback`,
},
versionNotifications: {
enabled: config.get('versionNotifications.enabled'),
endpoint: config.get('versionNotifications.endpoint'),
infoUrl: config.get('versionNotifications.infoUrl'),
},
instanceId: '',
};
}
@ -225,6 +231,7 @@ class App {
this.versions = await GenericHelpers.getVersions();
this.frontendSettings.versionCli = this.versions.cli;
this.frontendSettings.instanceId = await generateInstanceId() as string;
await this.externalHooks.run('frontend.settings', [this.frontendSettings]);
@ -2210,3 +2217,10 @@ async function getExecutionsCount(countFilter: IDataObject): Promise<{ count: nu
const count = await Db.collections.Execution!.count(countFilter);
return { count, estimate: false };
}
async function generateInstanceId() {
const encryptionKey = await UserSettings.getEncryptionKey();
const hash = encryptionKey ? createHash('sha256').update(encryptionKey.slice(Math.round(encryptionKey.length / 2))).digest('hex') : undefined;
return hash;
}

View file

@ -25,7 +25,9 @@
"test:unit": "vue-cli-service test:unit"
},
"dependencies": {
"v-click-outside": "^3.1.2"
"timeago.js": "^4.0.2",
"v-click-outside": "^3.1.2",
"vue-fragment": "^1.5.2"
},
"devDependencies": {
"@beyonk/google-fonts-webpack-plugin": "^1.5.0",

View file

@ -445,6 +445,12 @@ export interface IPushDataConsoleMessage {
message: string;
}
export interface IVersionNotificationSettings {
enabled: boolean;
endpoint: string;
infoUrl: string;
}
export interface IN8nUISettings {
endpointWebhook: string;
endpointWebhookTest: string;
@ -463,6 +469,8 @@ export interface IN8nUISettings {
n8nMetadata?: {
[key: string]: string | number | undefined;
};
versionNotifications: IVersionNotificationSettings;
instanceId: string;
}
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
@ -547,6 +555,29 @@ export interface ITagRow {
delete?: boolean;
}
export interface IVersion {
name: string;
nodes: IVersionNode[];
createdAt: string;
description: string;
documentationUrl: string;
hasBreakingChange: boolean;
hasSecurityFix: boolean;
hasSecurityIssue: boolean;
securityIssueFixVersion: string;
}
export interface IVersionNode {
name: string;
displayName: string;
icon: string;
defaults: INodeParameters;
iconData: {
type: string;
icon?: string;
fileBuffer?: string;
};
}
export interface IRootState {
activeExecutions: IExecutionsCurrentSummaryExtended[];
activeWorkflows: string[];
@ -583,6 +614,7 @@ export interface IRootState {
urlBaseWebhook: string;
workflow: IWorkflowDb;
sidebarMenuItems: IMenuItem[];
instanceId: string;
}
export interface ITagsState {
@ -605,6 +637,12 @@ export interface IUiState {
isPageLoading: boolean;
}
export interface IVersionsState {
versionNotificationSettings: IVersionNotificationSettings;
nextVersions: IVersion[];
currentVersion: IVersion | undefined;
}
export interface IWorkflowsState {
}

View file

@ -6,7 +6,6 @@ import {
IRestApiContext,
} from '../Interface';
class ResponseError extends Error {
// The HTTP status code of response
httpStatusCode?: number;
@ -91,6 +90,6 @@ export async function makeRestApiRequest(context: IRestApiContext, method: Metho
return response.data;
}
export async function get(baseURL: string, endpoint: string, params?: IDataObject) {
return await request({method: 'GET', baseURL, endpoint, data: params});
export async function get(baseURL: string, endpoint: string, params?: IDataObject, headers?: IDataObject) {
return await request({method: 'GET', baseURL, endpoint, headers, data: params});
}

View file

@ -0,0 +1,9 @@
import { IVersion } from '@/Interface';
import { INSTANCE_ID_HEADER } from '@/constants';
import { IDataObject } from 'n8n-workflow';
import { get } from './helpers';
export async function getNextVersions(endpoint: string, version: string, instanceId: string): Promise<IVersion[]> {
const headers = {[INSTANCE_ID_HEADER as string] : instanceId};
return await get(endpoint, version, {}, headers);
}

View file

@ -0,0 +1,51 @@
<template functional>
<fragment>
<el-tag
v-if="props.type === 'danger'"
type="danger"
size="small"
:class="$style['danger']"
>
{{ props.text }}
</el-tag>
<el-tag
v-else-if="props.type === 'warning'"
size="small"
:class="$style['warning']"
>
{{ props.text }}
</el-tag>
</fragment>
</template>
<script lang="ts">
export default {
props: ["text", "type"],
};
</script>
<style lang="scss" module>
.badge {
font-size: 11px;
line-height: 18px;
max-height: 18px;
font-weight: 400;
display: flex;
align-items: center;
padding: 2px 4px;
}
.danger {
composes: badge;
color: $--badge-danger-color;
background-color: $--badge-danger-background-color;
border-color: $--badge-danger-border-color;
}
.warning {
composes: badge;
background-color: $--badge-warning-background-color;
color: $--badge-warning-color;
border: none;
}
</style>

View file

@ -0,0 +1,36 @@
<template>
<div :class="$style['gift-icon']">
<font-awesome-icon icon="gift" />
<div :class="$style['notification']">
<div></div>
</div>
</div>
</template>
<style lang="scss" module>
.gift-icon {
display: flex;
position: relative;
.notification {
height: .47em;
width: .47em;
border-radius: 50%;
color: $--gift-notification-active-color;
position: absolute;
background-color: $--gift-notification-outer-color;
right: -.3em;
display: flex;
align-items: center;
justify-content: center;
top: -.148em;
div {
height: .36em;
width: .36em;
background-color: $--gift-notification-inner-color;
border-radius: 50%;
}
}
}
</style>

View file

@ -127,6 +127,14 @@
<MenuItemsIterator :items="sidebarMenuBottomItems" :root="true"/>
<div class="footer-menu-items">
<el-menu-item index="updates" class="updates" v-if="hasVersionUpdates" @click="openUpdatesPanel">
<div class="gift-container">
<GiftNotificationIcon />
</div>
<span slot="title" class="item-title-root">{{nextVersions.length > 99 ? '99+' : nextVersions.length}} update{{nextVersions.length > 1 ? 's' : ''}} available</span>
</el-menu-item>
</div>
</el-menu>
</div>
@ -149,6 +157,7 @@ import About from '@/components/About.vue';
import CredentialsEdit from '@/components/CredentialsEdit.vue';
import CredentialsList from '@/components/CredentialsList.vue';
import ExecutionsList from '@/components/ExecutionsList.vue';
import GiftNotificationIcon from './GiftNotificationIcon.vue';
import WorkflowSettings from '@/components/WorkflowSettings.vue';
import { genericHelpers } from '@/components/mixins/genericHelpers';
@ -212,6 +221,7 @@ export default mixins(
CredentialsEdit,
CredentialsList,
ExecutionsList,
GiftNotificationIcon,
WorkflowSettings,
MenuItemsIterator,
},
@ -232,6 +242,10 @@ export default mixins(
...mapGetters('ui', {
isCollapsed: 'sidebarMenuCollapsed',
}),
...mapGetters('versions', [
'hasVersionUpdates',
'nextVersions',
]),
exeuctionId (): string | undefined {
return this.$route.params.id;
},
@ -311,6 +325,9 @@ export default mixins(
openTagManager() {
this.$store.dispatch('ui/openTagsManagerModal');
},
openUpdatesPanel() {
this.$store.dispatch('ui/openUpdatesPanel');
},
async stopExecution () {
const executionId = this.$store.getters.activeExecutionId;
if (executionId === null) {
@ -574,6 +591,39 @@ a.logo {
&.expanded {
width: $--sidebar-expanded-width;
}
ul {
display: flex;
flex-direction: column;
}
}
.footer-menu-items {
display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: flex-end;
padding-bottom: 32px;
}
.el-menu-item.updates {
color: $--sidebar-inactive-color;
.item-title-root {
font-size: 13px;
top: 0 !important;
}
&:hover {
color: $--sidebar-active-color;
}
.gift-container {
display: flex;
justify-content: flex-start;
align-items: center;
height: 100%;
width: 100%;
}
}
</style>

View file

@ -1,6 +1,21 @@
<template>
<div v-if="dialogVisible">
<div>
<el-drawer
v-if="drawer"
:direction="drawerDirection"
:visible="visible && visibleDrawer"
:size="drawerWidth"
:before-close="closeDrawer"
>
<template v-slot:title>
<slot name="header" />
</template>
<template>
<slot name="content"/>
</template>
</el-drawer>
<el-dialog
v-else
:visible="dialogVisible"
:before-close="closeDialog"
:title="title"
@ -32,7 +47,12 @@ const sizeMap: {[size: string]: string} = {
export default Vue.extend({
name: "Modal",
props: ['name', 'title', 'eventBus', 'size'],
props: ['name', 'title', 'eventBus', 'size', 'drawer', 'drawerDirection', 'drawerWidth', 'visible'],
data() {
return {
visibleDrawer: this.drawer,
};
},
mounted() {
window.addEventListener('keydown', this.onWindowKeydown);
@ -65,8 +85,18 @@ export default Vue.extend({
this.$emit('enter');
}
},
closeDialog() {
closeDialog(callback?: () => void) {
this.$store.commit('ui/closeTopModal');
if (callback) {
callback();
}
},
closeDrawer() {
this.visibleDrawer = false;
setTimeout(() =>{
this.$store.commit('ui/closeTopModal');
this.visibleDrawer = true;
}, 300); // delayed for closing animation to take effect
},
},
computed: {
@ -84,6 +114,15 @@ export default Vue.extend({
</script>
<style lang="scss">
.el-drawer__header {
margin: 0;
padding: 30px 30px 0 30px;
}
.el-drawer__body {
overflow: hidden;
}
.dialog-wrapper {
* {
box-sizing: border-box;

View file

@ -1,8 +1,8 @@
<template>
<div
v-if="isOpen(name)"
v-if="isOpen(name) || keepAlive"
>
<slot :modalName="name" :active="isActive(name)"></slot>
<slot :modalName="name" :active="isActive(name)" :open="isOpen(name)"></slot>
</div>
</template>
@ -11,7 +11,7 @@ import Vue from "vue";
export default Vue.extend({
name: "ModalRoot",
props: ["name"],
props: ["name", "keepAlive"],
methods: {
isActive(name: string) {
return this.$store.getters['ui/isModalActive'](name);

View file

@ -24,17 +24,26 @@
/>
</template>
</ModalRoot>
<ModalRoot :name="VERSIONS_MODAL_KEY" :keepAlive="true">
<template v-slot="{ modalName, open }">
<UpdatesPanel
:modalName="modalName"
:visible="open"
/>
</template>
</ModalRoot>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY } from '@/constants';
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY, VERSIONS_MODAL_KEY } from '@/constants';
import TagsManager from "@/components/TagsManager/TagsManager.vue";
import DuplicateWorkflowDialog from "@/components/DuplicateWorkflowDialog.vue";
import WorkflowOpen from "@/components/WorkflowOpen.vue";
import ModalRoot from "./ModalRoot.vue";
import UpdatesPanel from "./UpdatesPanel.vue";
export default Vue.extend({
name: "Modals",
@ -43,11 +52,13 @@ export default Vue.extend({
DuplicateWorkflowDialog,
WorkflowOpen,
ModalRoot,
UpdatesPanel,
},
data: () => ({
DUPLICATE_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
WORKLOW_OPEN_MODAL_KEY,
VERSIONS_MODAL_KEY,
}),
});
</script>

View file

@ -30,7 +30,7 @@
</div>
</div>
<NodeIcon class="node-icon" :nodeType="nodeType" size="60" :style="nodeIconStyle" :shrink="true"/>
<NodeIcon class="node-icon" :nodeType="nodeType" size="60" :shrink="true" :disabled="this.data.disabled"/>
</div>
<div class="node-description">
<div class="node-name" :title="data.name">
@ -77,11 +77,6 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
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);
},

View file

@ -1,6 +1,6 @@
<template functional>
<div :class="{[$style['node-item']]: true, [$style.bordered]: props.bordered}">
<NodeIcon :class="$style['node-icon']" :nodeType="props.nodeType" :style="{color: props.nodeType.defaults.color}" />
<NodeIcon :class="$style['node-icon']" :nodeType="props.nodeType" />
<div>
<div :class="$style.details">
<span :class="$style.name">{{props.nodeType.displayName}}</span>

View file

@ -1,8 +1,8 @@
<template>
<div class="node-icon-wrapper" :style="iconStyleData" :class="{shrink: isSvgIcon && shrink, full: !shrink}">
<div v-if="nodeIconData !== null" class="icon">
<img :src="nodeIconData.path" style="max-width: 100%; max-height: 100%;" v-if="nodeIconData.type === 'file'"/>
<font-awesome-icon :icon="nodeIconData.path" v-else-if="nodeIconData.type === 'fa'" />
<img v-if="nodeIconData.type === 'file'" :src="nodeIconData.fileBuffer || nodeIconData.path" style="max-width: 100%; max-height: 100%;" />
<font-awesome-icon v-else :icon="nodeIconData.icon || nodeIconData.path" />
</div>
<div v-else class="node-icon-placeholder">
{{nodeType !== null ? nodeType.displayName.charAt(0) : '?' }}
@ -26,16 +26,19 @@ export default Vue.extend({
'nodeType',
'size',
'shrink',
'disabled',
],
computed: {
iconStyleData (): object {
const color = this.disabled ? '#ccc' : this.nodeType.defaults && this.nodeType.defaults.color;
if (!this.size) {
return {};
return {color};
}
const size = parseInt(this.size, 10);
return {
color,
width: size + 'px',
height: size + 'px',
'font-size': Math.floor(parseInt(this.size, 10) * 0.6) + 'px',
@ -54,6 +57,10 @@ export default Vue.extend({
return null;
}
if (this.nodeType.iconData) {
return this.nodeType.iconData;
}
const restUrl = this.$store.getters.getRestUrl;
if (this.nodeType.icon) {
@ -94,6 +101,10 @@ export default Vue.extend({
&.full .icon {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
&.shrink .icon {

View file

@ -0,0 +1,15 @@
<template functional>
<span>
{{$options.format(props.date)}}
</span>
</template>
<script lang="ts">
import { format } from 'timeago.js';
export default {
name: 'UpdatesPanel',
props: ['date'],
format,
};
</script>

View file

@ -0,0 +1,122 @@
<template>
<Modal
:name="modalName"
:drawer="true"
:visible="visible"
drawerDirection="ltr"
drawerWidth="520px"
>
<template slot="header">
<span :class="$style.title">Weve been busy </span>
</template>
<template slot="content">
<section :class="$style['description']">
<p v-if="currentVersion">
Youre on {{ currentVersion.name }}, which was released
<strong><TimeAgo :date="currentVersion.createdAt" /></strong> and is
<strong>{{ nextVersions.length }} version{{nextVersions.length > 1 ? "s" : ""}}</strong>
behind the latest and greatest n8n
</p>
<a
:class="$style['info-url']"
:href="infoUrl"
v-if="infoUrl"
target="_blank"
>
<font-awesome-icon icon="info-circle"></font-awesome-icon>
<span>How to update your n8n version</span>
</a>
</section>
<section :class="$style.versions">
<div
v-for="version in nextVersions"
:key="version.name"
:class="$style['versions-card']"
>
<VersionCard :version="version" />
</div>
</section>
</template>
</Modal>
</template>
<script lang="ts">
import Vue from 'vue';
import { mapGetters } from 'vuex';
import Modal from './Modal.vue';
import TimeAgo from './TimeAgo.vue';
import VersionCard from './VersionCard.vue';
export default Vue.extend({
name: 'UpdatesPanel',
components: {
Modal,
VersionCard,
TimeAgo,
},
props: ['modalName', 'visible'],
computed: {
...mapGetters('versions', ['nextVersions', 'currentVersion', 'infoUrl']),
},
});
</script>
<style module lang="scss">
.title {
margin: 0;
font-size: 24px;
line-height: 24px;
color: $--updates-panel-text-color;
font-weight: 400;
}
.description {
padding: 0px 30px;
margin-block-start: 16px;
margin-block-end: 30px;
p {
font-size: 16px;
line-height: 22px;
color: $--updates-panel-description-text-color;
font-weight: 400;
margin: 0 0 16px 0;
}
div {
padding-top: 20px;
}
}
.versions {
background-color: $--updates-panel-dark-background-color;
border-top: $--updates-panel-border;
height: 100%;
padding: 30px;
overflow-y: scroll;
padding-bottom: 220px;
}
.versions-card {
margin-block-end: 15px;
}
.info-url {
text-decoration: none;
font-size: 14px;
svg {
color: $--updates-panel-info-icon-color;
margin-right: 5px;
}
span {
color: $--updates-panel-info-url-color;
font-weight: 600;
}
}
</style>

View file

@ -0,0 +1,132 @@
<template functional>
<a v-if="props.version" :set="version = props.version" :href="version.documentationUrl" target="_blank" :class="$style.card">
<div :class="$style.header">
<div>
<div :class="$style.name">
Version {{version.name}}
</div>
<WarningTooltip v-if="version.hasSecurityIssue">
<template>
This version has a security issue.<br/>It is listed here for completeness.
</template>
</WarningTooltip>
<Badge
v-if="version.hasSecurityFix"
text="Security update"
type="danger"
/>
<Badge
v-if="version.hasBreakingChange"
text="Breaking changes"
type="warning"
/>
</div>
<div :class="$style['release-date']">
Released&nbsp;<TimeAgo :date="version.createdAt" />
</div>
</div>
<div :class="$style.divider" v-if="version.description || (version.nodes && version.nodes.length)"></div>
<div>
<div v-if="version.description" v-html="version.description" :class="$style.description"></div>
<div v-if="version.nodes && version.nodes.length > 0" :class="$style.nodes">
<NodeIcon
v-for="node in version.nodes"
:key="node.name"
:nodeType="node"
:title="$options.nodeName(node)"
/>
</div>
</div>
</a>
</template>
<script lang="ts">
import Vue from 'vue';
import NodeIcon from './NodeIcon.vue';
import TimeAgo from './TimeAgo.vue';
import Badge from './Badge.vue';
import WarningTooltip from './WarningTooltip.vue';
import { IVersionNode } from '@/Interface';
Vue.component('NodeIcon', NodeIcon);
Vue.component('TimeAgo', TimeAgo);
Vue.component('Badge', Badge);
Vue.component('WarningTooltip', WarningTooltip);
export default Vue.extend({
components: { NodeIcon, TimeAgo, Badge, WarningTooltip },
name: 'UpdatesPanel',
props: ['version'],
// @ts-ignore
nodeName (node: IVersionNode): string {
return node !== null ? node.displayName : 'unknown';
},
});
</script>
<style module lang="scss">
.card {
background-color: $--version-card-background-color;
border: $--version-card-border;
border-radius: 8px;
display: block;
padding: 16px;
text-decoration: none;
&:hover {
box-shadow: 0px 2px 10px $--version-card-box-shadow-color;
}
}
.header {
display: flex;
flex-wrap: wrap;
> * {
display: flex;
margin-bottom: 5px;
}
> div:first-child {
flex-grow: 1;
> * {
margin-right: 5px;
}
}
}
.name {
font-weight: 600;
font-size: 16px;
line-height: 18px;
color: $--version-card-name-text-color;
}
.divider {
border-bottom: $--version-card-border;
width: 100%;
margin: 10px 0 15px 0;
}
.description {
font-size: 14px;
font-weight: 400;
line-height: 19px;
color: $--version-card-description-text-color;
}
.release-date {
font-size: 12px;
line-height: 18px;
font-weight: 400;
color: $--version-card-release-date-text-color;
}
.nodes {
display: grid;
grid-template-columns: repeat(10, 1fr);
grid-row-gap: 12px;
margin-block-start: 24px;
}
</style>

View file

@ -0,0 +1,15 @@
<template functional>
<el-tooltip effect="light" content=" " placement="top" >
<div slot="content"><slot /></div>
<font-awesome-icon :class="$style['icon']" icon="exclamation-triangle"></font-awesome-icon>
</el-tooltip>
</template>
<style lang="scss" module>
.icon {
font-size: 14px;
height: 18px;
color: $--warning-tooltip-color;
}
</style>

View file

@ -0,0 +1,40 @@
import mixins from 'vue-typed-mixins';
import { showMessage } from './showMessage';
import {
IVersion,
} from '../../Interface';
export const newVersions = mixins(
showMessage,
).extend({
methods: {
async checkForNewVersions() {
const enabled = this.$store.getters['versions/areNotificationsEnabled'];
if (!enabled) {
return;
}
await this.$store.dispatch('versions/fetchVersions');
const currentVersion: IVersion | undefined = this.$store.getters['versions/currentVersion'];
const nextVersions: IVersion[] = this.$store.getters['versions/nextVersions'];
if (currentVersion && currentVersion.hasSecurityIssue && nextVersions.length) {
const fixVersion = currentVersion.securityIssueFixVersion;
let message = `Please update to latest version.`;
if (fixVersion) {
message = `Please update to version ${fixVersion} or higher.`;
}
message = `${message} <a class="primary-color">More info</a>`;
this.$showWarning('Critical update available', message, {
onClick: () => {
this.$store.dispatch('ui/openUpdatesPanel');
},
closeOnClick: true,
customClass: 'clickable',
duration: 0,
});
}
},
},
});

View file

@ -1,5 +1,5 @@
import { Notification } from 'element-ui';
import { ElNotificationOptions } from 'element-ui/types/notification';
import { ElNotificationComponent, ElNotificationOptions } from 'element-ui/types/notification';
import mixins from 'vue-typed-mixins';
import { externalHooks } from '@/components/mixins/externalHooks';
@ -16,6 +16,30 @@ export const showMessage = mixins(externalHooks).extend({
return Notification(messageData);
},
$showWarning(title: string, message: string, config?: {onClick?: () => void, duration?: number, customClass?: string, closeOnClick?: boolean}) {
let notification: ElNotificationComponent;
if (config && config.closeOnClick) {
const cb = config.onClick;
config.onClick = () => {
if (notification) {
notification.close();
}
if (cb) {
cb();
}
};
}
notification = this.$showMessage({
title,
message,
type: 'warning',
...(config || {}),
});
return notification;
},
$getExecutionError(error?: ExecutionError) {
// There was a problem with executing the workflow
let errorMessage = 'There was a problem executing the workflow!';

View file

@ -14,6 +14,7 @@ export const MAX_TAG_NAME_LENGTH = 24;
export const DUPLICATE_MODAL_KEY = 'duplicate';
export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
export const WORKLOW_OPEN_MODAL_KEY = 'workflowOpen';
export const VERSIONS_MODAL_KEY = 'versions';
// breakpoints
export const BREAKPOINT_SM = 768;
@ -48,3 +49,6 @@ export const HIDDEN_NODES = ['n8n-nodes-base.start'];
export const WEBHOOK_NODE_NAME = 'n8n-nodes-base.webhook';
export const HTTP_REQUEST_NODE_NAME = 'n8n-nodes-base.httpRequest';
export const REQUEST_NODE_FORM_URL = 'https://n8n-community.typeform.com/to/K1fBVTZ3';
// General
export const INSTANCE_ID_HEADER = 'n8n-instance-id';

View file

@ -21,6 +21,7 @@ import { runExternalHook } from './components/mixins/externalHooks';
// @ts-ignore
import vClickOutside from 'v-click-outside';
import Fragment from 'vue-fragment';
import { library } from '@fortawesome/fontawesome-svg-core';
import {
@ -62,6 +63,7 @@ import {
faFileImport,
faFilePdf,
faFolderOpen,
faGift,
faHdd,
faHome,
faHourglass,
@ -151,6 +153,7 @@ library.add(faFileExport);
library.add(faFileImport);
library.add(faFilePdf);
library.add(faFolderOpen);
library.add(faGift);
library.add(faHdd);
library.add(faHome);
library.add(faHourglass);
@ -194,6 +197,7 @@ library.add(faUsers);
library.add(faClock);
Vue.component('font-awesome-icon', FontAwesomeIcon);
Vue.use(Fragment.Plugin);
Vue.config.productionTip = false;
router.afterEach((to, from) => {

View file

@ -1,4 +1,4 @@
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY } from '@/constants';
import { DUPLICATE_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, VERSIONS_MODAL_KEY, WORKLOW_OPEN_MODAL_KEY } from '@/constants';
import Vue from 'vue';
import { ActionContext, Module } from 'vuex';
import {
@ -19,12 +19,18 @@ const module: Module<IUiState, IRootState> = {
[WORKLOW_OPEN_MODAL_KEY]: {
open: false,
},
[VERSIONS_MODAL_KEY]: {
open: false,
},
},
modalStack: [],
sidebarMenuCollapsed: true,
isPageLoading: true,
},
getters: {
isVersionsOpen: (state: IUiState) => {
return state.modals[VERSIONS_MODAL_KEY].open;
},
isModalOpen: (state: IUiState) => {
return (name: string) => state.modals[name].open;
},
@ -58,6 +64,9 @@ const module: Module<IUiState, IRootState> = {
openDuplicateModal: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', DUPLICATE_MODAL_KEY);
},
openUpdatesPanel: async (context: ActionContext<IUiState, IRootState>) => {
context.commit('openModal', VERSIONS_MODAL_KEY);
},
},
};

View file

@ -0,0 +1,62 @@
import { getNextVersions } from '@/api/versions';
import { ActionContext, Module } from 'vuex';
import {
IRootState,
IVersion,
IVersionsState,
} from '../Interface';
const module: Module<IVersionsState, IRootState> = {
namespaced: true,
state: {
versionNotificationSettings: {
enabled: false,
endpoint: '',
infoUrl: '',
},
nextVersions: [],
currentVersion: undefined,
},
getters: {
hasVersionUpdates(state: IVersionsState) {
return state.nextVersions.length > 0;
},
nextVersions(state: IVersionsState) {
return state.nextVersions;
},
currentVersion(state: IVersionsState) {
return state.currentVersion;
},
areNotificationsEnabled(state: IVersionsState) {
return state.versionNotificationSettings.enabled;
},
infoUrl(state: IVersionsState) {
return state.versionNotificationSettings.infoUrl;
},
},
mutations: {
setVersions(state: IVersionsState, {versions, currentVersion}: {versions: IVersion[], currentVersion: string}) {
state.nextVersions = versions.filter((version) => version.name !== currentVersion);
state.currentVersion = versions.find((version) => version.name === currentVersion);
},
setVersionNotificationSettings(state: IVersionsState, settings: {enabled: true, endpoint: string, infoUrl: string}) {
state.versionNotificationSettings = settings;
},
},
actions: {
async fetchVersions(context: ActionContext<IVersionsState, IRootState>) {
try {
const { enabled, endpoint } = context.state.versionNotificationSettings;
if (enabled && endpoint) {
const currentVersion = context.rootState.versionCli;
const instanceId = context.rootState.instanceId;
const versions = await getNextVersions(endpoint, currentVersion, instanceId);
context.commit('setVersions', {versions, currentVersion});
}
} catch (e) {
}
},
},
};
export default module;

View file

@ -28,6 +28,16 @@ $--custom-success-text : #40c351;
$--custom-warning-background : #ffffe5;
$--custom-warning-text : #eb9422;
// Badge
$--badge-danger-color: #f45959;
$--badge-danger-background-color: #fef0f0;
$--badge-danger-border-color: #fde2e2;
$--badge-warning-background-color: rgba(255, 229, 100, 0.3);
$--badge-warning-color: #6b5900;
// Warning tooltip
$--warning-tooltip-color: #ff8080;
$--custom-node-view-background : #faf9fe;
// Table
@ -44,8 +54,18 @@ $--custom-input-border-shadow: 1px solid $--custom-input-border-color;
$--header-height: 65px;
// sidebar
$--sidebar-width: 65px;
$--sidebar-expanded-width: 200px;
$--sidebar-inactive-color: #909399;
$--sidebar-active-color: $--color-primary;
// gifts notification
$--gift-notification-active-color: $--color-primary;
$--gift-notification-inner-color: $--color-primary;
$--gift-notification-outer-color: #fff;
// tags manager
$--tags-manager-min-height: 300px;
// based on element.io breakpoints
@ -83,3 +103,23 @@ $--node-creator-description-color: #7d7d87;
// trigger icon
$--trigger-icon-border-color: #dcdfe6;
$--trigger-icon-background-color: #fff;
// drawer
$--drawer-background-color: #fff;
// updates-panel
$--updates-panel-info-icon-color: #909399;
$--updates-panel-info-url-color: $--color-primary;
$--updates-panel-border: 1px #dbdfe7 solid;
$--updates-panel-dark-background-color: #f8f9fb;
$--updates-panel-description-text-color: #7d7d87;
$--updates-panel-text-color: #555;
// versions card
$--version-card-name-text-color: #666;
$--version-card-background-color: #fff;
$--version-card-border: 1px #dbdfe7 solid;
$--version-card-description-text-color: #7d7d87;
$--version-card-release-date-text-color: #909399;
$--version-card-box-shadow-color: rgba(109, 48, 40, 0.07);

View file

@ -36,6 +36,7 @@ import {
import tags from './modules/tags';
import ui from './modules/ui';
import workflows from './modules/workflows';
import versions from './modules/versions';
Vue.use(Vuex);
@ -86,12 +87,14 @@ const state: IRootState = {
tags: [],
},
sidebarMenuItems: [],
instanceId: '',
};
const modules = {
tags,
ui,
workflows,
versions,
};
export const store = new Vuex.Store({
@ -543,9 +546,12 @@ export const store = new Vuex.Store({
setMaxExecutionTimeout (state, maxExecutionTimeout: number) {
Vue.set(state, 'maxExecutionTimeout', maxExecutionTimeout);
},
setVersionCli (state, version: string) {
setVersionCli(state, version: string) {
Vue.set(state, 'versionCli', version);
},
setInstanceId(state, instanceId: string) {
Vue.set(state, 'instanceId', instanceId);
},
setOauthCallbackUrls(state, urls: IDataObject) {
Vue.set(state, 'oauthCallbackUrls', urls);
},

View file

@ -125,6 +125,7 @@ import { moveNodeWorkflow } from '@/components/mixins/moveNodeWorkflow';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '@/components/mixins/showMessage';
import { titleChange } from '@/components/mixins/titleChange';
import { newVersions } from '@/components/mixins/newVersions';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { workflowRun } from '@/components/mixins/workflowRun';
@ -198,6 +199,7 @@ export default mixins(
titleChange,
workflowHelpers,
workflowRun,
newVersions,
)
.extend({
name: 'NodeView',
@ -2200,8 +2202,10 @@ export default mixins(
this.$store.commit('setExecutionTimeout', settings.executionTimeout);
this.$store.commit('setMaxExecutionTimeout', settings.maxExecutionTimeout);
this.$store.commit('setVersionCli', settings.versionCli);
this.$store.commit('setInstanceId', settings.instanceId);
this.$store.commit('setOauthCallbackUrls', settings.oauthCallbackUrls);
this.$store.commit('setN8nMetadata', settings.n8nMetadata || {});
this.$store.commit('versions/setVersionNotificationSettings', settings.versionNotifications);
},
async loadNodeTypes (): Promise<void> {
const nodeTypes = await this.restApi().getNodeTypes();
@ -2228,6 +2232,7 @@ export default mixins(
},
},
async mounted () {
this.$root.$on('importWorkflowData', async (data: IDataObject) => {
const resData = await this.importWorkflowData(data.data as IWorkflowDataUpdate);
@ -2267,6 +2272,10 @@ export default mixins(
this.$showError(error, 'Init Problem', 'There was a problem initializing the workflow:');
}
this.stopLoading();
setTimeout(() => {
this.checkForNewVersions();
}, 0);
});
this.$externalHooks().run('nodeView.mount');