feat(editor): implement executions preview via the new executions tab in node view (#4311)

*  Added main header tabs with current workflow execution count
*  feat(editor): header tab navigation (no-changelog) (#4244)
*  Adding current workflow execution list to the Vuex store
*  Updating current workflow executions after running a workflow from the node view
*  Keeping the tab view content alive when switching tabs in main header
*  Updating main header controls to work with current workflow regardless of active tab
* 🐛 Fixing a bug with previous WF executions still visible after creating a new WF
*  Updating saved status when new WF is created
*  Implemented initial version of execution perview
*  Keeping the WF view alive when switching to executions tab in new navigation
*  Implemented executions landing page
*  Simplifying node view navigation
*  Updating executions view zoom and selection to work with the new layout
*  Using N8nRadioButtons component for main header tabs
* 💄 Implementing executions page states. Minor refactoring.
*  Merge conflict fixes and pieces of code that were left behind
*  Fixing layout and scrolling changes introduced after sync with master branch
*  Removing keep-alive from node view which broke template opening and some more leftover code
* ✔️ Fixing linting errors
* ✔️ One more lint error
*  Implemented executions preview using iframes
*  Fixing zoom menu positioning in iframe and adding different loading types to workflow preview
*  Fixing navigation to and from WF templates and template loading
*  Updating and fixing navigation to and from node view
* 👌 Addressing previous PR comments
* 🐛 Fixing infinite loading when saving a new workflow
* 🐛 Handling opening already opened WF when not on Node view
*  Implemented empty states for executions view
*  Adding execute button shake flag to the store so it doesn't mess up navigation by modifying route params
* 💄 Started adding new styles to execution sidebar
* 💄 Adding hover style for execution list
*  Added ExecutionsCard component and added executions helper mixin
* ✔️ Fixing leftover conflict
* ✔️ One more conflict
*  Implemented retry execution menu and manual execution icon. Other minor updates
*  Implemented executions filtering
* 💄 Updating running executions details in preview
*  Added info accordion to executions sidebar
*  Implemented auto-refresh for executions sidebar
* 💄 Adding running execution landing page, minor fixes
* 💄 General refactoring
* ✔️ Adding leftover conflict changes
* ✔️ Updating `InfoTip` component test snapshots
* ✔️ Fixing linting error
* ✔️ Fixing lint errors in vuex store module
* 👌 Started addressing review feedback
*  Updating executions preview behaviour when filters are applied
* 🐛 Fixing a bug where nodes and connections disappear if something is saved from executions view before loading WF in the main NodeView
* 🐛 Fixing pasting in executions view and wrong workflow activator state
*  Improved workflow switching and navigation, updated error message when trying to paste into execution
*  Some more navigation updates
* 💄 Fixing tab centering, execution filter button layout, added auto-refresh checkbox
* 🐛 Fixing a bug when saving workflow using save button
* 💄 Addressing design feedback, added delete execution button
*  Moving main execution logic to the root executions view
*  Implemented execution delete function
*  Updating how switching tabs for new unsaved workflows work
*  Remembering active execution when switching tabs
* 💄 Addressing design feedback regarding info accordion
* 💄 Updating execution card styling
*  Resetting executions when creating new workflow
* Fixing lint error
*  Hiding executions preview is active execution is not in the results. Updated execution list spacing
*  Fixing navigation to and from templates and executions
*  Implemented execution lazy loading and added new background to execution preview
* 💄 Disabling import when on executions tab
*  Handling opening executions from different workflow
*  Updating active execution on route change
*  Updating execution tab detection
*  Simplifying and updating navigation. Adding new route for new workflows
*  Updating workflow saving logic to work with new routes
* 🐛 Fixing a bug when returning to executions from different workflow
* 💄 Updating executions info accordion and node details view modal in execution preview
* 💄 Updating workflow activated modal to point to new executions view
*  Implemented opening new executions view from execution modal
*  Handling jsplumb init errors, updating unknown executions style
*  Updating main sidebar after syncing branch
*  Opening new trigger menu from executions view
* 💄 Updating sidebar resize behaviour
* ✔️ Fixing lint errors
*  Loading executions when mounting executions view
*  Resetting execution data when creating a new workflow
* 💄 Minor wording updates
*  Not reloading node view when new workflows are saved
* Removing leftover console log
* 🐛 Fixed a bug with save dialog not appearing when leaving executions tab
*  Updating manual execution settings detection in info accordion
* 💄 Addressing UI issues found during bug bash
* Fixing workflow saving logic
*  Preventing navigation if clicked tab is already opened
*  Updating lazy loading behaviour
*  Updating delete executions flow
*  Added retry executions button to the execution preview
*  Adding empty execution state, updating trigger detection logic, removing listeners when node view is not active
* 💄 Cosmetic code improvements
*  Trying the performance fix for nodeBase
*  Removing the `NodeBase`fix
* 🐛 Fixing a bug when saving the current workflow
* 👌 Addressing code review feedback
This commit is contained in:
Milorad FIlipović 2022-10-26 10:02:56 +02:00 committed by GitHub
parent 99157cf581
commit d833345092
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 2473 additions and 184 deletions

24
package-lock.json generated
View file

@ -46646,7 +46646,7 @@
"@oclif/errors": "^1.3.6",
"@oclif/parser": "^3.8.8",
"debug": "^4.3.4",
"globby": "^11.0.2",
"globby": "^11.1.0",
"is-wsl": "^2.1.1",
"tslib": "^2.3.1"
},
@ -46672,10 +46672,10 @@
"clean-stack": "^3.0.1",
"cli-progress": "^3.10.0",
"debug": "^4.3.4",
"ejs": "^3.1.8",
"ejs": "^3.1.6",
"fs-extra": "^9.1.0",
"get-package-type": "^0.1.0",
"globby": "^11.0.2",
"globby": "^11.1.0",
"hyperlinker": "^1.0.0",
"indent-string": "^4.0.0",
"is-wsl": "^2.2.0",
@ -47028,7 +47028,7 @@
"@oclif/errors": "^1.3.3",
"@oclif/parser": "^3.8.0",
"debug": "^4.1.1",
"globby": "^11.0.2",
"globby": "^11.0.1",
"is-wsl": "^2.1.1",
"tslib": "^2.0.0"
}
@ -47534,7 +47534,7 @@
"css-loader": "^3.6.0",
"file-loader": "^6.2.0",
"find-up": "^5.0.0",
"fork-ts-checker-webpack-plugin": "^6.0.4",
"fork-ts-checker-webpack-plugin": "^4.1.6",
"glob": "^7.1.6",
"glob-promise": "^3.4.0",
"global": "^4.4.0",
@ -50091,7 +50091,7 @@
"@typescript-eslint/types": "5.40.0",
"@typescript-eslint/visitor-keys": "5.40.0",
"debug": "^4.3.4",
"globby": "^11.0.2",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"semver": "^7.3.7",
"tsutils": "^3.21.0"
@ -51409,7 +51409,7 @@
"integrity": "sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==",
"dev": true,
"requires": {
"browserslist": "^4.21.3",
"browserslist": "^4.12.0",
"caniuse-lite": "^1.0.30001109",
"normalize-range": "^0.1.2",
"num2fraction": "^1.2.2",
@ -54206,7 +54206,7 @@
"integrity": "sha512-ovcyhs2DEBUIE0MGEKHP4olCUW/XYte3Vroyxuh38rD1wAO4dHohsovUC4eAOuzFxE6b+RXvBU3UZ9o0YhUTkA==",
"dev": true,
"requires": {
"browserslist": "^4.21.3"
"browserslist": "^4.21.4"
}
},
"core-js-pure": {
@ -54280,7 +54280,7 @@
"requires": {
"arrify": "^2.0.1",
"cp-file": "^7.0.0",
"globby": "^11.0.2",
"globby": "^9.2.0",
"has-glob": "^1.0.0",
"junk": "^3.1.0",
"nested-error-stacks": "^2.1.0",
@ -55883,7 +55883,7 @@
"functional-red-black-tree": "^1.0.1",
"glob-parent": "^6.0.1",
"globals": "^13.15.0",
"globby": "^11.0.2",
"globby": "^11.1.0",
"grapheme-splitter": "^1.0.4",
"ignore": "^5.2.0",
"import-fresh": "^3.0.0",
@ -68649,7 +68649,7 @@
"fs-extra": "^6.0.1",
"get-stream": "^5.1.0",
"glob": "^7.1.2",
"globby": "^11.0.2",
"globby": "^10.0.1",
"http-call": "^5.1.2",
"load-json-file": "^6.2.0",
"pkg-dir": "^4.2.0",
@ -74342,7 +74342,7 @@
"consola": "^2.15.3",
"dotenv": "^16.0.0",
"dotenv-expand": "^8.0.2",
"ejs": "^3.1.8",
"ejs": "^3.1.6",
"fast-glob": "^3.2.11",
"fs-extra": "^10.0.1",
"html-minifier-terser": "^6.1.0",

View file

@ -1,7 +1,7 @@
<template>
<div :class="['action-dropdown-container', $style.actionDropdownContainer]">
<el-dropdown :placement="placement" :trigger="trigger" @command="onSelect">
<div :class="$style.activator">
<div :class="$style.activator" @click.prevent>
<n8n-icon :icon="activatorIcon"/>
</div>
<el-dropdown-menu slot="dropdown" :class="$style.userActionsMenu">
@ -18,7 +18,7 @@
[item.customClass]: item.customClass !== undefined,
}">
<span v-if="item.icon" :class="$style.icon">
<n8n-icon :icon="item.icon"/>
<n8n-icon :icon="item.icon" :size="item.iconSize"/>
</span>
<span :class="$style.label">
{{ item.label }}
@ -75,6 +75,12 @@ export default Vue.extend({
type: String,
default: 'ellipsis-v',
},
iconSize: {
type: String,
default: 'medium',
validator: (value: string): boolean =>
['small', 'medium', 'large'].includes(value),
},
trigger: {
type: String,
default: 'click',

View file

@ -1,15 +1,21 @@
<template>
<div :class="['accordion', $style.container]" >
<div :class="{[$style.header]: true, [$style.expanded]: expanded}" @click="toggle">
<n8n-text color="text-base" size="small" align="left" bold>{{ title }}</n8n-text>
<n8n-icon
:icon="expanded? 'chevron-up' : 'chevron-down'"
bold
/>
<div :class="{[$style.header]: true, [$style.expanded]: expanded }" @click="toggle">
<n8n-icon v-if="headerIcon" :icon="headerIcon.icon" :color="headerIcon.color" size="small" class="mr-2xs"/>
<n8n-text :class="$style.headerText" color="text-base" size="small" align="left" bold>{{ title }}</n8n-text>
<n8n-icon :icon="expanded? 'chevron-up' : 'chevron-down'" bold />
</div>
<div v-if="expanded" :class="{[$style.description]: true, [$style.collapsed]: !expanded}" @click="onClick">
<!-- Info accordion can display list of items with icons or just a HTML description -->
<div v-if="items.length > 0" :class="$style.accordionItems">
<div v-for="item in items" :key="item.id" :class="$style.accordionItem">
<n8n-tooltip :disabled="!item.tooltip">
<div slot="content" v-html="item.tooltip" @click="onTooltipClick(item.id, $event)"></div>
<n8n-icon :icon="item.icon" :color="item.iconColor" size="small" class="mr-2xs"/>
</n8n-tooltip>
<n8n-text size="small" color="text-base">{{ item.label }}</n8n-text>
</div>
</div>
<n8n-text color="text-base" size="small" align="left">
<span v-html="description"></span>
</n8n-text>
@ -17,11 +23,16 @@
</div>
</template>
<script>
<script lang="ts">
import N8nText from '../N8nText';
import N8nIcon from '../N8nIcon';
import Vue, { PropType } from 'vue';
import Vue from 'vue';
interface IAccordionItem {
id: string;
label: string;
icon: string;
}
export default Vue.extend({
name: 'n8n-info-accordion',
@ -36,11 +47,26 @@ export default Vue.extend({
description: {
type: String,
},
items: {
type: Array as PropType<IAccordionItem[]>,
default() {
return [];
},
},
initiallyExpanded: {
type: Boolean,
default: false,
},
headerIcon: {
type: Object as () => { icon: string, color: string },
required: false,
},
},
mounted() {
this.$on('expand', () => {
this.expanded = true;
});
this.expanded = this.initiallyExpanded;
},
data() {
return {
@ -54,6 +80,9 @@ export default Vue.extend({
onClick(e) {
this.$emit('click', e);
},
onTooltipClick(item: string, event: MouseEvent) {
this.$emit('tooltipClick', item, event);
},
},
});
</script>
@ -67,8 +96,9 @@ export default Vue.extend({
cursor: pointer;
display: flex;
padding: var(--spacing-s);
align-items: center;
*:first-child {
.headerText {
flex-grow: 1;
}
}
@ -77,6 +107,18 @@ export default Vue.extend({
padding: var(--spacing-s) var(--spacing-s) var(--spacing-2xs) var(--spacing-s);
}
.accordionItems {
display: flex;
flex-direction: column !important;
align-items: flex-start !important;
width: 100%;
}
.accordionItem {
display: block !important;
text-align: left;
}
.description {
display: flex;
padding: 0 var(--spacing-s) var(--spacing-s) var(--spacing-s);

View file

@ -1,9 +1,9 @@
// Vitest Snapshot v1
exports[`N8nInfoTip > should render correctly as note 1`] = `"<div class=\\"n8n-info-tip _info_3egb8_33 _note_3egb8_16 _base_3egb8_1 _bold_3egb8_12\\"><span class=\\"_iconText_3egb8_28\\"><span class=\\"n8n-icon n8n-text _compact_odhsl_34 _size-medium_odhsl_19 _regular_odhsl_5\\"></span><span>Need help doing something?<a href=\\"/docs\\" target=\\"_blank\\">Open docs</a></span></span></div>"`;
exports[`N8nInfoTip > should render correctly as note 1`] = `"<div class=\\"n8n-info-tip _info_3egb8_33 _note_3egb8_16 _base_3egb8_1 _bold_3egb8_12\\"><span class=\\"_iconText_3egb8_28\\"><span class=\\"n8n-icon n8n-text _compact_e4k11_34 _size-medium_e4k11_19 _regular_e4k11_5\\"></span><span>Need help doing something?<a href=\\"/docs\\" target=\\"_blank\\">Open docs</a></span></span></div>"`;
exports[`N8nInfoTip > should render correctly as tooltip 1`] = `
"<div class=\\"n8n-info-tip _info_3egb8_33 _tooltip_3egb8_23 _base_3egb8_1 _bold_3egb8_12\\">
<n8n-tooltip-stub justifybuttons=\\"flex-end\\" buttons=\\"\\" placement=\\"top\\"><span class=\\"_iconText_3egb8_28\\"><span class=\\"n8n-icon n8n-text _compact_odhsl_34 _size-medium_odhsl_19 _regular_odhsl_5\\"></span></span><span>Need help doing something?<a href=\\"/docs\\" target=\\"_blank\\">Open docs</a></span></n8n-tooltip-stub>
<n8n-tooltip-stub justifybuttons=\\"flex-end\\" buttons=\\"\\" placement=\\"top\\"><span class=\\"_iconText_3egb8_28\\"><span class=\\"n8n-icon n8n-text _compact_e4k11_34 _size-medium_e4k11_19 _regular_e4k11_5\\"></span></span><span>Need help doing something?<a href=\\"/docs\\" target=\\"_blank\\">Open docs</a></span></n8n-tooltip-stub>
</div>"
`;

View file

@ -65,6 +65,7 @@ export default Vue.extend({
color: var(--color-text-base);
transition: background-color 0.2s ease;
cursor: pointer;
user-select: none;
}
.disabled {

View file

@ -6,8 +6,8 @@
v-bind="option"
:active="value === option.value"
:size="size"
:disabled="disabled"
@click="(e) => onClick(option.value, e)"
:disabled="disabled || option.disabled"
@click="(e) => onClick(option, e)"
/>
</div>
</template>
@ -36,11 +36,11 @@ export default Vue.extend({
RadioButton,
},
methods: {
onClick(value: unknown) {
if (this.disabled) {
onClick(option: {label: string, value: string, disabled?: boolean}) {
if (this.disabled || option.disabled) {
return;
}
this.$emit('input', value);
this.$emit('input', option.value);
},
},
});

View file

@ -21,7 +21,7 @@ export default Vue.extend({
},
color: {
type: String,
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight', 'danger', 'success'].includes(value),
validator: (value: string): boolean => ['primary', 'text-dark', 'text-base', 'text-light', 'text-xlight', 'danger', 'success', 'warning'].includes(value),
},
align: {
type: String,
@ -126,6 +126,10 @@ export default Vue.extend({
color: var(--color-success);
}
.warning {
color: var(--color-warning);
}
.align-left {
text-align: left;
}

View file

@ -9,14 +9,16 @@
[$style.sidebarCollapsed]: sidebarMenuCollapsed
}"
>
<div id="header" :class="$style['header']">
<div id="header" :class="$style.header">
<router-view name="header"></router-view>
</div>
<div id="sidebar" :class="$style['sidebar']">
<div id="sidebar" :class="$style.sidebar">
<router-view name="sidebar"></router-view>
</div>
<div id="content" :class="$style['content']">
<router-view />
<div id="content" :class="$style.content">
<keep-alive include="NodeView" :max="1">
<router-view />
</keep-alive>
</div>
<Modals />
<Telemetry />
@ -35,7 +37,7 @@ import { showMessage } from './components/mixins/showMessage';
import { IUser } from './Interface';
import { mapGetters } from 'vuex';
import { userHelpers } from './components/mixins/userHelpers';
import { addHeaders, loadLanguage } from './plugins/i18n';
import { loadLanguage } from './plugins/i18n';
import { restApi } from '@/components/mixins/restApi';
import { globalLinkActions } from '@/components/mixins/globalLinkActions';

View file

@ -1009,6 +1009,9 @@ export interface IUiState {
isPageLoading: boolean;
currentView: string;
fakeDoorFeatures: IFakeDoor[];
nodeViewInitialized: boolean;
addFirstStepOnLoad: boolean;
executionSidebarAutoRefresh: boolean;
}
export type ILogLevel = 'info' | 'debug' | 'warn' | 'error' | 'verbose';
@ -1088,7 +1091,12 @@ export interface IUsersState {
users: {[userId: string]: IUser};
}
export interface IWorkflowsMap {
export interface IWorkflowsState {
currentWorkflowExecutions: IExecutionsSummary[];
activeWorkflowExecution: IExecutionsSummary | null;
finishedExecutionsCount: number;
}
export interface IWorkflowsMap {
[name: string]: IWorkflowDb;
}
@ -1145,6 +1153,12 @@ export interface ITab {
tooltip?: string;
}
export interface ITabBarItem {
value: string;
label: string;
disabled?: boolean;
}
export interface IResourceLocatorReqParams {
nodeTypeAndVersion: INodeTypeNameVersion;
path: string;

View file

@ -1,4 +1,5 @@
import { IRestApiContext } from '@/Interface';
import { IDataObject } from 'n8n-workflow';
import { makeRestApiRequest } from './helpers';
export async function getNewWorkflow(context: IRestApiContext, name?: string) {
@ -19,3 +20,10 @@ export async function getActiveWorkflows(context: IRestApiContext) {
return await makeRestApiRequest(context, 'GET', `/active`);
}
export async function getCurrentExecutions(context: IRestApiContext, filter: IDataObject) {
return await makeRestApiRequest(context, 'GET', '/executions-current', { filter });
}
export async function getFinishedExecutions(context: IRestApiContext, filter: IDataObject) {
return await makeRestApiRequest(context, 'GET', '/executions', { filter });
}

View file

@ -37,7 +37,7 @@
import Vue from 'vue';
import Modal from '@/components/Modal.vue';
import { WORKFLOW_ACTIVE_MODAL_KEY, EXECUTIONS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, LOCAL_STORAGE_ACTIVATION_FLAG } from '../constants';
import { WORKFLOW_ACTIVE_MODAL_KEY, EXECUTIONS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, LOCAL_STORAGE_ACTIVATION_FLAG, VIEWS } from '../constants';
import { getActivatableTriggerNodes, getTriggerNodeServiceName } from './helpers';
import { INodeTypeDescription } from 'n8n-workflow';
@ -53,11 +53,23 @@ export default Vue.extend({
return {
WORKFLOW_ACTIVE_MODAL_KEY,
checked: false,
modalBus: new Vue(),
};
},
methods: {
async showExecutionsList () {
this.$store.dispatch('ui/openModal', EXECUTIONS_MODAL_KEY);
const activeExecution = this.$store.getters['workflows/getActiveWorkflowExecution'];
const currentWorkflow = this.$store.getters.workflowId;
if (activeExecution) {
this.$router.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: currentWorkflow, executionId: activeExecution.id },
}).catch(()=>{});;
} else {
this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: currentWorkflow } }).catch(() => {});
}
this.$store.commit('ui/closeModal', WORKFLOW_ACTIVE_MODAL_KEY);
},
async showSettings() {
this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY);

View file

@ -344,16 +344,13 @@ export default mixins(
convertToDisplayDate,
displayExecution (execution: IExecutionShortResponse, e: PointerEvent) {
if (e.metaKey || e.ctrlKey) {
const route = this.$router.resolve({name: VIEWS.EXECUTION, params: {id: execution.id}});
const route = this.$router.resolve({ name: VIEWS.EXECUTION_PREVIEW, params: { name: execution.workflowId, executionId: execution.id } });
window.open(route.href, '_blank');
return;
}
this.$router.push({
name: VIEWS.EXECUTION,
params: { id: execution.id },
});
this.$router.push({ name: VIEWS.EXECUTION_PREVIEW, params: { name: execution.workflowId, executionId: execution.id } }).catch(()=>{});;
this.modalBus.$emit('closeAll');
},
handleAutoRefreshToggle () {
@ -409,6 +406,35 @@ export default mixins(
try {
await this.restApi().deleteExecutions(sendData);
let removedCurrentlyLoadedExecution = false;
let removedActiveExecution = false;
const currentWorkflow: string = this.$store.getters.workflowId;
const activeExecution: IExecutionsSummary = this.$store.getters['workflows/getActiveWorkflowExecution'];
// Also update current workflow executions view if needed
for (const selectedId of Object.keys(this.selectedItems)) {
const execution: IExecutionsSummary = this.$store.getters['workflows/getExecutionDataById'](selectedId);
if (execution && execution.workflowId === currentWorkflow) {
this.$store.commit('workflows/deleteExecution', execution);
removedCurrentlyLoadedExecution = true;
}
if (execution.id === activeExecution.id) {
removedActiveExecution = true;
}
}
// Also update route if needed
if (removedCurrentlyLoadedExecution) {
const currentWorkflowExecutions: IExecutionsSummary[] = this.$store.getters['workflows/currentWorkflowExecutions'];
if (currentWorkflowExecutions.length === 0) {
this.$store.commit('workflows/setActiveWorkflowExecution', null);
this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: currentWorkflow } });
} else if (removedActiveExecution) {
this.$store.commit('workflows/setActiveWorkflowExecution', currentWorkflowExecutions[0]);
this.$router.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: currentWorkflow, executionId: currentWorkflowExecutions[0].id },
}).catch(()=>{});;
}
}
} catch (error) {
this.isDataLoading = false;
this.$showError(

View file

@ -0,0 +1,206 @@
<template>
<div
:class="{
['execution-card']: true,
[$style.executionCard]: true,
[$style.active]: isActive,
[$style[executionUIDetails.name]]: true,
[$style.highlight]: highlight,
}"
>
<router-link
:class="$style.executionLink"
:to="{ name: VIEWS.EXECUTION_PREVIEW, params: { workflowId: currentWorkflow, executionId: execution.id }}"
>
<div :class="$style.description">
<n8n-text color="text-dark" :bold="true" size="medium">{{ executionUIDetails.startTime }}</n8n-text>
<div :class="$style.executionStatus">
<n8n-spinner v-if="executionUIDetails.name === 'running'" size="small" :class="[$style.spinner, 'mr-4xs']"/>
<n8n-text :class="$style.statusLabel" size="small">{{ executionUIDetails.label }}</n8n-text>
<n8n-text v-if="executionUIDetails.name === 'running'" :color="isActive? 'text-dark' : 'text-base'" size="small">
{{ $locale.baseText('executionDetails.runningTimeRunning', { interpolate: { time: executionUIDetails.runningTime } }) }}
</n8n-text>
<n8n-text v-else-if="executionUIDetails.name !== 'waiting' && executionUIDetails.name !== 'unknown'" :color="isActive? 'text-dark' : 'text-base'" size="small">
{{ $locale.baseText('executionDetails.runningTimeFinished', { interpolate: { time: executionUIDetails.runningTime } }) }}
</n8n-text>
</div>
<div v-if="execution.mode === 'retry'">
<n8n-text :color="isActive? 'text-dark' : 'text-base'" size="small">
{{ $locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }}
</n8n-text>
</div>
</div>
<div :class="$style.icons">
<n8n-action-dropdown
v-if="executionUIDetails.name === 'error'"
:class="[$style.icon, $style.retry]"
:items="retryExecutionActions"
activatorIcon="redo"
@select="onRetryMenuItemSelect"
/>
<font-awesome-icon
v-if="execution.mode === 'manual'"
:class="[$style.icon, $style.manual]"
:title="$locale.baseText('executionsList.manual')"
icon="flask"
/>
</div>
</router-link>
</div>
</template>
<script lang="ts">
import { IExecutionsSummary } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { executionHelpers, IExecutionUIData } from '../mixins/executionsHelpers';
import { VIEWS } from '../../constants';
import { showMessage } from '../mixins/showMessage';
import { restApi } from '../mixins/restApi';
export default mixins(
executionHelpers,
showMessage,
restApi,
).extend({
name: 'execution-card',
data() {
return {
VIEWS,
};
},
props: {
execution: {
type: Object as () => IExecutionsSummary,
required: true,
},
highlight: {
type: Boolean,
default: false,
},
},
computed: {
retryExecutionActions(): object[] {
return [
{ id: 'current-workflow', label: this.$locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') },
{ id: 'original-workflow', label: this.$locale.baseText('executionsList.retryWithOriginalWorkflow') },
];
},
executionUIDetails(): IExecutionUIData {
return this.getExecutionUIDetails(this.execution);
},
isActive(): boolean {
return this.execution.id === this.$route.params.executionId;
},
},
methods: {
onRetryMenuItemSelect(action: string): void {
this.$emit('retryExecution', { execution: this.execution, command: action });
},
},
});
</script>
<style module lang="scss">
.executionCard {
display: flex;
padding-right: var(--spacing-2xs);
&.active {
padding: 0 var(--spacing-2xs) var(--spacing-2xs) 0;
border-left: var(--spacing-4xs) var(--border-style-base) transparent !important;
.executionStatus {
color: var(--color-text-dark) !important;
}
}
& + &.active { padding-top: var(--spacing-2xs); }
&:hover, &.active {
.executionLink {
background-color: var(--color-foreground-base);
}
}
&.running {
.spinner {
position: relative;
top: 1px;
}
&, & .executionLink {
border-left: var(--spacing-4xs) var(--border-style-base) hsl(var(--color-warning-h), 94%, 80%);
}
.statusLabel, .spinner { color: var(--color-warning); }
}
&.success {
&, & .executionLink {
border-left: var(--spacing-4xs) var(--border-style-base) hsl(var(--color-success-h), 60%, 70%);
}
}
&.waiting {
&, & .executionLink {
border-left: var(--spacing-4xs) var(--border-style-base) hsl(var(--color-secondary-h), 94%, 80%);
}
.statusLabel { color: var(--color-secondary); }
}
&.error {
&, & .executionLink {
border-left: var(--spacing-4xs) var(--border-style-base) hsl(var(--color-danger-h), 94%, 80%);
}
.statusLabel { color: var(--color-danger ); }
}
&.unknown {
&, & .executionLink {
border-left: var(--spacing-4xs) var(--border-style-base) var(--color-text-light);
}
}
}
.executionLink {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
color: var(--color-text-base);
font-size: var(--font-size-xs);
padding: var(--spacing-xs);
padding-right: var(--spacing-s);
border-radius: var(--border-radius-base);
position: relative;
left: calc(-1 * var(--spacing-4xs)); // Hide link border under card border so it's not visible when not hovered
&:active {
.icon, .statusLabel {
color: var(--color-text-base);;
}
}
}
.icons {
display: flex;
align-items: baseline;
}
.icon {
font-size: var(--font-size-s);
&.retry {
svg {
color: var(--color-primary);
}
}
&.manual {
position: relative;
top: 1px;
}
& + & {
margin-left: var(--spacing-2xs);
}
}
</style>

View file

@ -0,0 +1,151 @@
<template>
<div v-if="executionUIDetails && executionUIDetails.name === 'running'" :class="$style.runningInfo">
<div :class="$style.spinner">
<font-awesome-icon icon="spinner" spin />
</div>
<n8n-text :class="$style.runningMessage">
{{ $locale.baseText('executionDetails.runningMessage') }}
</n8n-text>
</div>
<div v-else :class="$style.previewContainer">
<div :class="{[$style.executionDetails]: true, [$style.sidebarCollapsed]: sidebarCollapsed }" v-if="activeExecution">
<div>
<n8n-text size="large" color="text-base" :bold="true">{{ executionUIDetails.startTime }}</n8n-text><br>
<n8n-spinner v-if="executionUIDetails.name === 'running'" size="small" :class="[$style.spinner, 'mr-4xs']"/>
<n8n-text size="medium" :class="[$style.status, $style[executionUIDetails.name]]">{{ executionUIDetails.label }}</n8n-text>
<n8n-text v-if="executionUIDetails.name === 'running'" color="text-base" size="medium">
{{ $locale.baseText('executionDetails.runningTimeRunning', { interpolate: { time: executionUIDetails.runningTime } }) }} | ID#{{ activeExecution.id }}
</n8n-text>
<n8n-text v-else-if="executionUIDetails.name !== 'waiting'" color="text-base" size="medium">
{{ $locale.baseText('executionDetails.runningTimeFinished', { interpolate: { time: executionUIDetails.runningTime } }) }} | ID#{{ activeExecution.id }}
</n8n-text>
<n8n-text v-else-if="executionUIDetails.name === 'waiting'" color="text-base" size="medium">
| ID#{{ activeExecution.id }}
</n8n-text>
<br><n8n-text v-if="activeExecution.mode === 'retry'" color="text-base" size= "medium">
{{ $locale.baseText('executionDetails.retry') }}
<router-link
:class="$style.executionLink"
:to="{ name: VIEWS.EXECUTION_PREVIEW, params: { workflowId: activeExecution.workflowId, executionId: activeExecution.retryOf }}"
>
#{{ activeExecution.retryOf }}
</router-link>
</n8n-text>
</div>
<div>
<el-dropdown v-if="executionUIDetails.name === 'error'" trigger="click" class="mr-xs" @command="handleRetryClick">
<span class="retry-button">
<n8n-icon-button
size="large"
type="tertiary"
:title="$locale.baseText('executionsList.retryExecution')"
icon="redo"
/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="current-workflow">
{{ $locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
</el-dropdown-item>
<el-dropdown-item command="original-workflow">
{{ $locale.baseText('executionsList.retryWithOriginalworkflow') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<n8n-icon-button :title="$locale.baseText('executionDetails.deleteExecution')" icon="trash" size="large" type="tertiary" @click="onDeleteExecution" />
</div>
</div>
<workflow-preview mode="execution" loaderType="spinner" :executionId="executionId"/>
</div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '../mixins/showMessage';
import WorkflowPreview from '@/components/WorkflowPreview.vue';
import { executionHelpers, IExecutionUIData } from '../mixins/executionsHelpers';
import { VIEWS } from '../../constants';
export default mixins(restApi, showMessage, executionHelpers).extend({
name: 'execution-preview',
components: {
WorkflowPreview,
},
data() {
return {
VIEWS,
};
},
computed: {
executionUIDetails(): IExecutionUIData | null {
return this.activeExecution ? this.getExecutionUIDetails(this.activeExecution) : null;
},
sidebarCollapsed(): boolean {
return this.$store.getters['ui/sidebarMenuCollapsed'];
},
},
methods: {
async onDeleteExecution(): Promise<void> {
const deleteConfirmed = await this.confirmMessage(
this.$locale.baseText('executionDetails.confirmMessage.message'),
this.$locale.baseText('executionDetails.confirmMessage.headline'),
'warning',
this.$locale.baseText('executionDetails.confirmMessage.confirmButtonText'),
'',
);
if (!deleteConfirmed) {
return;
}
this.$emit('deleteCurrentExecution');
},
handleRetryClick(command: string): void {
this.$emit('retryExecution', { execution: this.activeExecution, command });
},
},
});
</script>
<style module lang="scss">
.previewContainer {
height: calc(100% - $header-height);
overflow: hidden;
}
.executionDetails {
position: absolute;
padding: var(--spacing-m);
padding-right: var(--spacing-xl);
width: calc(100% - 510px);
display: flex;
justify-content: space-between;
transition: all 150ms ease-in-out;
&.sidebarCollapsed {
width: calc(100% - 375px);
}
}
.running, .spinner { color: var(--color-warning); }
.waiting { color: var(--color-secondary); }
.success { color: var(--color-success); }
.error { color: var(--color-danger); }
.runningInfo {
display: flex;
flex-direction: column;
align-items: center;
margin-top: var(--spacing-4xl);
}
.spinner {
font-size: var(--font-size-2xl);
color: var(--color-primary);
}
.runningMessage {
width: 200px;
margin-top: var(--spacing-l);
text-align: center;
}
</style>

View file

@ -0,0 +1,174 @@
<template>
<n8n-info-accordion
:class="[$style.accordion, 'mt-2xl']"
:title="$locale.baseText('executionsLandingPage.emptyState.accordion.title')"
:items="accordionItems"
:description="accordionDescription"
:initiallyExpanded="shouldExpandAccordion"
:headerIcon="accordionIcon"
@click="onAccordionClick"
@tooltipClick="onItemTooltipClick"
></n8n-info-accordion>
</template>
<script lang="ts">
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import { deepCopy, IWorkflowSettings } from 'n8n-workflow';
import Vue from 'vue';
interface IWorkflowSaveSettings {
saveFailedExecutions: boolean,
saveSuccessfulExecutions: boolean,
saveManualExecutions: boolean,
};
export default Vue.extend({
name: 'executions-info-accordion',
props: {
initiallyExpanded: {
type: Boolean,
default: false,
},
},
data() {
return {
defaultValues: {
saveFailedExecutions: 'all',
saveSuccessfulExecutions: 'all',
saveManualExecutions: false,
},
workflowSaveSettings: {
saveFailedExecutions: false,
saveSuccessfulExecutions: false,
saveManualExecutions: false,
} as IWorkflowSaveSettings,
};
},
mounted() {
this.defaultValues.saveFailedExecutions = this.$store.getters.saveDataErrorExecution;
this.defaultValues.saveSuccessfulExecutions = this.$store.getters.saveDataSuccessExecution;
this.defaultValues.saveManualExecutions = this.$store.getters.saveManualExecutions;
this.updateSettings(this.workflowSettings);
},
watch: {
workflowSettings(newSettings: IWorkflowSettings) {
this.updateSettings(newSettings);
},
},
computed: {
accordionItems(): Object[] {
return [
{
id: 'productionExecutions',
label: this.$locale.baseText('executionsLandingPage.emptyState.accordion.productionExecutions'),
icon: this.productionExecutionsIcon.icon,
iconColor: this.productionExecutionsIcon.color,
tooltip: this.productionExecutionsStatus === 'unknown' ? this.$locale.baseText('executionsLandingPage.emptyState.accordion.productionExecutionsWarningTooltip') : null,
},
{
id: 'manualExecutions',
label: this.$locale.baseText('executionsLandingPage.emptyState.accordion.manualExecutions'),
icon: this.workflowSaveSettings.saveManualExecutions ? 'check' : 'times',
iconColor: this.workflowSaveSettings.saveManualExecutions ? 'success' : 'danger',
},
];
},
shouldExpandAccordion(): boolean {
if (this.initiallyExpanded === false) {
return false;
}
return this.workflowSaveSettings.saveFailedExecutions === false ||
this.workflowSaveSettings.saveSuccessfulExecutions === false ||
this.workflowSaveSettings.saveManualExecutions === false;
},
productionExecutionsIcon(): { icon: string, color: string } {
if (this.productionExecutionsStatus === 'saving') {
return { icon: 'check', color: 'success' };
} else if (this.productionExecutionsStatus === 'not-saving') {
return { icon: 'times', color: 'danger' };
}
return { icon: 'exclamation-triangle', color: 'warning' };
},
productionExecutionsStatus(): string {
if (this.workflowSaveSettings.saveSuccessfulExecutions === this.workflowSaveSettings.saveFailedExecutions) {
if (this.workflowSaveSettings.saveSuccessfulExecutions === true) {
return 'saving';
}
return 'not-saving';
} else {
return 'unknown';
}
},
workflowSettings(): IWorkflowSettings {
const workflowSettings = deepCopy(this.$store.getters.workflowSettings);
return workflowSettings;
},
accordionDescription(): string {
return `
<footer class="mt-2xs">
${this.$locale.baseText('executionsLandingPage.emptyState.accordion.footer')}
</footer>
`;
},
accordionIcon(): { icon: string, color: string }|null {
if (this.workflowSaveSettings.saveManualExecutions !== true || this.productionExecutionsStatus !== 'saving') {
return { icon: 'exclamation-triangle', color: 'warning' };
}
return null;
},
},
methods: {
updateSettings(settingsInStore: IWorkflowSettings): void {
this.workflowSaveSettings.saveFailedExecutions = settingsInStore.saveDataErrorExecution !== 'none';
this.workflowSaveSettings.saveSuccessfulExecutions = settingsInStore.saveDataSuccessExecution !== 'none';
this.workflowSaveSettings.saveManualExecutions = settingsInStore.saveManualExecutions === undefined ? this.defaultValues.saveManualExecutions : settingsInStore.saveManualExecutions as boolean;
},
onAccordionClick(event: MouseEvent): void {
if (event.target instanceof HTMLAnchorElement) {
event.preventDefault();
this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY);
}
},
onItemTooltipClick(item: string, event: MouseEvent): void {
if (item === 'productionExecutions' && event.target instanceof HTMLAnchorElement) {
event.preventDefault();
this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY);
}
},
},
});
</script>
<style module lang="scss">
.accordion {
background: none;
width: 320px;
// Accordion header
& > div:nth-child(1) {
display: flex;
flex-direction: row;
padding: var(--spacing-xs);
width: 100%;
user-select: none;
color: var(--color-text-base) !important;
}
// Accordion description
& > div:nth-child(2) {
display: flex;
flex-direction: column;
width: 100%;
padding: 0 var(--spacing-l) var(--spacing-s) !important;
span { width: 100%; }
}
footer {
text-align: left;
width: 100%;
}
}
</style>

View file

@ -0,0 +1,96 @@
<template>
<div :class="['workflow-executions-container', $style.container]">
<div v-if="executionCount === 0" :class="[$style.messageContainer, $style.noExecutionsMessage]">
<div v-if="!containsTrigger">
<n8n-heading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
{{ $locale.baseText('executionsLandingPage.emptyState.noTrigger.heading') }}
</n8n-heading>
<n8n-text size="medium">
{{ $locale.baseText('executionsLandingPage.emptyState.message') }}
</n8n-text>
<n8n-button class="mt-l" type="tertiary" size="large" @click="onSetupFirstStep">
{{ $locale.baseText('executionsLandingPage.emptyState.noTrigger.buttonText') }}
</n8n-button>
</div>
<div v-else>
<n8n-heading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
{{ $locale.baseText('executionsLandingPage.emptyState.heading') }}
</n8n-heading>
<n8n-text size="medium">
{{ $locale.baseText('executionsLandingPage.emptyState.message') }}
</n8n-text>
<executions-info-accordion />
</div>
</div>
</div>
</template>
<script lang="ts">
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
import { IExecutionsSummary } from '@/Interface';
import Vue from 'vue';
import ExecutionsInfoAccordion from './ExecutionsInfoAccordion.vue';
export default Vue.extend({
name: 'executions-landing-page',
components: {
ExecutionsInfoAccordion,
},
computed: {
executionCount(): number {
return (this.$store.getters['workflows/currentWorkflowExecutions'] as IExecutionsSummary[]).length;
},
containsTrigger(): boolean {
return this.$store.getters.workflowTriggerNodes.length > 0;
},
currentWorkflowId(): string {
return this.$store.getters.workflowId;
},
},
methods: {
onSetupFirstStep(event: MouseEvent): void {
this.$store.commit('ui/setAddFirstStepOnLoad', true);
const workflowRoute = this.getWorkflowRoute();
this.$router.push(workflowRoute);
},
getWorkflowRoute(): { name: string, params: {}} {
const workflowId = this.currentWorkflowId || this.$route.params.name;
if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
return { name: VIEWS.NEW_WORKFLOW, params: {} };
} else {
return { name: VIEWS.WORKFLOW, params: { name: workflowId } };
}
},
},
});
</script>
<style module lang="scss">
.container {
width: 100%;
height: 100%;
flex: 1;
background-color: var(--color-background-light);
display: flex;
flex-direction: column;
align-items: center;
}
.messageContainer {
margin-top: var(--spacing-4xl);
color: var(--color-text-base);
div {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
}
.icon {
font-size: 24px;
color: var(--color-foreground-dark);
}
</style>

View file

@ -0,0 +1,270 @@
<template>
<div :class="['executions-sidebar', $style.container]">
<div :class="$style.heading">
<n8n-heading tag="h2" size="medium" color="text-dark">
{{ $locale.baseText('generic.executions') }}
</n8n-heading>
</div>
<div :class="$style.controls">
<el-checkbox v-model="autoRefresh" @change="onAutoRefreshToggle">{{ $locale.baseText('executionsList.autoRefresh') }}</el-checkbox>
<n8n-popover trigger="click" >
<template slot="reference">
<div :class="$style.filterButton">
<n8n-button icon="filter" type="tertiary" size="medium" :active="statusFilterApplied">
<n8n-badge v-if="statusFilterApplied" theme="primary" class="mr-4xs">1</n8n-badge>
{{ $locale.baseText('executionsList.filters') }}
</n8n-button>
</div>
</template>
<div :class="$style['filters-dropdown']">
<div class="mb-s">
<n8n-input-label
:label="$locale.baseText('executions.ExecutionStatus')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
/>
<n8n-select
v-model="filter.status"
size="small"
ref="typeInput"
:class="$style['type-input']"
:placeholder="$locale.baseText('generic.any')"
@change="onFilterChange"
>
<n8n-option
v-for="item in executionStatuses"
:key="item.id"
:label="item.name"
:value="item.id">
</n8n-option>
</n8n-select>
</div>
<div :class="[$style.filterMessage, 'mt-s']" v-if="statusFilterApplied">
<n8n-link @click="resetFilters">
{{ $locale.baseText('generic.reset') }}
</n8n-link>
</div>
</div>
</n8n-popover>
</div>
<div v-show="statusFilterApplied" class="mb-xs">
<n8n-info-tip :bold="false">
{{ $locale.baseText('generic.filtersApplied') }}
<n8n-link @click="resetFilters" size="small">
{{ $locale.baseText('generic.resetAllFilters') }}
</n8n-link>
</n8n-info-tip>
</div>
<div :class="$style.executionList" ref="executionList" @scroll="loadMore">
<div v-if="loading" class="mr-m">
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
</div>
<execution-card
v-else
v-for="execution in executions"
:key="execution.id"
:execution="execution"
@refresh="onRefresh"
@retryExecution="onRetryExecution"
/>
<div v-if="loadingMore" class="mr-m">
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
</div>
</div>
<div :class="$style.infoAccordion">
<executions-info-accordion :initiallyExpanded="false" />
</div>
</div>
</template>
<script lang="ts">
import ExecutionCard from '@/components/ExecutionsView/ExecutionCard.vue';
import ExecutionsInfoAccordion from '@/components/ExecutionsView/ExecutionsInfoAccordion.vue';
import { VIEWS } from '../../constants';
import { range as _range } from 'lodash';
import { IExecutionsSummary } from "@/Interface";
import { Route } from 'vue-router';
import Vue from 'vue';
import { PropType } from 'vue';
export default Vue.extend({
name: 'executions-sidebar',
components: {
ExecutionCard,
ExecutionsInfoAccordion,
},
props: {
executions: {
type: Array as PropType<IExecutionsSummary[]>,
required: true,
},
loading: {
type: Boolean,
default: true,
},
loadingMore: {
type: Boolean,
default: false,
},
},
data() {
return {
VIEWS,
filter: {
status: '',
},
autoRefresh: false,
autoRefreshInterval: undefined as undefined | NodeJS.Timer,
};
},
computed: {
statusFilterApplied(): boolean {
return this.filter.status !== '';
},
executionStatuses(): Array<{ id: string, name: string }> {
return [
{ id: 'error', name: this.$locale.baseText('executionsList.error') },
{ id: 'running', name: this.$locale.baseText('executionsList.running') },
{ id: 'success', name: this.$locale.baseText('executionsList.success') },
{ id: 'waiting', name: this.$locale.baseText('executionsList.waiting') },
];
},
},
watch: {
$route (to: Route, from: Route) {
if (from.name === VIEWS.EXECUTION_PREVIEW && to.name === VIEWS.EXECUTION_HOME) {
// Skip parent route when navigating through executions with back button
this.$router.go(-1);
}
},
},
mounted() {
this.autoRefresh = this.$store.getters['ui/isExecutionSidebarAutoRefreshOn'];
if (this.autoRefresh) {
this.autoRefreshInterval = setInterval(() => this.onRefresh(), 4000);
}
},
beforeDestroy() {
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = undefined;
}
},
methods: {
loadMore(): void {
if (!this.loading) {
const executionsList = this.$refs.executionList as HTMLElement;
if (executionsList) {
const diff = executionsList.offsetHeight - (executionsList.scrollHeight - executionsList.scrollTop);
if (diff > -10 && diff < 10) {
this.$emit('loadMore');
}
}
}
},
onRetryExecution(payload: Object) {
this.$emit('retryExecution', payload);
},
onRefresh(): void {
this.$emit('refresh');
},
onFilterChange(): void {
this.$emit('filterUpdated', this.prepareFilter());
},
reloadExecutions(): void {
this.$emit('reloadExecutions');
},
onAutoRefreshToggle(): void {
this.$store.commit('ui/setExecutionsSidebarAutoRefresh', this.autoRefresh);
if (this.autoRefreshInterval) {
// Clear any previously existing intervals (if any - there shouldn't)
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = undefined;
}
if (this.autoRefresh) {
this.autoRefreshInterval = setInterval(() => this.onRefresh(), 4 * 1000); // refresh data every 4 secs
}
},
async resetFilters(): Promise<void> {
this.filter.status = '';
this.$emit('filterUpdated', this.prepareFilter());
},
prepareFilter(): object {
return {
finished: this.filter.status !== 'running',
status: this.filter.status,
};
},
},
});
</script>
<style module lang="scss">
.container {
flex: 310px 0 0;
background-color: var(--color-background-xlight);
border-right: var(--border-base);
padding: var(--spacing-l) 0 var(--spacing-l) var(--spacing-l);
z-index: 1;
overflow: hidden;
}
.heading {
display: flex;
justify-content: space-between;
align-items: baseline;
padding-right: var(--spacing-l);
}
.controls {
padding: var(--spacing-s) 0 var(--spacing-xs);
display: flex;
align-items: center;
justify-content: space-between;
padding-right: var(--spacing-l);
button {
display: flex;
align-items: center;
}
}
.executionList {
height: calc(100% - 10.5em);
overflow: auto;
margin-bottom: var(--spacing-m);
background-color: var(--color-background-xlight) !important;
// Scrolling fader
&::before {
position: absolute;
display: block;
width: 270px;
height: 6px;
background: linear-gradient(to bottom, rgba(251, 251, 251, 1) 0%, rgba(251, 251, 251, 0) 100%);
z-index: 999;
}
// Lower first execution card so fader is not visible when not scrolled
& > div:first-child {
margin-top: 3px;
}
}
.infoAccordion {
position: absolute;
bottom: 0;
margin-left: calc(-1 * var(--spacing-l));
border-top: var(--border-base);
& > div {
width: 309px;
background-color: var(--color-background-light);
margin-top: 0 !important;
}
}
</style>

View file

@ -0,0 +1,517 @@
<template>
<div :class="$style.container" v-if="!loading">
<executions-sidebar
v-if="showSidebar"
:executions="executions"
:loading="loading"
:loadingMore="loadingMore"
@reloadExecutions="setExecutions"
@filterUpdated="onFilterUpdated"
@loadMore="loadMore"
@retryExecution="onRetryExecution"
/>
<div :class="$style.content" v-if="!hidePreview">
<router-view name="executionPreview" @deleteCurrentExecution="onDeleteCurrentExecution" @retryExecution="onRetryExecution"/>
</div>
<div v-if="executions.length === 0 && filterApplied" :class="$style.noResultsContainer">
<n8n-text color="text-base" size="medium" align="center">
{{ $locale.baseText('executionsLandingPage.noResults') }}
</n8n-text>
</div>
</div>
</template>
<script lang="ts">
import ExecutionsSidebar from '@/components/ExecutionsView/ExecutionsSidebar.vue';
import { MODAL_CANCEL, MODAL_CLOSE, MODAL_CONFIRMED, PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS, WEBHOOK_NODE_TYPE } from '@/constants';
import { IExecutionsListResponse, IExecutionsSummary, INodeUi, ITag, IWorkflowDb } from '@/Interface';
import { IConnection, IConnections, IDataObject, INodeTypeDescription, INodeTypeNameVersion, NodeHelpers } from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
import { restApi } from '../mixins/restApi';
import { showMessage } from '../mixins/showMessage';
import { v4 as uuid } from 'uuid';
import { Route } from 'vue-router';
import { executionHelpers } from '../mixins/executionsHelpers';
import { range as _range } from 'lodash';
import { debounceHelper } from '../mixins/debounce';
import { getNodeViewTab } from '../helpers';
import { workflowHelpers } from '../mixins/workflowHelpers';
export default mixins(restApi, showMessage, executionHelpers, debounceHelper, workflowHelpers).extend({
name: 'executions-page',
components: {
ExecutionsSidebar,
},
data() {
return {
loading: false,
loadingMore: false,
filter: { finished: true, status: '' },
};
},
computed: {
hidePreview(): boolean {
const nothingToShow = this.executions.length === 0 && this.filterApplied;
const activeNotPresent = this.filterApplied && (this.executions as IExecutionsSummary[]).find(ex => ex.id === this.activeExecution.id) === undefined;
return this.loading || nothingToShow || activeNotPresent;
},
showSidebar(): boolean {
if (this.executions.length === 0) {
return this.filterApplied;
}
return true;
},
filterApplied(): boolean {
return this.filter.status !== '';
},
workflowDataNotLoaded(): boolean {
return this.$store.getters.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID && this.$store.getters.workflowName === '';
},
loadedFinishedExecutionsCount(): number {
return (this.$store.getters['workflows/getAllLoadedFinishedExecutions'] as IExecutionsSummary[]).length;
},
totalFinishedExecutionsCount(): number {
return this.$store.getters['workflows/getTotalFinishedExecutionsCount'];
},
},
watch:{
$route (to: Route, from: Route) {
const workflowChanged = from.params.name !== to.params.name;
this.initView(workflowChanged);
if (to.params.executionId) {
const execution = this.$store.getters['workflows/getExecutionDataById'](to.params.executionId);
if (execution) {
this.$store.commit('workflows/setActiveWorkflowExecution', execution);
}
}
},
},
async beforeRouteLeave(to, from, next) {
const nextTab = getNodeViewTab(to);
// When leaving for a page that's not a workflow view tab, ask to save changes
if (!nextTab) {
const result = this.$store.getters.getStateIsDirty;
if (result) {
const confirmModal = await this.confirmModal(
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
'warning',
this.$locale.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
true,
);
if (confirmModal === MODAL_CONFIRMED) {
const saved = await this.saveCurrentWorkflow({}, false);
if (saved) this.$store.dispatch('settings/fetchPromptsData');
this.$store.commit('setStateDirty', false);
next();
} else if (confirmModal === MODAL_CANCEL) {
this.$store.commit('setStateDirty', false);
next();
} else if (confirmModal === MODAL_CLOSE) {
next(false);
}
} else {
next();
}
}
next();
},
async mounted() {
this.loading = true;
const workflowUpdated = this.$route.params.name !== this.$store.getters.workflowId;
const onNewWorkflow = this.$route.params.name === 'new' && this.$store.getters.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID;
const shouldUpdate = workflowUpdated && !onNewWorkflow;
await this.initView(shouldUpdate);
if (!shouldUpdate) {
await this.setExecutions();
}
this.loading = false;
},
methods: {
async initView(loadWorkflow: boolean) : Promise<void> {
if (loadWorkflow) {
if (this.$store.getters['nodeTypes/allNodeTypes'].length === 0) {
await this.$store.dispatch('nodeTypes/getNodeTypes');
}
await this.openWorkflow(this.$route.params.name);
this.$store.commit('ui/setNodeViewInitialized', false);
this.setExecutions();
if (this.activeExecution) {
this.$router.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: this.currentWorkflow, executionId: this.activeExecution.id },
}).catch(()=>{});;
}
}
},
async onLoadMore(): Promise<void> {
if (!this.loadingMore) {
this.callDebounced("loadMore", { debounceTime: 1000 });
}
},
async loadMore(): Promise<void> {
if (this.filter.status === 'running' || this.loadedFinishedExecutionsCount >= this.totalFinishedExecutionsCount) {
return;
}
this.loadingMore = true;
let lastId: string | number | undefined;
if (this.executions.length !== 0) {
const lastItem = this.executions.slice(-1)[0];
lastId = lastItem.id;
}
const requestFilter: IDataObject = { workflowId: this.currentWorkflow };
if (this.filter.status === 'waiting') {
requestFilter.waitTill = true;
} else if (this.filter.status !== '') {
requestFilter.finished = this.filter.status === 'success';
}
let data: IExecutionsListResponse;
try {
data = await this.restApi().getPastExecutions(requestFilter, 20, lastId);
} catch (error) {
this.loadingMore = false;
this.$showError(
error,
this.$locale.baseText('executionsList.showError.loadMore.title'),
);
return;
}
data.results = data.results.map((execution) => {
// @ts-ignore
return { ...execution, mode: execution.mode };
});
const currentExecutions = [ ...this.executions ];
for (const newExecution of data.results) {
if (currentExecutions.find(ex => ex.id === newExecution.id) === undefined) {
currentExecutions.push(newExecution);
}
}
this.$store.commit('workflows/setCurrentWorkflowExecutions', currentExecutions);
this.loadingMore = false;
},
async onDeleteCurrentExecution(): Promise<void> {
this.loading = true;
try {
await this.restApi().deleteExecutions({ ids: [ this.$route.params.executionId ] });
await this.setExecutions();
// Select first execution in the list after deleting the current one
if (this.executions.length > 0) {
this.$store.commit('workflows/setActiveWorkflowExecution', this.executions[0]);
this.$router.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: this.currentWorkflow, executionId: this.executions[0].id },
}).catch(()=>{});;
} else { // If there are no executions left, show empty state and clear active execution from the store
this.$store.commit('workflows/setActiveWorkflowExecution', null);
this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: this.currentWorkflow } });
}
} catch (error) {
this.loading = false;
this.$showError(
error,
this.$locale.baseText('executionsList.showError.handleDeleteSelected.title'),
);
return;
}
this.loading = false;
this.$showMessage({
title: this.$locale.baseText('executionsList.showMessage.handleDeleteSelected.title'),
type: 'success',
});
},
onFilterUpdated(newFilter: { finished: boolean, status: string }): void {
this.filter = newFilter;
this.setExecutions();
},
async setExecutions(): Promise<void> {
const workflowExecutions = await this.loadExecutions();
this.$store.commit('workflows/setCurrentWorkflowExecutions', workflowExecutions);
this.setActiveExecution();
},
async loadAutoRefresh(): Promise<void> {
// Most of the auto-refresh logic is taken from the `ExecutionsList` component
const fetchedExecutions: IExecutionsSummary[] = await this.loadExecutions();
let existingExecutions: IExecutionsSummary[] = [ ...this.executions ];
const alreadyPresentExecutionIds = existingExecutions.map(exec => parseInt(exec.id, 10));
let lastId = 0;
const gaps = [] as number[];
for(let i = fetchedExecutions.length - 1; i >= 0; i--) {
const currentItem = fetchedExecutions[i];
const currentId = parseInt(currentItem.id, 10);
if (lastId !== 0 && isNaN(currentId) === false) {
if (currentId - lastId > 1) {
const range = _range(lastId + 1, currentId);
gaps.push(...range);
}
}
lastId = parseInt(currentItem.id, 10) || 0;
const executionIndex = alreadyPresentExecutionIds.indexOf(currentId);
if (executionIndex !== -1) {
const existingExecution = existingExecutions.find(ex => ex.id === currentItem.id);
const existingStillRunning = existingExecution && existingExecution.finished === false || existingExecution?.stoppedAt === undefined;
const currentFinished = currentItem.finished === true || currentItem.stoppedAt !== undefined;
if (existingStillRunning && currentFinished) {
existingExecutions[executionIndex] = currentItem;
}
continue;
}
let j;
for (j = existingExecutions.length - 1; j >= 0; j--) {
if (currentId < parseInt(existingExecutions[j].id, 10)) {
existingExecutions.splice(j + 1, 0, currentItem);
break;
}
}
if (j === -1) {
existingExecutions.unshift(currentItem);
}
}
existingExecutions = existingExecutions.filter(execution => !gaps.includes(parseInt(execution.id, 10)) && lastId >= parseInt(execution.id, 10));
this.$store.commit('workflows/setCurrentWorkflowExecutions', existingExecutions);
},
async loadExecutions(): Promise<IExecutionsSummary[]> {
if (!this.currentWorkflow) {
return [];
}
try {
const executions: IExecutionsSummary[] =
await this.$store.dispatch('workflows/loadCurrentWorkflowExecutions', this.filter);
return executions;
} catch (error) {
this.$showError(
error,
this.$locale.baseText('executionsList.showError.refreshData.title'),
);
return [];
}
},
setActiveExecution(): void {
const activeExecutionId = this.$route.params.executionId;
if (activeExecutionId) {
const execution = this.$store.getters['workflows/getExecutionDataById'](activeExecutionId);
if (execution) {
this.$store.commit('workflows/setActiveWorkflowExecution', execution);
}
}
// If there is no execution in the route, select the first one
if (this.$store.getters['workflows/getActiveWorkflowExecution'] === null && this.executions.length > 0) {
this.$store.commit('workflows/setActiveWorkflowExecution', this.executions[0]);
this.$router.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: this.currentWorkflow, executionId: this.executions[0].id },
}).catch(()=>{});;
}
},
async openWorkflow(workflowId: string): Promise<void> {
await this.loadActiveWorkflows();
let data: IWorkflowDb | undefined;
try {
data = await this.restApi().getWorkflow(workflowId);
} catch (error) {
this.$showError(
error,
this.$locale.baseText('nodeView.showError.openWorkflow.title'),
);
return;
}
if (data === undefined) {
throw new Error(
this.$locale.baseText(
'nodeView.workflowWithIdCouldNotBeFound',
{ interpolate: { workflowId } },
),
);
}
await this.addNodes(data.nodes, data.connections);
this.$store.commit('setActive', data.active || false);
this.$store.commit('setWorkflowId', workflowId);
this.$store.commit('setWorkflowName', { newName: data.name, setStateDirty: false });
this.$store.commit('setWorkflowSettings', data.settings || {});
this.$store.commit('setWorkflowPinData', data.pinData || {});
const tags = (data.tags || []) as ITag[];
this.$store.commit('tags/upsertTags', tags);
const tagIds = tags.map((tag) => tag.id);
this.$store.commit('setWorkflowTagIds', tagIds || []);
this.$externalHooks().run('workflow.open', { workflowId, workflowName: data.name });
this.$store.commit('setStateDirty', false);
},
async addNodes(nodes: INodeUi[], connections?: IConnections) {
if (!nodes || !nodes.length) {
return;
}
await this.loadNodesProperties(nodes.map(node => ({ name: node.type, version: node.typeVersion })));
let nodeType: INodeTypeDescription | null;
nodes.forEach((node) => {
if (!node.id) {
node.id = uuid();
}
nodeType = this.$store.getters['nodeTypes/getNodeType'](node.type, node.typeVersion) as INodeTypeDescription | null;
// Make sure that some properties always exist
if (!node.hasOwnProperty('disabled')) {
node.disabled = false;
}
if (!node.hasOwnProperty('parameters')) {
node.parameters = {};
}
// Load the defaul parameter values because only values which differ
// from the defaults get saved
if (nodeType !== null) {
let nodeParameters = null;
try {
nodeParameters = NodeHelpers.getNodeParameters(nodeType.properties, node.parameters, true, false, node);
} catch (e) {
console.error(this.$locale.baseText('nodeView.thereWasAProblemLoadingTheNodeParametersOfNode') + `: "${node.name}"`); // eslint-disable-line no-console
console.error(e); // eslint-disable-line no-console
}
node.parameters = nodeParameters !== null ? nodeParameters : {};
// if it's a webhook and the path is empty set the UUID as the default path
if (node.type === WEBHOOK_NODE_TYPE && node.parameters.path === '') {
node.parameters.path = node.webhookId as string;
}
}
this.$store.commit('addNode', node);
});
// Load the connections
if (connections !== undefined) {
let connectionData;
for (const sourceNode of Object.keys(connections)) {
for (const type of Object.keys(connections[sourceNode])) {
for (let sourceIndex = 0; sourceIndex < connections[sourceNode][type].length; sourceIndex++) {
const outwardConnections = connections[sourceNode][type][sourceIndex];
if (!outwardConnections) {
continue;
}
outwardConnections.forEach((
targetData,
) => {
connectionData = [
{
node: sourceNode,
type,
index: sourceIndex,
},
{
node: targetData.node,
type: targetData.type,
index: targetData.index,
},
] as [IConnection, IConnection];
this.$store.commit('addConnection', { connection: connectionData, setStateDirty: false });
});
}
}
}
}
},
async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
const allNodes: INodeTypeDescription[] = this.$store.getters['nodeTypes/allNodeTypes'];
const nodesToBeFetched: INodeTypeNameVersion[] = [];
allNodes.forEach(node => {
const nodeVersions = Array.isArray(node.version) ? node.version : [node.version];
if (!!nodeInfos.find(n => n.name === node.name && nodeVersions.includes(n.version)) && !node.hasOwnProperty('properties')) {
nodesToBeFetched.push({
name: node.name,
version: Array.isArray(node.version)
? node.version.slice(-1)[0]
: node.version,
});
}
});
if (nodesToBeFetched.length > 0) {
// Only call API if node information is actually missing
await this.$store.dispatch('nodeTypes/getNodesInformation', nodesToBeFetched);
}
},
async loadActiveWorkflows(): Promise<void> {
const activeWorkflows = await this.restApi().getActiveWorkflows();
this.$store.commit('setActiveWorkflows', activeWorkflows);
},
async onRetryExecution(payload: { execution: IExecutionsSummary, command: string }) {
const loadWorkflow = payload.command === 'current-workflow';
this.$showMessage({
title: this.$locale.baseText('executionDetails.runningMessage'),
type: 'info',
duration: 2000,
});
await this.retryExecution(payload.execution, loadWorkflow);
this.loadAutoRefresh();
this.$telemetry.track('User clicked retry execution button', {
workflow_id: this.$store.getters.workflowId,
execution_id: payload.execution.id,
retry_type: loadWorkflow ? 'current' : 'original',
});
},
async retryExecution(execution: IExecutionsSummary, loadWorkflow?: boolean) {
try {
const retrySuccessful = await this.restApi().retryExecution(execution.id, loadWorkflow);
if (retrySuccessful === true) {
this.$showMessage({
title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulTrue.title'),
type: 'success',
});
} else {
this.$showMessage({
title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulFalse.title'),
type: 'error',
});
}
} catch (error) {
this.$showError(
error,
this.$locale.baseText('executionsList.showError.retryExecution.title'),
);
}
},
},
});
</script>
<style module lang="scss">
.container {
display: flex;
height: 100%;
}
.content {
flex: 1;
}
.noResultsContainer {
width: 100%;
margin-top: var(--spacing-2xl);
text-align: center;
}
</style>

View file

@ -4,6 +4,7 @@
<div v-show="!hideMenuBar" class="top-menu">
<ExecutionDetails v-if="isExecutionPage" />
<WorkflowDetails v-else />
<tab-bar v-if="onWorkflowPage && !isExecutionPage" :items="tabBarItems" :activeTab="activeHeaderTab" @select="onTabSelected"/>
</div>
</div>
</div>
@ -12,27 +13,42 @@
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import { pushConnection } from '@/components/mixins/pushConnection';
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
import ExecutionDetails from '@/components/MainHeader/ExecutionDetails/ExecutionDetails.vue';
import { STICKY_NODE_TYPE, VIEWS } from '@/constants';
import { INodeUi } from '@/Interface';
import TabBar from '@/components/MainHeader/TabBar.vue';
import { MAIN_HEADER_TABS, PLACEHOLDER_EMPTY_WORKFLOW_ID, STICKY_NODE_TYPE, VIEWS } from '@/constants';
import { IExecutionsSummary, INodeUi, ITabBarItem } from '@/Interface';
import { workflowHelpers } from '../mixins/workflowHelpers';
import { Route } from 'vue-router';
export default mixins(
pushConnection,
)
.extend({
workflowHelpers,
).extend({
name: 'MainHeader',
components: {
WorkflowDetails,
ExecutionDetails,
TabBar,
},
data() {
return {
activeHeaderTab: MAIN_HEADER_TABS.WORKFLOW,
workflowToReturnTo: '',
dirtyState: this.$store.getters.getStateIsDirty,
};
},
computed: {
...mapGetters('ui', [
'sidebarMenuCollapsed',
]),
tabBarItems(): ITabBarItem[] {
return [
{ value: MAIN_HEADER_TABS.WORKFLOW, label: this.$locale.baseText('generic.workflow') },
{ value: MAIN_HEADER_TABS.EXECUTIONS, label: this.$locale.baseText('generic.executions') },
];
},
isExecutionPage (): boolean {
return this.$route.name === VIEWS.EXECUTION;
},
@ -42,14 +58,82 @@ export default mixins(
hideMenuBar(): boolean {
return Boolean(this.activeNode && this.activeNode.type !== STICKY_NODE_TYPE);
},
workflowName (): string {
return this.$store.getters.workflowName;
},
currentWorkflow (): string {
return this.$route.params.name || this.$store.getters.workflowId;
},
onWorkflowPage(): boolean {
return this.$route.meta && (this.$route.meta.nodeView || this.$route.meta.keepWorkflowAlive === true);
},
activeExecution(): IExecutionsSummary {
return this.$store.getters['workflows/getActiveWorkflowExecution'];
},
},
async mounted() {
mounted() {
this.syncTabsWithRoute(this.$route);
// Initialize the push connection
this.pushConnect();
},
beforeDestroy() {
this.pushDisconnect();
},
watch: {
$route (to, from){
this.syncTabsWithRoute(to);
},
},
methods: {
syncTabsWithRoute(route: Route): void {
if (route.name === VIEWS.EXECUTION_HOME || route.name === VIEWS.EXECUTIONS || route.name === VIEWS.EXECUTION_PREVIEW) {
this.activeHeaderTab = MAIN_HEADER_TABS.EXECUTIONS;
} else if (route.name === VIEWS.WORKFLOW || route.name === VIEWS.NEW_WORKFLOW) {
this.activeHeaderTab = MAIN_HEADER_TABS.WORKFLOW;
}
const workflowName = route.params.name;
if (workflowName !== 'new') {
this.workflowToReturnTo = workflowName;
}
},
onTabSelected(tab: string, event: MouseEvent) {
switch (tab) {
case MAIN_HEADER_TABS.WORKFLOW:
if (!['', 'new', PLACEHOLDER_EMPTY_WORKFLOW_ID].includes(this.workflowToReturnTo)) {
if (this.$route.name !== VIEWS.WORKFLOW) {
this.$router.push({
name: VIEWS.WORKFLOW,
params: { name: this.workflowToReturnTo },
});
}
} else {
if (this.$route.name !== VIEWS.NEW_WORKFLOW) {
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
this.$store.commit('setStateDirty', this.dirtyState);
}
}
this.activeHeaderTab = MAIN_HEADER_TABS.WORKFLOW;
break;
case MAIN_HEADER_TABS.EXECUTIONS:
this.dirtyState = this.$store.getters.getStateIsDirty;
this.workflowToReturnTo = this.currentWorkflow;
const routeWorkflowId = this.currentWorkflow === PLACEHOLDER_EMPTY_WORKFLOW_ID ? 'new' : this.currentWorkflow;
if (this.activeExecution) {
this.$router.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: routeWorkflowId, executionId: this.activeExecution.id },
}).catch(()=>{});;
} else {
this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: routeWorkflowId } });
}
// this.modalBus.$emit('closeAll');
this.activeHeaderTab = MAIN_HEADER_TABS.EXECUTIONS;
break;
default:
break;
}
},
},
});
</script>
@ -68,6 +152,6 @@ export default mixins(
font-size: 0.9em;
height: $header-height;
font-weight: 400;
padding: 0 20px;
padding: 0 var(--spacing-m) 0 var(--spacing-xs);
}
</style>

