diff --git a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts index b6e86675309..b04ecda704a 100644 --- a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts @@ -1756,6 +1756,44 @@ describe('Execution Lifecycle Hooks', () => { ); } + /** + * A child workflow entered through an Execute Sub-workflow Trigger named + * "Trigger". `connections` is shorthand for main connections by source + * node name; `extraConnections` is merged in verbatim for non-main ones. + */ + function buildChildWorkflow(opts: { + nodes: string[]; + connections: Record; + extraNodes?: INode[]; + extraConnections?: IWorkflowBase['connections']; + }): IWorkflowBase { + const noOp = workflowData.nodes[0]; + return { + ...workflowData, + nodes: [ + { + id: 'child-trigger', + name: 'Trigger', + type: 'n8n-nodes-base.executeWorkflowTrigger', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ...opts.nodes.map((name) => ({ ...noOp, id: `node-${name}`, name })), + ...(opts.extraNodes ?? []), + ], + connections: { + ...Object.fromEntries( + Object.entries(opts.connections).map(([source, targets]) => [ + source, + { main: [targets.map((node) => ({ node, type: 'main' as const, index: 0 }))] }, + ]), + ), + ...opts.extraConnections, + }, + }; + } + function stubActiveExecution(id: string, opts: { pushRef?: string; parentId?: string }) { activeExecutions.has.mockImplementation((requested) => requested === id); activeExecutions.getExecutionOrFail.mockImplementation((requested) => { @@ -1959,7 +1997,12 @@ describe('Execution Lifecycle Hooks', () => { it('counts unique nodes, not executions, so loops never exceed the total', async () => { stubActiveExecution(parentExecutionId, { pushRef: rootPushRef }); - const hooks = buildHooks(); + const hooks = buildHooks( + buildChildWorkflow({ + nodes: ['Node A', 'Node B'], + connections: { Trigger: ['Node A'], 'Node A': ['Node B'], 'Node B': ['Node A'] }, + }), + ); // A loop revisits node A: A → B → A await hooks.runHook('nodeExecuteBefore', ['Node A', taskStartedData]); @@ -1973,7 +2016,127 @@ describe('Execution Lifecycle Hooks', () => { const lastCall = push.send.mock.calls.at(-1)?.[0]; expect(lastCall).toMatchObject({ type: 'subworkflowNodeProgress', - data: { currentNodeName: 'Node A', currentNodeIndex: 2 }, + data: { currentNodeName: 'Node A', currentNodeIndex: 2, totalNodes: 3 }, + }); + }); + + it('keeps counting past totalNodes when an unpredicted node reports', async () => { + stubActiveExecution(parentExecutionId, { pushRef: rootPushRef }); + const hooks = buildHooks( + buildChildWorkflow({ nodes: ['Node A'], connections: { Trigger: ['Node A'] } }), + ); + + await hooks.runHook('nodeExecuteBefore', ['Trigger', taskStartedData]); + await hooks.runHook('nodeExecuteBefore', ['Node A', taskStartedData]); + await hooks.runHook('nodeExecuteBefore', ['Surprise Node', taskStartedData]); + vi.advanceTimersByTime(150); + + // The count reports what actually ran; `totalNodes` stays the estimate + // it always was. Clamping here would freeze the one truthful number. + const lastCall = push.send.mock.calls.at(-1)?.[0]; + expect(lastCall).toMatchObject({ + type: 'subworkflowNodeProgress', + data: { currentNodeName: 'Surprise Node', currentNodeIndex: 3, totalNodes: 2 }, + }); + }); + + describe('totalNodes reachability', () => { + it('excludes nodes only reachable from a different trigger', async () => { + stubActiveExecution(parentExecutionId, { pushRef: rootPushRef }); + // A leftover Manual Trigger branch never runs in a sub-execution, so + // its nodes must not inflate the denominator. + const hooks = buildHooks( + buildChildWorkflow({ + nodes: ['Node A', 'Manual Only'], + extraNodes: [ + { + id: 'manual-trigger', + name: 'Manual Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 200], + parameters: {}, + }, + ], + connections: { Trigger: ['Node A'], 'Manual Trigger': ['Manual Only'] }, + }), + ); + + await hooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]); + + expect(push.send).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'subworkflowExecutionStarted', + data: expect.objectContaining({ totalNodes: 2 }), + }), + rootPushRef, + ); + }); + + it('excludes disconnected islands', async () => { + stubActiveExecution(parentExecutionId, { pushRef: rootPushRef }); + const hooks = buildHooks( + buildChildWorkflow({ + nodes: ['Node A', 'Orphan'], + connections: { Trigger: ['Node A'] }, + }), + ); + + await hooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]); + + expect(push.send).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'subworkflowExecutionStarted', + data: expect.objectContaining({ totalNodes: 2 }), + }), + rootPushRef, + ); + }); + + it('includes non-main sub-nodes attached to a reachable node', async () => { + stubActiveExecution(parentExecutionId, { pushRef: rootPushRef }); + // A chat model connects *into* its agent, so it is not a main + // descendant — but it executes and reports progress. + const hooks = buildHooks( + buildChildWorkflow({ + nodes: ['Agent', 'Chat Model'], + connections: { Trigger: ['Agent'] }, + extraConnections: { + 'Chat Model': { + ai_languageModel: [[{ node: 'Agent', type: 'ai_languageModel', index: 0 }]], + }, + }, + }), + ); + + await hooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]); + + expect(push.send).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'subworkflowExecutionStarted', + data: expect.objectContaining({ totalNodes: 3 }), + }), + rootPushRef, + ); + }); + + it('falls back to every executable node when the child has no start node', async () => { + stubActiveExecution(parentExecutionId, { pushRef: rootPushRef }); + const activeNode = workflowData.nodes[0]; + const hooks = buildHooks({ + ...workflowData, + nodes: [activeNode, { ...activeNode, id: 'orphan', name: 'Orphan' }], + }); + + await hooks.runHook('workflowExecuteBefore', [workflow, runExecutionData]); + + expect(push.send).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'subworkflowExecutionStarted', + data: expect.objectContaining({ totalNodes: 2 }), + }), + rootPushRef, + ); }); }); diff --git a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts index 9d663d37697..168e68c8624 100644 --- a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts +++ b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts @@ -24,6 +24,9 @@ import type { WorkflowExecuteMode, } from 'n8n-workflow'; import { + getChildNodes, + getParentNodes, + mapConnectionsByDestination, runDataAttemptedDynamicCredentials, runDataUsedDynamicCredentials, STICKY_NODE_TYPE, @@ -37,7 +40,7 @@ import { ExecutionRedactionServiceProxy } from '@/executions/execution-redaction import { ExternalHooks } from '@/external-hooks'; import { Push } from '@/push'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; -import { isWorkflowIdValid } from '@/utils'; +import { findSubworkflowStartOrUndefined, isWorkflowIdValid } from '@/utils'; import { getItemCountByConnectionType } from '@/utils/get-item-count-by-connection-type'; import { getLastExecutedNodeData } from '@/workflow-helpers'; import { WorkflowHookContextService } from '@/workflow-hook-context.service'; @@ -490,6 +493,41 @@ function findParentPushRef(parentExecutionId: string): string | undefined { return activeExecutions.getExecutionOrFail(parentExecutionId).executionData.pushRef; } +/** + * Upper bound on the distinct nodes a sub-workflow run can reach, used to scale + * the editor's progress arc. Counting every node would inflate it — a leftover + * Manual Trigger branch or a disconnected island never runs in a sub-execution. + * + * Only an upper bound: which side of an IF/Switch runs isn't knowable ahead of + * time. Computed once, since a total that shrinks mid-run reads as a bug. + */ +function countReachableNodes(workflowData: IWorkflowBase): number { + const executable = workflowData.nodes.filter( + // Sticky notes and disabled nodes never execute. + (node) => !node.disabled && node.type !== STICKY_NODE_TYPE, + ); + + const startNode = findSubworkflowStartOrUndefined(workflowData.nodes); + // No entry point: the run will fail anyway, so fall back rather than report 0. + if (!startNode) return executable.length; + + const reachable = new Set([ + startNode.name, + ...getChildNodes(workflowData.connections, startNode.name), + ]); + + // Sub-nodes (AI models, memory, tools) connect *into* their parent, so they're + // not main descendants — but they execute and report progress. + const connectionsByDestination = mapConnectionsByDestination(workflowData.connections); + for (const nodeName of [...reachable]) { + for (const subNode of getParentNodes(connectionsByDestination, nodeName, 'ALL_NON_MAIN')) { + reachable.add(subNode); + } + } + + return executable.filter((node) => reachable.has(node.name)).length; +} + /** * Push hooks for a sub-workflow execution. Forwards a lightweight progress * stream up to the root parent's pushRef so the editor can render a live @@ -504,11 +542,7 @@ function hookFunctionsPushSubExecution( parentExecution: RelatedExecution, parentNode: INode, ) { - // Sticky notes and disabled nodes never execute, so they must not count - // towards the "X / Y" indicator's denominator. - const totalNodes = workflowData.nodes.filter( - (node) => !node.disabled && node.type !== STICKY_NODE_TYPE, - ).length; + const totalNodes = countReachableNodes(workflowData); // Push + pushRef are resolved lazily on first emit. This keeps hook // registration free of DI side effects (cli tests globally `jest.mock` @@ -581,6 +615,9 @@ function hookFunctionsPushSubExecution( parentNodeName: parentNode.name, executionId: childId, currentNodeName: nodeName, + // Deliberately unbounded. Nodes reached is knowable, `totalNodes` is only + // estimated, so clamping to it would spoil the one exact number here. The + // editor shows this as a plain count and clamps the arc separately. currentNodeIndex: reachedNodeNames.size, totalNodes, phase, diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index a8c50f3be15..57e55518e6f 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -1,4 +1,9 @@ -import { CliWorkflowOperationError, isHitlToolType, SubworkflowOperationError } from 'n8n-workflow'; +import { + CliWorkflowOperationError, + EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, + isHitlToolType, + SubworkflowOperationError, +} from 'n8n-workflow'; import type { INode, INodeType, Workflow } from 'n8n-workflow'; import { STARTING_NODES } from '@/constants'; @@ -29,15 +34,21 @@ export function satisfiesToolCapability(syntheticToolName: string, nodeType: INo return isHitlToolType(syntheticToolName) || !!nodeType.description.usableAsTool; } +/** + * The node a sub-workflow execution starts from, or `undefined` when there is + * none. Non-throwing counterpart to `findSubworkflowStart`, for callers that + * only want to inspect the entry point. + */ +export function findSubworkflowStartOrUndefined(nodes: INode[]): INode | undefined { + return ( + nodes.find((node) => node.type === EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE) ?? + nodes.find((node) => STARTING_NODES.includes(node.type)) + ); +} + function findWorkflowStart(executionMode: 'integrated' | 'cli') { return function (nodes: INode[]) { - const executeWorkflowTriggerNode = nodes.find( - (node) => node.type === 'n8n-nodes-base.executeWorkflowTrigger', - ); - - if (executeWorkflowTriggerNode) return executeWorkflowTriggerNode; - - const startNode = nodes.find((node) => STARTING_NODES.includes(node.type)); + const startNode = findSubworkflowStartOrUndefined(nodes); if (startNode) return startNode; diff --git a/packages/frontend/@n8n/design-system/src/css/_tokens.scss b/packages/frontend/@n8n/design-system/src/css/_tokens.scss index e3ffafd69f1..416ce055fc3 100644 --- a/packages/frontend/@n8n/design-system/src/css/_tokens.scss +++ b/packages/frontend/@n8n/design-system/src/css/_tokens.scss @@ -84,6 +84,10 @@ --node--color--background--executing-1: var(--color--primary--tint-3); --node--border-color--pinned: var(--color--secondary); --node--border-color--running: var(--color--primary); + // Determinate progress within a running node. Stays in the running colour + // family — it qualifies that state rather than being a new one — separating + // from it by lightness instead, per theme. + --node--border-color--progress: var(--color--orange-700); --node--type-main--color: var(--color--neutral-500); // Sticky @@ -766,6 +770,9 @@ --node--color--background--executing: var(--color--neutral-700); --node--color--background--executing-1: var(--color--neutral-700); --node--border-color--pinned: var(--color--purple-500); + // Lighter than the running border here, where the darker light-mode value + // would disappear into the node background. + --node--border-color--progress: var(--color--orange-300); --node--type-main--color: var(--color--neutral-400); // Sticky diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index ab94a086a23..d52c3287222 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2002,7 +2002,7 @@ "node.customColor": "Custom color", "node.disabled": "Deactivated", "node.subworkflow.progress.running": "Running: {nodeName}", - "node.subworkflow.progress.count": "{current} / {total}", + "node.subworkflow.progress.step": "Step {current}", "node.testStep": "Execute step", "node.disable": "Deactivate", "node.enable": "Activate", diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.test.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.test.ts index f49f0a10df6..487c226989d 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.test.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.test.ts @@ -420,9 +420,34 @@ describe('useWorkflowDocumentRenderData — subworkflowProgressByNodeId', () => currentNodeName: 'Child Node', currentNodeIndex: 2, totalNodes: 5, - phase: 'running', }); }); + + it('returns a stable reference when only the phase changes', () => { + const { docId } = setupWorkflow('wf-subwf-stable', [{ id: 'a', name: 'Alpha' }]); + setActiveExecution(docId, [alpha()], {}); + const { renderData } = createRenderData(docId); + const store = useSubworkflowProgressStore(); + + const payload = { + parentExecutionId: `exec-${docId}`, + parentNodeName: 'Alpha', + executionId: 'child-1', + currentNodeName: 'Child Node', + currentNodeIndex: 2, + totalNodes: 5, + }; + store.updateProgress({ ...payload, phase: 'running' }); + const first = renderData.subworkflowProgressByNodeId.get('a')?.value; + + // A running -> success flip on the same node must not yield a new object, + // otherwise every push re-maps the whole canvas (and the label flickers). + store.updateProgress({ ...payload, phase: 'success' }); + const second = renderData.subworkflowProgressByNodeId.get('a')?.value; + + expect(first).toBeDefined(); + expect(second).toBe(first); + }); }); describe('useWorkflowDocumentRenderData — trigger tooltip', () => { diff --git a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.ts b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.ts index 8cdadd3877c..c280366f7c5 100644 --- a/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.ts +++ b/packages/frontend/editor-ui/src/app/stores/workflowDocument/useWorkflowDocumentRenderData.ts @@ -270,11 +270,14 @@ export function useWorkflowDocumentRenderData(workflowDocumentId: WorkflowDocume const progress = subworkflowProgressStore.getFor(activeExecutionId, node.name); if (!progress) return undefined; + // `phase` is intentionally omitted — see CanvasNodeData. Without it, + // consecutive pushes for the same node produce a structurally equal + // value, so `structuralComputed` returns the cached reference and the + // canvas is not re-mapped. return { currentNodeName: progress.currentNodeName, currentNodeIndex: progress.currentNodeIndex, totalNodes: progress.totalNodes, - phase: progress.phase, }; } 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 085e67d213c..42722cd4ac0 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 @@ -142,11 +142,13 @@ export interface CanvasNodeData { waiting?: string; running: boolean; waitingForNext?: boolean; + // `phase` is deliberately not surfaced here: nothing renders it, and + // including it would make otherwise-identical progress snapshots compare + // unequal, re-mapping the whole canvas on every push. subworkflowProgress?: { currentNodeName?: string; currentNodeIndex: number; totalNodes: number; - phase: 'running' | 'success' | 'error'; }; }; runData: { diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.test.ts index dd780e7c3c8..45ab644a4c9 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.test.ts @@ -17,6 +17,7 @@ import { CanvasConnectionMode, CanvasNodeRenderType, type CanvasConnectionPort, + type CanvasNodeData, } from '../../../../canvas.types'; import CanvasNodeDefault from './CanvasNodeDefault.vue'; @@ -341,6 +342,95 @@ describe('CanvasNodeDefault', () => { }); }); + describe('sub-workflow progress arc', () => { + function renderWithProgress( + subworkflowProgress?: NonNullable, + ) { + const { getByText } = renderComponent({ + global: { + stubs, + provide: { + ...createCanvasNodeProvide({ + data: { execution: { running: true, subworkflowProgress } }, + }), + }, + }, + }); + return getByText('Test Node').closest('.node'); + } + + it('should set the arc fraction from the reported progress', () => { + const node = renderWithProgress({ currentNodeIndex: 1, totalNodes: 4 }); + + expect(node).toHaveClass('progress'); + expect(node?.getAttribute('style')).toContain('--node--progress--fraction: 25.00%'); + }); + + it('should stop short of a full turn so only completion closes the loop', () => { + const node = renderWithProgress({ currentNodeIndex: 3, totalNodes: 3 }); + + expect(node?.getAttribute('style')).toContain('--node--progress--fraction: 90.00%'); + }); + + it('should not set an arc without progress', () => { + const node = renderWithProgress(); + + expect(node).not.toHaveClass('progress'); + expect(node?.getAttribute('style')).not.toContain('--node--progress--fraction'); + }); + + it('should not set an arc when the total is unknown', () => { + const node = renderWithProgress({ currentNodeIndex: 0, totalNodes: 0 }); + + expect(node?.getAttribute('style')).not.toContain('--node--progress--fraction'); + }); + + it('should show the running child node in place of the subtitle', () => { + const { getByText, queryByText } = renderComponent({ + global: { + stubs, + provide: { + ...createCanvasNodeProvide({ + data: { + execution: { + running: true, + subworkflowProgress: { + currentNodeName: 'Fetch Orders', + currentNodeIndex: 2, + totalNodes: 4, + }, + }, + }, + }), + }, + }, + }); + + expect(getByText('Running: Fetch Orders')).toBeVisible(); + expect(queryByText('Test Node Subtitle')).not.toBeInTheDocument(); + }); + + it('should fall back to the subtitle once no child node is reported', () => { + const { getByText } = renderComponent({ + global: { + stubs, + provide: { + ...createCanvasNodeProvide({ + data: { + execution: { + running: true, + subworkflowProgress: { currentNodeIndex: 0, totalNodes: 4 }, + }, + }, + }), + }, + }, + }); + + expect(getByText('Test Node Subtitle')).toBeVisible(); + }); + }); + describe('execution pin data', () => { it('should apply pinned styling instead of success styling when node output used execution pin data', () => { executionPinDataByNodeName['Test Node'] = [{ json: { ok: true } }]; diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.vue b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.vue index 7ea7420a489..699276765d1 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.vue +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/CanvasNodeDefault.vue @@ -7,8 +7,8 @@ import type { CanvasNodeDefaultRender } from '../../../../canvas.types'; import { injectCanvasRenderData } from '@/features/workflows/canvas/canvas.utils'; import { useCanvas } from '../../../../composables/useCanvas'; import { useZoomAdjustedValues } from '../../../../composables/useZoomAdjustedValues'; +import { useSubworkflowProgressArc } from '../../../../composables/useSubworkflowProgressArc'; import CanvasNodeSettingsIcons from './parts/CanvasNodeSettingsIcons.vue'; -import CanvasNodeSubworkflowProgress from './parts/CanvasNodeSubworkflowProgress.vue'; import { useNodePrivateCredential } from '@/features/resolvers/composables/useNodePrivateCredential'; import { useNodeHelpers } from '@/app/composables/useNodeHelpers'; import { calculateNodeSize } from '@/app/utils/nodeViewUtils'; @@ -94,6 +94,7 @@ const classes = computed(() => { ), [$style.error]: hasExecutionErrors.value, [$style.running]: running, + [$style.progress]: running && Boolean(subworkflowProgress.value), [$style.waiting]: waiting, [$style.pinned]: hasSubstitutedOutput.value, [$style.configurable]: renderOptions.value.configurable, @@ -125,11 +126,35 @@ const nodeSize = computed(() => const nodeBorderOpacityStyle = calculateNodeBorderOpacityStyle(); +/** + * While a sub-workflow runs, the subtitle line carries the live step instead of + * the static subtitle. Reusing the line means nothing shifts as the child advances. + */ +const displaySubtitle = computed(() => { + const currentNodeName = subworkflowProgress.value?.currentNodeName; + if (!currentNodeName) return subtitle.value; + + return i18n.baseText('node.subworkflow.progress.running', { + interpolate: { nodeName: currentNodeName }, + }); +}); + +const { fraction: subworkflowProgressFraction } = useSubworkflowProgressArc(subworkflowProgress); + +const subworkflowProgressStyle = computed(() => { + if (subworkflowProgressFraction.value <= 0) return {}; + + return { + '--node--progress--fraction': `${(subworkflowProgressFraction.value * 100).toFixed(2)}%`, + }; +}); + const styles = computed(() => ({ '--canvas-node--width': `${nodeSize.value.width}px`, '--canvas-node--height': `${nodeSize.value.height}px`, '--node--icon--size': `${iconSize.value}px`, ...nodeBorderOpacityStyle.value, + ...subworkflowProgressStyle.value, })); const dataTestId = computed(() => { @@ -246,10 +271,13 @@ function onActivate(event: MouseEvent) {
({{ i18n.baseText('node.disabled') }})
-
- {{ subtitle }} +
+ {{ displaySubtitle }}
-
@@ -417,6 +445,15 @@ function onActivate(event: MouseEvent) { @include styles.status-waiting-animation; } +// Own layer, separate from the `.running::after` halo — see the mixin. +.progress::before { + @include styles.status-progress-arc; +} + +.progress { + transition: --node--progress--fraction 300ms ease-out; +} + @include styles.status-animation-definitions; /* stylelint-enable */ diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap index 50923ea2357..603b51263b4 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap @@ -33,10 +33,10 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
Test Node Subtitle
- @@ -68,10 +68,10 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
Test Node Subtitle
- @@ -103,10 +103,10 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
Test Node Subtitle
- @@ -145,10 +145,10 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
Test Node Subtitle
- @@ -187,10 +187,10 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
Test Node Subtitle
- diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/_canvasNodeStyles.scss b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/_canvasNodeStyles.scss index 5ea7f8dd170..8122a098987 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/_canvasNodeStyles.scss +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/_canvasNodeStyles.scss @@ -53,21 +53,50 @@ --canvas-node--border-color: transparent; } +$status-running-color: rgba(255, 109, 90, 1); + +$status-shimmer-gradient: conic-gradient( + from var(--node--gradient-angle), + $status-running-color, + $status-running-color 20%, + rgba(255, 109, 90, 0.2) 35%, + rgba(255, 109, 90, 0.2) 65%, + $status-running-color 90%, + $status-running-color +); + @mixin status-animated-after { content: ''; position: absolute; inset: -3px; border-radius: 10px; z-index: -1; + background: $status-shimmer-gradient; +} + +// Determinate progress arc, from top-centre clockwise. Deliberately its own +// layer rather than sharing the running halo's ring: compositing both there made +// the shimmer's bright head vanish behind the solid arc, which read as a flicker. +@mixin status-progress-arc { + content: ''; + position: absolute; + inset: 0; + // Follows the node's shape, so per-variant radius overrides aren't repeated here. + border-radius: inherit; + padding: 2px; // arc thickness background: conic-gradient( - from var(--node--gradient-angle), - rgba(255, 109, 90, 1), - rgba(255, 109, 90, 1) 20%, - rgba(255, 109, 90, 0.2) 35%, - rgba(255, 109, 90, 0.2) 65%, - rgba(255, 109, 90, 1) 90%, - rgba(255, 109, 90, 1) + from -90deg, + var(--node--border-color--progress) 0turn, + var(--node--border-color--progress) var(--node--progress--fraction), + transparent var(--node--progress--fraction) ); + // Ring-only fill: cover the whole box, then punch out the content box. + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask-composite: exclude; + -webkit-mask-composite: xor; + pointer-events: none; } @mixin status-running-animation { @@ -88,6 +117,14 @@ inherits: false; } + // Registered so the arc can transition instead of jumping; inherited so it can + // be set on the node while its pseudo-element paints the arc. + @property --node--progress--fraction { + syntax: ''; + initial-value: 0%; + inherits: true; + } + @keyframes border-rotate { from { --node--gradient-angle: 0deg; diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts index 9b722df5790..07e6685bca4 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeStatusIcons.test.ts @@ -6,6 +6,7 @@ import { createComponentRenderer } from '@/__tests__/render'; import { mockedStore, type MockedStore } from '@/__tests__/utils'; import { VIEWS } from '@/app/constants'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; +import type { CanvasNodeData } from '../../../../../canvas.types'; import { CanvasNodeDirtiness, CanvasNodeRenderType } from '../../../../../canvas.types'; import { createTestingPinia } from '@pinia/testing'; import type { IPinData } from 'n8n-workflow'; @@ -252,6 +253,100 @@ describe('CanvasNodeStatusIcons', () => { expect(getByTestId('canvas-node-status-warning')).toBeInTheDocument(); }); + describe('sub-workflow progress', () => { + type Progress = NonNullable; + + function renderWithProgress(subworkflowProgress?: Progress) { + return renderComponent({ + global: { + provide: { + ...createCanvasProvide(), + ...createCanvasNodeProvide({ + data: { execution: { status: 'running', running: true, subworkflowProgress } }, + }), + }, + }, + }); + } + + it('should render a bare step count while the sub-workflow runs', () => { + const { getByTestId } = renderWithProgress({ + currentNodeName: 'Child Node', + currentNodeIndex: 2, + totalNodes: 3, + }); + + // No denominator: the total is an estimate, the count isn't. + expect(getByTestId('canvas-node-status-subworkflow-progress')).toHaveTextContent('Step 2'); + }); + + it('should still count when the total is unknown', () => { + const { getByTestId } = renderWithProgress({ currentNodeIndex: 2, totalNodes: 0 }); + + expect(getByTestId('canvas-node-status-subworkflow-progress')).toHaveTextContent('Step 2'); + }); + + it('should keep counting past the estimated total', () => { + const { getByTestId } = renderWithProgress({ currentNodeIndex: 5, totalNodes: 3 }); + + expect(getByTestId('canvas-node-status-subworkflow-progress')).toHaveTextContent('Step 5'); + }); + + it('should not render a step count without progress', () => { + const { queryByTestId } = renderWithProgress(); + + expect(queryByTestId('canvas-node-status-subworkflow-progress')).not.toBeInTheDocument(); + }); + + it('should not render a step count before the first node reports', () => { + const { queryByTestId } = renderWithProgress({ currentNodeIndex: 0, totalNodes: 3 }); + + expect(queryByTestId('canvas-node-status-subworkflow-progress')).not.toBeInTheDocument(); + }); + + it('should give way to the success checkmark once progress is cleared', () => { + const { getByTestId, queryByTestId } = renderComponent({ + global: { + provide: { + ...createCanvasProvide(), + ...createCanvasNodeProvide({ + data: { + execution: { status: 'success', running: false }, + runData: { outputMap: {}, iterations: 1, visible: true }, + }, + }), + }, + }, + }); + + expect(queryByTestId('canvas-node-status-subworkflow-progress')).not.toBeInTheDocument(); + expect(getByTestId('canvas-node-status-success')).toBeInTheDocument(); + }); + + it('should keep node issues ahead of the step count', () => { + const { getByTestId, queryByTestId } = renderComponent({ + global: { + provide: { + ...createCanvasProvide(), + ...createCanvasNodeProvide({ + data: { + issues: { validation: ['Parameter "Workflow" is required.'], visible: true }, + execution: { + status: 'running', + running: true, + subworkflowProgress: { currentNodeIndex: 1, totalNodes: 3 }, + }, + }, + }), + }, + }, + }); + + expect(getByTestId('node-issues')).toBeInTheDocument(); + expect(queryByTestId('canvas-node-status-subworkflow-progress')).not.toBeInTheDocument(); + }); + }); + it('should render warning icon when node is not installed', () => { nodeTypesStore.getIsNodeInstalled = vi.fn().mockReturnValue(false); const { queryByTestId } = renderComponent({ diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue index b2772659e44..f2b3ed2b90d 100644 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeStatusIcons.vue @@ -30,6 +30,7 @@ const { validationErrors, hasValidationErrors, executionStatus, + subworkflowProgress, hasRunData, runDataIterations, isDisabled, @@ -66,6 +67,18 @@ const commonClasses = computed(() => [ spinnerLayout === 'absolute' ? $style.absoluteSpinner : '', ]); +// Shares the success checkmark's slot, so it's replaced by the checkmark on +// finish. No denominator: the arc carries the rough "how far", this carries the +// exact "how much", and only the latter is actually knowable mid-run. +const subworkflowStepLabel = computed(() => { + const currentNodeIndex = subworkflowProgress.value?.currentNodeIndex ?? 0; + if (currentNodeIndex <= 0) return undefined; + + return i18n.baseText('node.subworkflow.progress.step', { + interpolate: { current: String(currentNodeIndex) }, + }); +}); + const groupedExecutionErrors = computed(() => { const errorCounts = executionErrors.value.reduce( (acc, error) => { @@ -119,6 +132,13 @@ const groupedExecutionErrors = computed(() => { +
+ {{ subworkflowStepLabel }} +
@@ -208,4 +228,15 @@ const groupedExecutionErrors = computed(() => { .disabled { color: var(--color--foreground--shade-2); } + +.subworkflowProgress { + // Matches the arc, so the count and the border read as one indicator. + color: var(--node--border-color--progress); + cursor: default; +} + +.count { + font-size: var(--font-size--sm); + font-variant-numeric: tabular-nums; +} diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeSubworkflowProgress.vue b/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeSubworkflowProgress.vue deleted file mode 100644 index 912b39617ea..00000000000 --- a/packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeSubworkflowProgress.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useSubworkflowProgressArc.test.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useSubworkflowProgressArc.test.ts new file mode 100644 index 00000000000..bb929c4de69 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useSubworkflowProgressArc.test.ts @@ -0,0 +1,105 @@ +import { effectScope, ref } from 'vue'; +import { useSubworkflowProgressArc } from './useSubworkflowProgressArc'; + +type Progress = { currentNodeIndex: number; totalNodes: number } | undefined; + +/** + * `useIntervalFn` disposes with its effect scope, so each case runs inside its + * own and stops it afterwards — otherwise timers leak across tests and the + * trickle from one case advances the next. + */ +function withArc(initial: Progress) { + const progress = ref(initial); + const scope = effectScope(); + const arc = scope.run(() => useSubworkflowProgressArc(progress))!; + + return { progress, fraction: arc.fraction, stop: () => scope.stop() }; +} + +describe('useSubworkflowProgressArc', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('reports 0 without progress, so the caller can skip the arc', () => { + const { fraction, stop } = withArc(undefined); + + expect(fraction.value).toBe(0); + stop(); + }); + + it('reports 0 when the total is unknown', () => { + const { fraction, stop } = withArc({ currentNodeIndex: 2, totalNodes: 0 }); + + expect(fraction.value).toBe(0); + stop(); + }); + + it('moves to the step fraction as soon as a step lands', async () => { + const { progress, fraction, stop } = withArc({ currentNodeIndex: 1, totalNodes: 4 }); + + progress.value = { currentNodeIndex: 2, totalNodes: 4 }; + await vi.waitFor(() => expect(fraction.value).toBe(0.5)); + stop(); + }); + + it('trickles forward between steps without reaching the next one', async () => { + const { fraction, stop } = withArc({ currentNodeIndex: 2, totalNodes: 4 }); + expect(fraction.value).toBe(0.5); + + await vi.advanceTimersByTimeAsync(60_000); + + // Bounded by half a step: 0.5 + (1/4 * 0.5) = 0.625, approached but never met. + expect(fraction.value).toBeGreaterThan(0.5); + expect(fraction.value).toBeLessThan(0.625); + stop(); + }); + + it('never exceeds the cap, however long a step runs', async () => { + const { fraction, stop } = withArc({ currentNodeIndex: 4, totalNodes: 4 }); + + await vi.advanceTimersByTimeAsync(600_000); + + expect(fraction.value).toBeLessThanOrEqual(0.9); + stop(); + }); + + it('does not move backwards when a step lands behind the trickle', async () => { + const { progress, fraction, stop } = withArc({ currentNodeIndex: 2, totalNodes: 4 }); + + await vi.advanceTimersByTimeAsync(30_000); + const trickled = fraction.value; + expect(trickled).toBeGreaterThan(0.5); + + // Step 3 of 4 lands at 0.75, ahead of the trickle — but assert the general + // rule: a real step is never allowed to regress the arc. + progress.value = { currentNodeIndex: 3, totalNodes: 4 }; + await vi.waitFor(() => expect(fraction.value).toBeGreaterThanOrEqual(trickled)); + stop(); + }); + + it('snaps back when a new child execution restarts the count', async () => { + const { progress, fraction, stop } = withArc({ currentNodeIndex: 3, totalNodes: 4 }); + expect(fraction.value).toBe(0.75); + + // A parent loop invoking the sub-workflow again: the arc must restart, not + // hold the previous run's high-water mark. + progress.value = { currentNodeIndex: 1, totalNodes: 4 }; + await vi.waitFor(() => expect(fraction.value).toBe(0.25)); + stop(); + }); + + it('stops trickling once its scope is disposed', async () => { + const { fraction, stop } = withArc({ currentNodeIndex: 2, totalNodes: 4 }); + + stop(); + const settled = fraction.value; + await vi.advanceTimersByTimeAsync(60_000); + + expect(fraction.value).toBe(settled); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useSubworkflowProgressArc.ts b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useSubworkflowProgressArc.ts new file mode 100644 index 00000000000..c47863cff71 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/workflows/canvas/composables/useSubworkflowProgressArc.ts @@ -0,0 +1,64 @@ +import { computed, ref, toValue, watch } from 'vue'; +import type { MaybeRefOrGetter, Ref } from 'vue'; +import { useIntervalFn } from '@vueuse/core'; + +/** The total is only an upper bound, so a full turn is reserved for completion. */ +const MAX_FRACTION = 0.9; + +/** Share of the gap closed per tick — an exponential approach, so it decelerates. */ +const TRICKLE_RATE = 0.15; + +const TRICKLE_INTERVAL_MS = 1000; + +/** Half a step: bounds the overstatement to less than one node, by construction. */ +const TRICKLE_STEP_SHARE = 0.5; + +type Progress = { currentNodeIndex: number; totalNodes: number } | undefined; + +/** + * Eased fill fraction for the sub-workflow progress arc, in [0, MAX_FRACTION]. + * Steps move it immediately; between them it trickles so a slow child node + * doesn't read as a stalled bar. Returns 0 when there's nothing to draw. + */ +export function useSubworkflowProgressArc(progress: MaybeRefOrGetter): { + fraction: Ref; +} { + /** Fraction implied by completed steps alone — the part we actually know. */ + const stepFraction = computed(() => { + const value = toValue(progress); + if (!value || value.totalNodes <= 0) return 0; + + return Math.min(Math.max(value.currentNodeIndex / value.totalNodes, 0), MAX_FRACTION); + }); + + /** Ceiling the trickle may approach: this step plus part of the next. */ + const trickleTarget = computed(() => { + const value = toValue(progress); + if (!value || value.totalNodes <= 0) return 0; + + const stepSize = 1 / value.totalNodes; + return Math.min(stepFraction.value + stepSize * TRICKLE_STEP_SHARE, MAX_FRACTION); + }); + + const fraction = ref(stepFraction.value); + + watch(stepFraction, (next, previous) => { + // A drop means a new child execution restarted the count (a parent loop + // calling the sub-workflow repeatedly), so don't hold the old high-water mark. + if (next < previous) { + fraction.value = next; + return; + } + + fraction.value = Math.max(fraction.value, next); + }); + + useIntervalFn(() => { + const target = trickleTarget.value; + if (fraction.value >= target) return; + + fraction.value += (target - fraction.value) * TRICKLE_RATE; + }, TRICKLE_INTERVAL_MS); + + return { fraction }; +}