diff --git a/packages/core/src/awsService/cloudformation/consoleLinksUtils.ts b/packages/core/src/awsService/cloudformation/consoleLinksUtils.ts index 67a56fc6eeb..8f8736c69d9 100644 --- a/packages/core/src/awsService/cloudformation/consoleLinksUtils.ts +++ b/packages/core/src/awsService/cloudformation/consoleLinksUtils.ts @@ -11,6 +11,11 @@ export function arnToConsoleTabUrl(arn: string, tab: 'resources' | 'events' | 'o return `https://${region}.console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/${tab}?stackId=${encodeURIComponent(arn)}` } +export function operationIdToConsoleUrl(arn: string, operationId: string): string { + const region = arn.split(':')[3] + return `https://${region}.console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/operations/info?stackId=${encodeURIComponent(arn)}&operationId=${operationId}` +} + // Reference link - https://cloudscape.design/foundation/visual-foundation/iconography/ - icon name: external export function externalLinkSvg(): string { return `` diff --git a/packages/core/src/awsService/cloudformation/ui/stackEventsWebviewProvider.ts b/packages/core/src/awsService/cloudformation/ui/stackEventsWebviewProvider.ts index b17ac79950d..b0eedfcb3b6 100644 --- a/packages/core/src/awsService/cloudformation/ui/stackEventsWebviewProvider.ts +++ b/packages/core/src/awsService/cloudformation/ui/stackEventsWebviewProvider.ts @@ -9,20 +9,30 @@ import { LanguageClient } from 'vscode-languageclient/node' import { extractErrorMessage, getStackStatusClass, isStackInTransientState } from '../utils' import { GetStackEventsRequest, ClearStackEventsRequest } from '../stacks/actions/stackActionProtocol' import { StackViewCoordinator } from './stackViewCoordinator' -import { arnToConsoleTabUrl, externalLinkSvg, consoleLinkStyles } from '../consoleLinksUtils' +import { arnToConsoleTabUrl, operationIdToConsoleUrl, externalLinkSvg, consoleLinkStyles } from '../consoleLinksUtils' const EventsPerPage = 50 const RefreshIntervalMs = 5000 +interface StackEventWithOperationId extends StackEvent { + OperationId?: string +} + +interface GroupedEvent extends StackEventWithOperationId { + isParent?: boolean + groupId: string + groupParentId?: string +} + export class StackEventsWebviewProvider implements WebviewViewProvider, Disposable { private view?: WebviewView private stackName?: string private stackArn?: string - private allEvents: StackEvent[] = [] + private allEvents: StackEventWithOperationId[] = [] private currentPage = 0 private nextToken?: string private refreshTimer?: NodeJS.Timeout - private readonly disposables: Disposable[] = [] + private expandedGroups = new Set() private readonly coordinatorSubscription: Disposable constructor( @@ -57,13 +67,16 @@ export class StackEventsWebviewProvider implements WebviewViewProvider, Disposab this.allEvents = [] this.currentPage = 0 this.nextToken = undefined + this.expandedGroups.clear() try { - const result = await this.client.sendRequest(GetStackEventsRequest, { - stackName: this.stackName, - }) + const result = await this.client.sendRequest(GetStackEventsRequest, { stackName }) this.allEvents = result.events this.nextToken = result.nextToken + + if (this.allEvents.length > 0 && this.allEvents[0].OperationId) { + this.expandedGroups.add(`op-${this.allEvents[0].OperationId}`) + } } catch (error) { this.renderError(`Failed to load events: ${extractErrorMessage(error)}`) } @@ -80,17 +93,25 @@ export class StackEventsWebviewProvider implements WebviewViewProvider, Disposab }) webviewView.onDidChangeVisibility(() => { if (webviewView.visible) { + this.render() this.startAutoRefresh() } else { this.stopAutoRefresh() } }) - webviewView.webview.onDidReceiveMessage(async (message: { command: string }) => { + webviewView.webview.onDidReceiveMessage(async (message: { command: string; groupId?: string }) => { if (message.command === 'nextPage') { await this.nextPage() } else if (message.command === 'prevPage') { await this.prevPage() + } else if (message.command === 'toggle' && message.groupId) { + if (this.expandedGroups.has(message.groupId)) { + this.expandedGroups.delete(message.groupId) + } else { + this.expandedGroups.add(message.groupId) + } + this.render() } }) @@ -102,9 +123,6 @@ export class StackEventsWebviewProvider implements WebviewViewProvider, Disposab if (this.stackName) { void this.client.sendRequest(ClearStackEventsRequest, { stackName: this.stackName }) } - for (const d of this.disposables) { - d.dispose() - } this.coordinatorSubscription.dispose() } @@ -190,6 +208,52 @@ export class StackEventsWebviewProvider implements WebviewViewProvider, Disposab } } + private groupEvents(events: StackEventWithOperationId[]): GroupedEvent[] { + const operationGroups = new Map() + const eventsWithoutOperationId: StackEventWithOperationId[] = [] + + for (const event of events) { + if (event.OperationId) { + if (!operationGroups.has(event.OperationId)) { + operationGroups.set(event.OperationId, []) + } + operationGroups.get(event.OperationId)!.push(event) + } else { + eventsWithoutOperationId.push(event) + } + } + + const grouped: GroupedEvent[] = [] + + for (const [operationId, operationEvents] of operationGroups.entries()) { + const groupId = `op-${operationId}` + grouped.push({ + ...operationEvents[0], + groupId, + isParent: true, + }) + + for (const [index, event] of operationEvents.entries()) { + grouped.push({ + ...event, + groupId: `${groupId}-${index}`, + groupParentId: groupId, + isParent: false, + }) + } + } + + for (const [index, event] of eventsWithoutOperationId.entries()) { + grouped.push({ + ...event, + groupId: `flat-${index}`, + isParent: true, + }) + } + + return grouped + } + private renderError(message: string): void { if (!this.view || this.view.visible === false) { return @@ -214,14 +278,15 @@ export class StackEventsWebviewProvider implements WebviewViewProvider, Disposab } private render(notification?: string): void { - if (!this.view || this.view.visible === false) { + if (!this.view || !this.view.visible) { return } + const groupedEvents = this.groupEvents(this.allEvents) const start = this.currentPage * EventsPerPage const end = start + EventsPerPage - const pageEvents = this.allEvents.slice(start, end) - const totalPages = Math.ceil(this.allEvents.length / EventsPerPage) + const pageEvents = groupedEvents.slice(start, end) + const totalPages = Math.ceil(groupedEvents.length / EventsPerPage) const hasMore = this.nextToken !== undefined this.view.webview.html = this.getHtml( @@ -235,157 +300,119 @@ export class StackEventsWebviewProvider implements WebviewViewProvider, Disposab } private getHtml( - events: StackEvent[], + events: GroupedEvent[], currentPage: number, totalPages: number, hasMore: boolean, totalEvents: number, notification?: string ): string { + const emptyMessage = + totalEvents === 0 + ? '
No events found.
' + : '' + return ` - - - - + +
+
+
${this.stackName ?? ''} +${this.stackArn ? `${externalLinkSvg()}` : ''} +(${totalEvents} events${hasMore ? ' loaded' : ''}) +
+ +
+
+${notification ? `
${notification}
` : ''} +
+${ + emptyMessage || + ` + + + + + + + + +${events.map((e) => this.renderEventRow(e)).join('')} + +
Operation IDTimestampLogical IDStatusStatus Reason
` +} +
+ +` + } + + private renderEventRow(event: GroupedEvent): string { + if (event.isParent) { + const expanded = this.expandedGroups.has(event.groupId) + const chevron = event.OperationId ? `` : '' + const opIdDisplay = + event.OperationId && this.stackArn + ? `${event.OperationId}` + : (event.OperationId ?? '-') + + return ` +${chevron} ${opIdDisplay} +${event.Timestamp ? new Date(event.Timestamp).toLocaleString() : '-'} +${event.LogicalResourceId ?? '-'} +${event.ResourceStatus ?? '-'} +${event.ResourceStatusReason ?? '-'} +` } - - - -
-
-
- ${this.stackName ?? ''} - ${this.stackArn ? `${externalLinkSvg()}` : ''} - (${totalEvents} events${hasMore ? ' loaded' : ''}) -
- -
-
- ${notification ? `
${notification}
` : ''} -
- - - - - - - - - - - - ${events - .map( - (e) => ` - - - - - - - - ` - ) - .join('')} - -
TimestampResourceTypeStatusReason
${e.Timestamp ? new Date(e.Timestamp).toLocaleString() : '-'}${e.LogicalResourceId ?? '-'}${e.ResourceType ?? '-'}${e.ResourceStatus ?? '-'}${e.ResourceStatusReason ?? '-'}
-
- - -` + + const opIdDisplay = + event.OperationId && this.stackArn + ? `${event.OperationId}` + : (event.OperationId ?? '-') + + return ` +${opIdDisplay} +${event.Timestamp ? new Date(event.Timestamp).toLocaleString() : '-'} +${event.LogicalResourceId ?? '-'} +${event.ResourceStatus ?? '-'} +${event.ResourceStatusReason ?? '-'} +` } } diff --git a/packages/core/src/awsService/cloudformation/ui/stackOutputsWebviewProvider.ts b/packages/core/src/awsService/cloudformation/ui/stackOutputsWebviewProvider.ts index 8c54b5d9067..bd42f9d6c21 100644 --- a/packages/core/src/awsService/cloudformation/ui/stackOutputsWebviewProvider.ts +++ b/packages/core/src/awsService/cloudformation/ui/stackOutputsWebviewProvider.ts @@ -44,6 +44,12 @@ export class StackOutputsWebviewProvider implements WebviewViewProvider, Disposa this.view = webviewView webviewView.webview.options = { enableScripts: true } + webviewView.onDidChangeVisibility(() => { + if (webviewView.visible) { + this.render() + } + }) + if (this.stackName) { await this.loadOutputs() } else { diff --git a/packages/core/src/awsService/cloudformation/ui/stackOverviewWebviewProvider.ts b/packages/core/src/awsService/cloudformation/ui/stackOverviewWebviewProvider.ts index d240be89247..3b3cbaa52da 100644 --- a/packages/core/src/awsService/cloudformation/ui/stackOverviewWebviewProvider.ts +++ b/packages/core/src/awsService/cloudformation/ui/stackOverviewWebviewProvider.ts @@ -87,6 +87,7 @@ export class StackOverviewWebviewProvider implements WebviewViewProvider, Dispos webviewView.onDidChangeVisibility(() => { if (webviewView.visible && this.currentStackName) { + this.render() this.startAutoRefresh() } else { this.stopAutoRefresh() diff --git a/packages/core/src/awsService/cloudformation/ui/stackResourcesWebviewProvider.ts b/packages/core/src/awsService/cloudformation/ui/stackResourcesWebviewProvider.ts index 5c78f99635f..25aaefe5044 100644 --- a/packages/core/src/awsService/cloudformation/ui/stackResourcesWebviewProvider.ts +++ b/packages/core/src/awsService/cloudformation/ui/stackResourcesWebviewProvider.ts @@ -99,6 +99,7 @@ export class StackResourcesWebviewProvider implements WebviewViewProvider, Dispo private setupLifecycleHandlers(webviewView: WebviewView) { webviewView.onDidChangeVisibility(() => { if (webviewView.visible) { + this.render() this.startAutoUpdate() } else { this.stopAutoUpdate() diff --git a/packages/core/src/test/awsService/cloudformation/consoleLinksUtils.test.ts b/packages/core/src/test/awsService/cloudformation/consoleLinksUtils.test.ts new file mode 100644 index 00000000000..ab96a161ce9 --- /dev/null +++ b/packages/core/src/test/awsService/cloudformation/consoleLinksUtils.test.ts @@ -0,0 +1,71 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import { + arnToConsoleUrl, + arnToConsoleTabUrl, + operationIdToConsoleUrl, +} from '../../../awsService/cloudformation/consoleLinksUtils' + +describe('consoleLinksUtils', () => { + const testArn = 'arn:aws:cloudformation:us-west-2:123456789012:stack/test-stack/abc-123' + + describe('arnToConsoleUrl', () => { + it('should generate correct console URL', () => { + const url = arnToConsoleUrl(testArn) + assert.strictEqual( + url, + 'https://console.aws.amazon.com/go/view?arn=arn%3Aaws%3Acloudformation%3Aus-west-2%3A123456789012%3Astack%2Ftest-stack%2Fabc-123' + ) + }) + }) + + describe('arnToConsoleTabUrl', () => { + it('should generate correct events tab URL', () => { + const url = arnToConsoleTabUrl(testArn, 'events') + assert.strictEqual( + url, + 'https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/events?stackId=arn%3Aaws%3Acloudformation%3Aus-west-2%3A123456789012%3Astack%2Ftest-stack%2Fabc-123' + ) + }) + + it('should generate correct resources tab URL', () => { + const url = arnToConsoleTabUrl(testArn, 'resources') + assert.strictEqual( + url, + 'https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/resources?stackId=arn%3Aaws%3Acloudformation%3Aus-west-2%3A123456789012%3Astack%2Ftest-stack%2Fabc-123' + ) + }) + + it('should generate correct outputs tab URL', () => { + const url = arnToConsoleTabUrl(testArn, 'outputs') + assert.strictEqual( + url, + 'https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/outputs?stackId=arn%3Aaws%3Acloudformation%3Aus-west-2%3A123456789012%3Astack%2Ftest-stack%2Fabc-123' + ) + }) + }) + + describe('operationIdToConsoleUrl', () => { + it('should generate correct operation details URL', () => { + const operationId = '056a1310-6307-466a-a167-2cbbd353b29f' + const url = operationIdToConsoleUrl(testArn, operationId) + assert.strictEqual( + url, + 'https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/operations/info?stackId=arn%3Aaws%3Acloudformation%3Aus-west-2%3A123456789012%3Astack%2Ftest-stack%2Fabc-123&operationId=056a1310-6307-466a-a167-2cbbd353b29f' + ) + }) + + it('should handle different regions', () => { + const euArn = 'arn:aws:cloudformation:eu-west-1:123456789012:stack/test-stack/abc-123' + const operationId = 'op-456' + const url = operationIdToConsoleUrl(euArn, operationId) + assert.ok(url.includes('eu-west-1.console.aws.amazon.com')) + assert.ok(url.includes('region=eu-west-1')) + assert.ok(url.includes('operationId=op-456')) + }) + }) +}) diff --git a/packages/core/src/test/awsService/cloudformation/ui/stackEventsWebviewProvider.test.ts b/packages/core/src/test/awsService/cloudformation/ui/stackEventsWebviewProvider.test.ts index ca53d1d5dcf..37488007a2c 100644 --- a/packages/core/src/test/awsService/cloudformation/ui/stackEventsWebviewProvider.test.ts +++ b/packages/core/src/test/awsService/cloudformation/ui/stackEventsWebviewProvider.test.ts @@ -61,6 +61,65 @@ describe('StackEventsWebviewProvider', () => { assert.strictEqual(mockClient.sendRequest.calledOnce, true) }) + it('should group events by operation ID', async () => { + mockClient.sendRequest.resolves({ + events: [ + { + EventId: 'event-1', + StackName: 'test-stack', + Timestamp: new Date(), + ResourceStatus: 'CREATE_COMPLETE', + OperationId: 'op-123', + }, + { + EventId: 'event-2', + StackName: 'test-stack', + Timestamp: new Date(), + ResourceStatus: 'CREATE_IN_PROGRESS', + OperationId: 'op-123', + }, + { + EventId: 'event-3', + StackName: 'test-stack', + Timestamp: new Date(), + ResourceStatus: 'UPDATE_COMPLETE', + }, + ], + nextToken: undefined, + }) + + const view = createMockView() + provider.resolveWebviewView(view as any) + await provider.showStackEvents('test-stack') + + const html = view.webview.html + assert.ok(html.includes('op-123')) + assert.ok(html.includes('parent-row')) + assert.ok(html.includes('child-row')) + }) + + it('should expand first operation group by default', async () => { + mockClient.sendRequest.resolves({ + events: [ + { + EventId: 'event-1', + StackName: 'test-stack', + Timestamp: new Date(), + ResourceStatus: 'CREATE_COMPLETE', + OperationId: 'op-123', + }, + ], + nextToken: undefined, + }) + + const view = createMockView() + provider.resolveWebviewView(view as any) + await provider.showStackEvents('test-stack') + + const html = view.webview.html + assert.ok(html.includes('expanded')) + }) + it('should stop auto-refresh on terminal state', async () => { const clock = sandbox.useFakeTimers() diff --git a/packages/core/src/test/awsService/cloudformation/ui/stackOutputsWebviewProvider.test.ts b/packages/core/src/test/awsService/cloudformation/ui/stackOutputsWebviewProvider.test.ts index db5c0e3ab4c..03a62fe349f 100644 --- a/packages/core/src/test/awsService/cloudformation/ui/stackOutputsWebviewProvider.test.ts +++ b/packages/core/src/test/awsService/cloudformation/ui/stackOutputsWebviewProvider.test.ts @@ -19,6 +19,8 @@ describe('StackOutputsWebviewProvider', () => { options: {}, html: '', }, + onDidChangeVisibility: sandbox.stub(), + visible: true, } } diff --git a/packages/toolkit/.changes/next-release/Bug Fix-7c9aadfa-cbb0-436d-aed7-8314b5f6b074.json b/packages/toolkit/.changes/next-release/Bug Fix-7c9aadfa-cbb0-436d-aed7-8314b5f6b074.json new file mode 100644 index 00000000000..44d3bdc544f --- /dev/null +++ b/packages/toolkit/.changes/next-release/Bug Fix-7c9aadfa-cbb0-436d-aed7-8314b5f6b074.json @@ -0,0 +1,4 @@ +{ + "type": "Bug Fix", + "description": "CloudFormation: render stack detail views on stack change" +} diff --git a/packages/toolkit/.changes/next-release/Feature-abd545f4-f267-4bb5-8deb-012260756b62.json b/packages/toolkit/.changes/next-release/Feature-abd545f4-f267-4bb5-8deb-012260756b62.json new file mode 100644 index 00000000000..9ae28d2d486 --- /dev/null +++ b/packages/toolkit/.changes/next-release/Feature-abd545f4-f267-4bb5-8deb-012260756b62.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "CloudFormation: group stack events by operation id and display in stack events view" +}