From bb8ac5acef0bc6c0dace91be620e0f9d486f2d74 Mon Sep 17 00:00:00 2001 From: Daria Date: Mon, 15 Jun 2026 11:54:55 +0300 Subject: [PATCH] feat(editor): Surface execution status on canvas group title bars (no-changelog) (#31594) Co-authored-by: Claude Opus 4.7 (1M context) --- .../workflows/canvas/__tests__/utils.ts | 17 ++ .../features/workflows/canvas/canvas.types.ts | 26 +++ .../canvas/components/WorkflowCanvas.vue | 2 + .../groups/CanvasNodeGroupTitleBar.test.ts | 72 +++++++ .../groups/CanvasNodeGroupTitleBar.vue | 90 ++++++++- .../nodes/render-types/CanvasNodeDefault.vue | 80 ++------ .../nodes/render-types/_canvasNodeStyles.scss | 93 ++++++++- .../parts/CanvasNodeStatusIcons.vue | 19 +- .../parts/CanvasNodeStatusMark.vue | 54 +++++ .../useCanvasMapping.groups.test.ts | 128 +++++++++++- .../composables/useCanvasMapping.groups.ts | 62 +++++- .../composables/useCanvasMapping.test.ts | 185 +++++++++++++++++- .../canvas/composables/useCanvasMapping.ts | 142 ++++++++++---- .../workflowDiff/useWorkflowDiff.test.ts | 2 + 14 files changed, 845 insertions(+), 127 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeStatusMark.vue diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/__tests__/utils.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/__tests__/utils.ts index 9a643dd162c..a17bab7e288 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/__tests__/utils.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/__tests__/utils.ts @@ -18,6 +18,7 @@ import { CanvasConnectionMode, CanvasNodeRenderType, } from '@/features/workflows/canvas/canvas.types'; +import type { NodeExecutionSnapshot } from '@/features/workflows/canvas/canvas.types'; import type { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; import type { GraphEdge, GraphNode, ViewportTransform } from '@vue-flow/core'; @@ -98,6 +99,22 @@ export function createCanvasGroupElement({ }; } +export function createNodeExecutionSnapshot( + overrides: Partial = {}, +): NodeExecutionSnapshot { + return { + running: false, + waitingForNext: false, + waiting: undefined, + hasExecutionError: false, + hasValidationError: false, + status: undefined, + dirty: false, + iterations: 0, + ...overrides, + }; +} + export function createCanvasGraphNode({ id = '1', type = 'default', diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.types.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.types.ts index ffe2fb16c4f..416c4297105 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.types.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/canvas.types.ts @@ -141,10 +141,36 @@ export const CANVAS_NODE_GROUP_ID_PREFIX = 'group:'; export const CANVAS_NODE_GROUP_HANDLE_LEFT = 'left'; export const CANVAS_NODE_GROUP_HANDLE_RIGHT = 'right'; +/** + * The only execution states a group can surface — node-level statuses like + * `crashed` are folded into these during aggregation. + */ +export type GroupExecutionStatus = + | 'waiting' + | 'running' + | 'error' + | 'issues' + | 'warning' + | 'success'; + +/** Per-node execution state used to roll a group up into one status. */ +export interface NodeExecutionSnapshot { + running: boolean; + waitingForNext: boolean; + waiting: string | undefined; + hasExecutionError: boolean; + hasValidationError: boolean; + status: ExecutionStatus | undefined; + /** Parameters changed since the last run — the single-node "dirty" warning. */ + dirty: boolean; + iterations: number; +} + export interface CanvasGroupNodeData { group: IWorkflowGroup; nodesRect: { x: number; y: number; width: number; height: number }; isCollapsed: boolean; + executionStatus?: GroupExecutionStatus; } export type CanvasGroupNode = Node; diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/WorkflowCanvas.vue b/packages/frontend/editor-ui/src/features/workflows/canvas/components/WorkflowCanvas.vue index 63d81967be9..137f22f2046 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/WorkflowCanvas.vue +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/WorkflowCanvas.vue @@ -114,6 +114,7 @@ const { nodes: mappedWorkflowNodes, connections: mappedConnections, nodeDisplaySizeById, + getNodeExecutionSnapshot, } = useCanvasMapping({ nodes, connections, @@ -130,6 +131,7 @@ const mappedGroupVueFlowNodes = computed(() => getNodeDisplaySize: (id) => nodeDisplaySizeById.value[id], isGroupCollapsed: (id) => nodeGroupView.isGroupCollapsed(id), readOnly: readOnlyRef.value || suppressInteractionRef.value, + getNodeExecutionSnapshot, }), ); diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/groups/CanvasNodeGroupTitleBar.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/groups/CanvasNodeGroupTitleBar.test.ts index cd3f2039e2b..ab81b62ec6e 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/groups/CanvasNodeGroupTitleBar.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/groups/CanvasNodeGroupTitleBar.test.ts @@ -136,6 +136,78 @@ describe('CanvasNodeGroupTitleBar', () => { }); }); + describe('execution-status classes', () => { + it('applies no status class when executionStatus is undefined (idle)', () => { + const wrapper = render({ data: makeData({ executionStatus: undefined }) }); + const root = wrapper.getByTestId('canvas-node-group'); + // No status icon and no .success / .error / .running class semantics. + expect(wrapper.queryByTestId('canvas-node-group-status-success')).toBeNull(); + expect(wrapper.queryByTestId('canvas-node-group-status-error')).toBeNull(); + // status classes are CSS module hashed; we can only check via test ids. + expect(root).toBeTruthy(); + }); + + it('shows success icon when executionStatus is success', () => { + const wrapper = render({ + data: makeData({ executionStatus: 'success' }), + }); + expect(wrapper.getByTestId('canvas-node-group-status-success')).toBeTruthy(); + }); + + it('shows error icon when executionStatus is error', () => { + const wrapper = render({ + data: makeData({ executionStatus: 'error' }), + }); + expect(wrapper.getByTestId('canvas-node-group-status-error')).toBeTruthy(); + }); + + it('shows warning icon when executionStatus is warning (a member node is dirty)', () => { + const wrapper = render({ + data: makeData({ executionStatus: 'warning' }), + }); + expect(wrapper.getByTestId('canvas-node-group-status-warning')).toBeTruthy(); + }); + + it('shows the validation issues triangle when executionStatus is issues', () => { + const wrapper = render({ + data: makeData({ executionStatus: 'issues' }), + }); + expect(wrapper.getByTestId('canvas-node-group-status-issues')).toBeTruthy(); + // Issues must not render the execution-error mark. + expect(wrapper.queryByTestId('canvas-node-group-status-error')).toBeNull(); + }); + + it('hides the status mark when the group is expanded (member nodes show their own)', () => { + const wrapper = render({ + data: makeData({ executionStatus: 'success', isCollapsed: false }), + }); + expect(wrapper.queryByTestId('canvas-node-group-status-success')).toBeNull(); + }); + + it('hides the validation issues triangle when the group is expanded', () => { + const wrapper = render({ + data: makeData({ executionStatus: 'issues', isCollapsed: false }), + }); + expect(wrapper.queryByTestId('canvas-node-group-status-issues')).toBeNull(); + }); + + it('applies a hashed `running` class when executionStatus is running', () => { + const wrapper = render({ + data: makeData({ executionStatus: 'running' }), + }); + const root = wrapper.getByTestId('canvas-node-group'); + expect([...root.classList].some((c) => /running/i.test(c))).toBe(true); + }); + + it('applies a hashed `waiting` class when executionStatus is waiting', () => { + const wrapper = render({ + data: makeData({ executionStatus: 'waiting' }), + }); + const root = wrapper.getByTestId('canvas-node-group'); + expect([...root.classList].some((c) => /waiting/i.test(c))).toBe(true); + }); + }); + describe('title rename + ungroup parity with old overlay', () => { it('emits update:name on commit', async () => { const wrapper = render({ data: makeData({ isCollapsed: false }) }); diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/groups/CanvasNodeGroupTitleBar.vue b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/groups/CanvasNodeGroupTitleBar.vue index 6df5792024a..c5a3dfd5338 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/groups/CanvasNodeGroupTitleBar.vue +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/groups/CanvasNodeGroupTitleBar.vue @@ -1,9 +1,10 @@ + + + + diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.groups.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.groups.test.ts index b440f08b2af..19b5c32e771 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.groups.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.groups.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it } from 'vitest'; import type { IWorkflowGroup } from 'n8n-workflow'; import type { INodeUi } from '@/Interface'; -import type { CanvasConnection } from '../canvas.types'; +import type { CanvasConnection, NodeExecutionSnapshot } from '../canvas.types'; import { + aggregateGroupExecution, buildCollapsedGroupByNodeId, computeNodesRectFromStore, mapGroupsToVueFlowNodes, @@ -16,6 +17,7 @@ import { } from '../stores/canvasNodeGroups.constants'; import { GRID_SIZE } from '@/app/utils/nodeViewUtils'; import { STICKY_NODE_TYPE } from '@/app/constants/nodeTypes'; +import { createNodeExecutionSnapshot } from '../__tests__/utils'; const snapToGrid = (v: number) => Math.round(v / GRID_SIZE) * GRID_SIZE; @@ -48,6 +50,10 @@ function nodeStore(...nodes: INodeUi[]) { return (id: string) => map.get(id); } +function snapshotGetter(byId: Record> = {}) { + return (id: string): NodeExecutionSnapshot => createNodeExecutionSnapshot(byId[id]); +} + describe('computeNodesRectFromStore', () => { // Same defaults used by the design system canvas grid (16 × 6). const NODE_W = 96; @@ -105,6 +111,119 @@ describe('computeNodesRectFromStore', () => { }); }); +describe('aggregateGroupExecution', () => { + function statusOf(nodeIds: string[], byId: Record> = {}) { + return aggregateGroupExecution(nodeIds, snapshotGetter(byId)); + } + + it('returns running when any node is running', () => { + expect(statusOf(['a', 'b'], { a: { running: true } })).toBe('running'); + }); + + it('returns running when any node is waitingForNext', () => { + expect(statusOf(['a'], { a: { waitingForNext: true } })).toBe('running'); + }); + + it('returns error when any node has an execution error', () => { + expect(statusOf(['a', 'b'], { b: { hasExecutionError: true } })).toBe('error'); + }); + + it('returns issues (not error) when a node has only validation errors and never ran', () => { + expect(statusOf(['a', 'b'], { b: { hasValidationError: true } })).toBe('issues'); + }); + + it('execution error beats validation issues', () => { + expect( + statusOf(['a', 'b'], { + a: { hasExecutionError: true }, + b: { hasValidationError: true }, + }), + ).toBe('error'); + }); + + it('validation issues beat warning (dirty) and success', () => { + expect( + statusOf(['a', 'b'], { + a: { hasValidationError: true }, + b: { status: 'success', dirty: true }, + }), + ).toBe('issues'); + }); + + it('ignores canceled / new for the success-success rollup (treated as idle, mirroring single-node)', () => { + expect(statusOf(['a', 'b'], { a: { status: 'success' }, b: { status: 'canceled' } })).toBe( + 'success', + ); + expect( + statusOf(['a', 'b'], { a: { status: 'canceled' }, b: { status: 'new' } }), + ).toBeUndefined(); + }); + + it('returns success when all nodes are success', () => { + expect(statusOf(['a', 'b'], { a: { status: 'success' }, b: { status: 'success' } })).toBe( + 'success', + ); + }); + + it('returns success when one node is success and others never ran (unknown — e.g. untaken conditional branch)', () => { + expect(statusOf(['a', 'b'], { a: { status: 'success' }, b: { status: 'unknown' } })).toBe( + 'success', + ); + }); + + it('returns undefined (idle) when all nodes are unknown — workflow has never executed', () => { + expect( + statusOf(['a', 'b'], { a: { status: 'unknown' }, b: { status: 'unknown' } }), + ).toBeUndefined(); + }); + + it('returns undefined when no node status is set', () => { + expect(statusOf(['a', 'b'])).toBeUndefined(); + }); + + it('returns waiting when any node has a waiting reason (form/webhook/etc.)', () => { + expect(statusOf(['a', 'b'], { a: { waiting: 'waiting for webhook' } })).toBe('waiting'); + }); + + it('returns waiting when any node has executionStatus waiting', () => { + expect(statusOf(['a'], { a: { status: 'waiting' } })).toBe('waiting'); + }); + + it('running beats error', () => { + expect(statusOf(['a', 'b'], { a: { running: true }, b: { hasExecutionError: true } })).toBe( + 'running', + ); + }); + + it('error beats success', () => { + expect(statusOf(['a', 'b'], { a: { status: 'success' }, b: { hasExecutionError: true } })).toBe( + 'error', + ); + }); + + it('returns warning when any node is dirty (parameters changed since its last run)', () => { + expect( + statusOf(['a', 'b'], { a: { status: 'success' }, b: { status: 'success', dirty: true } }), + ).toBe('warning'); + }); + + it('error beats warning, warning beats success — mirrors single-node CSS rule order', () => { + expect( + statusOf(['a', 'b'], { + a: { hasExecutionError: true }, + b: { status: 'success', dirty: true }, + }), + ).toBe('error'); + expect(statusOf(['a', 'b'], { a: { status: 'success' }, b: { dirty: true } })).toBe('warning'); + }); + + it('waiting beats running — mirrors single-node CSS rule order', () => { + expect(statusOf(['a', 'b'], { a: { running: true }, b: { waiting: 'waiting for form' } })).toBe( + 'waiting', + ); + }); +}); + describe('mapGroupsToVueFlowNodes', () => { const group: IWorkflowGroup = { id: 'g1', name: 'G', nodeIds: ['a', 'b'] }; @@ -115,6 +234,7 @@ describe('mapGroupsToVueFlowNodes', () => { getNodeById: getById, isGroupCollapsed: () => isCollapsed, readOnly: false, + getNodeExecutionSnapshot: snapshotGetter(), }); } @@ -158,12 +278,14 @@ describe('mapGroupsToVueFlowNodes', () => { getNodeById: getById, isGroupCollapsed: () => true, readOnly: false, + getNodeExecutionSnapshot: snapshotGetter(), }); const expanded = mapGroupsToVueFlowNodes({ allGroups: [group], getNodeById: getById, isGroupCollapsed: () => false, readOnly: false, + getNodeExecutionSnapshot: snapshotGetter(), }); expect(collapsed[0].width).toBe(GROUP_HEADER_WIDTH_COLLAPSED); expect(expanded[0].width).toBe(GROUP_HEADER_WIDTH_COLLAPSED); @@ -187,6 +309,7 @@ describe('mapGroupsToVueFlowNodes', () => { getNodeById: getById, isGroupCollapsed: () => true, readOnly: true, + getNodeExecutionSnapshot: snapshotGetter(), }); expect(out[0].selectable).toBe(true); expect(out[0].draggable).toBe(false); @@ -199,6 +322,7 @@ describe('mapGroupsToVueFlowNodes', () => { getNodeById: getById, isGroupCollapsed: () => true, readOnly: false, + getNodeExecutionSnapshot: snapshotGetter(), }); expect(out).toHaveLength(0); }); @@ -210,6 +334,7 @@ describe('mapGroupsToVueFlowNodes', () => { getNodeById: getById, isGroupCollapsed: () => true, readOnly: true, + getNodeExecutionSnapshot: snapshotGetter(), }); expect(out[0].draggable).toBe(false); }); @@ -221,6 +346,7 @@ describe('mapGroupsToVueFlowNodes', () => { getNodeById: getById, isGroupCollapsed: () => false, readOnly: false, + getNodeExecutionSnapshot: snapshotGetter(), }); expect(Math.abs(out[0].position.x % GRID_SIZE)).toBe(0); expect(Math.abs(out[0].position.y % GRID_SIZE)).toBe(0); diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.groups.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.groups.ts index cd0519e037b..28bb29b2b7a 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.groups.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.groups.ts @@ -1,6 +1,12 @@ -import type { IWorkflowGroup } from 'n8n-workflow'; +import type { ExecutionStatus, IWorkflowGroup } from 'n8n-workflow'; import type { INodeUi } from '@/Interface'; -import type { CanvasConnection, CanvasGroupNode, CanvasGroupNodeData } from '../canvas.types'; +import type { + CanvasConnection, + CanvasGroupNode, + CanvasGroupNodeData, + GroupExecutionStatus, + NodeExecutionSnapshot, +} from '../canvas.types'; import { CANVAS_NODE_GROUP_HANDLE_LEFT, CANVAS_NODE_GROUP_HANDLE_RIGHT, @@ -127,12 +133,62 @@ export function computeNodesRectFromStore( }; } +// Highest priority first. `success` is resolved separately. +const GROUP_STATUS_PRIORITY: readonly GroupExecutionStatus[] = [ + 'waiting', + 'running', + 'error', + 'issues', + 'warning', +]; + +const IDLE_STATUSES: readonly ExecutionStatus[] = ['new', 'unknown', 'canceled']; + +/** + * Classify a single member for the group rollup by this priority: + * waiting > running > error > issues > warning > success > idle. + * Validation issues are kept distinct from execution errors. + * Other is an active-but-unhandled status that must block a misleading success. + * Idle statuses return undefined (they neither paint nor veto). + */ +function classifyNodeForGroup( + snapshot: NodeExecutionSnapshot, +): GroupExecutionStatus | 'other' | undefined { + const { status } = snapshot; + if (snapshot.waiting || status === 'waiting') return 'waiting'; + if (snapshot.running || snapshot.waitingForNext) return 'running'; + if (snapshot.hasExecutionError) return 'error'; + if (snapshot.hasValidationError) return 'issues'; + if (snapshot.dirty) return 'warning'; + if (status === 'success') return 'success'; + if (status === undefined || IDLE_STATUSES.includes(status)) return undefined; + return 'other'; +} + +/** Reduce a group's per-node state into one dominant status. */ +export function aggregateGroupExecution( + nodeIds: string[], + getNodeExecutionSnapshot: (id: string) => NodeExecutionSnapshot, +): GroupExecutionStatus | undefined { + const seen = new Set(); + for (const id of nodeIds) { + seen.add(classifyNodeForGroup(getNodeExecutionSnapshot(id))); + } + + for (const status of GROUP_STATUS_PRIORITY) { + if (seen.has(status)) return status; + } + // success is the only status that speaks for every member + return seen.has('success') && !seen.has('other') ? 'success' : undefined; +} + export interface MapGroupsToVueFlowNodesInputs { allGroups: IWorkflowGroup[]; getNodeById: (id: string) => INodeUi | undefined; getNodeDisplaySize?: GetNodeDisplaySize; isGroupCollapsed: (id: string) => boolean; readOnly: boolean; + getNodeExecutionSnapshot: (id: string) => NodeExecutionSnapshot; } /** @@ -145,6 +201,7 @@ export function mapGroupsToVueFlowNodes({ getNodeDisplaySize, isGroupCollapsed, readOnly, + getNodeExecutionSnapshot, }: MapGroupsToVueFlowNodesInputs): CanvasGroupNode[] { const out: CanvasGroupNode[] = []; for (const group of allGroups) { @@ -159,6 +216,7 @@ export function mapGroupsToVueFlowNodes({ group, nodesRect, isCollapsed: collapsed, + executionStatus: aggregateGroupExecution(group.nodeIds, getNodeExecutionSnapshot), }; const titleBar = titleBarFromNodesRect(nodesRect, collapsed); diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.test.ts index b6fa92e5b5e..f61f541579c 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.test.ts @@ -9,7 +9,7 @@ * `executionData.store.test.ts`. These tests verify the shape of the canvas * output and that renderData values flow into the right fields. */ -import type { ITaskData, IConnections } from 'n8n-workflow'; +import type { ITaskData, IConnections, IWorkflowGroup } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; import { createPinia, setActivePinia } from 'pinia'; import { computed, ref, shallowRef } from 'vue'; @@ -18,6 +18,7 @@ import { type CanvasRenderData, } from '@/features/workflows/canvas/canvas.utils'; import { useCanvasMapping } from '@/features/workflows/canvas/composables/useCanvasMapping'; +import type { CanvasNodeGroupView } from './useCanvasNodeGroupView'; import { createTestNode } from '@/__tests__/mocks'; import type { INodeUi } from '@/Interface'; import { CanvasNodeRenderType, type CanvasNodeData } from '../canvas.types'; @@ -275,6 +276,77 @@ describe('useCanvasMapping — mapped nodes', () => { }); }); +describe('useCanvasMapping — getNodeExecutionSnapshot', () => { + it('reads hasExecutionError from executionIssuesByNodeName (single-node parity)', () => { + const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi; + const rd = createEmptyCanvasRenderData(); + rd.executionIssuesByNodeName.set( + 'Alpha', + computed(() => ['Boom']), + ); + + const { getNodeExecutionSnapshot } = useCanvasMapping({ + nodes: ref([node]), + connections: ref({}), + renderData: shallowRef(rd), + }); + + const snapshot = getNodeExecutionSnapshot('a'); + expect(snapshot.hasExecutionError).toBe(true); + expect(snapshot.hasValidationError).toBe(false); + }); + + it('reads hasValidationError without flagging an execution error', () => { + const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi; + const rd = createEmptyCanvasRenderData(); + rd.validationErrorsByNodeId.set( + 'a', + computed(() => ['Missing parameter']), + ); + + const { getNodeExecutionSnapshot } = useCanvasMapping({ + nodes: ref([node]), + connections: ref({}), + renderData: shallowRef(rd), + }); + + const snapshot = getNodeExecutionSnapshot('a'); + expect(snapshot.hasValidationError).toBe(true); + expect(snapshot.hasExecutionError).toBe(false); + }); + + it.each(['error', 'crashed'] as const)( + 'flags hasExecutionError on %s status even without execution-issue text', + (status) => { + const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi; + const rd = createEmptyCanvasRenderData(); + setStatus(rd, 'a', status); + + const { getNodeExecutionSnapshot } = useCanvasMapping({ + nodes: ref([node]), + connections: ref({}), + renderData: shallowRef(rd), + }); + + expect(getNodeExecutionSnapshot('a').hasExecutionError).toBe(true); + }, + ); + + it('flags hasExecutionError from a last-task error when no issue text exists', () => { + const node = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi; + const rd = createEmptyCanvasRenderData(); + setRunData(rd, 'a', [{ error: { message: 'Boom' } } as unknown as ITaskData]); + + const { getNodeExecutionSnapshot } = useCanvasMapping({ + nodes: ref([node]), + connections: ref({}), + renderData: shallowRef(rd), + }); + + expect(getNodeExecutionSnapshot('a').hasExecutionError).toBe(true); + }); +}); + describe('useCanvasMapping — mapped connections', () => { function makeWorkflow(connections: IConnections, nodes: INodeUi[] = []) { const alpha = createTestNode({ id: 'a', name: 'Alpha' }) as INodeUi; @@ -517,4 +589,115 @@ describe('useCanvasMapping — mapped connections', () => { expect(mapped.value[0].data?.status).toBe('running'); }); }); + + describe('collapsed group merged edge status', () => { + // Two grouped nodes feeding the same external input merge into a single + // edge when the group is collapsed; the edge must surface the + // highest-priority status among the underlying connections. + const group: IWorkflowGroup = { id: 'g1', name: 'G', nodeIds: ['m1', 'm2'] }; + const collapsedView = { isGroupCollapsed: () => true } as unknown as CanvasNodeGroupView; + + function fanInWorkflow() { + const m1 = createTestNode({ id: 'm1', name: 'M1' }) as INodeUi; + const m2 = createTestNode({ id: 'm2', name: 'M2' }) as INodeUi; + const external = createTestNode({ id: 'x', name: 'X' }) as INodeUi; + const connections: IConnections = { + M1: { main: [[{ node: 'X', type: NodeConnectionTypes.Main, index: 0 }]] }, + M2: { main: [[{ node: 'X', type: NodeConnectionTypes.Main, index: 0 }]] }, + }; + return { nodes: [m1, m2, external], connections }; + } + + it('surfaces the status of a non-first merged connection (only the second member ran)', () => { + const { nodes, connections } = fanInWorkflow(); + const rd = createEmptyCanvasRenderData(); + setRunData(rd, 'm2', [{ executionStatus: 'success' } as ITaskData]); + rd.executionRunDataOutputMapByNodeId.set('m2', { + main: { '0': { total: 1, iterations: 1 } }, + }); + + const { connections: mapped } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + renderData: shallowRef(rd), + allGroups: ref([group]), + nodeGroupView: collapsedView, + }); + + expect(mapped.value).toHaveLength(1); + expect(mapped.value[0].source).toBe('group:g1'); + expect(mapped.value[0].target).toBe('x'); + expect(mapped.value[0].data?.status).toBe('success'); + }); + + it('picks the highest-priority status across merged connections, not the first one', () => { + const { nodes, connections } = fanInWorkflow(); + const rd = createEmptyCanvasRenderData(); + // Both members ran, the second one is pinned — pinned outranks success, + // so it must win even though the first connection comes first. + setRunData(rd, 'm1', [{ executionStatus: 'success' } as ITaskData]); + setRunData(rd, 'm2', [{ executionStatus: 'success' } as ITaskData]); + rd.executionRunDataOutputMapByNodeId.set('m1', { + main: { '0': { total: 1, iterations: 1 } }, + }); + rd.executionRunDataOutputMapByNodeId.set('m2', { + main: { '0': { total: 1, iterations: 1 } }, + }); + rd.pinnedDataByNodeId.set( + 'm2', + computed(() => [{ json: {} }]), + ); + + const { connections: mapped } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + renderData: shallowRef(rd), + allGroups: ref([group]), + nodeGroupView: collapsedView, + }); + + expect(mapped.value).toHaveLength(1); + expect(mapped.value[0].data?.status).toBe('pinned'); + }); + + it('resolves the item-count label through the canonical source (not the group id)', () => { + const { nodes, connections } = fanInWorkflow(); + const rd = createEmptyCanvasRenderData(); + setRunData(rd, 'm1', [{ executionStatus: 'success' } as ITaskData]); + rd.executionRunDataOutputMapByNodeId.set('m1', { + main: { '0': { total: 5, iterations: 1 } }, + }); + + const { connections: mapped } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + renderData: shallowRef(rd), + allGroups: ref([group]), + nodeGroupView: collapsedView, + }); + + expect(mapped.value).toHaveLength(1); + expect(mapped.value[0].source).toBe('group:g1'); + expect(mapped.value[0].label).toBe('5 items'); + }); + + it('resolves the pinned item-count label through the canonical source', () => { + const { nodes, connections } = fanInWorkflow(); + const rd = createEmptyCanvasRenderData(); + rd.pinnedDataByNodeId.set( + 'm1', + computed(() => [{ json: {} }, { json: {} }]), + ); + + const { connections: mapped } = useCanvasMapping({ + nodes: ref(nodes), + connections: ref(connections), + renderData: shallowRef(rd), + allGroups: ref([group]), + nodeGroupView: collapsedView, + }); + + expect(mapped.value[0].label).toBe('2 items'); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts index 70f400f2d32..0645df58938 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useCanvasMapping.ts @@ -12,6 +12,7 @@ import type { CanvasConnectionData, CanvasNode, CanvasNodeData, + NodeExecutionSnapshot, } from '../canvas.types'; import { CanvasConnectionMode, CanvasNodeRenderType } from '../canvas.types'; import type { CanvasNodeGroupView } from './useCanvasNodeGroupView'; @@ -28,8 +29,12 @@ import type { IConnections, ITaskData, IWorkflowGroup } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; import type { INodeUi } from '@/Interface'; import { MarkerType } from '@vue-flow/core'; +import type { Connection } from '@vue-flow/core'; import * as workflowUtils from 'n8n-workflow/common'; +// Highest priority first — single source of precedence for connection status. +const CONNECTION_STATUS_PRIORITY = ['running', 'pinned', 'error', 'success'] as const; + /** * Maps workflow nodes and connections into the vue-flow canvas shape. * @@ -56,9 +61,49 @@ export function useCanvasMapping({ }) { const i18n = useI18n(); - function filterOutCanceled(tasks: ITaskData[] | null): ITaskData[] | null { - if (!tasks) return null; - return tasks.filter((task) => task.executionStatus !== 'canceled'); + // `executionIssuesByNodeName` is keyed by name; groups address nodes by id. + const nodeNameById = computed(() => { + const map = new Map(); + for (const node of nodes.value) map.set(node.id, node.name); + return map; + }); + + function countNonCanceledIterations(tasks: ITaskData[] | null | undefined): number { + if (!tasks) return 0; + let count = 0; + for (const task of tasks) { + if (task.executionStatus !== 'canceled') count++; + } + return count; + } + + // Per-node execution projection feeding the group-status aggregation. + function getNodeExecutionSnapshot(id: string): NodeExecutionSnapshot { + const rd = renderData.value; + const render = rd.renderTypeByNodeId.get(id)?.value; + const name = nodeNameById.value.get(id); + const status = rd.executionStatusByNodeId.get(id)?.value; + const tasks = rd.executionRunDataByNodeId.get(id)?.value; + + // Mirror the single-node `computeHasIssues` + const executionIssues = name ? rd.executionIssuesByNodeName.get(name)?.value : undefined; + const hasExecutionError = + status === 'error' || + status === 'crashed' || + (executionIssues?.length ?? 0) > 0 || + Boolean(tasks?.at(-1)?.error); + + return { + running: rd.executionRunningByNodeId.get(id)?.value ?? false, + waitingForNext: rd.executionWaitingForNextByNodeId.get(id)?.value ?? false, + waiting: rd.executionWaitingByNodeId.get(id)?.value, + hasExecutionError, + hasValidationError: (rd.validationErrorsByNodeId.get(id)?.value?.length ?? 0) > 0, + status, + dirty: + render?.type === CanvasNodeRenderType.Default && render.options.dirtiness !== undefined, + iterations: countNonCanceledIterations(tasks), + }; } // Node id → its collapsed group, for nodes hidden by a collapsed group. @@ -101,6 +146,7 @@ export function useCanvasMapping({ const inputConnections = connectionsByDestinationNode[node.name] ?? {}; const runData = rd.executionRunDataByNodeId.get(node.id)?.value ?? null; + const executionSnapshot = getNodeExecutionSnapshot(node.id); const data: CanvasNodeData = { id: node.id, @@ -118,14 +164,14 @@ export function useCanvasMapping({ visible: rd.hasIssuesByNodeId.get(node.id)?.value ?? false, }, execution: { - status: rd.executionStatusByNodeId.get(node.id)?.value, - waiting: rd.executionWaitingByNodeId.get(node.id)?.value, - waitingForNext: rd.executionWaitingForNextByNodeId.get(node.id)?.value ?? false, - running: rd.executionRunningByNodeId.get(node.id)?.value ?? false, + status: executionSnapshot.status, + waiting: executionSnapshot.waiting, + waitingForNext: executionSnapshot.waitingForNext, + running: executionSnapshot.running, }, runData: { outputMap: rd.executionRunDataOutputMapByNodeId.get(node.id), - iterations: filterOutCanceled(runData)?.length ?? 0, + iterations: executionSnapshot.iterations, visible: !!runData, }, render: @@ -158,43 +204,56 @@ export function useCanvasMapping({ })); }); - function getConnectionData(connection: CanvasConnection): CanvasConnectionData { + function getConnectionStatus(connection: Connection): CanvasConnectionData['status'] { const rd = renderData.value; const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle); - const outputMap = rd.executionRunDataOutputMapByNodeId.get(connection.source); - const runData = outputMap?.[type]?.[index]; + + const runData = rd.executionRunDataOutputMapByNodeId.get(connection.source)?.[type]?.[index]; const runDataTotal = runData?.total ?? 0; - const sourceTasks = rd.executionRunDataByNodeId.get(connection.source)?.value ?? []; - let lastSourceTask: ITaskData | undefined = sourceTasks[sourceTasks.length - 1]; - if (lastSourceTask?.executionStatus === 'canceled' && sourceTasks.length > 1) { + const sourceTasks = rd.executionRunDataByNodeId.get(connection.source)?.value; + let lastSourceTask: ITaskData | undefined = sourceTasks?.[sourceTasks.length - 1]; + if (lastSourceTask?.executionStatus === 'canceled' && sourceTasks && sourceTasks.length > 1) { lastSourceTask = sourceTasks[sourceTasks.length - 2]; } - const sourcePinned = rd.pinnedDataByNodeId.get(connection.source)?.value; - const sourceRunData = rd.executionRunDataByNodeId.get(connection.source)?.value; - const targetRunData = rd.executionRunDataByNodeId.get(connection.target)?.value; - const sourceRunning = rd.executionRunningByNodeId.get(connection.source)?.value ?? false; - const sourceHasIssues = rd.hasIssuesByNodeId.get(connection.source)?.value ?? false; + // Non-main connections (model, memory, tool) are passive — count as + // executed only if the target node also ran. + const targetExecuted = + type === NodeConnectionTypes.Main || + Boolean(rd.executionRunDataByNodeId.get(connection.target)?.value); + + const matches: Record<(typeof CONNECTION_STATUS_PRIORITY)[number], boolean> = { + running: + (rd.executionRunningByNodeId.get(connection.source)?.value ?? false) && runDataTotal === 0, + pinned: Boolean(rd.pinnedDataByNodeId.get(connection.source)?.value && sourceTasks), + error: rd.hasIssuesByNodeId.get(connection.source)?.value ?? false, + success: runDataTotal > 0 && lastSourceTask?.executionStatus !== 'canceled' && targetExecuted, + }; + + return CONNECTION_STATUS_PRIORITY.find((status) => matches[status]); + } + + function getConnectionData(connection: CanvasConnection): CanvasConnectionData { + const rd = renderData.value; + // For edges remapped to `group:*` ids, the real endpoints live on + // `data.canonicals` (multiple when same-endpoint edges were merged). + // The edge surfaces the highest-priority status among them. + const canonicals: Connection[] = connection.data?.canonicals ?? [connection]; let status: CanvasConnectionData['status']; - if (sourceRunning && runDataTotal === 0) { - status = 'running'; - } else if (sourcePinned && sourceRunData) { - status = 'pinned'; - } else if (sourceHasIssues) { - status = 'error'; - } else if (runDataTotal > 0 && lastSourceTask?.executionStatus !== 'canceled') { - // Non-main connections (model/memory/tool) are passive — only mark - // success when the target node also produced run data. - const isMainConnection = type === NodeConnectionTypes.Main; - if (isMainConnection || targetRunData) { - status = 'success'; - } + if (canonicals.length === 1) { + status = getConnectionStatus(canonicals[0]); + } else { + const statuses = canonicals.map(getConnectionStatus); + status = CONNECTION_STATUS_PRIORITY.find((s) => statuses.includes(s)); } - const sourceInputs = rd.nodeInputsByNodeId.get(connection.source)?.value ?? []; - const targetInputs = rd.nodeInputsByNodeId.get(connection.target)?.value ?? []; + const { source: sourceNodeId, target: targetNodeId, sourceHandle } = canonicals[0]; + const { type } = parseCanvasConnectionHandleString(sourceHandle); + + const sourceInputs = rd.nodeInputsByNodeId.get(sourceNodeId)?.value ?? []; + const targetInputs = rd.nodeInputsByNodeId.get(targetNodeId)?.value ?? []; const maxConnections = [...sourceInputs, ...targetInputs] .filter((port) => port.type === type) .reduce((acc, port) => { @@ -211,7 +270,13 @@ export function useCanvasMapping({ function getConnectionLabel(connection: CanvasConnection): string { const rd = renderData.value; - const sourceId = connection.source; + // For edges remapped to `group:*` ids, the real endpoints live on + // `data.canonicals`; the label describes the underlying node, like the status. + const { + source: sourceId, + target: targetId, + sourceHandle, + } = connection.data?.canonicals?.[0] ?? connection; const pinned = rd.pinnedDataByNodeId.get(sourceId)?.value; if (pinned) { @@ -227,17 +292,17 @@ export function useCanvasMapping({ const sourceRunData = rd.executionRunDataByNodeId.get(sourceId)?.value; if (!sourceRunData) return ''; - const { type, index } = parseCanvasConnectionHandleString(connection.sourceHandle); + const { type, index } = parseCanvasConnectionHandleString(sourceHandle); const outputMap = rd.executionRunDataOutputMapByNodeId.get(sourceId); const outputData = outputMap?.[type]?.[index]; const isMainConnection = type === NodeConnectionTypes.Main; - const targetRunData = rd.executionRunDataByNodeId.get(connection.target)?.value; + const targetRunData = rd.executionRunDataByNodeId.get(targetId)?.value; // Non-main connections (AI tool/memory/embedding) track per-target counts // when the target has run data; otherwise stay quiet. if (!isMainConnection && outputData?.byTarget) { - const targetData = outputData.byTarget[connection.target]; + const targetData = outputData.byTarget[targetId]; if (targetData && targetData.total > 0 && targetRunData) { return i18n.baseText( targetData.iterations > 1 ? 'ndv.output.itemsTotal' : 'ndv.output.items', @@ -265,5 +330,6 @@ export function useCanvasMapping({ nodes: mappedNodes, connections: mappedConnections, nodeDisplaySizeById, + getNodeExecutionSnapshot, }; } diff --git a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.test.ts b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.test.ts index 4b2db445718..e25de399518 100644 --- a/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/workflowDiff/useWorkflowDiff.test.ts @@ -6,6 +6,7 @@ import type { ExecutionOutputMap } from '@/app/types/executionData'; import type { INodeUi, IWorkflowDb } from '@/Interface'; import { NodeDiffStatus, type IConnections } from 'n8n-workflow'; import { useCanvasMapping } from '@/features/workflows/canvas/composables/useCanvasMapping'; +import { createNodeExecutionSnapshot } from '@/features/workflows/canvas/__tests__/utils'; import { disposeWorkflowDocumentStore } from '@/app/stores/workflowDocument.store'; import { disposeWorkflowExecutionStateStore } from '@/app/stores/workflowExecutionState.store'; @@ -172,6 +173,7 @@ describe('useWorkflowDiff', () => { nodeDisplaySizeById: computed( () => ({}) as Record, ), + getNodeExecutionSnapshot: () => createNodeExecutionSnapshot(), nodes: computed(() => nodes as CanvasNode[]), connections: computed(() => connections as CanvasConnection[]), });