feat(editor): Debug executions in the editor (#6834)

This commit is contained in:
Csaba Tuncsik 2023-08-25 09:39:14 +02:00 committed by GitHub
parent 72f65dcdd6
commit c833078c87
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 675 additions and 86 deletions

129
cypress/e2e/28-debug.cy.ts Normal file
View file

@ -0,0 +1,129 @@
import {
HTTP_REQUEST_NODE_NAME, IF_NODE_NAME,
INSTANCE_OWNER,
MANUAL_TRIGGER_NODE_NAME,
SET_NODE_NAME,
} from '../constants';
import { WorkflowPage, NDV, WorkflowExecutionsTab } from '../pages';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
const executionsTab = new WorkflowExecutionsTab();
describe('Debug', () => {
it('should be able to debug executions', () => {
cy.intercept('GET', '/rest/settings', (req) => {
req.on('response', (res) => {
res.send({
data: { ...res.body.data, enterprise: { debugInEditor: true } },
});
});
}).as('loadSettings');
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
cy.intercept('GET', '/rest/executions/*').as('getExecution');
cy.intercept('GET', '/rest/executions-current?filter=*').as('getCurrentExecutions');
cy.intercept('POST', '/rest/workflows/run').as('postWorkflowRun');
cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password });
workflowPage.actions.visit();
workflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME);
workflowPage.actions.openNode(HTTP_REQUEST_NODE_NAME);
ndv.actions.typeIntoParameterInput('url', 'https://foo.bar');
ndv.actions.close();
workflowPage.actions.addNodeToCanvas(SET_NODE_NAME, true);
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();
workflowPage.actions.executeWorkflow();
cy.wait(['@postWorkflowRun']);
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions', '@getCurrentExecutions']);
executionsTab.getters.executionDebugButton().should('have.text', 'Debug in editor').click();
cy.get('.el-notification').contains('Execution data imported').should('be.visible');
cy.get('.matching-pinned-nodes-confirmation').should('not.exist');
workflowPage.actions.openNode(HTTP_REQUEST_NODE_NAME);
ndv.actions.clearParameterInput('url');
ndv.actions.typeIntoParameterInput('url', 'https://postman-echo.com/get?foo1=bar1&foo2=bar2');
ndv.actions.close();
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();
workflowPage.actions.executeWorkflow();
cy.wait(['@postWorkflowRun']);
workflowPage.actions.openNode(HTTP_REQUEST_NODE_NAME);
ndv.actions.pinData();
ndv.actions.close();
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions', '@getCurrentExecutions']);
executionsTab.getters.executionListItems().should('have.length', 2).first().click();
cy.wait(['@getExecution']);
executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click();
let confirmDialog = cy.get('.matching-pinned-nodes-confirmation').filter(':visible');
confirmDialog.find('li').should('have.length', 2);
confirmDialog.get('.btn--cancel').click();
cy.wait(['@getExecutions', '@getCurrentExecutions']);
executionsTab.getters.executionListItems().should('have.length', 2).first().click();
cy.wait(['@getExecution']);
executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click();
confirmDialog = cy.get('.matching-pinned-nodes-confirmation').filter(':visible');
confirmDialog.find('li').should('have.length', 2);
confirmDialog.get('.btn--confirm').click();
workflowPage.getters.canvasNodes().first().should('have.descendants', '.node-pin-data-icon');
workflowPage.getters.canvasNodes().not(':first').should('not.have.descendants', '.node-pin-data-icon');
cy.reload(true);
cy.wait(['@getExecution']);
confirmDialog = cy.get('.matching-pinned-nodes-confirmation').filter(':visible');
confirmDialog.find('li').should('have.length', 1);
confirmDialog.get('.btn--confirm').click();
workflowPage.getters.canvasNodePlusEndpointByName(SET_NODE_NAME).click();
workflowPage.actions.addNodeToCanvas(IF_NODE_NAME, false);
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions', '@getCurrentExecutions']);
executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click();
confirmDialog = cy.get('.matching-pinned-nodes-confirmation').filter(':visible');
confirmDialog.find('li').should('have.length', 1);
confirmDialog.get('.btn--confirm').click();
workflowPage.getters.canvasNodes().last().find('.node-info-icon').should('be.empty');
workflowPage.getters.canvasNodes().first().dblclick();
ndv.getters.pinDataButton().click();
ndv.actions.close();
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();
workflowPage.actions.executeWorkflow();
workflowPage.actions.deleteNode(IF_NODE_NAME);
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions', '@getCurrentExecutions']);
executionsTab.getters.executionListItems().should('have.length', 3).first().click();
cy.wait(['@getExecution']);
executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click();
cy.get('.el-notification').contains('Some execution data wasn\'t imported').should('be.visible');
});
});

View file

@ -8,4 +8,5 @@ export * from './settings-log-streaming';
export * from './sidebar';
export * from './ndv';
export * from './bannerStack';
export * from './workflow-executions-tab';
export * from './signin';