View file

@ -0,0 +1,70 @@
<template>
<div v-if="items" :class="{[$style.container]: true, ['tab-bar-container']: true, [$style.menuCollapsed]: mainSidebarCollapsed}">
<n8n-radio-buttons
:value="activeTab"
:options="items"
@input="onSelect"
/>
</div>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue';
import { ITabBarItem } from '@/Interface';
import { MAIN_HEADER_TABS } from '@/constants';
export default Vue.extend({
name: 'tab-bar',
data() {
return {
MAIN_HEADER_TABS,
};
},
props: {
items: {
type: Array as PropType<ITabBarItem[]>,
required: true,
},
activeTab: {
type: String,
default: MAIN_HEADER_TABS.WORKFLOW,
},
},
computed: {
mainSidebarCollapsed(): boolean {
return this.$store.getters['ui/sidebarMenuCollapsed'];
},
},
methods: {
onSelect(tab: string, event: MouseEvent): void {
this.$emit('select', tab, event);
},
},
});
</script>
<style module lang="scss">
.container {
position: absolute;
top: 47px;
left: calc(50% + 100px);
transform: translateX(-50%);
min-height: 30px;
display: flex;
padding: var(--spacing-5xs);
background-color: var(--color-foreground-base);
border-radius: var(--border-radius-base);
transition: all 150ms ease-in-out;
&.menuCollapsed {
left: 52%;
}
}
@media screen and (max-width: 430px) {
.container {
flex-direction: column;
}
}
</style>

