mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 03:24:59 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
bcc49935c2
commit
396fc5dfcf
|
|
@ -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<string, string[]>;
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<CanvasNodeData['execution']['subworkflowProgress']>,
|
||||
) {
|
||||
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 } }];
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<div v-if="isDisabled" :class="$style.disabledLabel">
|
||||
({{ i18n.baseText('node.disabled') }})
|
||||
</div>
|
||||
<div v-if="subtitle && !isNotInstalledCommunityNode" :class="$style.subtitle">
|
||||
{{ subtitle }}
|
||||
<div
|
||||
v-if="displaySubtitle && !isNotInstalledCommunityNode"
|
||||
:class="$style.subtitle"
|
||||
:title="displaySubtitle"
|
||||
>
|
||||
{{ displaySubtitle }}
|
||||
</div>
|
||||
<CanvasNodeSubworkflowProgress v-if="subworkflowProgress" :progress="subworkflowProgress" />
|
||||
</div>
|
||||
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
|
||||
</div>
|
||||
|
|
@ -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 */
|
||||
|
||||
|
|
|
|||
|
|
@ -33,10 +33,10 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
|
|||
<!--v-if-->
|
||||
<div
|
||||
class="subtitle"
|
||||
title="Test Node Subtitle"
|
||||
>
|
||||
Test Node Subtitle
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -68,10 +68,10 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
|
|||
<!--v-if-->
|
||||
<div
|
||||
class="subtitle"
|
||||
title="Test Node Subtitle"
|
||||
>
|
||||
Test Node Subtitle
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -103,10 +103,10 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
|
|||
<!--v-if-->
|
||||
<div
|
||||
class="subtitle"
|
||||
title="Test Node Subtitle"
|
||||
>
|
||||
Test Node Subtitle
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -145,10 +145,10 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
|
|||
<!--v-if-->
|
||||
<div
|
||||
class="subtitle"
|
||||
title="Test Node Subtitle"
|
||||
>
|
||||
Test Node Subtitle
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
@ -187,10 +187,10 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
|
|||
<!--v-if-->
|
||||
<div
|
||||
class="subtitle"
|
||||
title="Test Node Subtitle"
|
||||
>
|
||||
Test Node Subtitle
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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: '<percentage>';
|
||||
initial-value: 0%;
|
||||
inherits: true;
|
||||
}
|
||||
|
||||
@keyframes border-rotate {
|
||||
from {
|
||||
--node--gradient-angle: 0deg;
|
||||
|
|
|
|||
|
|
@ -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<CanvasNodeData['execution']['subworkflowProgress']>;
|
||||
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
|||
<N8nIcon icon="node-validation-error" :size="size" />
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="subworkflowStepLabel"
|
||||
data-test-id="canvas-node-status-subworkflow-progress"
|
||||
:class="[...commonClasses, $style.subworkflowProgress]"
|
||||
>
|
||||
<span :class="$style.count">{{ subworkflowStepLabel }}</span>
|
||||
</div>
|
||||
<div v-else-if="executionStatus === 'unknown'">
|
||||
<!-- Do nothing, unknown means the node never executed -->
|
||||
</div>
|
||||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,92 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
progress: {
|
||||
currentNodeName?: string;
|
||||
currentNodeIndex: number;
|
||||
totalNodes: number;
|
||||
phase: 'running' | 'success' | 'error';
|
||||
};
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const percent = computed(() => {
|
||||
if (props.progress.totalNodes <= 0) return 0;
|
||||
const ratio = props.progress.currentNodeIndex / props.progress.totalNodes;
|
||||
return Math.max(0, Math.min(100, Math.round(ratio * 100)));
|
||||
});
|
||||
|
||||
const countLabel = computed(() =>
|
||||
i18n.baseText('node.subworkflow.progress.count', {
|
||||
interpolate: {
|
||||
current: String(props.progress.currentNodeIndex),
|
||||
total: String(props.progress.totalNodes),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const runningLabel = computed(() => {
|
||||
// Only label the node as "running" while it actually is. Between nodes
|
||||
// the store legitimately holds a `success`/`error` snapshot, but reading
|
||||
// that as "Running: X" would mislead the user.
|
||||
if (props.progress.phase !== 'running' || !props.progress.currentNodeName) return '';
|
||||
return i18n.baseText('node.subworkflow.progress.running', {
|
||||
interpolate: { nodeName: props.progress.currentNodeName },
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="canvas-subworkflow-progress">
|
||||
<div v-if="runningLabel" :class="$style.nodeName" :title="runningLabel">
|
||||
{{ runningLabel }}
|
||||
</div>
|
||||
<div :class="$style.counter">{{ countLabel }}</div>
|
||||
<div :class="$style.bar" :aria-label="countLabel">
|
||||
<div :class="$style.barFill" :style="{ width: `${percent}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing--5xs);
|
||||
width: 100%;
|
||||
margin-top: var(--spacing--4xs);
|
||||
color: var(--color--text--tint-1);
|
||||
font-size: var(--font-size--xs);
|
||||
line-height: var(--line-height--sm);
|
||||
}
|
||||
|
||||
.nodeName {
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: var(--font-weight--medium);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 80%;
|
||||
height: 3px;
|
||||
background: var(--color--foreground--tint-2);
|
||||
border-radius: var(--radius--full);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.barFill {
|
||||
height: 100%;
|
||||
background: var(--color--primary);
|
||||
transition: width 200ms ease-out;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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<Progress>(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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Progress>): {
|
||||
fraction: Ref<number>;
|
||||
} {
|
||||
/** 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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user