diff --git a/cypress/composables/workflow.ts b/cypress/composables/workflow.ts index 791e101984..3af533e8e6 100644 --- a/cypress/composables/workflow.ts +++ b/cypress/composables/workflow.ts @@ -120,6 +120,18 @@ export function getNodeByName(name: string) { ); } +export function getNodesWithSpinner() { + return cy + .getByTestId('canvas-node') + .filter((_, el) => Cypress.$(el).find('[data-icon=sync-alt]').length > 0); +} + +export function getWaitingNodes() { + return cy + .getByTestId('canvas-node') + .filter((_, el) => Cypress.$(el).find('[data-icon=clock]').length > 0); +} + export function getNodeRenderedTypeByName(name: string) { return cy.ifCanvasVersion( () => getNodeByName(name), diff --git a/cypress/e2e/50-logs.cy.ts b/cypress/e2e/50-logs.cy.ts index f9ac7c33bc..2489633d0f 100644 --- a/cypress/e2e/50-logs.cy.ts +++ b/cypress/e2e/50-logs.cy.ts @@ -6,6 +6,7 @@ import * as workflow from '../composables/workflow'; import Workflow_chat from '../fixtures/Workflow_ai_agent.json'; import Workflow_if from '../fixtures/Workflow_if.json'; import Workflow_loop from '../fixtures/Workflow_loop.json'; +import Workflow_wait_for_webhook from '../fixtures/Workflow_wait_for_webhook.json'; describe('Logs', () => { beforeEach(() => { @@ -193,4 +194,42 @@ describe('Logs', () => { executions.getLogEntries().eq(1).should('contain.text', 'AI Agent'); executions.getLogEntries().eq(2).should('contain.text', 'E2E Chat Model'); }); + + it('should show logs for a workflow with a node that waits for webhook', () => { + workflow.navigateToNewWorkflowPage(); + workflow.pasteWorkflow(Workflow_wait_for_webhook); + workflow.clickZoomToFit(); + logs.openLogsPanel(); + + workflow.executeWorkflow(); + + workflow.getNodesWithSpinner().should('contain.text', 'Wait'); + workflow.getWaitingNodes().should('contain.text', 'Wait'); + logs.getLogEntries().should('have.length', 2); + logs.getLogEntries().eq(0).click(); // click selected row to deselect + logs.getLogEntries().eq(1).should('contain.text', 'Wait node'); + logs.getLogEntries().eq(1).should('contain.text', 'Waiting'); + + workflow.openNode('Wait node'); + ndv + .getOutputPanelDataContainer() + .find('a') + .should('have.attr', 'href') + .then((url) => { + cy.request(url as unknown as string).then((response) => { + expect(response.status).to.eq(200); + }); + }); + ndv.getBackToCanvasButton().click(); + + workflow.getNodesWithSpinner().should('not.exist'); + workflow.getWaitingNodes().should('not.exist'); + logs + .getOverviewStatus() + .contains(/Success in [\d\.]+m?s/) + .should('exist'); + logs.getLogEntries().should('have.length', 2); + logs.getLogEntries().eq(1).should('contain.text', 'Wait node'); + logs.getLogEntries().eq(1).should('contain.text', 'Success'); + }); }); diff --git a/cypress/fixtures/Workflow_wait_for_webhook.json b/cypress/fixtures/Workflow_wait_for_webhook.json new file mode 100644 index 0000000000..7206b18227 --- /dev/null +++ b/cypress/fixtures/Workflow_wait_for_webhook.json @@ -0,0 +1,41 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "42c6e003-10e7-4100-aff8-8865c49f384c", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": { + "resume": "webhook", + "options": {} + }, + "type": "n8n-nodes-base.wait", + "typeVersion": 1.1, + "position": [220, 0], + "id": "77614c15-c41e-4b8c-95d6-084d48fed328", + "name": "Wait node", + "webhookId": "62aad98c-81b3-4c44-9adb-b33a23d1271d" + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "Wait node", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {}, + "meta": { + "instanceId": "eea4a7b09aa7ee308bc067003a65466862f88be8d9309a2bb16297f6bb2616ec" + } +} diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.test.ts b/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.test.ts index ea4b9901a6..565d1717f8 100644 --- a/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.test.ts +++ b/packages/frontend/editor-ui/src/components/CanvasChat/future/LogsPanel.test.ts @@ -224,7 +224,7 @@ describe('LogsPanel', () => { expect(rendered.getByText('Running')).toBeInTheDocument(); expect(rendered.queryByText('AI Agent')).not.toBeInTheDocument(); - workflowsStore.addNodeExecutionData({ + workflowsStore.addNodeExecutionStartedData({ nodeName: 'AI Agent', executionId: '567', data: { executionIndex: 0, startTime: Date.parse('2025-04-20T12:34:51.000Z'), source: [] }, diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/future/composables/useExecutionData.ts b/packages/frontend/editor-ui/src/components/CanvasChat/future/composables/useExecutionData.ts index 9ec629f3f8..a66c332dcf 100644 --- a/packages/frontend/editor-ui/src/components/CanvasChat/future/composables/useExecutionData.ts +++ b/packages/frontend/editor-ui/src/components/CanvasChat/future/composables/useExecutionData.ts @@ -8,6 +8,7 @@ import { useThrottleFn } from '@vueuse/core'; import { createLogTree, deepToRaw, + mergeStartData, type LatestNodeInfo, type LogEntry, } from '@/components/RunDataAi/utils'; @@ -106,11 +107,19 @@ export function useExecutionData() { () => workflowsStore.workflowExecutionData?.workflowData.id, () => workflowsStore.workflowExecutionData?.status, () => workflowsStore.workflowExecutionResultDataLastUpdate, + () => workflowsStore.workflowExecutionStartedData, ], useThrottleFn( ([executionId], [previousExecutionId]) => { - // Create deep copy to disable reactivity - execData.value = deepToRaw(workflowsStore.workflowExecutionData ?? undefined); + execData.value = + workflowsStore.workflowExecutionData === null + ? undefined + : deepToRaw( + mergeStartData( + workflowsStore.workflowExecutionStartedData?.[1] ?? {}, + workflowsStore.workflowExecutionData, + ), + ); // Create deep copy to disable reactivity if (executionId !== previousExecutionId) { // Reset sub workflow data when top-level execution changes diff --git a/packages/frontend/editor-ui/src/components/RunDataAi/utils.test.ts b/packages/frontend/editor-ui/src/components/RunDataAi/utils.test.ts index f07687df50..61995fbc03 100644 --- a/packages/frontend/editor-ui/src/components/RunDataAi/utils.test.ts +++ b/packages/frontend/editor-ui/src/components/RunDataAi/utils.test.ts @@ -14,11 +14,13 @@ import { getDefaultCollapsedEntries, getTreeNodeData, getTreeNodeDataV2, + mergeStartData, } from '@/components/RunDataAi/utils'; import { AGENT_LANGCHAIN_NODE_TYPE, type ExecutionError, type ITaskData, + type ITaskStartedData, NodeConnectionTypes, } from 'n8n-workflow'; import { type LogEntrySelection } from '../CanvasChat/types/logs'; @@ -1259,7 +1261,7 @@ describe(createLogTree, () => { executionIndex: 3, }), createTestTaskData({ - startTime: Date.parse('2025-04-04T00:00:03.000Z'), + startTime: Date.parse('2025-04-04T00:00:02.000Z'), executionIndex: 2, }), ], @@ -1318,7 +1320,7 @@ describe(createLogTree, () => { executionIndex: 3, }), createTestTaskData({ - startTime: Date.parse('2025-04-04T00:00:03.000Z'), + startTime: Date.parse('2025-04-04T00:00:02.000Z'), executionIndex: 2, }), ], @@ -1457,6 +1459,90 @@ describe(deepToRaw, () => { }); }); +describe(mergeStartData, () => { + it('should return unchanged execution response if start data is empty', () => { + const response = createTestWorkflowExecutionResponse({ + data: { + resultData: { + runData: { + A: [createTestTaskData()], + B: [createTestTaskData(), createTestTaskData()], + }, + }, + }, + }); + + expect(mergeStartData({}, response)).toEqual(response); + }); + + it('should add runs in start data to the execution response as running state', () => { + const response = createTestWorkflowExecutionResponse({ + data: { + resultData: { + runData: { + A: [createTestTaskData({ startTime: 0, executionIndex: 0 })], + B: [ + createTestTaskData({ startTime: 1, executionIndex: 1 }), + createTestTaskData({ startTime: 2, executionIndex: 2 }), + ], + }, + }, + }, + }); + const startData: { [nodeName: string]: ITaskStartedData[] } = { + B: [{ startTime: 3, executionIndex: 3, source: [] }], + C: [{ startTime: 4, executionIndex: 4, source: [] }], + }; + const merged = mergeStartData(startData, response); + + expect(merged.data?.resultData.runData.A).toEqual(response.data?.resultData.runData.A); + expect(merged.data?.resultData.runData.B).toEqual([ + response.data!.resultData.runData.B[0], + response.data!.resultData.runData.B[1], + { ...startData.B[0], executionStatus: 'running', executionTime: 0 }, + ]); + expect(merged.data?.resultData.runData.C).toEqual([ + { ...startData.C[0], executionStatus: 'running', executionTime: 0 }, + ]); + }); + + it('should not add runs in start data if a run with the same executionIndex already exists in response', () => { + const response = createTestWorkflowExecutionResponse({ + data: { + resultData: { + runData: { + A: [createTestTaskData({ executionIndex: 0 })], + }, + }, + }, + }); + const startData = { + A: [createTestTaskData({ executionIndex: 0 })], + }; + const merged = mergeStartData(startData, response); + + expect(merged.data?.resultData.runData.A).toEqual(response.data?.resultData.runData.A); + }); + + it('should not add runs in start data if a run for the same node with larger start time already exists in response', () => { + const response = createTestWorkflowExecutionResponse({ + data: { + resultData: { + runData: { + A: [createTestTaskData({ startTime: 1, executionIndex: 1 })], + }, + }, + }, + }); + const startData = { + A: [createTestTaskData({ startTime: 0, executionIndex: 0 })], + }; + const merged = mergeStartData(startData, response); + + expect(merged.data?.resultData.runData.A).toEqual(response.data?.resultData.runData.A); + }); +}); + describe(getDefaultCollapsedEntries, () => { it('should recursively find logs for runs with a sub execution and has no child logs', () => { const entries = [ diff --git a/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts b/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts index 255b45469f..9433625ecd 100644 --- a/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts +++ b/packages/frontend/editor-ui/src/components/RunDataAi/utils.ts @@ -11,6 +11,7 @@ import { type ITaskDataConnections, type NodeConnectionType, type Workflow, + type ITaskStartedData, type IRunExecutionData, } from 'n8n-workflow'; import { type LogEntrySelection } from '../CanvasChat/types/logs'; @@ -397,17 +398,7 @@ function getTreeNodeDataRecV2( runIndex: number | undefined, ): LogEntry[] { const treeNode = createNodeV2(node, context, runIndex ?? 0, runData); - const children = getChildNodes(treeNode, node, runIndex, context).sort((a, b) => { - // Sort the data by execution index or start time - if (a.runData.executionIndex !== undefined && b.runData.executionIndex !== undefined) { - return a.runData.executionIndex - b.runData.executionIndex; - } - - const aTime = a.runData.startTime ?? 0; - const bTime = b.runData.startTime ?? 0; - - return aTime - bTime; - }); + const children = getChildNodes(treeNode, node, runIndex, context).sort(sortLogEntries); treeNode.children = children; @@ -483,23 +474,15 @@ function createLogTreeRec(context: LogTreeCreationContext) { ? [] // skip sub nodes and disabled nodes : taskData.map((task, runIndex) => ({ nodeName, - task, + runData: task, runIndex, nodeHasMultipleRuns: taskData.length > 1, })), ) - .sort((a, b) => { - if (a.task.executionIndex !== undefined && b.task.executionIndex !== undefined) { - return a.task.executionIndex - b.task.executionIndex; - } + .sort(sortLogEntries); - return a.nodeName === b.nodeName - ? a.runIndex - b.runIndex - : a.task.startTime - b.task.startTime; - }); - - return runs.flatMap(({ nodeName, runIndex, task, nodeHasMultipleRuns }) => - getTreeNodeDataV2(nodeName, task, nodeHasMultipleRuns ? runIndex : undefined, context), + return runs.flatMap(({ nodeName, runIndex, runData, nodeHasMultipleRuns }) => + getTreeNodeDataV2(nodeName, runData, nodeHasMultipleRuns ? runIndex : undefined, context), ); } @@ -595,6 +578,65 @@ export function flattenLogEntries( return ret; } +function sortLogEntries(a: T, b: T) { + // We rely on execution index only when startTime is different + // Because it is reset to 0 when execution is waited, and therefore not necessarily unique + if (a.runData.startTime === b.runData.startTime) { + return a.runData.executionIndex - b.runData.executionIndex; + } + + return a.runData.startTime - b.runData.startTime; +} + +export function mergeStartData( + startData: { [nodeName: string]: ITaskStartedData[] }, + response: IExecutionResponse, +): IExecutionResponse { + if (!response.data) { + return response; + } + + const nodeNames = [ + ...new Set( + Object.keys(startData).concat(Object.keys(response.data.resultData.runData)), + ).values(), + ]; + const runData = Object.fromEntries( + nodeNames.map<[string, ITaskData[]]>((nodeName) => { + const tasks = response.data?.resultData.runData[nodeName] ?? []; + const mergedTasks = tasks.concat( + (startData[nodeName] ?? []) + .filter((task) => + // To remove duplicate runs, we check start time in addition to execution index + // because nodes such as Wait and Form emits multiple websocket events with + // different execution index for a single run + tasks.every( + (t) => t.startTime < task.startTime && t.executionIndex !== task.executionIndex, + ), + ) + .map((task) => ({ + ...task, + executionTime: 0, + executionStatus: 'running', + })), + ); + + return [nodeName, mergedTasks]; + }), + ); + + return { + ...response, + data: { + ...response.data, + resultData: { + ...response.data.resultData, + runData, + }, + }, + }; +} + export function hasSubExecution(entry: LogEntry): boolean { return !!entry.runData.metadata?.subExecution; } diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/executionFinished.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/executionFinished.ts index 92e47fdd5f..5d765b44b1 100644 --- a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/executionFinished.ts +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/executionFinished.ts @@ -21,7 +21,6 @@ import type { ExpressionError, IDataObject, IRunExecutionData, - IRunData, } from 'n8n-workflow'; import { codeNodeEditorEventBus, globalLinkActionsEventBus } from '@/event-bus'; import { h } from 'vue'; @@ -467,8 +466,6 @@ export function setRunExecutionData( runExecutionData.resultData.runData = workflowsStore.getWorkflowRunData; } - removeRunningTaskData(runExecutionData.resultData.runData); - workflowsStore.executingNode.length = 0; workflowsStore.setWorkflowExecutionData({ @@ -508,11 +505,3 @@ export function setRunExecutionData( const lineNumber = runExecutionData.resultData?.error?.lineNumber; codeNodeEditorEventBus.emit('highlightLine', lineNumber ?? 'last'); } - -function removeRunningTaskData(runData: IRunData): void { - for (const [nodeName, taskItems] of Object.entries(runData)) { - if (taskItems.some((item) => item.executionStatus === 'running')) { - runData[nodeName] = taskItems.filter((item) => item.executionStatus !== 'running'); - } - } -} diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteBefore.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteBefore.ts index df1a25a2b3..41e684325f 100644 --- a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteBefore.ts +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteBefore.ts @@ -8,5 +8,5 @@ export async function nodeExecuteBefore({ data }: NodeExecuteBefore) { const workflowsStore = useWorkflowsStore(); workflowsStore.addExecutingNode(data.nodeName); - workflowsStore.addNodeExecutionData(data); + workflowsStore.addNodeExecutionStartedData(data); } diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.ts b/packages/frontend/editor-ui/src/stores/workflows.store.ts index 56f43ac62d..86c1fe57cf 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.ts @@ -55,6 +55,7 @@ import type { ITaskData, IWorkflowSettings, INodeType, + ITaskStartedData, } from 'n8n-workflow'; import { deepCopy, @@ -141,6 +142,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { const activeWorkflowExecution = ref(null); const currentWorkflowExecutions = ref([]); const workflowExecutionData = ref(null); + const workflowExecutionStartedData = + ref<[executionId: string, data: { [nodeName: string]: ITaskStartedData[] }]>(); const workflowExecutionResultDataLastUpdate = ref(); const workflowExecutionPairedItemMappings = ref>>({}); const subWorkflowExecutionError = ref(null); @@ -868,6 +871,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { workflowExecutionData.value = workflowResultData; workflowExecutionPairedItemMappings.value = getPairedItemsMapping(workflowResultData); workflowExecutionResultDataLastUpdate.value = Date.now(); + workflowExecutionStartedData.value = undefined; } function setWorkflowExecutionRunData(workflowResultData: IRunExecutionData) { @@ -877,6 +881,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { data: workflowResultData, }; workflowExecutionResultDataLastUpdate.value = Date.now(); + workflowExecutionStartedData.value = undefined; } } @@ -1484,24 +1489,19 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { return testUrl; } - function addNodeExecutionData(data: NodeExecuteBefore['data']): void { - if (settingsStore.isNewLogsEnabled) { - const node = getNodeByName(data.nodeName); - if (!node || !workflowExecutionData.value?.data) { - return; - } + function addNodeExecutionStartedData(data: NodeExecuteBefore['data']): void { + const currentData = + workflowExecutionStartedData.value?.[0] === data.executionId + ? workflowExecutionStartedData.value?.[1] + : {}; - if (workflowExecutionData.value.data.resultData.runData[data.nodeName] === undefined) { - workflowExecutionData.value.data.resultData.runData[data.nodeName] = []; - } - - workflowExecutionData.value.data.resultData.runData[data.nodeName].push({ - executionStatus: 'running', - executionTime: 0, - ...data.data, - }); - workflowExecutionResultDataLastUpdate.value = Date.now(); - } + workflowExecutionStartedData.value = [ + data.executionId, + { + ...currentData, + [data.nodeName]: [...(currentData[data.nodeName] ?? []), data.data], + }, + ]; } function updateNodeExecutionData(pushData: PushPayload<'nodeExecuteAfter'>): void { @@ -1533,6 +1533,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { const tasksData = workflowExecutionData.value.data!.resultData.runData[nodeName]; if (isNodeWaiting) { tasksData.push(data); + workflowExecutionResultDataLastUpdate.value = Date.now(); + if ( node.type === FORM_NODE_TYPE || (node.type === WAIT_NODE_TYPE && node.parameters.resume === 'form') @@ -1843,6 +1845,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { workflowExecutionData, workflowExecutionPairedItemMappings, workflowExecutionResultDataLastUpdate, + workflowExecutionStartedData, activeExecutionId: readonlyActiveExecutionId, previousExecutionId: readonlyPreviousExecutionId, setActiveExecutionId, @@ -1911,7 +1914,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { makeNewWorkflowShareable, resetWorkflow, resetState, - addNodeExecutionData, + addNodeExecutionStartedData, addExecutingNode, removeExecutingNode, setWorkflowId,