View file

@ -86,6 +86,7 @@ import { mapGetters } from "vuex";
import {
DUPLICATE_MODAL_KEY,
MAX_WORKFLOW_NAME_LENGTH,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
VIEWS, WORKFLOW_MENU_ACTIONS,
WORKFLOW_SETTINGS_MODAL_KEY,
} from "@/constants";
@ -145,29 +146,29 @@ export default mixins(workflowHelpers, titleChange).extend({
}),
...mapGetters('settings', ['areTagsEnabled']),
isNewWorkflow(): boolean {
return !this.$route.params.name;
return !this.currentWorkflowId || (this.currentWorkflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID || this.currentWorkflowId === 'new');
},
isWorkflowSaving(): boolean {
return this.$store.getters.isActionActive("workflowSaving");
},
currentWorkflowId(): string {
return this.$route.params.name;
},
currentWorkflow (): string {
return this.$route.params.name;
return this.$store.getters.workflowId;
},
workflowName (): string {
return this.$store.getters.workflowName;
},
onWorkflowPage(): boolean {
return this.$route.meta && this.$route.meta.nodeView;
return this.$route.meta && (this.$route.meta.nodeView || this.$route.meta.keepWorkflowAlive === true);
},
onExecutionsTab(): boolean {
return [ VIEWS.EXECUTION_HOME.toString(), VIEWS.EXECUTIONS.toString(), VIEWS.EXECUTION_PREVIEW ].includes(this.$route.name || '');
},
workflowMenuItems(): Array<{}> {
return [
{
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
label: this.$locale.baseText('menuActions.duplicate'),
disabled: !this.onWorkflowPage || !this.currentWorkflow,
disabled: !this.onWorkflowPage || !this.currentWorkflowId,
},
{
id: WORKFLOW_MENU_ACTIONS.DOWNLOAD,
@ -177,22 +178,22 @@ export default mixins(workflowHelpers, titleChange).extend({
{
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL,
label: this.$locale.baseText('menuActions.importFromUrl'),
disabled: !this.onWorkflowPage,
disabled: !this.onWorkflowPage || this.onExecutionsTab,
},
{
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE,
label: this.$locale.baseText('menuActions.importFromFile'),
disabled: !this.onWorkflowPage,
disabled: !this.onWorkflowPage || this.onExecutionsTab,
},
{
id: WORKFLOW_MENU_ACTIONS.SETTINGS,
label: this.$locale.baseText('generic.settings'),
disabled: !this.onWorkflowPage || !this.currentWorkflow,
disabled: !this.onWorkflowPage || this.isNewWorkflow,
},
{
id: WORKFLOW_MENU_ACTIONS.DELETE,
label: this.$locale.baseText('menuActions.delete'),
disabled: !this.onWorkflowPage || !this.currentWorkflow,
disabled: !this.onWorkflowPage || this.isNewWorkflow,
customClass: this.$style.deleteItem,
divided: true,
},
@ -201,7 +202,13 @@ export default mixins(workflowHelpers, titleChange).extend({
},
methods: {
async onSaveButtonClick () {
const saved = await this.saveCurrentWorkflow();
let currentId = undefined;
if (this.currentWorkflowId !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
currentId = this.currentWorkflowId;
} else if (this.$route.params.name && this.$route.params.name !== 'new') {
currentId = this.$route.params.name;
}
const saved = await this.saveCurrentWorkflow({ id: currentId, name: this.workflowName, tags: this.currentWorkflowTagIds });
if (saved) this.$store.dispatch('settings/fetchPromptsData');
},
onTagsEditEnable() {
@ -389,7 +396,7 @@ export default mixins(workflowHelpers, titleChange).extend({
}
try {
await this.restApi().deleteWorkflow(this.currentWorkflow);
await this.restApi().deleteWorkflow(this.currentWorkflowId);
} catch (error) {
this.$showError(
error,

View file

@ -78,6 +78,8 @@ import {
VERSIONS_MODAL_KEY,
EXECUTIONS_MODAL_KEY,
VIEWS,
WORKFLOW_OPEN_MODAL_KEY,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
} from '@/constants';
import { userHelpers } from './mixins/userHelpers';
import { debounceHelper } from './mixins/debounce';
@ -194,7 +196,7 @@ export default mixins(
{
id: 'executions',
icon: 'tasks',
label: this.$locale.baseText('mainSidebar.executions'),
label: this.$locale.baseText('generic.executions'),
position: 'top',
},
{
@ -267,7 +269,11 @@ export default mixins(
if (this.$refs.user) {
this.$externalHooks().run('mainSidebar.mounted', { userRef: this.$refs.user });
}
this.checkWidthAndAdjustSidebar(window.innerWidth);
if (window.innerWidth < 900 || this.isNodeView) {
this.$store.commit('ui/collapseSidebarMenu');
} else {
this.$store.commit('ui/expandSidebarMenu');
}
await Vue.nextTick();
this.fullyExpanded = !this.isCollapsed;
},
@ -394,6 +400,7 @@ export default mixins(
if (this.$router.currentRoute.name === VIEWS.NEW_WORKFLOW) {
this.$root.$emit('newWorkflow');
} else {
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
}
this.$showMessage({
@ -405,6 +412,7 @@ export default mixins(
}
} else {
if (this.$router.currentRoute.name !== VIEWS.NEW_WORKFLOW) {
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
}
this.$showMessage({
@ -438,10 +446,11 @@ export default mixins(
this.checkWidthAndAdjustSidebar(browserWidth);
},
checkWidthAndAdjustSidebar (width: number) {
if (width < 900 || this.isNodeView) {
if (width < 900) {
this.$store.commit('ui/collapseSidebarMenu');
} else {
this.$store.commit('ui/expandSidebarMenu');
Vue.nextTick(() => {
this.fullyExpanded = !this.isCollapsed;
});
}
},
},

View file

@ -374,9 +374,11 @@ export default mixins(
},
mounted() {
this.setSubtitle();
setTimeout(() => {
this.$emit('run', {name: this.data && this.data.name, data: this.nodeRunData, waiting: !!this.waiting});
}, 0);
if (this.nodeRunData) {
setTimeout(() => {
this.$emit('run', {name: this.data && this.data.name, data: this.nodeRunData, waiting: !!this.waiting});
}, 0);
}
},
data () {
return {

View file

@ -1,9 +1,15 @@
<template>
<div :class="$style.container">
<n8n-loading :loading="!showPreview" :rows="1" variant="image" />
<div v-if="loaderType === 'image' && !showPreview" :class="$style.imageLoader">
<n8n-loading :loading="!showPreview" :rows="1" variant="image" />
</div>
<div v-else-if="loaderType === 'spinner' && !showPreview" :class="$style.spinner">
<n8n-spinner type="dots" />
</div>
<iframe
:class="{
[$style.workflow]: !this.nodeViewDetailsOpened,
[$style.executionPreview]: mode === 'execution',
[$style.openNDV]: this.nodeViewDetailsOpened,
[$style.show]: this.showPreview,
}"
@ -18,10 +24,36 @@
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { showMessage } from '@/components/mixins/showMessage';
import { IWorkflowDb } from '../Interface';
export default mixins(showMessage).extend({
name: 'WorkflowPreview',
props: ['loading', 'workflow'],
props: {
loading: {
type: Boolean,
default: false,
},
mode: {
type: String,
default: 'workflow',
validator: (value: string): boolean =>
['workflow', 'execution', 'medium'].includes(value),
},
workflow: {
type: Object as () => IWorkflowDb,
required: false,
},
executionId: {
type: String,
required: false,
},
loaderType: {
type: String,
default: 'image',
validator: (value: string): boolean =>
['image', 'spinner'].includes(value),
},
},
data() {
return {
nodeViewDetailsOpened: false,
@ -33,7 +65,12 @@ export default mixins(showMessage).extend({
},
computed: {
showPreview(): boolean {
return !this.loading && !!this.workflow && this.ready;
return !this.loading &&
(
(this.mode === 'workflow' && !!this.workflow) ||
(this.mode === 'execution' && !!this.executionId)
) &&
this.ready;
},
},
methods: {
@ -72,6 +109,29 @@ export default mixins(showMessage).extend({
);
}
},
loadExecution() {
try {
if (!this.executionId) {
throw new Error(this.$locale.baseText('workflowPreview.showError.missingExecution'));
}
const iframe = this.$refs.preview_iframe as HTMLIFrameElement;
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(
JSON.stringify({
command: 'openExecution',
executionId: this.executionId,
}),
'*',
);
}
} catch (error) {
this.$showError(
error,
this.$locale.baseText('workflowPreview.showError.previewError.title'),
this.$locale.baseText('workflowPreview.executionMode.showError.previewError.message'),
);
}
},
receiveMessage({ data }: MessageEvent) {
try {
const json = JSON.parse(data);
@ -96,7 +156,16 @@ export default mixins(showMessage).extend({
watch: {
showPreview(show) {
if (show) {
this.loadWorkflow();
if (this.mode === 'workflow') {
this.loadWorkflow();
} else if (this.mode === 'execution') {
this.loadExecution();
}
}
},
executionId(value) {
if (this.mode === 'execution' && this.executionId) {
this.loadExecution();
}
},
},
@ -114,13 +183,12 @@ export default mixins(showMessage).extend({
<style lang="scss" module>
.container {
width: 100%;
height: 500px;
height: 100%;
display: flex;
justify-content: center;
}
.workflow {
border: var(--border-base);
border-radius: var(--border-radius-large);
// firefox bug requires loading iframe as such
visibility: hidden;
height: 0;
@ -141,4 +209,20 @@ export default mixins(showMessage).extend({
width: 100%;
z-index: 9999999;
}
.spinner {
color: var(--color-primary);
position: absolute;
top: 50% !important;
-ms-transform: translateY(-50%);
transform: translateY(-50%);
}
.imageLoader {
width: 100%;
}
.executionPreview {
height: 100%;
}
</style>

View file

@ -187,7 +187,7 @@ import {
IWorkflowShortResponse,
} from '@/Interface';
import Modal from './Modal.vue';
import { WORKFLOW_SETTINGS_MODAL_KEY } from '../constants';
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_SETTINGS_MODAL_KEY } from '../constants';
import mixins from 'vue-typed-mixins';
@ -244,7 +244,7 @@ export default mixins(
},
async mounted () {
if (this.$route.params.name === undefined) {
if (!this.workflowId || this.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
this.$showMessage({
title: 'No workflow active',
message: `No workflow active to display settings of.`,
@ -519,7 +519,7 @@ export default mixins(
this.isLoading = true;
try {
await this.restApi().updateWorkflow(this.$route.params.name, data);
await this.restApi().updateWorkflow(this.workflowId, data);
} catch (error) {
this.$showError(
error,

View file

@ -1,9 +1,10 @@
import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, MAPPING_PARAMS, TEMPLATES_NODES_FILTER, NON_ACTIVATABLE_TRIGGER_NODE_TYPES } from '@/constants';
import { CORE_NODES_CATEGORY, MAIN_HEADER_TABS, MAPPING_PARAMS, TEMPLATES_NODES_FILTER, VIEWS, NON_ACTIVATABLE_TRIGGER_NODE_TYPES } from '@/constants';
import { INodeUi, ITemplatesNode } from '@/Interface';
import { isResourceLocatorValue } from '@/typeGuards';
import dateformat from 'dateformat';
import {IDataObject, INodeProperties, INodeTypeDescription, NodeParameterValueType,INodeExecutionData, jsonParse} from 'n8n-workflow';
import { isJsonKeyObject } from "@/utils";
import { Route } from 'vue-router';
const CRED_KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
const NODE_KEYWORDS_TO_FILTER = ['Trigger'];
@ -175,3 +176,21 @@ export const clearJsonKey = (userInput: string | object) => {
return parsedUserInput.map(item => isJsonKeyObject(item) ? item.json : item);
};
export const getNodeViewTab = (route: Route): string|null => {
const routeMeta = route.meta;
if (routeMeta && routeMeta.nodeView === true) {
return MAIN_HEADER_TABS.WORKFLOW;
} else {
const executionTabRoutes = [
VIEWS.EXECUTION.toString(),
VIEWS.EXECUTION_PREVIEW.toString(),
VIEWS.EXECUTION_HOME.toString(),
];
if (executionTabRoutes.includes(route.name || '')) {
return MAIN_HEADER_TABS.EXECUTIONS;
}
}
return null;
};

View file

@ -0,0 +1,70 @@
import { IExecutionsSummary } from "@/Interface";
import dateFormat from "dateformat";
import mixins from "vue-typed-mixins";
import { genericHelpers } from "./genericHelpers";
export interface IExecutionUIData {
name: string;
label: string;
startTime: string;
runningTime: string;
}
export const executionHelpers = mixins(genericHelpers).extend({
computed: {
executionId(): string {
return this.$route.params.executionId;
},
workflowName (): string {
return this.$store.getters.workflowName;
},
currentWorkflow (): string {
return this.$route.params.name || this.$store.getters.workflowId;
},
executions(): IExecutionsSummary[] {
return this.$store.getters['workflows/currentWorkflowExecutions'];
},
activeExecution(): IExecutionsSummary {
return this.$store.getters['workflows/getActiveWorkflowExecution'];
},
},
methods: {
getExecutionUIDetails(execution: IExecutionsSummary): IExecutionUIData {
const status = {
name: 'unknown',
startTime: this.formatDate(new Date(execution.startedAt)),
label: 'Status unknown',
runningTime: '',
};
if (execution.waitTill) {
status.name = 'waiting';
status.label = this.$locale.baseText('executionsList.waiting');
} else if (execution.stoppedAt === undefined) {
status.name = 'running';
status.label = this.$locale.baseText('executionsList.running');
status.runningTime = this.displayTimer(new Date().getTime() - new Date(execution.startedAt).getTime(), true);
} else if (execution.finished) {
status.name = 'success';
status.label = this.$locale.baseText('executionsList.succeeded');
if (execution.stoppedAt) {
status.runningTime = this.displayTimer(new Date(execution.stoppedAt).getTime() - new Date(execution.startedAt).getTime(), true);
}
} else if (execution.stoppedAt !== null) {
status.name = 'error';
status.label = this.$locale.baseText('executionsList.error');
if (execution.stoppedAt) {
status.runningTime = this.displayTimer(new Date(execution.stoppedAt).getTime() - new Date(execution.startedAt).getTime(), true);
}
}
return status;
},
formatDate(date: Date) {
if (date.getFullYear() === new Date().getFullYear()) {
return dateFormat(date.getTime(), 'HH:MM:ss "on" d mmm');
}
return dateFormat(date.getTime(), 'HH:MM:ss "on" d mmm yyyy');
},
},
});

View file

@ -36,7 +36,7 @@ export const genericHelpers = mixins(showMessage).extend({
// title: 'Workflow can not be changed!',
title: this.$locale.baseText('genericHelpers.showMessage.title'),
message: this.$locale.baseText('genericHelpers.showMessage.message'),
type: 'error',
type: 'info',
duration: 0,
});

View file

@ -44,7 +44,11 @@
}
},
reload() {
window.location.reload();
if (window.top) {
window.top.location.reload();
} else {
window.location.reload();
}
},
},
});

View file

@ -3,7 +3,7 @@ import { INodeUi, XYPosition } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { getMousePosition, getRelativePosition, HEADER_HEIGHT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_EXPANDED } from '@/views/canvasHelpers';
import { getMousePosition, getRelativePosition, HEADER_HEIGHT, INNER_SIDEBAR_WIDTH, SIDEBAR_WIDTH, SIDEBAR_WIDTH_EXPANDED } from '@/views/canvasHelpers';
import { VIEWS } from '@/constants';
export const mouseSelect = mixins(
@ -49,8 +49,8 @@ export const mouseSelect = mixins(
},
getMousePositionWithinNodeView (event: MouseEvent | TouchEvent): XYPosition {
const [x, y] = getMousePosition(event);
const sidebarOffset = this.isDemo ? 0 : this.$store.getters['ui/sidebarMenuCollapsed'] ? SIDEBAR_WIDTH : SIDEBAR_WIDTH_EXPANDED;
const headerOffset = this.isDemo ? 0 : HEADER_HEIGHT;
const sidebarOffset = this.isDemo ? 0 : this.$store.getters['ui/sidebarMenuCollapsed'] ? SIDEBAR_WIDTH : SIDEBAR_WIDTH_EXPANDED;
// @ts-ignore
return getRelativePosition(x - sidebarOffset, y - headerOffset, this.nodeViewScale, this.$store.getters.getNodeViewOffsetPosition);
},

View file

@ -9,6 +9,7 @@ import {
INodeTypeDescription,
} from 'n8n-workflow';
import { getStyleTokenValue } from '../helpers';
import { readonly } from 'vue';
export const nodeBase = mixins(
deviceSupportHelpers,
@ -16,7 +17,12 @@ export const nodeBase = mixins(
mounted () {
// Initialize the node
if (this.data !== null) {
this.__addNode(this.data);
try {
this.__addNode(this.data);
} catch(error) {
// This breaks when new nodes are loaded into store but workflow tab is not currently active
// Shouldn't affect anything
}
}
},
computed: {

View file

@ -245,7 +245,7 @@ export const pushConnection = mixins(
action = '<a data-action="open-settings">Turn on saving manual executions</a> and run again to see what happened after this node.';
}
else {
action = `<a href="/execution/${activeExecutionId}" target="_blank">View the execution</a> to see what happened after this node.`;
action = `<a href="/workflow/${workflow.id}/executions/${activeExecutionId}">View the execution</a> to see what happened after this node.`;
}
// Workflow did start but had been put to wait

View file

@ -680,8 +680,9 @@ export const workflowHelpers = mixins(
}
},
async saveCurrentWorkflow({name, tags}: {name?: string, tags?: string[]} = {}, redirect = true): Promise<boolean> {
const currentWorkflow = this.$route.params.name;
async saveCurrentWorkflow({id, name, tags}: {id?: string, name?: string, tags?: string[]} = {}, redirect = true): Promise<boolean> {
const currentWorkflow = id || this.$route.params.name;
if (!currentWorkflow) {
return this.saveAsNewWorkflow({name, tags}, redirect);
}

View file

@ -230,9 +230,9 @@ export const workflowRun = mixins(
this.$store.commit('setWorkflowExecutionData', executionData);
this.updateNodesExecutionIssues();
const runWorkflowApiResponse = await this.runWorkflowApi(startRunData);
const runWorkflowApiResponse = await this.runWorkflowApi(startRunData);
this.$externalHooks().run('workflowRun.runWorkflow', { nodeName, source });
this.$externalHooks().run('workflowRun.runWorkflow', { nodeName, source });
return runWorkflowApiResponse;
} catch (error) {

View file

@ -287,6 +287,9 @@ export enum VIEWS {
HOMEPAGE = "Homepage",
COLLECTION = "TemplatesCollectionView",
EXECUTION = "ExecutionById",
EXECUTIONS = "ExecutionList",
EXECUTION_PREVIEW = "ExecutionPreview",
EXECUTION_HOME = "ExecutionsLandingPage",
TEMPLATE = "TemplatesWorkflowView",
TEMPLATES = "TemplatesSearchView",
CREDENTIALS = "CredentialsView",
@ -369,6 +372,11 @@ export enum EnterpriseEditionFeature {
}
export const MAIN_NODE_PANEL_WIDTH = 360;
export enum MAIN_HEADER_TABS {
WORKFLOW = 'workflow',
EXECUTIONS = 'executions',
SETTINGS = 'settings',
}
export const CURL_IMPORT_NOT_SUPPORTED_PROTOCOLS = [
'ftp',
'ftps',

View file

@ -30,6 +30,7 @@ import {
IFakeDoorLocation,
IRootState,
IUiState,
XYPosition,
IFakeDoor,
} from '../Interface';
@ -136,6 +137,9 @@ const module: Module<IUiState, IRootState> = {
uiLocations: ['credentialsModal'],
},
],
nodeViewInitialized: false,
addFirstStepOnLoad: false,
executionSidebarAutoRefresh: true,
},
getters: {
isVersionsOpen: (state: IUiState) => {
@ -175,6 +179,9 @@ const module: Module<IUiState, IRootState> = {
getCurrentView: (state: IUiState) => state.currentView,
isNodeView: (state: IUiState) => [VIEWS.NEW_WORKFLOW.toString(), VIEWS.WORKFLOW.toString(), VIEWS.EXECUTION.toString()].includes(state.currentView),
getNDVDataIsEmpty: (state: IUiState) => (panel: 'input' | 'output'): boolean => state.ndv[panel].data.isEmpty,
isNodeViewInitialized: (state: IUiState) => state.nodeViewInitialized,
getAddFirstStepOnLoad: (state: IUiState) => state.addFirstStepOnLoad,
isExecutionSidebarAutoRefreshOn: (state: IUiState) => state.executionSidebarAutoRefresh,
},
mutations: {
setMode: (state: IUiState, params: {name: string, mode: string}) => {
@ -231,6 +238,75 @@ const module: Module<IUiState, IRootState> = {
setCurrentView: (state: IUiState, currentView: string) => {
state.currentView = currentView;
},
setNDVSessionId: (state: IUiState) => {
Vue.set(state.ndv, 'sessionId', `ndv-${Math.random().toString(36).slice(-8)}`);
},
resetNDVSessionId: (state: IUiState) => {
Vue.set(state.ndv, 'sessionId', '');
},
setPanelDisplayMode: (state: IUiState, params: {pane: 'input' | 'output', mode: IRunDataDisplayMode}) => {
Vue.set(state.ndv[params.pane], 'displayMode', params.mode);
},
setOutputPanelEditModeEnabled: (state: IUiState, payload: boolean) => {
Vue.set(state.ndv.output.editMode, 'enabled', payload);
},
setOutputPanelEditModeValue: (state: IUiState, payload: string) => {
Vue.set(state.ndv.output.editMode, 'value', payload);
},
setMainPanelRelativePosition(state: IUiState, relativePosition: number) {
state.mainPanelPosition = relativePosition;
},
setMappableNDVInputFocus(state: IUiState, paramName: string) {
Vue.set(state.ndv, 'focusedMappableInput', paramName);
},
draggableStartDragging(state: IUiState, {type, data}: {type: string, data: string}) {
state.draggable = {
isDragging: true,
type,
data,
canDrop: false,
stickyPosition: null,
};
},
draggableStopDragging(state: IUiState) {
state.draggable = {
isDragging: false,
type: '',
data: '',
canDrop: false,
stickyPosition: null,
};
},
setDraggableStickyPos(state: IUiState, position: XYPosition | null) {
Vue.set(state.draggable, 'stickyPosition', position);
},
setDraggableCanDrop(state: IUiState, canDrop: boolean) {
Vue.set(state.draggable, 'canDrop', canDrop);
},
setMappingTelemetry(state: IUiState, telemetry: {[key: string]: string | number | boolean}) {
state.ndv.mappingTelemetry = {...state.ndv.mappingTelemetry, ...telemetry};
},
resetMappingTelemetry(state: IUiState) {
state.ndv.mappingTelemetry = {};
},
setHoveringItem(state: IUiState, item: null | IUiState['ndv']['hoveringItem']) {
Vue.set(state.ndv, 'hoveringItem', item);
},
setNDVBranchIndex(state: IUiState, e: {pane: 'input' | 'output', branchIndex: number}) {
Vue.set(state.ndv[e.pane], 'branch', e.branchIndex);
},
setNDVPanelDataIsEmpty(state: IUiState, payload: {panel: 'input' | 'output', isEmpty: boolean}) {
Vue.set(state.ndv[payload.panel].data, 'isEmpty', payload.isEmpty);
},
setNodeViewInitialized(state: IUiState, isInitialized: boolean) {
state.nodeViewInitialized = isInitialized;
},
setAddFirstStepOnLoad(state: IUiState, addStep: boolean) {
state.addFirstStepOnLoad = addStep;
},
setExecutionsSidebarAutoRefresh(state: IUiState, autoRefresh: boolean) {
state.executionSidebarAutoRefresh = autoRefresh;
},
},
actions: {
openModal: async (context: ActionContext<IUiState, IRootState>, modalKey: string) => {

View file

@ -1,14 +1,21 @@
import { getNewWorkflow } from '@/api/workflows';
import { makeRestApiRequest } from '@/api/helpers';
import { getCurrentExecutions, getFinishedExecutions, getNewWorkflow } from '@/api/workflows';
import { DUPLICATE_POSTFFIX, MAX_WORKFLOW_NAME_LENGTH, DEFAULT_NEW_WORKFLOW_NAME } from '@/constants';
import { IDataObject } from 'n8n-workflow';
import { ActionContext, Module } from 'vuex';
import {
IExecutionsSummary,
IRootState,
IWorkflowsState,
} from '../Interface';
const module: Module<IWorkflowsState, IRootState> = {
namespaced: true,
state: {},
state: {
currentWorkflowExecutions: [],
activeWorkflowExecution: null,
finishedExecutionsCount: 0,
},
actions: {
getNewWorkflowData: async (context: ActionContext<IWorkflowsState, IRootState>, name?: string): Promise<object> => {
let workflowData = {
@ -43,6 +50,66 @@ const module: Module<IWorkflowsState, IRootState> = {
return newName;
},
async loadCurrentWorkflowExecutions (
context: ActionContext<IWorkflowsState, IRootState>,
filter: { finished: boolean, status: string },
): Promise<IExecutionsSummary[]> {
let activeExecutions = [];
let finishedExecutions = [];
const requestFilter: IDataObject = { workflowId: context.rootGetters.workflowId };
if (!context.rootGetters.workflowId) {
return [];
}
try {
if (filter.status === ''|| !filter.finished) {
activeExecutions = await getCurrentExecutions(context.rootGetters.getRestApiContext, requestFilter);
}
if (filter.status === '' || filter.finished) {
if (filter.status === 'waiting') {
requestFilter.waitTill = true;
} else if (filter.status !== '') {
requestFilter.finished = filter.status === 'success';
}
finishedExecutions = await getFinishedExecutions(context.rootGetters.getRestApiContext, requestFilter);
}
context.commit('setTotalFinishedExecutionsCount', finishedExecutions.count);
return [...activeExecutions, ...finishedExecutions.results || []];
} catch (error) {
throw(error);
}
},
},
mutations: {
setCurrentWorkflowExecutions (state: IWorkflowsState, executions: IExecutionsSummary[]) {
state.currentWorkflowExecutions = executions;
},
setActiveWorkflowExecution (state: IWorkflowsState, executionData: IExecutionsSummary) {
state.activeWorkflowExecution = executionData;
},
setTotalFinishedExecutionsCount (state: IWorkflowsState, count: number) {
state.finishedExecutionsCount = count;
},
deleteExecution (state: IWorkflowsState, execution: IExecutionsSummary) {
state.currentWorkflowExecutions.splice(state.currentWorkflowExecutions.indexOf(execution), 1);
},
},
getters: {
currentWorkflowExecutions: (state: IWorkflowsState): IExecutionsSummary[] => {
return state.currentWorkflowExecutions;
},
getActiveWorkflowExecution: (state: IWorkflowsState): IExecutionsSummary|null => {
return state.activeWorkflowExecution;
},
getExecutionDataById: (state: IWorkflowsState) => (id: string) => {
return state.currentWorkflowExecutions.find(execution => execution.id === id);
},
getTotalFinishedExecutionsCount: (state: IWorkflowsState) : number => {
return state.finishedExecutionsCount;
},
getAllLoadedFinishedExecutions: (state: IWorkflowsState) : IExecutionsSummary[] => {
return state.currentWorkflowExecutions.filter(ex => ex.finished === true || ex.stoppedAt !== undefined);
},
},
};

View file

@ -13,13 +13,18 @@
"_reusableDynamicText.moreInfo": "More info",
"_reusableDynamicText.oauth2.clientId": "Client ID",
"_reusableDynamicText.oauth2.clientSecret": "Client Secret",
"generic.learnMore": "Learn more",
"generic.confirm": "Confirm",
"generic.any": "Any",
"generic.cancel": "Cancel",
"generic.confirm": "Confirm",
"generic.filtersApplied": "Filters are currently applied.",
"generic.learnMore": "Learn more",
"generic.reset": "Reset",
"generic.resetAllFilters": "Reset all filters",
"generic.communityNode": "Community Node",
"generic.communityNode.tooltip": "This is a node from our community. It's part of the {packageName} package. <a href=\"{docURL}\" target=\"_blank\" title=\"Read the n8n docs\">Learn more</a>",
"generic.delete": "Delete",
"generic.copy": "Copy",
"generic.delete": "Delete",
"generic.executions": "Executions",
"generic.or": "or",
"generic.clickToCopy": "Click to copy",
"generic.copiedToClipboard": "Copied to clipboard",
@ -31,6 +36,7 @@
"generic.unsavedWork.confirmMessage.message": "If you don't save, you will lose your changes.",
"generic.unsavedWork.confirmMessage.confirmButtonText": "Save",
"generic.unsavedWork.confirmMessage.cancelButtonText": "Leave without saving",
"generic.workflow": "Workflow",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",
@ -368,6 +374,11 @@
"error": "Error",
"error.goBack": "Go back",
"error.pageNotFound": "Oops, couldnt find that",
"executions.ExecutionStatus": "Execution status",
"executionDetails.confirmMessage.confirmButtonText": "Yes, delete",
"executionDetails.confirmMessage.headline": "Delete Execution?",
"executionDetails.confirmMessage.message": "Are you sure that you want to delete the current execution?",
"executionDetails.deleteExecution": "Delete this execution",
"executionDetails.executionFailed": "Execution failed",
"executionDetails.executionId": "Execution ID",
"executionDetails.executionWaiting": "Execution waiting",
@ -376,7 +387,22 @@
"executionDetails.openWorkflow": "Open Workflow",
"executionDetails.readOnly.readOnly": "Read only",
"executionDetails.readOnly.youreViewingTheLogOf": "You're viewing the log of a previous execution. You cannot<br />\n\t\tmake changes since this execution already occurred. Make changes<br />\n\t\tto this workflow by clicking on its name on the left.",
"executionDetails.retry": "Retry of execution",
"executionDetails.runningTimeFinished": "in {time}",
"executionDetails.runningTimeRunning": "for {time}",
"executionDetails.runningMessage": "Execution is running. It will show here once finished.",
"executionDetails.workflow": "workflow",
"executionsLandingPage.emptyState.noTrigger.heading": "Set up the first step. Then execute your workflow",
"executionsLandingPage.emptyState.noTrigger.buttonText": "Add first step...",
"executionsLandingPage.clickExecutionMessage": "Click on an execution from the list to view it",
"executionsLandingPage.emptyState.heading": " No executions yet",
"executionsLandingPage.emptyState.message": "New workflow executions will show here",
"executionsLandingPage.emptyState.accordion.title": "Which executions is this workflow saving?",
"executionsLandingPage.emptyState.accordion.productionExecutions": "Production executions",
"executionsLandingPage.emptyState.accordion.manualExecutions": "Manual executions",
"executionsLandingPage.emptyState.accordion.productionExecutionsWarningTooltip": "Not all production executions are being saved. Change this in the workflow's <a href=\"#\">settings</a>",
"executionsLandingPage.emptyState.accordion.footer": "You can change this in <a href=\"#\">Workflow Settings</a>",
"executionsLandingPage.noResults": "No executions found",
"executionsList.allWorkflows": "All Workflows",
"executionsList.anyStatus": "Any Status",
"executionsList.autoRefresh": "Auto refresh",
@ -385,7 +411,7 @@
"executionsList.confirmMessage.headline": "Delete Executions?",
"executionsList.confirmMessage.message": "Are you sure that you want to delete the {numSelected} selected execution(s)?",
"executionsList.deleteSelected": "Delete Selected",
"executionsList.error": "Error",
"executionsList.error": "Failed",
"executionsList.filters": "Filters",
"executionsList.loadMore": "Load More",
"executionsList.mode": "Mode",
@ -400,12 +426,14 @@
"executionsList.retryExecution": "Retry execution",
"executionsList.retryOf": "Retry of",
"executionsList.retryWithCurrentlySavedWorkflow": "Retry with currently saved workflow (from node with error)",
"executionsList.retryWithOriginalworkflow": "Retry with original workflow (from node with error)",
"executionsList.retryWithOriginalWorkflow": "Retry with original workflow (from node with error)",
"executionsList.running": "Running",
"executionsList.succeeded": "Succeeded",
"executionsList.runningTime": "Running Time",
"executionsList.selectStatus": "Select Status",
"executionsList.selectWorkflow": "Select Workflow",
"executionsList.selected": "Selected",
"executionsList.manual": "Manual execution",
"executionsList.showError.handleDeleteSelected.title": "Problem deleting executions",
"executionsList.showError.loadMore.title": "Problem loading executions",
"executionsList.showError.loadWorkflows.title": "Problem loading workflows",
@ -435,6 +463,10 @@
"executionsList.unsavedWorkflow": "[UNSAVED WORKFLOW]",
"executionsList.waiting": "Waiting",
"executionsList.workflowExecutions": "Workflow Executions",
"executionSidebar.executionName": "Execution {id}",
"executionSidebar.searchPlaceholder": "Search executions...",
"executionView.onPaste.title": "Cannot paste here",
"executionView.onPaste.message": "This view is read-only. Switch to <i>Workflow</i> tab to be able to edit the current workflow",
"expressionEdit.editExpression": "Edit Expression",
"expressionEdit.expression": "Expression",
"expressionEdit.result": "Result",
@ -483,15 +515,14 @@
"genericHelpers.loading": "Loading",
"genericHelpers.min": "min",
"genericHelpers.sec": "sec",
"genericHelpers.showMessage.message": "This is a read-only version of the workflow. To make changes, either open the original workflow or save it under a new name.",
"genericHelpers.showMessage.title": "Workflow cannot be changed",
"genericHelpers.showMessage.message": "Executions are read-only. Make changes from the <b>Workflow</b> tab.",
"genericHelpers.showMessage.title": "Cannot edit execution",
"mainSidebar.aboutN8n": "About n8n",
"mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "",
"mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete",
"mainSidebar.confirmMessage.workflowDelete.headline": "Delete Workflow?",
"mainSidebar.confirmMessage.workflowDelete.message": "Are you sure that you want to delete '{workflowName}'?",
"mainSidebar.credentials": "Credentials",
"mainSidebar.executions": "Executions",
"mainSidebar.help": "Help",
"mainSidebar.helpMenuItems.course": "Course",
"mainSidebar.helpMenuItems.documentation": "Documentation",
@ -1118,7 +1149,6 @@
"templates.newButton": "New blank workflow",
"templates.noSearchResults": "Nothing found. Try adjusting your search to see more.",
"templates.searchPlaceholder": "Search workflows",
"templates.workflow": "Workflow",
"templates.workflows": "Workflows",
"templates.workflowsNotFound": "Workflow could not be found",
"textEdit.edit": "Edit",
@ -1230,6 +1260,8 @@
"workflowPreview.showError.arrayEmpty": "Must have an array of nodes",
"workflowPreview.showError.missingWorkflow": "Missing workflow",
"workflowPreview.showError.previewError.message": "Unable to preview workflow",
"workflowPreview.showError.missingExecution": "Missing workflow execution",
"workflowPreview.executionMode.showError.previewError.message": "Unable to preview workflow execution",
"workflowPreview.showError.previewError.title": "Preview error",
"workflowRun.noActiveConnectionToTheServer": "Lost connection to the server",
"workflowRun.showError.title": "Problem running workflow",
@ -1250,11 +1282,11 @@
"workflowSettings.minutes": "minutes",
"workflowSettings.noWorkflow": "- No Workflow -",
"workflowSettings.save": "@:_reusableBaseText.save",
"workflowSettings.saveDataErrorExecution": "Save failed executions",
"workflowSettings.saveDataErrorExecution": "Save failed production executions",
"workflowSettings.saveDataErrorExecutionOptions.defaultSave": "Default - {defaultValue}",
"workflowSettings.saveDataErrorExecutionOptions.doNotSave": "Do not save",
"workflowSettings.saveDataErrorExecutionOptions.save": "@:_reusableBaseText.save",
"workflowSettings.saveDataSuccessExecution": "Save successful executions",
"workflowSettings.saveDataSuccessExecution": "Save successful production executions",
"workflowSettings.saveDataSuccessExecutionOptions.defaultSave": "Default - {defaultValue}",
"workflowSettings.saveDataSuccessExecutionOptions.doNotSave": "Do not save",
"workflowSettings.saveDataSuccessExecutionOptions.save": "@:_reusableBaseText.save",

View file

@ -50,12 +50,14 @@ import {
faFileImport,
faFilePdf,
faFilter,
faFlask,
faFolderOpen,
faGlobeAmericas,
faGift,
faGlobe,
faGraduationCap,
faGripVertical,
faHandPointLeft,
faHdd,
faHome,
faHourglass,
@ -172,11 +174,13 @@ addIcon(faFileExport);
addIcon(faFileImport);
addIcon(faFilePdf);
addIcon(faFilter);
addIcon(faFlask);
addIcon(faFolderOpen);
addIcon(faGift);
addIcon(faGlobe);
addIcon(faGlobeAmericas);
addIcon(faGraduationCap);
addIcon(faHandPointLeft);
addIcon(faHdd);
addIcon(faHome);
addIcon(faHourglass);

View file

@ -6,6 +6,9 @@ import ForgotMyPasswordView from './views/ForgotMyPasswordView.vue';
import MainHeader from '@/components/MainHeader/MainHeader.vue';
import MainSidebar from '@/components/MainSidebar.vue';
import NodeView from '@/views/NodeView.vue';
import ExecutionsView from '@/components/ExecutionsView/ExecutionsView.vue';
import ExecutionsLandingPage from '@/components/ExecutionsView/ExecutionsLandingPage.vue';
import ExecutionPreview from '@/components/ExecutionsView/ExecutionPreview.vue';
import SettingsPersonalView from './views/SettingsPersonalView.vue';
import SettingsUsersView from './views/SettingsUsersView.vue';
import SettingsCommunityNodesView from './views/SettingsCommunityNodesView.vue';
@ -22,7 +25,7 @@ import TemplatesSearchView from '@/views/TemplatesSearchView.vue';
import CredentialsView from '@/views/CredentialsView.vue';
import WorkflowsView from '@/views/WorkflowsView.vue';
import { Store } from 'vuex';
import { IPermissions, IRootState, IWorkflowsState } from './Interface';
import { IPermissions, IRootState } from './Interface';
import { LOGIN_STATUS, ROLE } from './modules/userHelpers';
import { RouteConfigSingleView } from 'vue-router/types/router';
import { VIEWS } from './constants';
@ -207,7 +210,7 @@ const router = new Router({
},
},
{
path: '/workflow',
path: '/workflow/new',
name: VIEWS.NEW_WORKFLOW,
components: {
default: NodeView,
@ -240,6 +243,55 @@ const router = new Router({
},
},
},
{
path: '/workflow/:name/executions',
name: VIEWS.EXECUTIONS,
components: {
default: ExecutionsView,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
keepWorkflowAlive: true,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
},
children: [
{
path: '',
name: VIEWS.EXECUTION_HOME,
components: {
executionPreview: ExecutionsLandingPage,
},
meta: {
keepWorkflowAlive: true,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
},
},
{
path: ':executionId',
name: VIEWS.EXECUTION_PREVIEW,
components: {
executionPreview: ExecutionPreview,
},
meta: {
keepWorkflowAlive: true,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
},
},
],
},
{
path: '/workflows/demo',
name: VIEWS.DEMO,

View file

@ -462,7 +462,7 @@ export const store = new Vuex.Store({
// Id
setWorkflowId (state, id: string) {
state.workflow.id = id;
state.workflow.id = id === 'new' ? PLACEHOLDER_EMPTY_WORKFLOW_ID : id;
},
// Name

View file

@ -1,8 +1,22 @@
<template>
<div class="node-view-root" @dragover="onDragOver" @drop="onDrop">
<div class="node-view-wrapper" :class="workflowClasses" @touchstart="mouseDown" @touchend="mouseUp"
@touchmove="mouseMoveNodeWorkflow" @mousedown="mouseDown" v-touch:tap="touchTap" @mouseup="mouseUp"
@wheel="wheelScroll">
<div :class="$style['content']">
<div
class="node-view-root"
id="node-view-root"
@dragover="onDragOver"
@drop="onDrop"
>
<div
class="node-view-wrapper"
:class="workflowClasses"
@touchstart="mouseDown"
@touchend="mouseUp"
@touchmove="mouseMoveNodeWorkflow"
@mousedown="mouseDown"
v-touch:tap="touchTap"
@mouseup="mouseUp"
@wheel="wheelScroll"
>
<div id="node-view-background" class="node-view-background" :style="backgroundStyle" />
<div
id="node-view"
@ -12,7 +26,7 @@
>
<canvas-add-button
:style="canvasAddButtonStyle"
@click="showTriggerCreator('tirger_placeholder_button')"
@click="showTriggerCreator('trigger_placeholder_button')"
v-show="showCanvasAddButton"
:showTooltip="!containsTrigger && showTriggerMissingTooltip"
:position="canvasAddButtonPosition"
@ -117,6 +131,7 @@
@click.stop="clearExecutionData()" />
</div>
</div>
</div>
</template>
<script lang="ts">
@ -130,6 +145,7 @@ import once from 'lodash/once';
import {
FIRST_ONBOARDING_PROMPT_TIMEOUT,
MAIN_HEADER_TABS,
MODAL_CANCEL,
MODAL_CLOSE,
MODAL_CONFIRMED,
@ -205,11 +221,14 @@ import {
IWorkflowToShare,
} from '@/Interface';
import { mapGetters } from 'vuex';
import '../plugins/N8nCustomConnectorType';
import '../plugins/PlusEndpointType';
import { getAccountAge } from '@/modules/userHelpers';
import { dataPinningEventBus } from "@/event-bus/data-pinning-event-bus";
import { debounceHelper } from '@/components/mixins/debounce';
import { getNodeViewTab } from '@/components/helpers';
import { Route } from 'vue-router';
interface AddNodeOptions {
position?: XYPosition;
@ -249,8 +268,44 @@ export default mixins(
},
watch: {
// Listen to route changes and load the workflow accordingly
'$route': 'initView',
activeNode() {
'$route' (to: Route, from: Route) {
const currentTab = getNodeViewTab(to);
const nodeViewNotInitialized = !this.$store.getters['ui/isNodeViewInitialized'];
let workflowChanged =
from.params.name !== to.params.name &&
// Both 'new' and __EMPTY__ are new workflow names, so ignore them when detecting if wf changed
!(from.params.name === 'new' && this.currentWorkflow === PLACEHOLDER_EMPTY_WORKFLOW_ID) &&
// Also ignore if workflow id changes when saving new workflow
to.params.action !== 'workflowSave';
const isOpeningTemplate = to.name === VIEWS.TEMPLATE_IMPORT;
// When entering this tab:
if (currentTab === MAIN_HEADER_TABS.WORKFLOW || isOpeningTemplate) {
if (workflowChanged || nodeViewNotInitialized || isOpeningTemplate) {
this.startLoading();
if (nodeViewNotInitialized) {
const previousDirtyState = this.$store.getters.getStateIsDirty;
this.resetWorkspace();
this.$store.commit('setStateDirty', previousDirtyState);
}
this.initView().then(() => {
this.stopLoading();
if (this.blankRedirect) {
this.blankRedirect = false;
}
});
}
}
// Also, when landing on executions tab, check if workflow data is changed
if (currentTab === MAIN_HEADER_TABS.EXECUTIONS) {
workflowChanged = from.params.name !== to.params.name && !(to.params.name === 'new' && from.params.name === undefined);
if (workflowChanged) {
// This will trigger node view to update next time workflow tab is opened
this.$store.commit('ui/setNodeViewInitialized', false);
}
}
},
activeNode () {
// When a node gets set as active deactivate the create-menu
this.createNodeActive = false;
},
@ -261,30 +316,40 @@ export default mixins(
},
},
async beforeRouteLeave(to, from, next) {
this.$store.commit('setSubworkflowExecutionError', null);
const result = this.$store.getters.getStateIsDirty;
if (result) {
const confirmModal = await this.confirmModal(
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
'warning',
this.$locale.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
true,
);
if (confirmModal === MODAL_CONFIRMED) {
const saved = await this.saveCurrentWorkflow({}, false);
if (saved) this.$store.dispatch('settings/fetchPromptsData');
this.$store.commit('setStateDirty', false);
const nextTab = getNodeViewTab(to);
// Only react if leaving workflow tab and going to a separate page
if (!nextTab) {
// Skip check if in the middle of template import
if (from.name === VIEWS.TEMPLATE_IMPORT) {
next();
} else if (confirmModal === MODAL_CANCEL) {
this.$store.commit('setStateDirty', false);
next();
} else if (confirmModal === MODAL_CLOSE) {
next(false);
return;
}
const result = this.$store.getters.getStateIsDirty;
if (result) {
const confirmModal = await this.confirmModal(
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
'warning',
this.$locale.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
true,
);
if (confirmModal === MODAL_CONFIRMED) {
const saved = await this.saveCurrentWorkflow({}, false);
if (saved) this.$store.dispatch('settings/fetchPromptsData');
this.$store.commit('setStateDirty', false);
next();
} else if (confirmModal === MODAL_CANCEL) {
this.$store.commit('setStateDirty', false);
next();
} else if (confirmModal === MODAL_CLOSE) {
next(false);
}
} else {
next();
}
} else {
next();
}
@ -351,7 +416,11 @@ export default mixins(
};
},
backgroundStyle(): object {
return CanvasHelpers.getBackgroundStyles(this.nodeViewScale, this.getNodeViewOffsetPosition);
return CanvasHelpers.getBackgroundStyles(
this.nodeViewScale,
this.$store.getters.getNodeViewOffsetPosition,
this.isExecutionPreview,
);
},
workflowClasses() {
const returnClasses = [];
@ -374,9 +443,14 @@ export default mixins(
workflowRunning(): boolean {
return this.$store.getters.isActionActive('workflowRunning');
},
currentWorkflow (): string {
return this.$route.params.name || this.$store.getters.workflowId;
},
workflowName (): string {
return this.$store.getters.workflowName;
},
allTriggersDisabled(): boolean {
const disabledTriggerNodes = this.triggerNodes.filter(node => node.disabled);
return disabledTriggerNodes.length === this.triggerNodes.length;
},
triggerNodes(): INodeUi[] {
@ -414,6 +488,7 @@ export default mixins(
dropPrevented: false,
renamingActive: false,
showStickyButton: false,
isExecutionPreview: false,
showTriggerMissingTooltip: false,
canvasAddButtonPosition: [1, 1] as XYPosition,
workflowData: null as INewWorkflowData | null,
@ -442,7 +517,7 @@ export default mixins(
this.$externalHooks().run('nodeView.onRunNode', telemetryPayload);
this.runWorkflow(nodeName, source);
},
onRunWorkflow() {
async onRunWorkflow() {
this.getWorkflowDataToSave().then((workflowData) => {
const telemetryPayload = {
workflow_id: this.$store.getters.workflowId,
@ -450,9 +525,10 @@ export default mixins(
};
this.$telemetry.track('User clicked execute workflow button', telemetryPayload);
this.$externalHooks().run('nodeView.onRunWorkflow', telemetryPayload);
});
this.runWorkflow();
await this.runWorkflow();
},
onRunContainerClick() {
if (this.containsTrigger && !this.allTriggersDisabled) return;
@ -559,8 +635,8 @@ export default mixins(
this.$nextTick(() => this.$store.commit('nodeCreator/setShowTabs', false));
},
async openExecution(executionId: string) {
this.startLoading();
this.resetWorkspace();
let data: IExecutionResponse | undefined;
try {
data = await this.restApi().getExecution(executionId);
@ -571,14 +647,11 @@ export default mixins(
);
return;
}
if (data === undefined) {
throw new Error(`Execution with id "${executionId}" could not be found!`);
}
this.$store.commit('setWorkflowName', { newName: data.workflowData.name, setStateDirty: false });
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
this.$store.commit('setWorkflowExecutionData', data);
this.$store.commit('setWorkflowPinData', data.workflowData.pinData);
@ -587,8 +660,6 @@ export default mixins(
this.zoomToFit();
this.$store.commit('setStateDirty', false);
});
this.$externalHooks().run('execution.open', { workflowId: data.workflowData.id, workflowName: data.workflowData.name, executionId });
this.$telemetry.track('User opened read-only execution', { workflow_id: data.workflowData.id, execution_mode: data.mode, execution_finished: data.finished });
@ -617,7 +688,6 @@ export default mixins(
message: errorMessage,
type: 'error',
}, shouldTrack);
if (data.data.resultData.error.stack) {
// Display some more information for now in console to make debugging easier
// TODO: Improve this in the future by displaying in UI
@ -626,7 +696,6 @@ export default mixins(
}
}
}
if ((data as IExecutionsSummary).waitTill) {
this.$showMessage({
title: this.$locale.baseText('nodeView.thisExecutionHasntFinishedYet'),
@ -635,6 +704,7 @@ export default mixins(
duration: 0,
});
}
this.stopLoading();
},
async importWorkflowExact(data: { workflow: IWorkflowDataUpdate }) {
if (!data.workflow.nodes || !data.workflow.connections) {
@ -654,9 +724,13 @@ export default mixins(
});
},
async openWorkflowTemplate(templateId: string) {
this.startLoading();
this.setLoadingText(this.$locale.baseText('nodeView.loadingTemplate'));
this.resetWorkspace();
this.$store.commit('workflows/setCurrentWorkflowExecutions', []);
this.$store.commit('workflows/setActiveWorkflowExecution', null);
let data: IWorkflowTemplate | undefined;
try {
this.$externalHooks().run('template.requested', { templateId });
@ -685,14 +759,16 @@ export default mixins(
this.workflowData = await this.$store.dispatch('workflows/getNewWorkflowData', data.name);
this.$nextTick(() => {
this.zoomToFit();
this.$store.commit('setStateDirty', true);
});
this.$externalHooks().run('template.open', { templateId, templateName: data.name, workflow: data.workflow });
this.stopLoading();
},
async openWorkflow(workflowId: string) {
this.startLoading();
this.resetWorkspace();
let data: IWorkflowDb | undefined;
try {
data = await this.restApi().getWorkflow(workflowId);
@ -712,28 +788,23 @@ export default mixins(
),
);
}
this.$store.commit('setActive', data.active || false);
this.$store.commit('setWorkflowId', workflowId);
this.$store.commit('setWorkflowName', { newName: data.name, setStateDirty: false });
this.$store.commit('setWorkflowSettings', data.settings || {});
this.$store.commit('setWorkflowPinData', data.pinData || {});
const tags = (data.tags || []) as ITag[];
this.$store.commit('tags/upsertTags', tags);
const tagIds = tags.map((tag) => tag.id);
this.$store.commit('setWorkflowTagIds', tagIds || []);
await this.addNodes(data.nodes, data.connections);
if (!this.credentialsUpdated) {
this.$store.commit('setStateDirty', false);
}
this.zoomToFit();
this.$externalHooks().run('workflow.open', { workflowId, workflowName: data.name });
this.$store.commit('workflows/setActiveWorkflowExecution', null);
this.stopLoading();
return data;
},
touchTap(e: MouseEvent | TouchEvent) {
@ -1188,7 +1259,7 @@ export default mixins(
return;
}
const { zoomLevel, offset } = CanvasHelpers.getZoomToFit(nodes);
const {zoomLevel, offset} = CanvasHelpers.getZoomToFit(nodes, !this.isDemo);
this.setZoomLevel(zoomLevel);
this.$store.commit('setNodeViewOffsetPosition', { newOffset: offset });
@ -1263,13 +1334,15 @@ export default mixins(
return;
}
},
/**
* This method gets called when data got pasted into the window
*/
async receivedCopyPasteData(plainTextData: string): Promise<void> {
let workflowData: IWorkflowDataUpdate | undefined;
if (this.editAllowedCheck() === false) {
return;
}
// Check if it is an URL which could contain workflow data
if (plainTextData.match(/^http[s]?:\/\/.*\.json$/i)) {
// Pasted data points to a possible workflow JSON file
@ -2089,8 +2162,11 @@ export default mixins(
});
},
async newWorkflow(): Promise<void> {
this.startLoading();
await this.resetWorkspace();
this.workflowData = await this.$store.dispatch('workflows/getNewWorkflowData');
this.$store.commit('workflows/setCurrentWorkflowExecutions', []);
this.$store.commit('workflows/setActiveWorkflowExecution', null);
this.$store.commit('setStateDirty', false);
this.setZoomLevel(1);
@ -2123,6 +2199,9 @@ export default mixins(
this.$telemetry.track('welcome note inserted');
}
}
this.$store.commit('ui/setNodeViewInitialized', true);
this.$store.commit('workflows/setActiveWorkflowExecution', null);
this.stopLoading();
}),
async initView(): Promise<void> {
if (this.$route.params.action === 'workflowSave') {
@ -2131,7 +2210,6 @@ export default mixins(
this.$store.commit('setStateDirty', false);
return Promise.resolve();
}
if (this.blankRedirect) {
this.blankRedirect = false;
}
@ -2144,7 +2222,6 @@ export default mixins(
const executionId = this.$route.params.id;
await this.openExecution(executionId);
} else {
const result = this.$store.getters.getStateIsDirty;
if (result) {
const confirmModal = await this.confirmModal(
@ -2155,7 +2232,6 @@ export default mixins(
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
true,
);
if (confirmModal === MODAL_CONFIRMED) {
const saved = await this.saveCurrentWorkflow();
if (saved) this.$store.dispatch('settings/fetchPromptsData');
@ -2163,7 +2239,6 @@ export default mixins(
return Promise.resolve();
}
}
// Load a workflow
let workflowId = null as string | null;
if (this.$route.params.name) {
@ -2185,15 +2260,14 @@ export default mixins(
// Open existing workflow
await this.openWorkflow(workflowId);
}
} else {
} else if (this.$route.meta?.nodeView === true) {
// Create new workflow
await this.newWorkflow();
}
}
this.$store.commit('ui/setNodeViewInitialized', true);
document.addEventListener('keydown', this.keyDown);
document.addEventListener('keyup', this.keyUp);
window.addEventListener("beforeunload", (e) => {
if (this.isDemo){
return;
@ -2206,7 +2280,6 @@ export default mixins(
this.startLoading(
this.$locale.baseText('nodeView.redirecting'),
);
return;
}
});
@ -3097,6 +3170,7 @@ export default mixins(
if (json && json.command === 'openWorkflow') {
try {
await this.importWorkflowExact(json);
this.isExecutionPreview = false;
} catch (e) {
if (window.top) {
window.top.postMessage(JSON.stringify({ command: 'error', message: this.$locale.baseText('openWorkflow.workflowImportError') }), '*');
@ -3107,6 +3181,20 @@ export default mixins(
type: 'error',
});
}
} else if (json && json.command === 'openExecution') {
try {
await this.openExecution(json.executionId);
this.isExecutionPreview = true;
} catch (e) {
if (window.top) {
window.top.postMessage(JSON.stringify({ command: 'error', message: this.$locale.baseText('nodeView.showError.openExecution.title') }), '*');
}
this.$showMessage({
title: this.$locale.baseText('nodeView.showError.openExecution.title'),
message: (e as Error).message,
type: 'error',
});
}
}
} catch (e) {
}
@ -3169,7 +3257,6 @@ export default mixins(
this.addNode(nodeTypeName, { position });
},
},
async mounted() {
this.$titleReset();
window.addEventListener('message', this.onPostMessageReceived);
@ -3178,6 +3265,7 @@ export default mixins(
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent);
this.startLoading();
this.resetWorkspace();
const loadPromises = [
this.loadActiveWorkflows(),
@ -3202,7 +3290,9 @@ export default mixins(
this.instance.ready(async () => {
try {
this.initNodeView();
try {
this.initNodeView();
} catch {} // This will break if mounted after jsplumb has been initiated from executions preview, so continue if it breaks
await this.initView();
if (window.top) {
window.top.postMessage(JSON.stringify({ command: 'n8nReady', version: this.$store.getters.versionCli }), '*');
@ -3257,7 +3347,33 @@ export default mixins(
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
},
activated() {
const openSideMenu = this.$store.getters['ui/getAddFirstStepOnLoad'];
if (openSideMenu) {
this.showTriggerCreator('trigger_placeholder_button');
}
this.$store.commit('ui/setAddFirstStepOnLoad', false);
document.addEventListener('keydown', this.keyDown);
document.addEventListener('keyup', this.keyUp);
window.addEventListener('message', this.onPostMessageReceived);
this.$root.$on('newWorkflow', this.newWorkflow);
this.$root.$on('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$on('importWorkflowUrl', this.onImportWorkflowUrlEvent);
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
},
deactivated () {
document.removeEventListener('keydown', this.keyDown);
document.removeEventListener('keyup', this.keyUp);
window.removeEventListener('message', this.onPostMessageReceived);
this.$root.$off('newWorkflow', this.newWorkflow);
this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent);
dataPinningEventBus.$off('pin-data', this.addPinDataConnections);
dataPinningEventBus.$off('unpin-data', this.removePinDataConnections);
},
destroyed() {
this.resetWorkspace();
this.$store.commit('setStateDirty', false);
@ -3276,18 +3392,15 @@ export default mixins(
.zoom-menu {
$--zoom-menu-margin: 15;
position: fixed;
position: absolute;
left: $sidebar-width + $--zoom-menu-margin;
width: 210px;
bottom: 44px;
bottom: 108px;
left: 35px;
line-height: 25px;
color: #444;
padding-right: 5px;
&:not(.demo-zoom-menu).expanded {
left: $sidebar-expanded-width + $--zoom-menu-margin;
}
button {
border: var(--border-base);
}
@ -3315,6 +3428,8 @@ export default mixins(
}
.node-view-root {
position: relative;
flex: 1;
overflow: hidden;
background-color: var(--color-canvas-background);
width: 100%;
@ -3474,3 +3589,36 @@ export default mixins(
}
}
</style>
<style module lang="scss">
.content {
position: relative;
display: flex;
overflow: auto;
height: 100vh;
}
.shake {
animation: 1s 200ms shake;
}
@keyframes shake {
10%, 90% {
transform: translate3d(-1px, 0, 0);
}
20%, 80% {
transform: translate3d(2px, 0, 0);
}
30%, 50%, 70% {
transform: translate3d(-4px, 0, 0);
}
40%, 60% {
transform: translate3d(4px, 0, 0);
}
}
</style>

View file

@ -204,6 +204,7 @@ export default mixins(genericHelpers, debounceHelper).extend({
}
},
openNewWorkflow() {
this.$store.commit('ui/setNodeViewInitialized', false);
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
},
onSearchInput(search: string) {

View file

@ -7,7 +7,7 @@
template.name
}}</n8n-heading>
<n8n-text v-if="template && template.name" color="text-base" size="small">
{{ $locale.baseText('templates.workflow') }}
{{ $locale.baseText('generic.workflow') }}
</n8n-text>
<n8n-loading :loading="!template || !template.name" :rows="2" variant="h1" />
</div>
@ -168,6 +168,10 @@ export default mixins(workflowHelpers).extend({
.image {
width: 100%;
height: 500px;
border: var(--border-base);
border-radius: var(--border-radius-large);
overflow: hidden;
img {
width: 100%;

View file

@ -113,6 +113,7 @@ export default mixins(
},
methods: {
addWorkflow() {
this.$store.commit('ui/setNodeViewInitialized', false);
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
this.$telemetry.track('User clicked add workflow button', {

View file

@ -33,6 +33,7 @@ export const DEFAULT_START_POSITION_X = 180;
export const DEFAULT_START_POSITION_Y = 240;
export const HEADER_HEIGHT = 65;
export const SIDEBAR_WIDTH = 65;
export const INNER_SIDEBAR_WIDTH = 310;
export const SIDEBAR_WIDTH_EXPANDED = 200;
export const MAX_X_TO_PUSH_DOWNSTREAM_NODES = 300;
export const PUSH_NODES_OFFSET = NODE_SIZE * 2 + GRID_SIZE;
@ -515,10 +516,19 @@ export const getMidCanvasPosition = (scale: number, offset: XYPosition): XYPosit
return getRelativePosition(editorWidth / 2, (editorHeight - HEADER_HEIGHT) / 2, scale, offset);
};
export const getBackgroundStyles = (scale: number, offsetPosition: XYPosition) => {
export const getBackgroundStyles = (scale: number, offsetPosition: XYPosition, executionPreview: boolean) => {
const squareSize = GRID_SIZE * scale;
const dotSize = 1 * scale;
const dotPosition = (GRID_SIZE / 2) * scale;
if (executionPreview) {
return {
'background-image': 'linear-gradient(135deg, #f9f9fb 25%, #ffffff 25%, #ffffff 50%, #f9f9fb 50%, #f9f9fb 75%, #ffffff 75%, #ffffff 100%)',
'background-size': `${squareSize}px ${squareSize}px`,
'background-position': `left ${offsetPosition[0]}px top ${offsetPosition[1]}px`,
};
}
const styles: object = {
'background-size': `${squareSize}px ${squareSize}px`,
'background-position': `left ${offsetPosition[0]}px top ${offsetPosition[1]}px`,
@ -530,6 +540,7 @@ export const getBackgroundStyles = (scale: number, offsetPosition: XYPosition) =
'background-image': `radial-gradient(circle at ${dotPosition}px ${dotPosition}px, ${dotColor} ${dotSize}px, transparent 0)`,
};
}
return styles;
};
@ -651,10 +662,10 @@ export const addConnectionOutputSuccess = (connection: Connection, output: {tota
const getContentDimensions = (): { editorWidth: number, editorHeight: number } => {
let contentWidth = window.innerWidth;
let contentHeight = window.innerHeight;
const contentElement = document.getElementById('content');
const nodeViewRoot = document.getElementById('node-view-root');
if (contentElement) {
const contentBounds = contentElement.getBoundingClientRect();
if (nodeViewRoot) {
const contentBounds = nodeViewRoot.getBoundingClientRect();
contentWidth = contentBounds.width;
contentHeight = contentBounds.height;
}