From cb63aa1b264f35eb0a286c457bf5c1d747d18330 Mon Sep 17 00:00:00 2001 From: Peter Smith Date: Tue, 5 Aug 2025 12:29:46 +1200 Subject: [PATCH] feat(stepfunctions): Show state machine executions in the Explorer view. --- .../core/src/shared/clients/stepFunctions.ts | 19 +++ .../explorer/stepFunctionsNodes.ts | 126 ++++++++++++++-- packages/core/src/stepFunctions/utils.ts | 23 +++ .../executeStateMachine.ts | 13 ++ .../explorer/stepFunctionNodes.test.ts | 139 +++++++++++++++++- ...-35f249eb-d145-44ed-90f3-c09a15e4f823.json | 4 + packages/toolkit/package.json | 4 +- 7 files changed, 309 insertions(+), 19 deletions(-) create mode 100644 packages/toolkit/.changes/next-release/Feature-35f249eb-d145-44ed-90f3-c09a15e4f823.json diff --git a/packages/core/src/shared/clients/stepFunctions.ts b/packages/core/src/shared/clients/stepFunctions.ts index 22483307654..83f7bc7dd7e 100644 --- a/packages/core/src/shared/clients/stepFunctions.ts +++ b/packages/core/src/shared/clients/stepFunctions.ts @@ -10,6 +10,10 @@ import { DescribeStateMachineCommand, DescribeStateMachineCommandInput, DescribeStateMachineCommandOutput, + ExecutionListItem, + ListExecutionsCommand, + ListExecutionsCommandInput, + ListExecutionsCommandOutput, ListStateMachinesCommand, ListStateMachinesCommandInput, ListStateMachinesCommandOutput, @@ -44,6 +48,21 @@ export class StepFunctionsClient extends ClientWrapper { } while (request.nextToken) } + public async *listExecutions( + request: ListExecutionsCommandInput = {}, + resultsToReturn: number + ): AsyncIterableIterator { + do { + request.maxResults = resultsToReturn + const response: ListExecutionsCommandOutput = await this.makeRequest(ListExecutionsCommand, request) + if (response.executions) { + resultsToReturn -= response.executions.length + yield* response.executions + } + request.nextToken = response.nextToken + } while (request.nextToken && resultsToReturn > 0) + } + public async getStateMachineDetails( request: DescribeStateMachineCommandInput ): Promise { diff --git a/packages/core/src/stepFunctions/explorer/stepFunctionsNodes.ts b/packages/core/src/stepFunctions/explorer/stepFunctionsNodes.ts index 8b693d3bb9e..bf27a9f1317 100644 --- a/packages/core/src/stepFunctions/explorer/stepFunctionsNodes.ts +++ b/packages/core/src/stepFunctions/explorer/stepFunctionsNodes.ts @@ -16,10 +16,8 @@ import { AWSTreeNodeBase } from '../../shared/treeview/nodes/awsTreeNodeBase' import { PlaceholderNode } from '../../shared/treeview/nodes/placeholderNode' import { makeChildrenNodes } from '../../shared/treeview/utils' import { toArrayAsync, toMap, updateInPlace } from '../../shared/utilities/collectionUtils' -import { listStateMachines } from '../../stepFunctions/utils' -import { getIcon } from '../../shared/icons' - -export const contextValueStateMachine = 'awsStateMachineNode' +import { listStateMachines, listExecutions } from '../../stepFunctions/utils' +import { getIcon, IconPath } from '../../shared/icons' const sfnNodeMap = new Map() @@ -74,20 +72,70 @@ export class StepFunctionsNode extends AWSTreeNodeBase { this.stateMachineNodes, functions.keys(), (key) => this.stateMachineNodes.get(key)!.update(functions.get(key)!), - (key) => makeStateMachineNode(this, this.regionCode, functions.get(key)!) + (key) => new StateMachineNode(this, this.regionCode, functions.get(key)!, this.client) ) } } +/** + * Represents a Step Functions state machine in the Explorer view. This node + * appears immediately underneath the "Step Functions" node. A StateMachineNode + * will contain children of type StateMachineExecutionNode, representing + * the most recent executions of that state machine. + */ export class StateMachineNode extends AWSTreeNodeBase implements AWSResourceNode { + public static readonly contextValue = 'awsStateMachineNode' + public static readonly maxExecutionsToShow = 10 + + private readonly stateMachineExecutionNodes: Map + public constructor( public readonly parent: AWSTreeNodeBase, public override readonly regionCode: string, - public details: StepFunctions.StateMachineListItem + public details: StepFunctions.StateMachineListItem, + private readonly client: StepFunctionsClient ) { - super('') + super('', vscode.TreeItemCollapsibleState.Collapsed) + this.stateMachineExecutionNodes = new Map() this.update(details) this.iconPath = getIcon('aws-stepfunctions-preview') + this.contextValue = StateMachineNode.contextValue + } + + public override async getChildren(): Promise { + return await makeChildrenNodes({ + getChildNodes: async () => { + await this.updateChildren() + return [...this.stateMachineExecutionNodes.values()] + }, + getNoChildrenPlaceholderNode: async () => + new PlaceholderNode( + this, + localize('AWS.explorerNode.stepfunctions.noStateMachineExecution', '[No Executions found]') + ), + /* + * Note: although Step Functions returns the executions in the correct order, this sorting + * is still needed to ensure newly added nodes (via updateChildren()) appear in the correct place. + */ + sort: (nodeA, nodeB) => { + const dateA = nodeA.details.startDate as Date // startDate will never be undefined. + const dateB = nodeB.details.startDate as Date + return dateB.getTime() - dateA.getTime() + }, + }) + } + + public async updateChildren(): Promise { + const executions: Map = toMap( + await toArrayAsync(listExecutions(this.client, this.arn, StateMachineNode.maxExecutionsToShow)), + (details) => details.name + ) + updateInPlace( + this.stateMachineExecutionNodes, + executions.keys(), + (key) => this.stateMachineExecutionNodes.get(key)!.update(executions.get(key)!), + (key) => new StateMachineExecutionNode(this, this.regionCode, executions.get(key)!) + ) } public update(details: StepFunctions.StateMachineListItem): void { @@ -113,13 +161,61 @@ export class StateMachineNode extends AWSTreeNodeBase implements AWSResourceNode } } -function makeStateMachineNode( - parent: AWSTreeNodeBase, - regionCode: string, - details: StepFunctions.StateMachineListItem -): StateMachineNode { - const node = new StateMachineNode(parent, regionCode, details) - node.contextValue = contextValueStateMachine +/** + * Represents a single execution of a Step Functions state machine in the Explorer + * view. This node appears immediately underneath the corresponding StateMachineNode. + */ +export class StateMachineExecutionNode extends AWSTreeNodeBase implements AWSResourceNode { + public static contextValue = 'awsStateMachineExecutionNode' + + public constructor( + public readonly parent: AWSTreeNodeBase, + public override readonly regionCode: string, + public details: StepFunctions.ExecutionListItem + ) { + super('') + this.update(details) + this.contextValue = StateMachineExecutionNode.contextValue + } + + public update(details: StepFunctions.ExecutionListItem): void { + this.details = details + this.label = this.details.name || '' + this.tooltip = this.getToolTip(this.details) + this.iconPath = this.getIconPathForStatus(this.details.status) + } + + public get arn(): string { + return this.details.executionArn || '' + } - return node + public get name(): string { + return this.details.name || '' + } + + private getIconPathForStatus(status?: string): IconPath { + switch (status) { + case 'RUNNING': + return getIcon('vscode-sync') + case 'SUCCEEDED': + return getIcon('vscode-check') + default: + return getIcon('vscode-error') + } + } + + private getToolTip(details: StepFunctions.ExecutionListItem) { + const startTimeText = localize('AWS.explorerNode.stepfunctions.startTime', 'Start Time') + const endTimeText = localize('AWS.explorerNode.stepfunctions.endTime', 'End Time') + const durationText = localize('AWS.explorerNode.stepfunctions.duration', 'Duration') + const secondsText = localize('AWS.explorerNode.stepfunctions.seconds', 'seconds') + + let text: string = `${details.status}${os.EOL}${startTimeText}: ${details.startDate?.toLocaleString()}${os.EOL}` + if (details.status !== 'RUNNING') { + text += `${endTimeText}: ${details.stopDate?.toLocaleString()}${os.EOL}` + const endDate = details.stopDate ? details.stopDate : new Date() + text += `${durationText}: ${Math.trunc((endDate.getTime() - details.startDate!.getTime()) / 1000)} ${secondsText}${os.EOL}` + } + return text + } } diff --git a/packages/core/src/stepFunctions/utils.ts b/packages/core/src/stepFunctions/utils.ts index f578d6cda86..9bf7f5dc65a 100644 --- a/packages/core/src/stepFunctions/utils.ts +++ b/packages/core/src/stepFunctions/utils.ts @@ -37,6 +37,29 @@ export async function* listStateMachines( } } +export async function* listExecutions( + client: StepFunctionsClient, + stateMachineArn: string, + resultsToReturn: number +): AsyncIterableIterator { + const status = vscode.window.setStatusBarMessage( + localize('AWS.message.statusBar.loading.statemachineexecutions', 'Loading State Machine Executions...') + ) + + try { + yield* client.listExecutions( + { + stateMachineArn: stateMachineArn, + }, + resultsToReturn + ) + } finally { + if (status) { + status.dispose() + } + } +} + /** * Checks if the given IAM Role is assumable by AWS Step Functions. * @param role The IAM role to check diff --git a/packages/core/src/stepFunctions/vue/executeStateMachine/executeStateMachine.ts b/packages/core/src/stepFunctions/vue/executeStateMachine/executeStateMachine.ts index b4e47bc65f6..e6937df113a 100644 --- a/packages/core/src/stepFunctions/vue/executeStateMachine/executeStateMachine.ts +++ b/packages/core/src/stepFunctions/vue/executeStateMachine/executeStateMachine.ts @@ -20,6 +20,7 @@ interface StateMachine { arn: string name: string region: string + explorerNode?: StateMachineNode } export class ExecuteStateMachineWebview extends VueWebview { @@ -64,6 +65,7 @@ export class ExecuteStateMachineWebview extends VueWebview { this.logger.info('started execution for Step Functions State Machine') this.channel.appendLine(localize('AWS.stepFunctions.executeStateMachine.info.started', 'Execution started')) this.channel.appendLine(startExecResponse.executionArn || '') + await this.refreshExplorerNode(this.stateMachine.explorerNode) } catch (e) { executeResult = 'Failed' const error = e as Error @@ -79,6 +81,16 @@ export class ExecuteStateMachineWebview extends VueWebview { telemetry.stepfunctions_executeStateMachine.emit({ result: executeResult }) } } + + /** + * Assuming the state machine execution was started via a StateMachineNode in the + * AWS Explorer, refresh the list of executions, so this new execution will appear. + */ + private async refreshExplorerNode(node?: StateMachineNode): Promise { + if (node) { + return vscode.commands.executeCommand('aws.refreshAwsExplorerNode', node) + } + } } const Panel = VueWebview.compilePanel(ExecuteStateMachineWebview) @@ -88,6 +100,7 @@ export async function executeStateMachine(context: ExtContext, node: StateMachin arn: node.details.stateMachineArn || '', name: node.details.name || '', region: node.regionCode, + explorerNode: node, }) await wv.show({ diff --git a/packages/core/src/test/stepFunctions/explorer/stepFunctionNodes.test.ts b/packages/core/src/test/stepFunctions/explorer/stepFunctionNodes.test.ts index 61b4f2b1813..1b1a76e9a22 100644 --- a/packages/core/src/test/stepFunctions/explorer/stepFunctionNodes.test.ts +++ b/packages/core/src/test/stepFunctions/explorer/stepFunctionNodes.test.ts @@ -4,8 +4,11 @@ */ import assert from 'assert' +import * as nls from 'vscode-nls' +const localize = nls.loadMessageBundle() + import { - contextValueStateMachine, + StateMachineExecutionNode, StateMachineNode, StepFunctionsNode, } from '../../../stepFunctions/explorer/stepFunctionsNodes' @@ -17,6 +20,7 @@ import { asyncGenerator } from '../../../shared/utilities/collectionUtils' import globals from '../../../shared/extensionGlobals' import { StepFunctionsClient } from '../../../shared/clients/stepFunctions' import { stub } from '../../utilities/stubber' +import { ExecutionStatus, StateMachineListItem, StateMachineType } from '@aws-sdk/client-sfn' const regionCode = 'someregioncode' @@ -56,7 +60,7 @@ describe('StepFunctionsNode', function () { assert.ok(node instanceof StateMachineNode, 'Expected child node to be StateMachineNode') assert.strictEqual( node.contextValue, - contextValueStateMachine, + StateMachineNode.contextValue, 'expected the node to have a State Machine contextValue' ) } @@ -79,3 +83,134 @@ describe('StepFunctionsNode', function () { assertNodeListOnlyHasErrorNode(await testNode.getChildren()) }) }) + +describe('StateMachineNode', function () { + type ExecutionTestData = { + name: string + status: ExecutionStatus + time: string + } + + const testStateMachineArn = 'arn:aws:states:us-east-1:123412341234:stateMachine:TestStateMachine' + + const testStateMachineListItem: StateMachineListItem = { + stateMachineArn: testStateMachineArn, + name: 'TestStateMachine', + type: StateMachineType.STANDARD, + creationDate: new Date('2025-07-28T11:22:17.986000+12:00'), + } + + /* + * Given a list of execution details (name, status, and start time), return a + * StateMachineNode containing mocked ExecutionListItem records as children. + */ + function createStateMachineNodeWithExecutions(...executions: ExecutionTestData[]) { + const client = stub(StepFunctionsClient, { regionCode }) + client.listExecutions.returns( + asyncGenerator( + executions.map((execution) => { + return { + executionArn: `arn:aws:states:us-east-1:123412341234:execution:TestStateMachine:${execution.name}`, + stateMachineArn: testStateMachineArn, + name: execution.name, + status: execution.status, + startDate: new Date(`2025-07-29T${execution.time}:17.986000`), + stopDate: new Date(`2025-07-30T${execution.time}:17.986000`), + } + }) + ) + ) + + return new StateMachineNode(new StepFunctionsNode(regionCode), regionCode, testStateMachineListItem, client) + } + + it('returns placeholder node if no executions are present', async function () { + const node = createStateMachineNodeWithExecutions() + assertNodeListOnlyHasPlaceholderNode(await node.getChildren()) + }) + + it('has StateMachineExecutionNode child nodes', async function () { + const node = createStateMachineNodeWithExecutions( + { name: 'bea3b400-4e7e-48d4-ab67-9de111fe929a', status: ExecutionStatus.SUCCEEDED, time: '10:03' }, + { name: '6ef3ed7e-8ce3-4b50-b1af-11a57dd96277', status: ExecutionStatus.SUCCEEDED, time: '10:02' }, + { name: '4007b51e-c573-46ab-8157-184452a04590', status: ExecutionStatus.SUCCEEDED, time: '10:01' } + ) + const childNodes = await node.getChildren() + assert.strictEqual(childNodes.length, 3, 'Unexpected child count') + + for (const node of childNodes) { + assert.ok(node instanceof StateMachineExecutionNode, 'Expected child node to be StateMachineExecutionNode') + assert.strictEqual( + node.contextValue, + StateMachineExecutionNode.contextValue, + 'expected the node to have a State Machine Execution contextValue' + ) + } + }) + + it('sorts the executions with newest first', async function () { + const node = createStateMachineNodeWithExecutions( + { name: 'Execution-3', status: ExecutionStatus.SUCCEEDED, time: '10:02' }, + { name: 'Execution-2', status: ExecutionStatus.FAILED, time: '10:03' }, + { name: 'Execution-0', status: ExecutionStatus.RUNNING, time: '10:05' }, + { name: 'Execution-4', status: ExecutionStatus.SUCCEEDED, time: '10:01' }, + { name: 'Execution-1', status: ExecutionStatus.SUCCEEDED, time: '10:04' } + ) + + const childNodes = await node.getChildren() + assert.equal(childNodes.length, 5) + + for (const [index, child] of childNodes.entries()) { + assert.equal(child.label, `Execution-${index}`) + } + }) + + it('shows the execution status as the icon', async function () { + const node = createStateMachineNodeWithExecutions( + { name: 'Execution-Succeeded', status: ExecutionStatus.SUCCEEDED, time: '10:05' }, + { name: 'Execution-Running', status: ExecutionStatus.RUNNING, time: '10:04' }, + { name: 'Execution-Failed', status: ExecutionStatus.FAILED, time: '10:03' }, + { name: 'Execution-Aborted', status: ExecutionStatus.ABORTED, time: '10:02' }, + { name: 'Execution-TimedOut', status: ExecutionStatus.TIMED_OUT, time: '10:01' } + ) + + const childNodes = await node.getChildren() + assert.equal(childNodes.length, 5) + + /* these are VS Code codicons */ + const expectedIcons = ['check', 'sync', 'error', 'error', 'error'] + for (const [index, child] of childNodes.entries()) { + assert.equal(child.iconPath?.toString(), `$(${expectedIcons[index]})`) + } + }) + + it('shows execution detail in the tooltip', async function () { + const node = createStateMachineNodeWithExecutions( + { name: 'Execution-Succeeded', status: ExecutionStatus.SUCCEEDED, time: '11:05' }, + { name: 'Execution-Running', status: ExecutionStatus.RUNNING, time: '10:04' }, + { name: 'Execution-Failed', status: ExecutionStatus.FAILED, time: '09:03' } + ) + + const childNodes = await node.getChildren() + assert.equal(childNodes.length, 3) + + const startTimeText = localize('AWS.explorerNode.stepfunctions.startTime', 'Start Time') + const endTimeText = localize('AWS.explorerNode.stepfunctions.endTime', 'End Time') + const durationText = localize('AWS.explorerNode.stepfunctions.duration', 'Duration') + const secondsText = localize('AWS.explorerNode.stepfunctions.seconds', 'seconds') + + /* Dates/times can be localized, so avoid matching against them */ + const expectedTooltips = [ + new RegExp( + `^SUCCEEDED[\r\n]${startTimeText}: .*[\r\n]${endTimeText}: .*[\r\n]${durationText}: 86400 ${secondsText}[\r\n]$` + ), + new RegExp(`^RUNNING[\r\n]${startTimeText}: .*[\r\n]$`), + new RegExp( + `^FAILED[\r\n]${startTimeText}: .*[\r\n]${endTimeText}: .*[\r\n]${durationText}: 86400 ${secondsText}[\r\n]$` + ), + ] + for (const [index, child] of childNodes.entries()) { + assert.match(child.tooltip as string, expectedTooltips[index]) + } + }) +}) diff --git a/packages/toolkit/.changes/next-release/Feature-35f249eb-d145-44ed-90f3-c09a15e4f823.json b/packages/toolkit/.changes/next-release/Feature-35f249eb-d145-44ed-90f3-c09a15e4f823.json new file mode 100644 index 00000000000..32ece443799 --- /dev/null +++ b/packages/toolkit/.changes/next-release/Feature-35f249eb-d145-44ed-90f3-c09a15e4f823.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Step Functions: Show recent executions for each state machine in the Explorer" +} diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 9539121648e..9c2cc50a0b8 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -1870,12 +1870,12 @@ }, { "command": "aws.copyName", - "when": "view =~ /^(aws.explorer|aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable|awsCloudFormationFunctionNode|awsStateMachineNode|awsCloudFormationNode|awsS3BucketNode|awsS3FolderNode|awsS3FileNode|awsApiGatewayNode|awsIotThingNode)$|^(awsAppRunnerServiceNode|awsIotCertificateNode|awsIotPolicyNode|awsIotPolicyVersionNode|(awsEc2(Running|Pending|Stopped)Node))/", + "when": "view =~ /^(aws.explorer|aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable|awsCloudFormationFunctionNode|awsStateMachineNode|awsStateMachineExecutionNode|awsCloudFormationNode|awsS3BucketNode|awsS3FolderNode|awsS3FileNode|awsApiGatewayNode|awsIotThingNode)$|^(awsAppRunnerServiceNode|awsIotCertificateNode|awsIotPolicyNode|awsIotPolicyVersionNode|(awsEc2(Running|Pending|Stopped)Node))/", "group": "2@1" }, { "command": "aws.copyArn", - "when": "view =~ /^(aws.explorer|aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable|awsCloudFormationFunctionNode|awsStateMachineNode|awsCloudFormationNode|awsCloudWatchLogNode|awsS3BucketNode|awsS3FolderNode|awsS3FileNode|awsApiGatewayNode|awsEcrRepositoryNode|awsIotThingNode)$|^(awsAppRunnerServiceNode|awsEcsServiceNode|awsIotCertificateNode|awsIotPolicyNode|awsIotPolicyVersionNode|awsMdeInstanceNode|(awsEc2(Running|Pending|Stopped)Node))/", + "when": "view =~ /^(aws.explorer|aws.appBuilder|aws.appBuilderForFileExplorer)$/ && viewItem =~ /^(awsRegionFunctionNode|awsRegionFunctionNodeDownloadable|awsCloudFormationFunctionNode|awsStateMachineNode|awsStateMachineExecutionNode|awsCloudFormationNode|awsCloudWatchLogNode|awsS3BucketNode|awsS3FolderNode|awsS3FileNode|awsApiGatewayNode|awsEcrRepositoryNode|awsIotThingNode)$|^(awsAppRunnerServiceNode|awsEcsServiceNode|awsIotCertificateNode|awsIotPolicyNode|awsIotPolicyVersionNode|awsMdeInstanceNode|(awsEc2(Running|Pending|Stopped)Node))/", "group": "2@2" }, {