View file

@ -22,6 +22,7 @@ export class WorkflowExecutionsTab extends BasePage {
this.getters.executionPreviewDetails().find('[data-test-id="execution-preview-label"]'),
executionPreviewId: () =>
this.getters.executionPreviewDetails().find('[data-test-id="execution-preview-id"]'),
executionDebugButton: () => cy.getByTestId('execution-debug-button'),
};
actions = {
toggleNodeEnabled: (nodeName: string) => {

View file

@ -725,7 +725,7 @@ export interface ITimeoutHMS {
seconds: number;
}
export type WorkflowTitleStatus = 'EXECUTING' | 'IDLE' | 'ERROR';
export type WorkflowTitleStatus = 'EXECUTING' | 'IDLE' | 'ERROR' | 'DEBUG';
export type ExtractActionKeys<T> = T extends SimplifiedNodeType ? T['name'] : never;
@ -897,6 +897,7 @@ export interface WorkflowsState {
workflowExecutionData: IExecutionResponse | null;
workflowExecutionPairedItemMappings: { [itemId: string]: Set<string> };
workflowsById: IWorkflowsMap;
isInDebugMode?: boolean;
}
export interface RootState {

View file

@ -0,0 +1,31 @@
import { createPinia, setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
import router from '@/router';
import { VIEWS } from '@/constants';
const App = {
template: '<div />',
};
const renderComponent = createComponentRenderer(App);
describe('router', () => {
beforeAll(() => {
const pinia = createPinia();
setActivePinia(pinia);
renderComponent({ pinia });
});
test.each([
['/', VIEWS.WORKFLOWS],
['/workflow', VIEWS.NEW_WORKFLOW],
['/workflow/new', VIEWS.NEW_WORKFLOW],
['/workflow/R9JFXwkUCL1jZBuw', VIEWS.WORKFLOW],
['/workflow/R9JFXwkUCL1jZBuw/executions/29021', VIEWS.EXECUTION_PREVIEW],
['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.EXECUTION_DEBUG],
['/workflows/templates/R9JFXwkUCL1jZBuw', VIEWS.TEMPLATE_IMPORT],
['/workflows/demo', VIEWS.DEMO],
])('should resolve %s to %s', async (path, name) => {
await router.push(path);
expect(router.currentRoute.value.name).toBe(name);
});
});

View file

@ -0,0 +1,40 @@
<script lang="ts" setup>
import { useI18n } from '@/composables';
import Modal from '@/components/Modal.vue';
const props = defineProps<{
modalName: string;
data: { title: string; footerButtonAction: () => void };
}>();
const i18n = useI18n();
</script>
<template>
<Modal width="500px" :title="props.data.title" :name="props.modalName">
<template #content>
<n8n-text>
{{ i18n.baseText('executionsList.debug.paywall.content') }}
<br />
<n8n-link :to="i18n.baseText('executionsList.debug.paywall.link.url')">
{{ i18n.baseText('executionsList.debug.paywall.link.text') }}
</n8n-link>
</n8n-text>
</template>
<template #footer>
<div :class="$style.footer">
<n8n-button @click="props.data.footerButtonAction">
{{ i18n.baseText('generic.seePlans') }}
</n8n-button>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
</style>

View file

@ -79,6 +79,29 @@
</n8n-text>
</div>
<div>
<n8n-button
size="large"
:type="debugButtonData.type"
:class="{
[$style.debugLink]: true,
[$style.secondary]: debugButtonData.type === 'secondary',
}"
>
<router-link
:to="{
name: VIEWS.EXECUTION_DEBUG,
params: {
name: activeExecution.workflowId,
executionId: activeExecution.id,
},
}"
>
<span @click="handleDebugLinkClick" data-test-id="execution-debug-button">{{
debugButtonData.text
}}</span>
</router-link>
</n8n-button>
<el-dropdown
v-if="executionUIDetails?.name === 'error'"
trigger="click"
@ -128,13 +151,12 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { useMessage } from '@/composables';
import { ElDropdown } from 'element-plus';
import { useExecutionDebugging, useMessage } from '@/composables';
import WorkflowPreview from '@/components/WorkflowPreview.vue';
import type { IExecutionUIData } from '@/mixins/executionsHelpers';
import { executionHelpers } from '@/mixins/executionsHelpers';
import { MODAL_CONFIRM, VIEWS } from '@/constants';
import { ElDropdown } from 'element-plus';
type RetryDropdownRef = InstanceType<typeof ElDropdown> & { hide: () => void };
@ -153,6 +175,7 @@ export default defineComponent({
setup() {
return {
...useMessage(),
...useExecutionDebugging(),
};
},
computed: {
@ -162,6 +185,17 @@ export default defineComponent({
executionMode(): string {
return this.activeExecution?.mode || '';
},
debugButtonData(): Record<string, string> {
return this.activeExecution?.status === 'success'
? {
text: this.$locale.baseText('executionsList.debug.button.copyToEditor'),
type: 'secondary',
}
: {
text: this.$locale.baseText('executionsList.debug.button.debugInEditor'),
type: 'primary',
};
},
},
methods: {
async onDeleteExecution(): Promise<void> {
@ -212,9 +246,15 @@ export default defineComponent({
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 150ms ease-in-out;
pointer-events: none;
> div:last-child {
display: flex;
align-items: center;
}
& * {
pointer-events: all;
}
@ -254,4 +294,21 @@ export default defineComponent({
margin-top: var(--spacing-l);
text-align: center;
}
.debugLink {
padding: 0;
margin-right: var(--spacing-xs);
&.secondary {
a span {
color: var(--color-primary-shade-1);
}
}
a span {
display: block;
padding: var(--spacing-xs) var(--spacing-m);
color: var(--color-text-xlight);
}
}
</style>

View file

@ -0,0 +1,112 @@
import { vi, describe, expect } from 'vitest';
import { render } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { faker } from '@faker-js/faker';
import { createRouter, createWebHistory } from 'vue-router';
import { createPinia, PiniaVuePlugin, setActivePinia } from 'pinia';
import type { IExecutionsSummary } from 'n8n-workflow';
import { useSettingsStore, useWorkflowsStore } from '@/stores';
import ExecutionPreview from '@/components/ExecutionsView/ExecutionPreview.vue';
import { VIEWS } from '@/constants';
import { i18nInstance, I18nPlugin } from '@/plugins/i18n';
import { FontAwesomePlugin } from '@/plugins/icons';
import { GlobalComponentsPlugin } from '@/plugins/components';
let pinia: ReturnType<typeof createPinia>;
const routes = [
{ path: '/', name: 'home', component: { template: '<div></div>' } },
{
path: '/workflow/:name/debug/:executionId',
name: VIEWS.EXECUTION_DEBUG,
component: { template: '<div></div>' },
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
const $route = {
params: {},
};
const generateUndefinedNullOrString = () => {
switch (Math.floor(Math.random() * 4)) {
case 0:
return undefined;
case 1:
return null;
case 2:
return faker.string.uuid();
case 3:
return '';
default:
return undefined;
}
};
const executionDataFactory = (): IExecutionsSummary => ({
id: faker.string.uuid(),
finished: faker.datatype.boolean(),
mode: faker.helpers.arrayElement(['manual', 'trigger']),
startedAt: faker.date.past(),
stoppedAt: faker.date.past(),
workflowId: faker.number.int().toString(),
workflowName: faker.string.sample(),
status: faker.helpers.arrayElement(['failed', 'success']),
nodeExecutionStatus: {},
retryOf: generateUndefinedNullOrString(),
retrySuccessId: generateUndefinedNullOrString(),
});
describe('ExecutionPreview.vue', () => {
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
const executionData: IExecutionsSummary = executionDataFactory();
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
workflowsStore = useWorkflowsStore();
settingsStore = useSettingsStore();
vi.spyOn(workflowsStore, 'activeWorkflowExecution', 'get').mockReturnValue(executionData);
});
test.each([
[false, '/'],
[true, `/workflow/${executionData.workflowId}/debug/${executionData.id}`],
])(
'when debug enterprise feature is %s it should handle debug link click accordingly',
async (availability, path) => {
vi.spyOn(settingsStore, 'isEnterpriseFeatureEnabled', 'get').mockReturnValue(
() => availability,
);
// Not using createComponentRenderer helper here because this component should not stub `router-link`
const { getByTestId } = render(ExecutionPreview, {
global: {
plugins: [
I18nPlugin,
i18nInstance,
PiniaVuePlugin,
FontAwesomePlugin,
GlobalComponentsPlugin,
pinia,
router,
],
mocks: {
$route,
},
},
});
await userEvent.click(getByTestId('execution-debug-button'));
expect(router.currentRoute.value.path).toBe(path);
},
);
});

View file

@ -109,7 +109,11 @@ export default defineComponent({
route.name === VIEWS.EXECUTION_PREVIEW
) {
this.activeHeaderTab = MAIN_HEADER_TABS.EXECUTIONS;
} else if (route.name === VIEWS.WORKFLOW || route.name === VIEWS.NEW_WORKFLOW) {
} else if (
route.name === VIEWS.WORKFLOW ||
route.name === VIEWS.NEW_WORKFLOW ||
route.name === VIEWS.EXECUTION_DEBUG
) {
this.activeHeaderTab = MAIN_HEADER_TABS.WORKFLOW;
}
const workflowName = route.params.name;

View file

@ -119,6 +119,12 @@
<SourceControlPullModal :modalName="modalName" :data="data" />
</template>
</ModalRoot>
<ModalRoot :name="DEBUG_PAYWALL_MODAL_KEY">
<template #default="{ modalName, data }">
<DebugPaywallModal data-test-id="debug-paywall-modal" :modalName="modalName" :data="data" />
</template>
</ModalRoot>
</div>
</template>
@ -147,6 +153,7 @@ import {
LOG_STREAM_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
DEBUG_PAYWALL_MODAL_KEY,
MFA_SETUP_MODAL_KEY,
} from '@/constants';
@ -174,6 +181,7 @@ import WorkflowShareModal from './WorkflowShareModal.ee.vue';
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue';
import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
export default defineComponent({
name: 'Modals',
@ -201,6 +209,7 @@ export default defineComponent({
EventDestinationSettingsModal,
SourceControlPushModal,
SourceControlPullModal,
DebugPaywallModal,
MfaSetupModal,
},
data: () => ({
@ -226,6 +235,7 @@ export default defineComponent({
LOG_STREAM_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
DEBUG_PAYWALL_MODAL_KEY,
MFA_SETUP_MODAL_KEY,
}),
});

View file

@ -13,3 +13,4 @@ export * from './useTitleChange';
export * from './useToast';
export * from './useNodeSpecificationValues';
export * from './useDataSchema';
export * from './useExecutionDebugging';

View file

@ -0,0 +1,143 @@
import { h, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n, useMessage, useToast } from '@/composables';
import {
DEBUG_PAYWALL_MODAL_KEY,
EnterpriseEditionFeature,
MODAL_CONFIRM,
VIEWS,
} from '@/constants';
import { useSettingsStore, useUIStore, useWorkflowsStore } from '@/stores';
import type { INodeUi } from '@/Interface';
export const useExecutionDebugging = () => {
const router = useRouter();
const i18n = useI18n();
const message = useMessage();
const toast = useToast();
const workflowsStore = useWorkflowsStore();
const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const isDebugEnabled = computed(() =>
settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.DebugInEditor),
);
const applyExecutionData = async (executionId: string): Promise<void> => {
const execution = await workflowsStore.getExecution(executionId);
const workflow = workflowsStore.getCurrentWorkflow();
const workflowNodes = workflowsStore.getNodes();
if (!execution?.data?.resultData) {
return;
}
const { runData } = execution.data.resultData;
const executionNodeNames = Object.keys(runData);
const missingNodeNames = executionNodeNames.filter(
(name) => !workflowNodes.some((node) => node.name === name),
);
// Using the pinned data of the workflow to check if the node is pinned
// because workflowsStore.getCurrentWorkflow() returns a cached workflow without the updated pinned data
const workflowPinnedNodeNames = Object.keys(workflowsStore.workflow.pinData ?? {});
const matchingPinnedNodeNames = executionNodeNames.filter((name) =>
workflowPinnedNodeNames.includes(name),
);
if (matchingPinnedNodeNames.length > 0) {
const confirmMessage = h('p', [
i18n.baseText('nodeView.confirmMessage.debug.message'),
h(
'ul',
{ class: 'mt-l ml-l' },
matchingPinnedNodeNames.map((name) => h('li', name)),
),
]);
const overWritePinnedDataConfirm = await message.confirm(
confirmMessage,
i18n.baseText('nodeView.confirmMessage.debug.headline'),
{
type: 'warning',
confirmButtonText: i18n.baseText('nodeView.confirmMessage.debug.confirmButtonText'),
cancelButtonText: i18n.baseText('nodeView.confirmMessage.debug.cancelButtonText'),
dangerouslyUseHTMLString: true,
customClass: 'matching-pinned-nodes-confirmation',
},
);
if (overWritePinnedDataConfirm === MODAL_CONFIRM) {
matchingPinnedNodeNames.forEach((name) => {
const node = workflowsStore.getNodeByName(name);
if (node) {
workflowsStore.unpinData({ node });
}
});
} else {
await router.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: workflow.id, executionId },
});
return;
}
}
// Set execution data
workflowsStore.setWorkflowExecutionData(execution);
// Pin data of all nodes which do not have a parent node
workflowNodes
.filter((node: INodeUi) => !workflow.getParentNodes(node.name).length)
.forEach((node: INodeUi) => {
const nodeData = runData[node.name]?.[0].data?.main[0];
if (nodeData) {
workflowsStore.pinData({
node,
data: nodeData,
});
}
});
toast.showToast({
title: i18n.baseText('nodeView.showMessage.debug.title'),
message: i18n.baseText('nodeView.showMessage.debug.content'),
type: 'info',
});
if (missingNodeNames.length) {
toast.showToast({
title: i18n.baseText('nodeView.showMessage.debug.missingNodes.title'),
message: i18n.baseText('nodeView.showMessage.debug.missingNodes.content', {
interpolate: { nodeNames: missingNodeNames.join(', ') },
}),
type: 'warning',
});
}
};
const handleDebugLinkClick = (event: Event): void => {
if (!isDebugEnabled.value) {
uiStore.openModalWithData({
name: DEBUG_PAYWALL_MODAL_KEY,
data: {
title: i18n.baseText(uiStore.contextBasedTranslationKeys.feature.unavailable.title),
footerButtonAction: () => {
uiStore.closeModal(DEBUG_PAYWALL_MODAL_KEY);
uiStore.goToUpgrade('debug', 'upgrade-debug');
},
},
});
event.preventDefault();
event.stopPropagation();
return;
}
workflowsStore.isInDebugMode = false;
};
return {
applyExecutionData,
handleDebugLinkClick,
};
};

View file

@ -10,7 +10,7 @@ export function useMessage() {
};
async function alert(
message: string,
message: ElMessageBoxOptions['message'],
configOrTitle?: string | ElMessageBoxOptions,
config?: ElMessageBoxOptions,
) {
@ -27,7 +27,7 @@ export function useMessage() {
}
async function confirm(
message: string,
message: ElMessageBoxOptions['message'],
configOrTitle?: string | ElMessageBoxOptions,
config?: ElMessageBoxOptions,
): Promise<MessageBoxConfirmResult> {
@ -51,7 +51,7 @@ export function useMessage() {
}
async function prompt(
message: string,
message: ElMessageBoxOptions['message'],
configOrTitle?: string | ElMessageBoxOptions,
config?: ElMessageBoxOptions,
) {

View file

@ -47,6 +47,7 @@ export const IMPORT_CURL_MODAL_KEY = 'importCurl';
export const LOG_STREAM_MODAL_KEY = 'settingsLogStream';
export const SOURCE_CONTROL_PUSH_MODAL_KEY = 'sourceControlPush';
export const SOURCE_CONTROL_PULL_MODAL_KEY = 'sourceControlPull';
export const DEBUG_PAYWALL_MODAL_KEY = 'debugPaywall';
export const MFA_SETUP_MODAL_KEY = 'mfaSetup';
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
@ -346,6 +347,7 @@ export const enum VIEWS {
COLLECTION = 'TemplatesCollectionView',
EXECUTIONS = 'Executions',
EXECUTION_PREVIEW = 'ExecutionPreview',
EXECUTION_DEBUG = 'ExecutionDebug',
EXECUTION_HOME = 'ExecutionsLandingPage',
TEMPLATE = 'TemplatesWorkflowView',
TEMPLATES = 'TemplatesSearchView',
@ -446,6 +448,7 @@ export const enum EnterpriseEditionFeature {
Saml = 'saml',
SourceControl = 'sourceControl',
AuditLogs = 'auditLogs',
DebugInEditor = 'debugInEditor',
}
export const MAIN_NODE_PANEL_WIDTH = 360;

View file

@ -19,9 +19,12 @@ export const genericHelpers = defineComponent({
computed: {
...mapStores(useSourceControlStore),
isReadOnlyRoute(): boolean {
return ![VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.LOG_STREAMING_SETTINGS].includes(
this.$route.name as VIEWS,
);
return ![
VIEWS.WORKFLOW,
VIEWS.NEW_WORKFLOW,
VIEWS.LOG_STREAMING_SETTINGS,
VIEWS.EXECUTION_DEBUG,
].includes(this.$route.name as VIEWS);
},
readOnlyEnv(): boolean {
return this.sourceControlStore.preferences.branchReadOnly;

View file

@ -61,6 +61,7 @@
"generic.workflow": "Workflow",
"generic.workflowSaved": "Workflow changes saved",
"generic.editor": "Editor",
"generic.seePlans": "See plans",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",
@ -564,6 +565,11 @@
"executionsList.view": "View",
"executionsList.stop": "Stop",
"executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely": "The workflow is waiting indefinitely for an incoming webhook call.",
"executionsList.debug.button.copyToEditor": "Copy to editor",
"executionsList.debug.button.debugInEditor": "Debug in editor",
"executionsList.debug.paywall.content": "Debug in Editor allows you to debug a previous execution with the actual data pinned, right in your editor.",
"executionsList.debug.paywall.link.text": "Read more in the docs",
"executionsList.debug.paywall.link.url": "#",
"executionSidebar.executionName": "Execution {id}",
"executionSidebar.searchPlaceholder": "Search executions...",
"executionView.onPaste.title": "Cannot paste here",
@ -884,6 +890,10 @@
"nodeView.confirmMessage.receivedCopyPasteData.confirmButtonText": "Yes, import",
"nodeView.confirmMessage.receivedCopyPasteData.headline": "Import Workflow?",
"nodeView.confirmMessage.receivedCopyPasteData.message": "Workflow will be imported from<br /><i>{plainTextData}<i>",
"nodeView.confirmMessage.debug.cancelButtonText": "Cancel",
"nodeView.confirmMessage.debug.confirmButtonText": "Unpin",
"nodeView.confirmMessage.debug.headline": "Unpin workflow data",
"nodeView.confirmMessage.debug.message": "Loading this execution will unpin the data currently pinned in these nodes",
"nodeView.couldntImportWorkflow": "Could not import workflow",
"nodeView.deletesTheCurrentExecutionData": "Deletes the current execution data",
"nodeView.executesTheWorkflowFromATriggerNode": "Runs the workflow, starting from a Trigger node",
@ -923,6 +933,10 @@
"nodeView.showMessage.stopExecutionCatch.message": "It completed before it could be stopped",
"nodeView.showMessage.stopExecutionCatch.title": "Workflow finished executing",
"nodeView.showMessage.stopExecutionTry.title": "Execution stopped",
"nodeView.showMessage.debug.title": "Execution data imported",
"nodeView.showMessage.debug.content": "You can make edits and re-execute. Once you're done, unpin the the first node.",
"nodeView.showMessage.debug.missingNodes.title": "Some execution data wasn't imported",
"nodeView.showMessage.debug.missingNodes.content": "Some nodes have been deleted or renamed or added to the workflow since the execution ran.",
"nodeView.stopCurrentExecution": "Stop current execution",
"nodeView.stopWaitingForWebhookCall": "Stop waiting for webhook call",
"nodeView.stoppingCurrentExecution": "Stopping current execution",
@ -1854,6 +1868,10 @@
"contextual.upgradeLinkUrl.cloud": "https://app.n8n.cloud/account/change-plan",
"contextual.upgradeLinkUrl.desktop": "https://n8n.io/pricing/?utm_source=n8n-internal&utm_medium=desktop",
"contextual.feature.unavailable.title": "Available on the Enterprise Plan",
"contextual.feature.unavailable.title.cloud": "Available on the Pro Plan",
"contextual.feature.unavailable.title.desktop": "Available on cloud hosting",
"settings.ldap": "LDAP",
"settings.ldap.note": "LDAP allows users to authenticate with their centralized account. It's compatible with services that provide an LDAP interface like Active Directory, Okta and Jumpcloud.",
"settings.ldap.infoTip": "Learn more about <a href='https://docs.n8n.io/user-management/ldap/' target='_blank'>LDAP in the Docs</a>",

View file

@ -39,7 +39,7 @@ import SignoutView from '@/views/SignoutView.vue';
import SamlOnboarding from '@/views/SamlOnboarding.vue';
import SettingsSourceControl from './views/SettingsSourceControl.vue';
import SettingsAuditLogs from './views/SettingsAuditLogs.vue';
import { VIEWS } from '@/constants';
import { EnterpriseEditionFeature, VIEWS } from '@/constants';
interface IRouteConfig {
meta: {
@ -207,10 +207,6 @@ export const routes = [
},
},
},
{
path: '/workflow',
redirect: '/workflow/new',
},
{
path: '/workflows',
name: VIEWS.WORKFLOWS,
@ -227,8 +223,8 @@ export const routes = [
},
},
{
path: '/workflow/new',
name: VIEWS.NEW_WORKFLOW,
path: '/workflow/:name/debug/:executionId',
name: VIEWS.EXECUTION_DEBUG,
components: {
default: NodeView,
header: MainHeader,
@ -240,22 +236,9 @@ export const routes = [
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
},
},
{
path: '/workflow/:name',
name: VIEWS.WORKFLOW,
components: {
default: NodeView,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
nodeView: true,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
deny: {
shouldDeny: () =>
!useSettingsStore().isEnterpriseFeatureEnabled(EnterpriseEditionFeature.DebugInEditor),
},
},
},
@ -309,20 +292,6 @@ export const routes = [
},
],
},
{
path: '/workflows/demo',
name: VIEWS.DEMO,
components: {
default: NodeView,
},
meta: {
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
},
},
{
path: '/workflows/templates/:id',
name: VIEWS.TEMPLATE_IMPORT,
@ -341,6 +310,58 @@ export const routes = [
},
},
},
{
path: '/workflow/new',
name: VIEWS.NEW_WORKFLOW,
components: {
default: NodeView,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
nodeView: true,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
},
},
{
path: '/workflows/demo',
name: VIEWS.DEMO,
components: {
default: NodeView,
},
meta: {
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
},
},
{
path: '/workflow/:name',
name: VIEWS.WORKFLOW,
components: {
default: NodeView,
header: MainHeader,
sidebar: MainSidebar,
},
meta: {
nodeView: true,
permissions: {
allow: {
loginStatus: [LOGIN_STATUS.LoggedIn],
},
},
},
},
{
path: '/workflow',
redirect: '/workflow/new',
},
{
path: '/signin',
name: VIEWS.SIGNIN,

View file

@ -31,6 +31,7 @@ import {
WORKFLOW_SHARE_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
DEBUG_PAYWALL_MODAL_KEY,
} from '@/constants';
import type {
CloudUpdateLinkSourceType,
@ -142,6 +143,9 @@ export const useUIStore = defineStore(STORES.UI, {
[SOURCE_CONTROL_PULL_MODAL_KEY]: {
open: false,
},
[DEBUG_PAYWALL_MODAL_KEY]: {
open: false,
},
},
modalStack: [],
sidebarMenuCollapsed: true,
@ -198,6 +202,11 @@ export const useUIStore = defineStore(STORES.UI, {
return {
upgradeLinkUrl: `contextual.upgradeLinkUrl${contextKey}`,
feature: {
unavailable: {
title: `contextual.feature.unavailable.title${contextKey}`,
},
},
credentials: {
sharing: {
unavailable: {
@ -284,7 +293,9 @@ export const useUIStore = defineStore(STORES.UI, {
this.fakeDoorFeatures.find((fakeDoor) => fakeDoor.id.toString() === id);
},
isReadOnlyView(): boolean {
return ![VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW].includes(this.currentView as VIEWS);
return ![VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG].includes(
this.currentView as VIEWS,
);
},
isNodeView(): boolean {
return [

View file

@ -128,6 +128,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
executingNode: null,
executionWaitingForWebhook: false,
nodeMetadata: {},
isInDebugMode: false,
}),
getters: {
// Workflow getters

View file

@ -218,6 +218,7 @@ import {
useMessage,
useToast,
useTitleChange,
useExecutionDebugging,
} from '@/composables';
import { useUniqueNodeName } from '@/composables/useUniqueNodeName';
import { useI18n } from '@/composables/useI18n';
@ -355,6 +356,7 @@ export default defineComponent({
...useToast(),
...useMessage(),
...useUniqueNodeName(),
...useExecutionDebugging(),
// eslint-disable-next-line @typescript-eslint/no-misused-promises
...workflowRun.setup?.(props),
};
@ -365,7 +367,7 @@ export default defineComponent({
},
watch: {
// Listen to route changes and load the workflow accordingly
$route(to: Route, from: Route) {
async $route(to: Route, from: Route) {
this.readOnlyEnvRouteCheck();
const currentTab = getNodeViewTab(to);
@ -388,14 +390,13 @@ export default defineComponent({
this.resetWorkspace();
this.uiStore.stateIsDirty = previousDirtyState;
}
void this.loadCredentials();
void this.initView().then(() => {
await Promise.all([this.loadCredentials(), this.initView()]);
this.stopLoading();
if (this.blankRedirect) {
this.blankRedirect = false;
}
});
}
await this.checkAndInitDebugMode();
}
// Also, when landing on executions tab, check if workflow data is changed
if (currentTab === MAIN_HEADER_TABS.EXECUTIONS) {
@ -561,7 +562,7 @@ export default defineComponent({
workflowClasses() {
const returnClasses = [];
if (this.ctrlKeyPressed || this.moveCanvasKeyPressed) {
if (this.uiStore.nodeViewMoveInProgress === true) {
if (this.uiStore.nodeViewMoveInProgress) {
returnClasses.push('move-in-process');
} else {
returnClasses.push('move-active');
@ -1084,7 +1085,7 @@ export default defineComponent({
lastSelectedNode.name,
);
}
} else if (e.key === 'a' && this.isCtrlKeyPressed(e) === true) {
} else if (e.key === 'a' && this.isCtrlKeyPressed(e)) {
// Select all nodes
e.stopPropagation();
e.preventDefault();
@ -1504,7 +1505,7 @@ export default defineComponent({
const currentTab = getNodeViewTab(this.$route);
if (currentTab === MAIN_HEADER_TABS.WORKFLOW) {
let workflowData: IWorkflowDataUpdate | undefined;
if (this.editAllowedCheck() === false) {
if (!this.editAllowedCheck()) {
return;
}
// Check if it is an URL which could contain workflow data
@ -1779,10 +1780,8 @@ export default defineComponent({
parameters: {},
};
const credentialPerType =
nodeTypeData.credentials &&
nodeTypeData.credentials
.map((type) => this.credentialsStore.getUsableCredentialByType(type.name))
const credentialPerType = nodeTypeData.credentials
?.map((type) => this.credentialsStore.getUsableCredentialByType(type.name))
.flat();
if (credentialPerType && credentialPerType.length === 1) {
@ -1820,10 +1819,7 @@ export default defineComponent({
return newNodeData;
}
if (
Object.keys(authDisplayOptions).length === 1 &&
authDisplayOptions['authentication']
) {
if (Object.keys(authDisplayOptions).length === 1 && authDisplayOptions.authentication) {
// ignore complex case when there's multiple dependencies
newNodeData.credentials = credentials;
@ -1957,7 +1953,7 @@ export default defineComponent({
newNodeData.name = this.uniqueNodeName(localizedName);
if (nodeTypeData.webhooks && nodeTypeData.webhooks.length) {
if (nodeTypeData.webhooks?.length) {
newNodeData.webhookId = uuid();
}
@ -2007,9 +2003,8 @@ export default defineComponent({
targetNodeName: string,
targetNodeOuputIndex: number,
): IConnection | undefined {
const nodeConnections = (
this.workflowsStore.outgoingConnectionsByNodeName(sourceNodeName) as INodeConnections
).main;
const nodeConnections =
this.workflowsStore.outgoingConnectionsByNodeName(sourceNodeName).main;
if (nodeConnections) {
const connections: IConnection[] | null = nodeConnections[sourceNodeOutputIndex];
@ -2091,7 +2086,7 @@ export default defineComponent({
if (lastSelectedNode) {
await this.$nextTick();
if (lastSelectedConnection && lastSelectedConnection.__meta) {
if (lastSelectedConnection?.__meta) {
this.__deleteJSPlumbConnection(lastSelectedConnection, trackHistory);
const targetNodeName = lastSelectedConnection.__meta.targetNodeName;
@ -2432,8 +2427,8 @@ export default defineComponent({
const { top, left, right, bottom } = element.getBoundingClientRect();
const [x, y] = NodeViewUtils.getMousePosition(e);
if (top <= y && bottom >= y && left - inputMargin <= x && right >= x) {
const nodeName = (element as HTMLElement).dataset['name'] as string;
const node = this.workflowsStore.getNodeByName(nodeName) as INodeUi | null;
const nodeName = (element as HTMLElement).dataset.name as string;
const node = this.workflowsStore.getNodeByName(nodeName);
if (node) {
const nodeType = this.nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (nodeType && nodeType.inputs && nodeType.inputs.length === 1) {
@ -2482,7 +2477,7 @@ export default defineComponent({
.forEach((endpoint) => setTimeout(() => endpoint.instance.revalidate(endpoint.element), 0));
},
onPlusEndpointClick(endpoint: Endpoint) {
if (endpoint && endpoint.__meta) {
if (endpoint?.__meta) {
this.insertNodeAfterSelected({
sourceId: endpoint.__meta.nodeId,
index: endpoint.__meta.index,
@ -2576,7 +2571,6 @@ export default defineComponent({
this.stopLoading();
},
async tryToAddWelcomeSticky(): Promise<void> {
const newWorkflow = this.workflowData;
this.canvasStore.zoomToFit();
},
async initView(): Promise<void> {
@ -2633,8 +2627,8 @@ export default defineComponent({
if (workflow) {
this.titleSet(workflow.name, 'IDLE');
// Open existing workflow
await this.openWorkflow(workflow);
await this.checkAndInitDebugMode();
}
} else if (this.$route.meta?.nodeView === true) {
// Create new workflow
@ -2758,8 +2752,7 @@ export default defineComponent({
const nodeTypeData = this.nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (
nodeTypeData &&
nodeTypeData.maxNodes !== undefined &&
nodeTypeData?.maxNodes !== undefined &&
this.getNodeTypeCount(node.type) >= nodeTypeData.maxNodes
) {
this.showMaxNodeTypeError(nodeTypeData);
@ -2914,7 +2907,7 @@ export default defineComponent({
}) {
const pinData = this.workflowsStore.getPinData;
if (pinData && pinData[name]) return;
if (pinData?.[name]) return;
const sourceNodeName = name;
const sourceNode = this.workflowsStore.getNodeByName(sourceNodeName);
@ -2959,7 +2952,7 @@ export default defineComponent({
if (output.isArtificialRecoveredEventItem) {
NodeViewUtils.recoveredConnection(connection);
} else if ((!output || !output.total) && !output.isArtificialRecoveredEventItem) {
} else if (!output?.total && !output.isArtificialRecoveredEventItem) {
NodeViewUtils.resetConnection(connection);
} else {
NodeViewUtils.addConnectionOutputSuccess(connection, output);
@ -2971,7 +2964,7 @@ export default defineComponent({
sourceNodeName,
parseInt(sourceOutputIndex, 10),
);
if (endpoint && endpoint.endpoint) {
if (endpoint?.endpoint) {
const output = outputMap[sourceOutputIndex][NODE_OUTPUT_DEFAULT_KEY][0];
if (output && output.total > 0) {
@ -3261,7 +3254,7 @@ export default defineComponent({
);
},
async addNodes(nodes: INodeUi[], connections?: IConnections, trackHistory = false) {
if (!nodes || !nodes.length) {
if (!nodes?.length) {
return;
}
@ -3751,7 +3744,7 @@ export default defineComponent({
const mode =
this.nodeCreatorStore.selectedView === TRIGGER_NODE_CREATOR_VIEW ? 'trigger' : 'regular';
if (createNodeActive === true) this.nodeCreatorStore.setOpenSource(source);
if (createNodeActive) this.nodeCreatorStore.setOpenSource(source);
void this.$externalHooks().run('nodeView.createNodeActiveChanged', {
source,
mode,
@ -3872,6 +3865,15 @@ export default defineComponent({
});
}
},
async checkAndInitDebugMode() {
if (this.$route.name === VIEWS.EXECUTION_DEBUG) {
this.titleSet(this.workflowName, 'DEBUG');
if (!this.workflowsStore.isInDebugMode) {
await this.applyExecutionData(this.$route.params.executionId as string);
this.workflowsStore.isInDebugMode = true;
}
}
},
},
async onSourceControlPull() {
let workflowId = null as string | null;