From 396fc5dfcfeb071c66ca0d6155db0adc8d035c48 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Mon, 27 Jul 2026 22:06:21 +0200 Subject: [PATCH] feat(editor): Show sub-workflow progress on the node border Replaces the in-body progress bar with a determinate arc on the node's own border, plus a step count in the status-icon slot that gives way to the success checkmark on finish. The running child node name reuses the subtitle line, so nothing shifts as the child advances. The arc is drawn on its own layer rather than sharing the running halo's ring: compositing the two there made the shimmer's bright head vanish behind the solid arc and read as a flicker. It trickles between steps, bounded to half a step, so a slow child node doesn't read as a stalled bar, and stops short of a full turn so only completion closes the loop. The step count carries no denominator. Nodes reached is knowable, the total is only estimated, so the count is reported unclamped and the estimate is used just to scale the arc. That total now counts only nodes reachable from the sub-workflow's entry point (plus their AI/tool sub-nodes), which stops leftover trigger branches and disconnected islands from scaling the arc down. Co-Authored-By: Claude --- .../execution-lifecycle-hooks.test.ts | 167 +++++++++++++++++- .../execution-lifecycle-hooks.ts | 49 ++++- packages/cli/src/utils.ts | 27 ++- .../@n8n/design-system/src/css/_tokens.scss | 7 + .../frontend/@n8n/i18n/src/locales/en.json | 2 +- .../useWorkflowDocumentRenderData.test.ts | 27 ++- .../useWorkflowDocumentRenderData.ts | 5 +- .../features/workflows/canvas/canvas.types.ts | 4 +- .../render-types/CanvasNodeDefault.test.ts | 90 ++++++++++ .../nodes/render-types/CanvasNodeDefault.vue | 45 ++++- .../CanvasNodeDefault.test.ts.snap | 10 +- .../nodes/render-types/_canvasNodeStyles.scss | 51 +++++- .../parts/CanvasNodeStatusIcons.test.ts | 95 ++++++++++ .../parts/CanvasNodeStatusIcons.vue | 31 ++++ .../parts/CanvasNodeSubworkflowProgress.vue | 92 ---------- .../useSubworkflowProgressArc.test.ts | 105 +++++++++++ .../composables/useSubworkflowProgressArc.ts | 64 +++++++ 17 files changed, 743 insertions(+), 128 deletions(-) delete mode 100644 packages/frontend/editor-ui/src/features/workflows/canvas/components/elements/nodes/render-types/parts/CanvasNodeSubworkflowProgress.vue create mode 100644 packages/frontend/editor-ui/src/features/workflows/canvas/composables/useSubworkflowProgressArc.test.ts create mode 100644 packages/frontend/editor-ui/src/features/workflows/canvas/composables/useSubworkflowProgressArc.ts 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 }; +}