diff --git a/cypress/e2e/28-debug.cy.ts b/cypress/e2e/28-debug.cy.ts new file mode 100644 index 0000000000000..ede3b500cb707 --- /dev/null +++ b/cypress/e2e/28-debug.cy.ts @@ -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'); + }); +}); diff --git a/cypress/pages/index.ts b/cypress/pages/index.ts index 895ae9b3f70eb..18e3649e1acda 100644 --- a/cypress/pages/index.ts +++ b/cypress/pages/index.ts @@ -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'; diff --git a/cypress/pages/workflow-executions-tab.ts b/cypress/pages/workflow-executions-tab.ts index 674ff6d5f3911..eff3fedd3095b 100644 --- a/cypress/pages/workflow-executions-tab.ts +++ b/cypress/pages/workflow-executions-tab.ts @@ -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) => { diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index e1a8b33a5e68f..c2dece42d778b 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -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 extends SimplifiedNodeType ? T['name'] : never; @@ -897,6 +897,7 @@ export interface WorkflowsState { workflowExecutionData: IExecutionResponse | null; workflowExecutionPairedItemMappings: { [itemId: string]: Set }; workflowsById: IWorkflowsMap; + isInDebugMode?: boolean; } export interface RootState { diff --git a/packages/editor-ui/src/__tests__/router.test.ts b/packages/editor-ui/src/__tests__/router.test.ts new file mode 100644 index 0000000000000..04e264474674b --- /dev/null +++ b/packages/editor-ui/src/__tests__/router.test.ts @@ -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: '
', +}; +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); + }); +}); diff --git a/packages/editor-ui/src/components/DebugPaywallModal.vue b/packages/editor-ui/src/components/DebugPaywallModal.vue new file mode 100644 index 0000000000000..667e067ce008b --- /dev/null +++ b/packages/editor-ui/src/components/DebugPaywallModal.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/packages/editor-ui/src/components/ExecutionsView/ExecutionPreview.vue b/packages/editor-ui/src/components/ExecutionsView/ExecutionPreview.vue index b9a4d4c845f7c..5468f6831fded 100644 --- a/packages/editor-ui/src/components/ExecutionsView/ExecutionPreview.vue +++ b/packages/editor-ui/src/components/ExecutionsView/ExecutionPreview.vue @@ -79,6 +79,29 @@
+ + + {{ + debugButtonData.text + }} + + + 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 & { 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 { + 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 { @@ -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); + } +} diff --git a/packages/editor-ui/src/components/ExecutionsView/__tests__/ExecutionPreview.test.ts b/packages/editor-ui/src/components/ExecutionsView/__tests__/ExecutionPreview.test.ts new file mode 100644 index 0000000000000..fb13c06a34958 --- /dev/null +++ b/packages/editor-ui/src/components/ExecutionsView/__tests__/ExecutionPreview.test.ts @@ -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; + +const routes = [ + { path: '/', name: 'home', component: { template: '
' } }, + { + path: '/workflow/:name/debug/:executionId', + name: VIEWS.EXECUTION_DEBUG, + component: { template: '
' }, + }, +]; + +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; + let settingsStore: ReturnType; + 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); + }, + ); +}); diff --git a/packages/editor-ui/src/components/MainHeader/MainHeader.vue b/packages/editor-ui/src/components/MainHeader/MainHeader.vue index fbd49bf83ef91..f66dc9344ba24 100644 --- a/packages/editor-ui/src/components/MainHeader/MainHeader.vue +++ b/packages/editor-ui/src/components/MainHeader/MainHeader.vue @@ -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; diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index 7dfb3806555fc..d67843314f4e0 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -119,6 +119,12 @@ + + + +
@@ -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, }), }); diff --git a/packages/editor-ui/src/composables/index.ts b/packages/editor-ui/src/composables/index.ts index 042d79f7b7ec2..29dbff0b27194 100644 --- a/packages/editor-ui/src/composables/index.ts +++ b/packages/editor-ui/src/composables/index.ts @@ -13,3 +13,4 @@ export * from './useTitleChange'; export * from './useToast'; export * from './useNodeSpecificationValues'; export * from './useDataSchema'; +export * from './useExecutionDebugging'; diff --git a/packages/editor-ui/src/composables/useExecutionDebugging.ts b/packages/editor-ui/src/composables/useExecutionDebugging.ts new file mode 100644 index 0000000000000..b0ca84a6bc0fd --- /dev/null +++ b/packages/editor-ui/src/composables/useExecutionDebugging.ts @@ -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 => { + 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, + }; +}; diff --git a/packages/editor-ui/src/composables/useMessage.ts b/packages/editor-ui/src/composables/useMessage.ts index 20847dffd92c4..066c627da7ddd 100644 --- a/packages/editor-ui/src/composables/useMessage.ts +++ b/packages/editor-ui/src/composables/useMessage.ts @@ -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 { @@ -51,7 +51,7 @@ export function useMessage() { } async function prompt( - message: string, + message: ElMessageBoxOptions['message'], configOrTitle?: string | ElMessageBoxOptions, config?: ElMessageBoxOptions, ) { diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 76a4e2e85c99a..f1f9c6d7e57f7 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -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; diff --git a/packages/editor-ui/src/mixins/genericHelpers.ts b/packages/editor-ui/src/mixins/genericHelpers.ts index a97fe4fda199e..4b28e9d04522a 100644 --- a/packages/editor-ui/src/mixins/genericHelpers.ts +++ b/packages/editor-ui/src/mixins/genericHelpers.ts @@ -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; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 214198da18ecd..b9c5d46ad8271 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -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
{plainTextData}", + "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 LDAP in the Docs", diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index b8403a1d2a396..64be7e18cee67 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -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,6 +292,41 @@ export const routes = [ }, ], }, + { + path: '/workflows/templates/:id', + name: VIEWS.TEMPLATE_IMPORT, + components: { + default: NodeView, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + templatesEnabled: true, + getRedirect: getTemplatesRedirect, + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], + }, + }, + }, + }, + { + 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, @@ -324,16 +342,15 @@ export const routes = [ }, }, { - path: '/workflows/templates/:id', - name: VIEWS.TEMPLATE_IMPORT, + path: '/workflow/:name', + name: VIEWS.WORKFLOW, components: { default: NodeView, header: MainHeader, sidebar: MainSidebar, }, meta: { - templatesEnabled: true, - getRedirect: getTemplatesRedirect, + nodeView: true, permissions: { allow: { loginStatus: [LOGIN_STATUS.LoggedIn], @@ -341,6 +358,10 @@ export const routes = [ }, }, }, + { + path: '/workflow', + redirect: '/workflow/new', + }, { path: '/signin', name: VIEWS.SIGNIN, diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts index 72e8613fd7b09..69e9a474743f7 100644 --- a/packages/editor-ui/src/stores/ui.store.ts +++ b/packages/editor-ui/src/stores/ui.store.ts @@ -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 [ diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index 8a79f18342b7b..811535ace04a8 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -128,6 +128,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, { executingNode: null, executionWaitingForWebhook: false, nodeMetadata: {}, + isInDebugMode: false, }), getters: { // Workflow getters diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 497faabc42e6f..9aff78569ab11 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -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(() => { - this.stopLoading(); - if (this.blankRedirect) { - this.blankRedirect = false; - } - }); + 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,11 +1780,9 @@ export default defineComponent({ parameters: {}, }; - const credentialPerType = - nodeTypeData.credentials && - nodeTypeData.credentials - .map((type) => this.credentialsStore.getUsableCredentialByType(type.name)) - .flat(); + const credentialPerType = nodeTypeData.credentials + ?.map((type) => this.credentialsStore.getUsableCredentialByType(type.name)) + .flat(); if (credentialPerType && credentialPerType.length === 1) { const defaultCredential = credentialPerType[0]; @@ -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 { - const newWorkflow = this.workflowData; this.canvasStore.zoomToFit(); }, async initView(): Promise { @@ -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;