From a23dfc12221488df2acae903026a5c937558cbd9 Mon Sep 17 00:00:00 2001 From: Megha Narayanan Date: Tue, 22 Jul 2025 15:10:23 -0700 Subject: [PATCH 1/5] deployment --- .changeset/proud-ravens-post.md | 2 + .../logging/cloudformation_format.ts | 139 +++- .../sandbox-devtools/react-app/src/App.tsx | 18 +- .../src/components/DeploymentProgress.tsx | 612 ++++++++++++++++++ .../src/contexts/socket_client_context.tsx | 17 + .../deployment_client_service.test.ts | 115 ++++ .../src/services/deployment_client_service.ts | 60 ++ .../src/services/sandbox_client_service.ts | 26 +- .../services/socket_handlers_resources.ts | 207 +++++- .../sandbox-devtools/shared/socket_events.ts | 27 +- 10 files changed, 1204 insertions(+), 19 deletions(-) create mode 100644 .changeset/proud-ravens-post.md create mode 100644 packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.tsx create mode 100644 packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/deployment_client_service.test.ts create mode 100644 packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/deployment_client_service.ts diff --git a/.changeset/proud-ravens-post.md b/.changeset/proud-ravens-post.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/proud-ravens-post.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/logging/cloudformation_format.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/logging/cloudformation_format.ts index afb078589be..9ef5795391d 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/logging/cloudformation_format.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/logging/cloudformation_format.ts @@ -1,5 +1,15 @@ -import { normalizeCDKConstructPath } from '@aws-amplify/cli-core'; - +import { + CloudFormationClient, + DescribeStackEventsCommand, + StackEvent, +} from '@aws-sdk/client-cloudformation'; +import { + LogLevel, + normalizeCDKConstructPath, + printer, +} from '@aws-amplify/cli-core'; +import { BackendIdentifierConversions } from '@aws-amplify/platform-core'; +import { BackendIdentifier } from '@aws-amplify/plugin-types'; /** * Creates a friendly name for a resource, using CDK metadata when available. * @param logicalId The logical ID of the resource @@ -51,3 +61,128 @@ export const createFriendlyName = ( const result = name || logicalId; return result; }; + +export type CloudFormationEventDetails = { + eventId: string; + timestamp: Date; + logicalId: string; + physicalId?: string; + resourceType: string; + status: string; + statusReason?: string; + stackId: string; + stackName: string; +}; + +/** + * Type for parsed CloudFormation resource status + */ +export type ResourceStatus = { + resourceType: string; + resourceName: string; + status: string; + timestamp: string; + key: string; + statusReason?: string; + eventId?: string; +}; + +/** + * Service for fetching CloudFormation events directly from the AWS API + */ +export class CloudFormationEventsService { + private cfnClient: CloudFormationClient; + + /** + * Creates a new CloudFormationEventsService instance + */ + constructor() { + this.cfnClient = new CloudFormationClient({}); + } + + /** + * Gets CloudFormation events for a stack + * @param backendId The backend identifier + * @param sinceTimestamp Optional timestamp to filter events that occurred after this time + * @returns Array of CloudFormation events + */ + async getStackEvents( + backendId: BackendIdentifier, + sinceTimestamp?: Date, + ): Promise { + try { + const stackName = BackendIdentifierConversions.toStackName(backendId); + printer.log( + `Fetching CloudFormation events for stack: ${stackName}`, + LogLevel.DEBUG, + ); + + const command = new DescribeStackEventsCommand({ StackName: stackName }); + + const response = await this.cfnClient.send(command); + + let events = response.StackEvents || []; + + // Filter events by timestamp if provided + if (sinceTimestamp) { + const beforeCount = events.length; + events = events.filter( + (event) => event.Timestamp && event.Timestamp > sinceTimestamp, + ); + printer.log( + `Filtered events by timestamp: ${beforeCount} -> ${events.length}`, + LogLevel.DEBUG, + ); + } + + const mappedEvents = events.map((event) => this.mapStackEvent(event)); + + return mappedEvents; + } catch (error) { + printer.log( + `Error fetching CloudFormation events: ${String(error)}`, + LogLevel.ERROR, + ); + if (error instanceof Error) { + printer.log(`Error stack: ${error.stack}`, LogLevel.DEBUG); + } + return []; + } + } + + /** + * Converts CloudFormation event details to ResourceStatus format + * @param event The CloudFormation event details + * @returns ResourceStatus object + */ + convertToResourceStatus(event: CloudFormationEventDetails): ResourceStatus { + return { + resourceType: event.resourceType, + resourceName: event.logicalId, + status: event.status, + timestamp: event.timestamp.toLocaleTimeString(), + key: `${event.resourceType}:${event.logicalId}`, + statusReason: event.statusReason, + eventId: event.eventId, + }; + } + + /** + * Maps AWS SDK StackEvent to our CloudFormationEventDetails type + * @param event The StackEvent from AWS SDK + * @returns CloudFormationEventDetails object + */ + private mapStackEvent(event: StackEvent): CloudFormationEventDetails { + return { + eventId: event.EventId || '', + timestamp: event.Timestamp || new Date(), + logicalId: event.LogicalResourceId || '', + physicalId: event.PhysicalResourceId, + resourceType: event.ResourceType || '', + status: event.ResourceStatus || '', + statusReason: event.ResourceStatusReason, + stackId: event.StackId || '', + stackName: event.StackName || '', + }; + } +} diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/App.tsx b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/App.tsx index 483895556ce..7f8191ffb7d 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/App.tsx +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/App.tsx @@ -2,11 +2,15 @@ import { useState, useEffect, useRef } from 'react'; import ConsoleViewer from './components/ConsoleViewer'; import Header from './components/Header'; import ResourceConsole from './components/ResourceConsole'; +import DeploymentProgress from './components/DeploymentProgress'; import SandboxOptionsModal from './components/SandboxOptionsModal'; import { DevToolsSandboxOptions } from '../../shared/socket_types'; import LogSettingsModal, { LogSettings } from './components/LogSettingsModal'; import { SocketClientProvider } from './contexts/socket_client_context'; -import { useSandboxClientService } from './contexts/socket_client_context'; +import { + useSandboxClientService, + useDeploymentClientService, +} from './contexts/socket_client_context'; import { SandboxStatusData } from '../../shared/socket_types'; import { SandboxStatus } from '@aws-amplify/sandbox'; @@ -57,6 +61,7 @@ function AppContent() { const [isDeletingLoading, setIsDeletingLoading] = useState(false); const sandboxClientService = useSandboxClientService(); + const deploymentClientService = useDeploymentClientService(); const deploymentInProgress = sandboxStatus === 'deploying'; @@ -509,7 +514,16 @@ function AppContent() { { id: 'logs', label: 'Console Logs', - content: , + content: ( + + + + + ), }, { id: 'resources', diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.tsx b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.tsx new file mode 100644 index 00000000000..708e0b2449a --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.tsx @@ -0,0 +1,612 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { DeploymentClientService } from '../services/deployment_client_service'; +import { + Container, + Header, + SpaceBetween, + Box, + Button, + Spinner, + ExpandableSection, + Alert, +} from '@cloudscape-design/components'; +import { SandboxStatus } from '@aws-amplify/sandbox'; + +interface DeploymentProgressProps { + deploymentClientService: DeploymentClientService; + visible: boolean; + status: SandboxStatus; +} + +interface ErrorState { + hasError: boolean; + name: string; + message: string; + resolution?: string; + timestamp: string; +} + +interface ResourceStatus { + resourceType: string; + resourceName: string; + status: string; + timestamp: string; + key: string; + statusReason?: string; + eventId?: string; +} + +interface DeploymentEvent { + message: string; + timestamp: string; + resourceStatus?: ResourceStatus; + isGeneric?: boolean; +} + +const DeploymentProgress: React.FC = ({ + deploymentClientService, + visible, + status, +}) => { + const [events, setEvents] = useState([]); + const [resourceStatuses, setResourceStatuses] = useState< + Record + >({}); + const [deploymentStartTime, setDeploymentStartTime] = useState( + null, + ); + const containerRef = useRef(null); + const [errorState, setErrorState] = useState({ + hasError: false, + name: '', + message: '', + timestamp: '', + }); + + const [expanded, setExpanded] = useState( + status === 'deploying' || status === 'deleting', + ); + + // Update expanded state when deployment or deletion status changes + useEffect(() => { + if (status === 'deploying' || status === 'deleting') { + setExpanded(true); + } else if ( + status === 'running' || + status === 'stopped' || + status === 'nonexistent' + ) { + // Close the expandable section when the operation is no longer in progress + setExpanded(false); + } + }, [status]); + + const getSpinnerStatus = (status: string): boolean => { + return status.includes('IN_PROGRESS'); + }; + + // Helper function to determine if a status is more recent/important + const isMoreRecentStatus = ( + newEvent: DeploymentEvent, + existingEvent: DeploymentEvent, + ): boolean => { + if (!newEvent.resourceStatus || !existingEvent.resourceStatus) return false; + + // First check timestamp - newer events take priority + const newTime = new Date(newEvent.timestamp).getTime(); + const existingTime = new Date(existingEvent.timestamp).getTime(); + + return newTime > existingTime; + }; + + // Helper function to merge and deduplicate events + const mergeEvents = ( + existingEvents: DeploymentEvent[], + newEvents: DeploymentEvent[], + ): DeploymentEvent[] => { + const eventMap = new Map(); + + // Add existing events + existingEvents.forEach((event) => { + const key = + event.resourceStatus?.eventId || `${event.timestamp}-${event.message}`; + eventMap.set(key, event); + }); + + // Add new events (will overwrite duplicates) + newEvents.forEach((event) => { + const key = + event.resourceStatus?.eventId || `${event.timestamp}-${event.message}`; + eventMap.set(key, event); + }); + + return Array.from(eventMap.values()); + }; + + // Helper function to get latest status for each resource + const getLatestResourceStatuses = ( + events: DeploymentEvent[], + ): Record => { + const resourceMap = new Map(); + + events.forEach((event) => { + if (event.resourceStatus) { + const existing = resourceMap.get(event.resourceStatus.key); + if (!existing || isMoreRecentStatus(event, existing)) { + resourceMap.set(event.resourceStatus.key, event); + } + } + }); + + const result: Record = {}; + resourceMap.forEach((event, key) => { + if (event.resourceStatus) { + result[key] = event.resourceStatus; + } + }); + + return result; + }; + + useEffect(() => { + const unsubscribeDeploymentError = + deploymentClientService.onDeploymentError((error) => { + setErrorState({ + hasError: true, + name: error.name, + message: error.message, + resolution: error.resolution, + timestamp: error.timestamp, + }); + }); + + return () => { + unsubscribeDeploymentError.unsubscribe(); + }; + }, [deploymentClientService]); + + // Set up stable event listeners + useEffect(() => { + // Handle saved CloudFormation events + const handleSavedCloudFormationEvents = ( + savedEvents: DeploymentEvent[], + ) => { + console.log('Received saved CloudFormation events:', savedEvents.length); + + // Don't process saved events during deployment OR deletion + if (status !== 'deploying' && status !== 'deleting') { + // Sort events by timestamp (newest first) + const sortedEvents = savedEvents.sort( + (a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ); + + // Get latest status for each resource + const latestResourceStatuses = getLatestResourceStatuses(sortedEvents); + + // Update state + setEvents(sortedEvents); + setResourceStatuses(latestResourceStatuses); + } else { + console.log( + 'Ignoring saved CloudFormation events because deployment or deletion is in progress', + ); + } + }; + + // Handle CloudFormation events from the API + const handleCloudFormationEvents = (cfnEvents: DeploymentEvent[]) => { + console.log( + `Received ${cfnEvents.length} CloudFormation events, current status: ${status}`, + ); + + if (cfnEvents.length === 0) { + console.log('No CloudFormation events received, returning early'); + return; + } + + // Filter events based on deployment start time during active deployment + let filteredEvents = cfnEvents; + if ( + (status === 'deploying' || status === 'deleting') && + deploymentStartTime + ) { + filteredEvents = cfnEvents.filter((event) => { + const eventTime = new Date(event.timestamp); + return eventTime >= deploymentStartTime; + }); + console.log( + `Filtered events from ${cfnEvents.length} to ${filteredEvents.length} (since deployment start)`, + ); + } + + // Merge with existing events and deduplicate + const mergedEvents = mergeEvents(events, filteredEvents); + + // Sort events by timestamp (newest first) + const sortedEvents = mergedEvents.sort( + (a, b) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ); + + // Get latest status for each resource + const latestResourceStatuses = getLatestResourceStatuses(sortedEvents); + + // Update state + setEvents(sortedEvents); + setResourceStatuses(latestResourceStatuses); + }; + + // Handle CloudFormation events error + const handleCloudFormationEventsError = (error: { error: string }) => { + console.error('Error fetching CloudFormation events:', error.error); + }; + + const unsubscribeCloudFormationEvents = + deploymentClientService.onCloudFormationEvents( + handleCloudFormationEvents, + ); + const unsubscribeCloudFormationEventsError = + deploymentClientService.onCloudFormationEventsError( + handleCloudFormationEventsError, + ); + const unsubscribeSavedCloudFormationEvents = + deploymentClientService.onSavedCloudFormationEvents( + handleSavedCloudFormationEvents, + ); + + return () => { + unsubscribeCloudFormationEvents.unsubscribe(); + unsubscribeCloudFormationEventsError.unsubscribe(); + unsubscribeSavedCloudFormationEvents.unsubscribe(); + }; + }, [deploymentClientService, status, deploymentStartTime, events]); + + // Separate useEffect for requesting events and polling + useEffect(() => { + if (status === 'deploying' || status === 'deleting') { + // Record deployment start time and clear previous events + setDeploymentStartTime(new Date()); + setEvents([]); + setResourceStatuses({}); + setErrorState({ + hasError: false, + name: '', + message: '', + timestamp: '', + }); + + // Only request CloudFormation events directly from the API during deployment + console.log( + `DeploymentProgress: Requesting CloudFormation events, status: ${status}`, + ); + deploymentClientService.getCloudFormationEvents(); + } else { + // Only request saved CloudFormation events when not deploying or deleting + console.log('DeploymentProgress: Requesting saved CloudFormation events'); + deploymentClientService.getSavedCloudFormationEvents(); + + // Also request current CloudFormation events for non-deployment states + console.log( + `DeploymentProgress: Requesting CloudFormation events, status: ${status}`, + ); + deploymentClientService.getCloudFormationEvents(); + } + + // Set up polling for CloudFormation events during deployment or deletion + let cfnEventsInterval: NodeJS.Timeout | null = null; + if (status === 'deploying' || status === 'deleting') { + console.log( + `Setting up polling for CloudFormation events (${status} state)`, + ); + cfnEventsInterval = setInterval(() => { + console.log(`Polling for CloudFormation events, status: ${status}`); + deploymentClientService.getCloudFormationEvents(); + }, 5000); + } + + return () => { + if (cfnEventsInterval) { + console.log(`Clearing CloudFormation events polling interval`); + clearInterval(cfnEventsInterval); + } + }; + }, [deploymentClientService, status]); + + // Auto-scroll to bottom when events change + useEffect(() => { + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + } + }, [events]); + + // Clear events + const clearEvents = () => { + setEvents([]); + setResourceStatuses({}); + }; + + const showContent = visible || events.length > 0; + + // Group resources by type for better organization + const resourcesByType: Record = {}; + Object.values(resourceStatuses).forEach((resource) => { + if (!resourcesByType[resource.resourceType]) { + resourcesByType[resource.resourceType] = []; + } + resourcesByType[resource.resourceType].push(resource); + }); + + // Sort resource types + const sortedResourceTypes = Object.keys(resourcesByType).sort(); + + return ( + + Clear Events + + } + > + Deployment Progress + {(status === 'deploying' || status === 'deleting') && ( + + + In progress + + )} + + } + > + {errorState.hasError && ( + + setErrorState({ + hasError: false, + name: '', + message: '', + timestamp: '', + }) + } + > +
+
{errorState.message}
+ + {errorState.resolution && ( +
+ Resolution: {errorState.resolution} +
+ )} +
+
+ )} + + setExpanded(detail.expanded)} + headerCounter={ + events.length > 0 ? `${events.length} events` : undefined + } + headerDescription={ + status === 'deploying' + ? 'Deployment is currently running' + : status === 'deleting' + ? 'Deletion is currently running' + : events.length > 0 + ? 'Previous deployment events' + : 'No deployment events' + } + > + {showContent && ( +
+ {events.length === 0 ? ( + + + {status === 'deploying' || status === 'deleting' ? ( + <> +
+ + + Waiting for deployment events... + +
+ + ) : ( +
No deployment events
+ )} +
+
+ ) : ( +
+ {/* Group resources by type */} + {sortedResourceTypes.map((resourceType) => ( +
+
+ {resourceType} +
+ + {resourcesByType[resourceType].map((resource) => ( +
+
+ {getSpinnerStatus(resource.status) ? ( +
+ ) : ( + + {resource.status.includes('COMPLETE') + ? '✓' + : resource.status.includes('FAILED') + ? '✗' + : resource.status.includes('DELETE') + ? '!' + : '•'} + + )} +
+
+
+ {resource.resourceName} +
+
+ {resource.status} • {resource.timestamp} +
+
+
+ ))} +
+ ))} + + {/* Show generic events at the bottom */} + {events.filter((event) => event.isGeneric).length > 0 && ( +
+ {events + .filter((event) => event.isGeneric) + .map((event, index) => ( +
+ {(status === 'deploying' || status === 'deleting') && + index === + events.filter((e) => e.isGeneric).length - 1 ? ( +
+ ) : ( + + • + + )} + {event.message} +
+ ))} +
+ )} +
+ )} +
+ )} + + + {/* Add CSS for spinner animation */} + + + ); +}; + +export default DeploymentProgress; diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/contexts/socket_client_context.tsx b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/contexts/socket_client_context.tsx index 464d3edc7de..d010225ecf6 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/contexts/socket_client_context.tsx +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/contexts/socket_client_context.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, ReactNode } from 'react'; import { SocketClientService } from '../services/socket_client_service'; import { SandboxClientService } from '../services/sandbox_client_service'; import { ResourceClientService } from '../services/resource_client_service'; +import { DeploymentClientService } from '../services/deployment_client_service'; /** * Interface for socket client services @@ -10,6 +11,7 @@ interface SocketClientServices { socketClientService: SocketClientService; sandboxClientService: SandboxClientService; resourceClientService: ResourceClientService; + deploymentClientService: DeploymentClientService; } /** @@ -34,11 +36,13 @@ export const SocketClientProvider: React.FC = ({ const socketClientService = new SocketClientService(); const sandboxClientService = new SandboxClientService(); const resourceClientService = new ResourceClientService(); + const deploymentClientService = new DeploymentClientService(); const services: SocketClientServices = { socketClientService, sandboxClientService, resourceClientService, + deploymentClientService, }; return ( @@ -89,3 +93,16 @@ export const useResourceClientService = (): ResourceClientService => { } return context.resourceClientService; }; +/** + * Hook to access the logging client service + * @returns The logging client service + */ +export const useDeploymentClientService = (): DeploymentClientService => { + const context = useContext(SocketClientContext); + if (!context) { + throw new Error( + 'useDeploymentClientService must be used within a SocketClientProvider', + ); + } + return context.deploymentClientService; +}; diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/deployment_client_service.test.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/deployment_client_service.test.ts new file mode 100644 index 00000000000..e48e0660889 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/deployment_client_service.test.ts @@ -0,0 +1,115 @@ +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert'; +import { DeploymentClientService } from './deployment_client_service'; +import { SOCKET_EVENTS } from '../../../shared/socket_events'; +import { createMockSocket } from './test_helpers'; + +void describe('DeploymentClientService', () => { + let service: DeploymentClientService; + let mockSocket: ReturnType; + + beforeEach(() => { + mockSocket = createMockSocket(); + + // Create service with mocked socket + service = new DeploymentClientService(); + }); + + void describe('getCloudFormationEvents', () => { + void it('emits GET_CLOUD_FORMATION_EVENTS event', () => { + service.getCloudFormationEvents(); + + assert.strictEqual(mockSocket.mockEmit.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockEmit.mock.calls[0].arguments[0], + SOCKET_EVENTS.GET_CLOUD_FORMATION_EVENTS, + ); + }); + }); + + void describe('getSavedCloudFormationEvents', () => { + void it('emits GET_SAVED_CLOUD_FORMATION_EVENTS event', () => { + service.getSavedCloudFormationEvents(); + + assert.strictEqual(mockSocket.mockEmit.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockEmit.mock.calls[0].arguments[0], + SOCKET_EVENTS.GET_SAVED_CLOUD_FORMATION_EVENTS, + ); + }); + }); + + void describe('event handlers', () => { + void it('registers onCloudFormationEvents handler correctly', () => { + const mockHandler = mock.fn(); + + service.onCloudFormationEvents(mockHandler); + + assert.strictEqual(mockSocket.mockOn.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockOn.mock.calls[0].arguments[0], + SOCKET_EVENTS.CLOUD_FORMATION_EVENTS, + ); + + // Call the registered handler + const registeredHandler = mockSocket.mockOn.mock.calls[0] + .arguments[1] as (events: any) => void; + const testEvents = [ + { + message: 'Resource creation started', + timestamp: '2023-01-01T12:00:00Z', + resourceStatus: { + resourceType: 'AWS::Lambda::Function', + resourceName: 'TestFunction', + status: 'CREATE_IN_PROGRESS', + timestamp: '2023-01-01T12:00:00Z', + key: 'test-key', + }, + }, + ]; + registeredHandler(testEvents); + + assert.strictEqual(mockHandler.mock.callCount(), 1); + assert.deepStrictEqual( + mockHandler.mock.calls[0].arguments[0], + testEvents, + ); + }); + + void it('registers onSavedCloudFormationEvents handler correctly', () => { + const mockHandler = mock.fn(); + + service.onSavedCloudFormationEvents(mockHandler); + + assert.strictEqual(mockSocket.mockOn.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockOn.mock.calls[0].arguments[0], + SOCKET_EVENTS.SAVED_CLOUD_FORMATION_EVENTS, + ); + }); + + void it('registers onCloudFormationEventsError handler correctly', () => { + const mockHandler = mock.fn(); + + service.onCloudFormationEventsError(mockHandler); + + assert.strictEqual(mockSocket.mockOn.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockOn.mock.calls[0].arguments[0], + SOCKET_EVENTS.CLOUD_FORMATION_EVENTS_ERROR, + ); + }); + + void it('registers onDeploymentError handler correctly', () => { + const mockHandler = mock.fn(); + + service.onDeploymentError(mockHandler); + + assert.strictEqual(mockSocket.mockOn.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockOn.mock.calls[0].arguments[0], + SOCKET_EVENTS.DEPLOYMENT_ERROR, + ); + }); + }); +}); diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/deployment_client_service.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/deployment_client_service.ts new file mode 100644 index 00000000000..f1f69fc238a --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/deployment_client_service.ts @@ -0,0 +1,60 @@ +import { SOCKET_EVENTS } from '../../../shared/socket_events'; +import { SocketClientService } from './socket_client_service'; + +export interface DeploymentEvent { + message: string; + timestamp: string; + resourceStatus?: ResourceStatus; + isGeneric?: boolean; +} + +export interface ResourceStatus { + resourceType: string; + resourceName: string; + status: string; + timestamp: string; + key: string; + statusReason?: string; + eventId?: string; +} + +export interface DeploymentError { + name: string; + message: string; + resolution?: string; + timestamp: string; +} + +export class DeploymentClientService extends SocketClientService { + public getCloudFormationEvents(): void { + this.emit(SOCKET_EVENTS.GET_CLOUD_FORMATION_EVENTS); + } + + public getSavedCloudFormationEvents(): void { + this.emit(SOCKET_EVENTS.GET_SAVED_CLOUD_FORMATION_EVENTS); + } + + public onCloudFormationEvents(handler: (events: DeploymentEvent[]) => void): { + unsubscribe: () => void; + } { + return this.on(SOCKET_EVENTS.CLOUD_FORMATION_EVENTS, handler); + } + + public onSavedCloudFormationEvents( + handler: (events: DeploymentEvent[]) => void, + ): { unsubscribe: () => void } { + return this.on(SOCKET_EVENTS.SAVED_CLOUD_FORMATION_EVENTS, handler); + } + + public onCloudFormationEventsError( + handler: (error: { error: string }) => void, + ): { unsubscribe: () => void } { + return this.on(SOCKET_EVENTS.CLOUD_FORMATION_EVENTS_ERROR, handler); + } + + public onDeploymentError(handler: (error: DeploymentError) => void): { + unsubscribe: () => void; + } { + return this.on(SOCKET_EVENTS.DEPLOYMENT_ERROR, handler); + } +} diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/sandbox_client_service.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/sandbox_client_service.ts index 9748e10e6d7..60fc3a125a1 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/sandbox_client_service.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/sandbox_client_service.ts @@ -5,14 +5,8 @@ import { ConsoleLogEntry, DevToolsSandboxOptions, } from '../../../shared/socket_types'; +import { DeploymentEvent } from './deployment_client_service'; -/** - * Interface for log settings data - */ -export interface LogSettings { - maxLogSizeMB: number; - currentSizeMB?: number; -} /** * Service for handling sandbox-related socket communication */ @@ -83,6 +77,13 @@ export class SandboxClientService extends SocketClientService { return this.on(SOCKET_EVENTS.LOG_SETTINGS, handler); } + /** + * Gets saved CloudFormation events + */ + public getSavedCloudFormationEvents(): void { + this.emit(SOCKET_EVENTS.GET_SAVED_CLOUD_FORMATION_EVENTS); + } + /** * Gets log settings */ @@ -101,6 +102,17 @@ export class SandboxClientService extends SocketClientService { return this.on('log', handler); } + /** + * Registers a handler for saved CloudFormation events + * @param handler The event handler + * @returns An object with an unsubscribe method + */ + public onSavedCloudFormationEvents( + handler: (events: DeploymentEvent[]) => void, + ): { unsubscribe: () => void } { + return this.on(SOCKET_EVENTS.SAVED_CLOUD_FORMATION_EVENTS, handler); + } + /** * Saves console logs * @param logs The console logs to save diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_resources.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_resources.ts index 61f42d67d4e..f9f1a8dd0ad 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_resources.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_resources.ts @@ -8,6 +8,10 @@ import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; import { SOCKET_EVENTS } from '../shared/socket_events.js'; import { LocalStorageManager } from '../local_storage_manager.js'; import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { + CloudFormationEventDetails, + CloudFormationEventsService, +} from '../logging/cloudformation_format.js'; import { FriendlyNameUpdate, ResourceIdentifier, @@ -19,7 +23,7 @@ import { SandboxStatus } from '@aws-amplify/sandbox'; * Service for handling socket events related to resources */ export class SocketHandlerResources { - private lastEventTimestamp: Record = {}; + private cloudFormationEventsService: CloudFormationEventsService; /** * Creates a new SocketHandlerResources @@ -31,7 +35,22 @@ export class SocketHandlerResources { private getSandboxState: () => Promise, private lambdaClient: LambdaClient, private printer: Printer = printerUtil, // Optional printer, defaults to cli-core printer - ) {} + ) { + this.cloudFormationEventsService = new CloudFormationEventsService(); + } + + /** + * Reset the last event timestamp to the current time + * This is used when starting a new deployment to avoid showing old events + */ + public resetLastEventTimestamp(): void { + const now = new Date(); + this.storageManager.saveLastCloudFormationTimestamp(now); + this.printer.log( + `Reset last CloudFormation timestamp for ${this.backendId.name} to ${now.toISOString()}`, + LogLevel.DEBUG, + ); + } /** * Handles the testLambdaFunction event @@ -96,11 +115,187 @@ export class SocketHandlerResources { } /** - * Handles the getSavedResources event + * Handles ResourceNotFoundException for log groups + * @param resourceId The resource ID + * @param error The error object + * @param socket Optional socket to emit errors to */ - public handleGetSavedResources(socket: Socket): void { - const resources = this.storageManager.loadResources(); - socket.emit(SOCKET_EVENTS.SAVED_RESOURCES, resources || []); + public handleResourceNotFoundException( + resourceId: string, + error: unknown, + socket?: Socket, + ): void { + // Check if this is a ResourceNotFoundException for missing log group + if ( + String(error).includes('ResourceNotFoundException') && + String(error).includes('log group does not exist') + ) { + this.printer.log( + `Log group does not exist yet for ${resourceId}`, + LogLevel.INFO, + ); + + if (socket) { + // Then send the error message + socket.emit(SOCKET_EVENTS.LOG_STREAM_ERROR, { + resourceId, + error: `The log group doesn't exist yet. Try turning on logs again after the resource has produced some logs.`, + }); + } + } else { + throw error; // Re-throw other errors for further handling + } + } + + /** + * Handles the getSavedCloudFormationEvents event + */ + public handleGetSavedCloudFormationEvents(socket: Socket): void { + const events = this.storageManager.loadCloudFormationEvents(); + socket.emit(SOCKET_EVENTS.SAVED_CLOUD_FORMATION_EVENTS, events); + } + + /** + * Handles the getCloudFormationEvents event + */ + public async handleGetCloudFormationEvents(socket: Socket): Promise { + if (!this.backendId) { + this.printer.log( + 'Backend ID not set, cannot fetch CloudFormation events', + LogLevel.ERROR, + ); + socket.emit(SOCKET_EVENTS.CLOUD_FORMATION_EVENTS_ERROR, { + error: 'Backend ID not set', + }); + return; + } + + try { + // Get current sandbox state + const sandboxState = await this.getSandboxState(); + + // Don't fetch events if sandbox doesn't exist + if (sandboxState === 'nonexistent' || sandboxState === 'unknown') { + return; + } + + // If not deploying or deleting, we can return a cached version if available + const shouldUseCachedEvents = + sandboxState !== 'deploying' && sandboxState !== 'deleting'; + + if (shouldUseCachedEvents) { + // Try to get cached events first + const cachedEvents = this.storageManager.loadCloudFormationEvents(); + + if (cachedEvents && cachedEvents.length > 0) { + socket.emit(SOCKET_EVENTS.CLOUD_FORMATION_EVENTS, cachedEvents); + return; + } + // No cached events and we're not in a deployment state, + // so don't fetch anything - just return + socket.emit(SOCKET_EVENTS.CLOUD_FORMATION_EVENTS, []); + return; + } + + // We only reach this code if we're in a deploying or deleting state + + // If this is the first time we're fetching events for this backend, + // initialize the timestamp to now to avoid fetching old events + let sinceTimestamp = + this.storageManager.loadLastCloudFormationTimestamp(); + if (!sinceTimestamp) { + const now = new Date(); + this.storageManager.saveLastCloudFormationTimestamp(now); + sinceTimestamp = now; + } + + // Fetch fresh events from CloudFormation API + const events = await this.cloudFormationEventsService.getStackEvents( + this.backendId, + sinceTimestamp, + ); + + // Only proceed if we have new events + if (events.length === 0) { + return; + } + + // Update the last event timestamp if we got any events + const latestEvent = events.reduce( + (latest, event) => + !latest || event.timestamp > latest.timestamp ? event : latest, + null as unknown as CloudFormationEventDetails, + ); + + if (latestEvent) { + this.storageManager.saveLastCloudFormationTimestamp( + latestEvent.timestamp, + ); + } + + // Map events to the format expected by the frontend + const formattedEvents = events.map((event) => { + const resourceStatus = + this.cloudFormationEventsService.convertToResourceStatus(event); + return { + message: `${event.timestamp.toLocaleTimeString()} | ${event.status} | ${event.resourceType} | ${event.logicalId}`, + timestamp: event.timestamp.toISOString(), + resourceStatus, + }; + }); + + // Merge with existing events and save to preserve complete deployment history + if (formattedEvents.length > 0) { + // Load existing events + const existingEvents = + this.storageManager.loadCloudFormationEvents() || []; + + // Merge events (avoiding duplicates by using a Map with event ID or timestamp+message as key) + const eventMap = new Map(); + + // Add existing events to the map + existingEvents.forEach((event) => { + const key = + event.resourceStatus?.eventId || + `${event.timestamp}-${event.message}`; + eventMap.set(key, event); + }); + + // Add new events to the map (will overwrite duplicates) + formattedEvents.forEach((event) => { + const key = + event.resourceStatus?.eventId || + `${event.timestamp}-${event.message}`; + eventMap.set(key, event); + }); + + // Convert map back to array + const mergedEvents = Array.from(eventMap.values()); + + // Save the merged events + this.storageManager.saveCloudFormationEvents(mergedEvents); + + // During active deployments, send ALL merged events to ensure complete history + // Otherwise just send the new events we just fetched + const isActiveDeployment = + sandboxState === 'deploying' || sandboxState === 'deleting'; + socket.emit( + SOCKET_EVENTS.CLOUD_FORMATION_EVENTS, + isActiveDeployment ? mergedEvents : formattedEvents, + ); + } else { + // If no new events were merged, just send whatever we fetched + socket.emit(SOCKET_EVENTS.CLOUD_FORMATION_EVENTS, formattedEvents); + } + } catch (error) { + this.printer.log( + `Error fetching CloudFormation events: ${String(error)}`, + LogLevel.ERROR, + ); + socket.emit(SOCKET_EVENTS.CLOUD_FORMATION_EVENTS_ERROR, { + error: String(error), + }); + } } /** diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/shared/socket_events.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/shared/socket_events.ts index a24aa07ec42..99c3ad710cb 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/shared/socket_events.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/shared/socket_events.ts @@ -93,7 +93,6 @@ export const SOCKET_EVENTS = { * Event received when log settings are sent from the server */ LOG_SETTINGS: 'logSettings', - /** * Event to test a Lambda function */ @@ -104,6 +103,31 @@ export const SOCKET_EVENTS = { */ LAMBDA_TEST_RESULT: 'lambdaTestResult', + /** + * Event to request CloudFormation events from the server + */ + GET_CLOUD_FORMATION_EVENTS: 'getCloudFormationEvents', + + /** + * Event received when CloudFormation events are sent from the server + */ + CLOUD_FORMATION_EVENTS: 'cloudFormationEvents', + + /** + * Event to request saved CloudFormation events from the server + */ + GET_SAVED_CLOUD_FORMATION_EVENTS: 'getSavedCloudFormationEvents', + + /** + * Event received when saved CloudFormation events are sent from the server + */ + SAVED_CLOUD_FORMATION_EVENTS: 'savedCloudFormationEvents', + + /** + * Event received when a CloudFormation events error occurs + */ + CLOUD_FORMATION_EVENTS_ERROR: 'cloudFormationEventsError', + /** * Event received when a log message is sent from the server */ @@ -128,7 +152,6 @@ export const SOCKET_EVENTS = { * Event received when saved console logs are sent from the server */ SAVED_CONSOLE_LOGS: 'savedConsoleLogs', - /** * Event which triggers UI to show a deployment error * Contains error details like name, message, resolution, and timestamp. From 59bc3a277b4c105c35de8c953f81df287cade37d Mon Sep 17 00:00:00 2001 From: Megha Narayanan Date: Fri, 25 Jul 2025 13:19:52 -0700 Subject: [PATCH 2/5] various fixes --- .../components/DeploymentProgress.test.tsx | 626 ++++++++++++++++++ .../src/components/DeploymentProgress.tsx | 100 +-- .../sandbox_devtools_command.ts | 17 +- 3 files changed, 661 insertions(+), 82 deletions(-) create mode 100644 packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.test.tsx diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.test.tsx b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.test.tsx new file mode 100644 index 00000000000..a1e511df060 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.test.tsx @@ -0,0 +1,626 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import DeploymentProgress from './DeploymentProgress'; +import type { DeploymentClientService } from '../services/deployment_client_service'; + +// Mock the imports +vi.mock('../services/deployment_client_service', () => ({ + DeploymentClientService: vi.fn(), +})); + +vi.mock('@aws-amplify/sandbox', () => ({ + // Mock the SandboxStatus type +})); + +// Create a mock deployment service +const createMockDeploymentService = () => { + const subscribers = { + cloudFormationEvents: [] as Array<(data: any) => void>, + savedCloudFormationEvents: [] as Array<(data: any) => void>, + cloudFormationEventsError: [] as Array<(data: any) => void>, + deploymentError: [] as Array<(data: any) => void>, + }; + + const mockService = { + getCloudFormationEvents: vi.fn(), + getSavedCloudFormationEvents: vi.fn(), + + onCloudFormationEvents: vi.fn((handler) => { + subscribers.cloudFormationEvents.push(handler); + return { + unsubscribe: vi.fn(() => { + const index = subscribers.cloudFormationEvents.indexOf(handler); + if (index !== -1) subscribers.cloudFormationEvents.splice(index, 1); + }), + }; + }), + + onSavedCloudFormationEvents: vi.fn((handler) => { + subscribers.savedCloudFormationEvents.push(handler); + return { + unsubscribe: vi.fn(() => { + const index = subscribers.savedCloudFormationEvents.indexOf(handler); + if (index !== -1) + subscribers.savedCloudFormationEvents.splice(index, 1); + }), + }; + }), + + onCloudFormationEventsError: vi.fn((handler) => { + subscribers.cloudFormationEventsError.push(handler); + return { + unsubscribe: vi.fn(() => { + const index = subscribers.cloudFormationEventsError.indexOf(handler); + if (index !== -1) + subscribers.cloudFormationEventsError.splice(index, 1); + }), + }; + }), + + onDeploymentError: vi.fn((handler) => { + subscribers.deploymentError.push(handler); + return { + unsubscribe: vi.fn(() => { + const index = subscribers.deploymentError.indexOf(handler); + if (index !== -1) subscribers.deploymentError.splice(index, 1); + }), + }; + }), + + // Helper to trigger events for testing + emitEvent: (eventName: string, data: any) => { + if (eventName === 'cloudFormationEvents') { + subscribers.cloudFormationEvents.forEach((handler) => handler(data)); + } else if (eventName === 'savedCloudFormationEvents') { + subscribers.savedCloudFormationEvents.forEach((handler) => + handler(data), + ); + } else if (eventName === 'cloudFormationEventsError') { + subscribers.cloudFormationEventsError.forEach((handler) => + handler(data), + ); + } else if (eventName === 'deploymentError') { + subscribers.deploymentError.forEach((handler) => handler(data)); + } + }, + }; + + return mockService; +}; + +describe('DeploymentProgress Component', () => { + // Create a mock service for each test + let mockDeploymentService: ReturnType; + + // Setup before each test + beforeEach(() => { + // Setup fake timers to control setTimeout/setInterval + vi.useFakeTimers(); + + // Create a fresh mock service for each test + mockDeploymentService = createMockDeploymentService(); + + // Reset mock calls + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // Helper function to create sample deployment events + const createSampleEvents = () => [ + { + message: 'Starting deployment', + timestamp: '2023-01-01T12:00:00Z', + isGeneric: true, + }, + { + message: 'Creating resources', + timestamp: '2023-01-01T12:01:00Z', + resourceStatus: { + resourceType: 'AWS::Lambda::Function', + resourceName: 'TestFunction', + status: 'CREATE_IN_PROGRESS', + timestamp: '2023-01-01T12:01:00Z', + key: 'lambda-1', + eventId: 'event-1', + }, + }, + { + message: 'Resource created', + timestamp: '2023-01-01T12:02:00Z', + resourceStatus: { + resourceType: 'AWS::Lambda::Function', + resourceName: 'TestFunction', + status: 'CREATE_COMPLETE', + timestamp: '2023-01-01T12:02:00Z', + key: 'lambda-1', + eventId: 'event-2', + }, + }, + { + message: 'Creating table', + timestamp: '2023-01-01T12:03:00Z', + resourceStatus: { + resourceType: 'AWS::DynamoDB::Table', + resourceName: 'TestTable', + status: 'CREATE_IN_PROGRESS', + timestamp: '2023-01-01T12:03:00Z', + key: 'table-1', + eventId: 'event-3', + }, + }, + ]; + + it('renders with correct header', () => { + render( + , + ); + + // Check for header + expect(screen.getByText('Deployment Progress')).toBeInTheDocument(); + + // Check for Clear Events button + expect(screen.getByText('Clear Events')).toBeInTheDocument(); + }); + + it('shows spinner when deployment is in progress', () => { + const { container } = render( + , + ); + + // Check for in progress indicator + expect(screen.getByText('In progress')).toBeInTheDocument(); + + // Check for spinner using container query instead of text + const spinners = container.querySelectorAll('.spinner'); + expect(spinners.length).toBeGreaterThan(0); + }); + + it('shows spinner when deletion is in progress', () => { + const { container } = render( + , + ); + + // Check for in progress indicator + expect(screen.getByText('In progress')).toBeInTheDocument(); + + // Check for spinner using container query instead of text + const spinners = container.querySelectorAll('.spinner'); + expect(spinners.length).toBeGreaterThan(0); + }); + + it('requests CloudFormation events on mount', () => { + render( + , + ); + + // Verify API calls + expect( + mockDeploymentService.getSavedCloudFormationEvents, + ).toHaveBeenCalled(); + expect(mockDeploymentService.getCloudFormationEvents).toHaveBeenCalled(); + }); + + it('sets up polling when deployment is in progress', () => { + render( + , + ); + + // Initial call + expect(mockDeploymentService.getCloudFormationEvents).toHaveBeenCalledTimes( + 2, + ); + + // Reset mock to check for polling calls + mockDeploymentService.getCloudFormationEvents.mockClear(); + + // Advance timer to trigger polling + act(() => { + vi.advanceTimersByTime(5000); + }); + + // Verify polling call + expect(mockDeploymentService.getCloudFormationEvents).toHaveBeenCalledTimes( + 1, + ); + + // Advance timer again + mockDeploymentService.getCloudFormationEvents.mockClear(); + act(() => { + vi.advanceTimersByTime(5000); + }); + + // Verify another polling call + expect(mockDeploymentService.getCloudFormationEvents).toHaveBeenCalledTimes( + 1, + ); + }); + + it('displays deployment events when received', () => { + const { container } = render( + , + ); + + // Initially no events + expect(screen.getByText('No deployment events')).toBeInTheDocument(); + + // Emit events + const sampleEvents = createSampleEvents(); + act(() => { + mockDeploymentService.emitEvent( + 'savedCloudFormationEvents', + sampleEvents, + ); + }); + + // Check for resource types + expect(screen.getByText('AWS::Lambda::Function')).toBeInTheDocument(); + expect(screen.getByText('AWS::DynamoDB::Table')).toBeInTheDocument(); + + // Check for resource names + expect(screen.getByText('TestFunction')).toBeInTheDocument(); + expect(screen.getByText('TestTable')).toBeInTheDocument(); + + // Check for status + expect(container.textContent).toContain('CREATE_COMPLETE'); + expect(container.textContent).toContain('CREATE_IN_PROGRESS'); + }); + + it('clears events when Clear Events button is clicked', () => { + render( + , + ); + + // Emit events + const sampleEvents = createSampleEvents(); + act(() => { + mockDeploymentService.emitEvent( + 'savedCloudFormationEvents', + sampleEvents, + ); + }); + + // Check events are displayed + expect(screen.getByText('AWS::Lambda::Function')).toBeInTheDocument(); + + // Click clear button + const clearButton = screen.getByText('Clear Events'); + fireEvent.click(clearButton); + + // Check events are cleared + expect(screen.queryByText('AWS::Lambda::Function')).not.toBeInTheDocument(); + expect(screen.getByText('No deployment events')).toBeInTheDocument(); + }); + + it('disables Clear Events button during deployment', () => { + render( + , + ); + + // Find Clear Events button and check it's disabled + const clearButton = screen.getByText('Clear Events'); + expect(clearButton).toBeDisabled(); + }); + + it('displays deployment error when received', () => { + const { container } = render( + , + ); + + // Emit error + act(() => { + mockDeploymentService.emitEvent('deploymentError', { + name: 'DeploymentError', + message: 'Failed to deploy resources', + timestamp: '2023-01-01T12:00:00Z', + resolution: 'Check your configuration', + }); + }); + + // Check error message is displayed + expect(container.textContent).toContain('Failed to deploy resources'); + + // Check for resolution text + expect(container.textContent).toContain('Check your configuration'); + + // Check for Resolution label + expect(container.textContent).toContain('Resolution:'); + }); + + it('dismisses error when dismiss button is clicked', () => { + const { container } = render( + , + ); + + // Emit error + act(() => { + mockDeploymentService.emitEvent('deploymentError', { + name: 'DeploymentError', + message: 'Failed to deploy resources', + timestamp: '2023-01-01T12:00:00Z', + }); + }); + + // Check error is displayed + expect(container.textContent).toContain('Failed to deploy resources'); + + // Find and click dismiss button (it's in the Alert component) + // The Alert component from @cloudscape-design/components has a dismissible button + const dismissButton = container.querySelector( + 'button[aria-label="Dismiss"]', + ); + expect(dismissButton).toBeInTheDocument(); + if (dismissButton) { + fireEvent.click(dismissButton); + } + + // Check error is dismissed + expect(container.textContent).not.toContain('Failed to deploy resources'); + }); + + it('handles CloudFormation events error', () => { + const { container } = render( + , + ); + + // Emit error + act(() => { + mockDeploymentService.emitEvent('cloudFormationEventsError', { + error: 'Failed to fetch events', + }); + }); + + // Error should be logged but not displayed in UI + // This is a console.error in the component + expect(container).toBeInTheDocument(); + }); + + it('expands section automatically when deployment starts', () => { + const { rerender } = render( + , + ); + + // Change status to deploying + rerender( + , + ); + + // Check for deployment in progress text in the header + expect(screen.getByText('Deployment in progress')).toBeInTheDocument(); + + // Check for waiting message which confirms the section is expanded + expect( + screen.getByText('Waiting for deployment events...'), + ).toBeInTheDocument(); + }); + + it('expands section automatically when deletion starts', () => { + const { rerender } = render( + , + ); + + // Change status to deleting + rerender( + , + ); + + // Check for deletion in progress text in the header + expect(screen.getByText('Deletion in progress')).toBeInTheDocument(); + + // Check for waiting message which confirms the section is expanded + expect( + screen.getByText('Waiting for deployment events...'), + ).toBeInTheDocument(); + }); + + it('shows waiting message when no events during deployment', () => { + render( + , + ); + + // Check for waiting message + expect( + screen.getByText('Waiting for deployment events...'), + ).toBeInTheDocument(); + }); + + it('merges and deduplicates events', () => { + render( + , + ); + + // Emit first set of events + const initialEvents = [ + { + message: 'Creating resources', + timestamp: '2023-01-01T12:01:00Z', + resourceStatus: { + resourceType: 'AWS::Lambda::Function', + resourceName: 'TestFunction', + status: 'CREATE_IN_PROGRESS', + timestamp: '2023-01-01T12:01:00Z', + key: 'lambda-1', + eventId: 'event-1', + }, + }, + ]; + + act(() => { + mockDeploymentService.emitEvent('cloudFormationEvents', initialEvents); + }); + + // Check initial event is displayed + expect(screen.getByText('TestFunction')).toBeInTheDocument(); + expect(screen.getByText(/CREATE_IN_PROGRESS/)).toBeInTheDocument(); + + // Emit updated event with same eventId + const updatedEvents = [ + { + message: 'Resource created', + timestamp: '2023-01-01T12:02:00Z', + resourceStatus: { + resourceType: 'AWS::Lambda::Function', + resourceName: 'TestFunction', + status: 'CREATE_COMPLETE', + timestamp: '2023-01-01T12:02:00Z', + key: 'lambda-1', + eventId: 'event-1', + }, + }, + ]; + + act(() => { + mockDeploymentService.emitEvent('cloudFormationEvents', updatedEvents); + }); + + // Check updated status is displayed + expect(screen.getByText('TestFunction')).toBeInTheDocument(); + expect(screen.getByText(/CREATE_COMPLETE/)).toBeInTheDocument(); + expect(screen.queryByText(/CREATE_IN_PROGRESS/)).not.toBeInTheDocument(); + }); + + it('clears events when deployment starts', () => { + const { rerender } = render( + , + ); + + // Emit events + const sampleEvents = createSampleEvents(); + act(() => { + mockDeploymentService.emitEvent( + 'savedCloudFormationEvents', + sampleEvents, + ); + }); + + // Check events are displayed + expect(screen.getByText('AWS::Lambda::Function')).toBeInTheDocument(); + + // Change status to deploying + rerender( + , + ); + + // Check events are cleared + expect(screen.queryByText('AWS::Lambda::Function')).not.toBeInTheDocument(); + expect( + screen.getByText('Waiting for deployment events...'), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.tsx b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.tsx index 708e0b2449a..045504f59e0 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.tsx +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.tsx @@ -52,9 +52,6 @@ const DeploymentProgress: React.FC = ({ const [resourceStatuses, setResourceStatuses] = useState< Record >({}); - const [deploymentStartTime, setDeploymentStartTime] = useState( - null, - ); const containerRef = useRef(null); const [errorState, setErrorState] = useState({ hasError: false, @@ -99,30 +96,6 @@ const DeploymentProgress: React.FC = ({ return newTime > existingTime; }; - // Helper function to merge and deduplicate events - const mergeEvents = ( - existingEvents: DeploymentEvent[], - newEvents: DeploymentEvent[], - ): DeploymentEvent[] => { - const eventMap = new Map(); - - // Add existing events - existingEvents.forEach((event) => { - const key = - event.resourceStatus?.eventId || `${event.timestamp}-${event.message}`; - eventMap.set(key, event); - }); - - // Add new events (will overwrite duplicates) - newEvents.forEach((event) => { - const key = - event.resourceStatus?.eventId || `${event.timestamp}-${event.message}`; - eventMap.set(key, event); - }); - - return Array.from(eventMap.values()); - }; - // Helper function to get latest status for each resource const getLatestResourceStatuses = ( events: DeploymentEvent[], @@ -173,25 +146,15 @@ const DeploymentProgress: React.FC = ({ ) => { console.log('Received saved CloudFormation events:', savedEvents.length); - // Don't process saved events during deployment OR deletion - if (status !== 'deploying' && status !== 'deleting') { - // Sort events by timestamp (newest first) - const sortedEvents = savedEvents.sort( - (a, b) => - new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), - ); - - // Get latest status for each resource - const latestResourceStatuses = getLatestResourceStatuses(sortedEvents); - - // Update state - setEvents(sortedEvents); - setResourceStatuses(latestResourceStatuses); - } else { - console.log( - 'Ignoring saved CloudFormation events because deployment or deletion is in progress', - ); - } + const sortedEvents = savedEvents.sort( + (a: DeploymentEvent, b: DeploymentEvent) => + new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), + ); + + const latestResourceStatuses = getLatestResourceStatuses(sortedEvents); + + setEvents(sortedEvents); + setResourceStatuses(latestResourceStatuses); }; // Handle CloudFormation events from the API @@ -205,27 +168,9 @@ const DeploymentProgress: React.FC = ({ return; } - // Filter events based on deployment start time during active deployment - let filteredEvents = cfnEvents; - if ( - (status === 'deploying' || status === 'deleting') && - deploymentStartTime - ) { - filteredEvents = cfnEvents.filter((event) => { - const eventTime = new Date(event.timestamp); - return eventTime >= deploymentStartTime; - }); - console.log( - `Filtered events from ${cfnEvents.length} to ${filteredEvents.length} (since deployment start)`, - ); - } - - // Merge with existing events and deduplicate - const mergedEvents = mergeEvents(events, filteredEvents); - // Sort events by timestamp (newest first) - const sortedEvents = mergedEvents.sort( - (a, b) => + const sortedEvents = cfnEvents.sort( + (a: DeploymentEvent, b: DeploymentEvent) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(), ); @@ -260,13 +205,11 @@ const DeploymentProgress: React.FC = ({ unsubscribeCloudFormationEventsError.unsubscribe(); unsubscribeSavedCloudFormationEvents.unsubscribe(); }; - }, [deploymentClientService, status, deploymentStartTime, events]); + }, [deploymentClientService, status]); // Separate useEffect for requesting events and polling useEffect(() => { if (status === 'deploying' || status === 'deleting') { - // Record deployment start time and clear previous events - setDeploymentStartTime(new Date()); setEvents([]); setResourceStatuses({}); setErrorState({ @@ -281,17 +224,16 @@ const DeploymentProgress: React.FC = ({ `DeploymentProgress: Requesting CloudFormation events, status: ${status}`, ); deploymentClientService.getCloudFormationEvents(); - } else { - // Only request saved CloudFormation events when not deploying or deleting - console.log('DeploymentProgress: Requesting saved CloudFormation events'); - deploymentClientService.getSavedCloudFormationEvents(); - - // Also request current CloudFormation events for non-deployment states - console.log( - `DeploymentProgress: Requesting CloudFormation events, status: ${status}`, - ); - deploymentClientService.getCloudFormationEvents(); } + // Only request saved CloudFormation events when not deploying or deleting + console.log('DeploymentProgress: Requesting saved CloudFormation events'); + deploymentClientService.getSavedCloudFormationEvents(); + + // Also request current CloudFormation events for non-deployment states + console.log( + `DeploymentProgress: Requesting CloudFormation events, status: ${status}`, + ); + deploymentClientService.getCloudFormationEvents(); // Set up polling for CloudFormation events during deployment or deletion let cfnEventsInterval: NodeJS.Timeout | null = null; diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/sandbox_devtools_command.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/sandbox_devtools_command.ts index 276ac9ced68..dddd348ec54 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/sandbox_devtools_command.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/sandbox_devtools_command.ts @@ -181,8 +181,7 @@ export class SandboxDevToolsCommand implements CommandModule { storageManager: LocalStorageManager, ): void { // Listen for deployment started - sandbox.on('deploymentStarted', (data: { timestamp?: string }) => { - void (async () => { + sandbox.on('deploymentStarted', (data: { timestamp?: string }) => { void (async () => { this.printer.log('Deployment started', LogLevel.DEBUG); const currentState = await getSandboxState(); @@ -195,6 +194,12 @@ export class SandboxDevToolsCommand implements CommandModule { ); } + // Clear CloudFormation events when a new deployment starts + storageManager.clearCloudFormationEvents(); + this.printer.log( + 'Cleared previous CloudFormation events', + LogLevel.DEBUG, + ); const statusData: SandboxStatusData = { status: currentState, // This should be 'deploying' after deployment starts, identifier: backendId.name, @@ -232,7 +237,13 @@ export class SandboxDevToolsCommand implements CommandModule { this.printer.log('Deletion started', LogLevel.DEBUG); const currentState = await getSandboxState(); - const statusData: SandboxStatusData = { + // Clear CloudFormation events when a new deletion starts + storageManager.clearCloudFormationEvents(); + this.printer.log( + 'Cleared previous CloudFormation events', + LogLevel.DEBUG, + ); + const statusData: SandboxStatusData = { status: currentState, // This should be 'deleting' after deletion starts identifier: backendId.name, message: 'Deletion started', From 5d75063e989aa08e28d4186f6dc8f3e5bb693d3e Mon Sep 17 00:00:00 2001 From: Megha Narayanan Date: Tue, 29 Jul 2025 09:52:50 -0700 Subject: [PATCH 3/5] add last event timestamp --- .../sandbox_devtools_command.ts | 18 +++- .../services/socket_handlers_resources.ts | 95 ------------------- 2 files changed, 17 insertions(+), 96 deletions(-) diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/sandbox_devtools_command.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/sandbox_devtools_command.ts index dddd348ec54..d0c872b06e3 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/sandbox_devtools_command.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/sandbox_devtools_command.ts @@ -196,6 +196,14 @@ export class SandboxDevToolsCommand implements CommandModule { // Clear CloudFormation events when a new deployment starts storageManager.clearCloudFormationEvents(); + // Reset CloudFormation timestamp to avoid showing old events + storageManager.clearCloudFormationTimestamp(); + // Save current timestamp to start tracking from now + storageManager.saveLastCloudFormationTimestamp(new Date()); + this.printer.log( + 'Cleared previous CloudFormation events and reset timestamp', + LogLevel.DEBUG, + ); this.printer.log( 'Cleared previous CloudFormation events', LogLevel.DEBUG, @@ -237,8 +245,16 @@ export class SandboxDevToolsCommand implements CommandModule { this.printer.log('Deletion started', LogLevel.DEBUG); const currentState = await getSandboxState(); - // Clear CloudFormation events when a new deletion starts + // Clear CloudFormation events when a new deployment starts storageManager.clearCloudFormationEvents(); + // Reset CloudFormation timestamp to avoid showing old events + storageManager.clearCloudFormationTimestamp(); + // Save current timestamp to start tracking from now + storageManager.saveLastCloudFormationTimestamp(new Date()); + this.printer.log( + 'Cleared previous CloudFormation events and reset timestamp', + LogLevel.DEBUG, + ); this.printer.log( 'Cleared previous CloudFormation events', LogLevel.DEBUG, diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_resources.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_resources.ts index f9f1a8dd0ad..edcb4559c11 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_resources.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_resources.ts @@ -52,101 +52,6 @@ export class SocketHandlerResources { ); } - /** - * Handles the testLambdaFunction event - */ - public async handleTestLambdaFunction( - socket: Socket, - data: SocketEvents['testLambdaFunction'], - ): Promise { - if (!data?.resourceId || !data?.functionName) { - socket.emit(SOCKET_EVENTS.LAMBDA_TEST_RESULT, { - resourceId: data?.resourceId || 'unknown', - error: 'Invalid function information provided', - }); - return; - } - - try { - const { resourceId, functionName, input } = data; - - // Parse the input as JSON - let payload: Record; - try { - payload = input ? JSON.parse(input) : {}; - } catch (error) { - socket.emit(SOCKET_EVENTS.LAMBDA_TEST_RESULT, { - resourceId, - error: `Invalid JSON input: ${String(error)}`, - }); - return; - } - - // Invoke the Lambda function - const command = new InvokeCommand({ - FunctionName: functionName, - Payload: JSON.stringify(payload), - }); - - const response = await this.lambdaClient.send(command); - - // Parse the response payload - let result = ''; - if (response.Payload) { - const responseText = Buffer.from(response.Payload).toString('utf-8'); - result = responseText; - } - - // Send the result to the client - socket.emit(SOCKET_EVENTS.LAMBDA_TEST_RESULT, { - resourceId, - result, - }); - } catch (error) { - this.printer.log( - `Error testing Lambda function ${data.functionName}: ${String(error)}`, - LogLevel.ERROR, - ); - socket.emit(SOCKET_EVENTS.LAMBDA_TEST_RESULT, { - resourceId: data.resourceId, - error: String(error), - }); - } - } - - /** - * Handles ResourceNotFoundException for log groups - * @param resourceId The resource ID - * @param error The error object - * @param socket Optional socket to emit errors to - */ - public handleResourceNotFoundException( - resourceId: string, - error: unknown, - socket?: Socket, - ): void { - // Check if this is a ResourceNotFoundException for missing log group - if ( - String(error).includes('ResourceNotFoundException') && - String(error).includes('log group does not exist') - ) { - this.printer.log( - `Log group does not exist yet for ${resourceId}`, - LogLevel.INFO, - ); - - if (socket) { - // Then send the error message - socket.emit(SOCKET_EVENTS.LOG_STREAM_ERROR, { - resourceId, - error: `The log group doesn't exist yet. Try turning on logs again after the resource has produced some logs.`, - }); - } - } else { - throw error; // Re-throw other errors for further handling - } - } - /** * Handles the getSavedCloudFormationEvents event */ From a74efcb6ea7db45f1ce69f0646e7c7aa0918d154 Mon Sep 17 00:00:00 2001 From: Megha Narayanan Date: Thu, 31 Jul 2025 15:11:40 -0700 Subject: [PATCH 4/5] isGeneric --- .../react-app/src/components/DeploymentProgress.tsx | 7 +++---- .../react-app/src/services/deployment_client_service.ts | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.tsx b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.tsx index 045504f59e0..aea82a7b749 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.tsx +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.tsx @@ -40,7 +40,6 @@ interface DeploymentEvent { message: string; timestamp: string; resourceStatus?: ResourceStatus; - isGeneric?: boolean; } const DeploymentProgress: React.FC = ({ @@ -484,7 +483,7 @@ const DeploymentProgress: React.FC = ({ ))} {/* Show generic events at the bottom */} - {events.filter((event) => event.isGeneric).length > 0 && ( + {events.filter((event) => !event.resourceStatus).length > 0 && (
= ({ }} > {events - .filter((event) => event.isGeneric) + .filter((event) => !event.resourceStatus) .map((event, index) => (
= ({ > {(status === 'deploying' || status === 'deleting') && index === - events.filter((e) => e.isGeneric).length - 1 ? ( + events.filter((e) => !e.resourceStatus).length - 1 ? (
Date: Fri, 1 Aug 2025 13:17:53 -0700 Subject: [PATCH 5/5] misc merging stuff --- .../services/sandbox_client_service.test.ts | 24 +++++++ .../src/services/sandbox_client_service.ts | 43 +++++++------ .../sandbox_devtools_command.ts | 59 +++++++++--------- .../services/socket_handlers_resources.ts | 62 +++++++++++++++++++ .../sandbox-devtools/shared/socket_events.ts | 2 + 5 files changed, 143 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/sandbox_client_service.test.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/sandbox_client_service.test.ts index ffe8188579b..cda2b3c119e 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/sandbox_client_service.test.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/sandbox_client_service.test.ts @@ -92,6 +92,18 @@ void describe('SandboxClientService', () => { }); }); + void describe('getSavedCloudFormationEvents', () => { + void it('emits GET_SAVED_CLOUD_FORMATION_EVENTS event', () => { + service.getSavedCloudFormationEvents(); + + assert.strictEqual(mockSocket.mockEmit.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockEmit.mock.calls[0].arguments[0], + SOCKET_EVENTS.GET_SAVED_CLOUD_FORMATION_EVENTS, + ); + }); + }); + void describe('saveLogSettings', () => { void it('emits SAVE_LOG_SETTINGS event with correct parameters', () => { const settings = { maxLogSizeMB: 50 }; @@ -216,5 +228,17 @@ void describe('SandboxClientService', () => { SOCKET_EVENTS.SAVED_CONSOLE_LOGS, ); }); + + void it('registers onSavedCloudFormationEvents handler correctly', () => { + const mockHandler = mock.fn(); + + service.onSavedCloudFormationEvents(mockHandler); + + assert.strictEqual(mockSocket.mockOn.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockOn.mock.calls[0].arguments[0], + SOCKET_EVENTS.SAVED_CLOUD_FORMATION_EVENTS, + ); + }); }); }); diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/sandbox_client_service.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/sandbox_client_service.ts index 60fc3a125a1..8447250a240 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/sandbox_client_service.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/sandbox_client_service.ts @@ -7,6 +7,13 @@ import { } from '../../../shared/socket_types'; import { DeploymentEvent } from './deployment_client_service'; +/** + * Interface for log settings data + */ +export interface LogSettings { + maxLogSizeMB: number; + currentSizeMB?: number; +} /** * Service for handling sandbox-related socket communication */ @@ -58,6 +65,13 @@ export class SandboxClientService extends SocketClientService { return this.on(SOCKET_EVENTS.SANDBOX_STATUS, handler); } + /** + * Gets saved CloudFormation events + */ + public getSavedCloudFormationEvents(): void { + this.emit(SOCKET_EVENTS.GET_SAVED_CLOUD_FORMATION_EVENTS); + } + /** * Saves log settings * @param settings The log settings to save @@ -77,13 +91,6 @@ export class SandboxClientService extends SocketClientService { return this.on(SOCKET_EVENTS.LOG_SETTINGS, handler); } - /** - * Gets saved CloudFormation events - */ - public getSavedCloudFormationEvents(): void { - this.emit(SOCKET_EVENTS.GET_SAVED_CLOUD_FORMATION_EVENTS); - } - /** * Gets log settings */ @@ -102,17 +109,6 @@ export class SandboxClientService extends SocketClientService { return this.on('log', handler); } - /** - * Registers a handler for saved CloudFormation events - * @param handler The event handler - * @returns An object with an unsubscribe method - */ - public onSavedCloudFormationEvents( - handler: (events: DeploymentEvent[]) => void, - ): { unsubscribe: () => void } { - return this.on(SOCKET_EVENTS.SAVED_CLOUD_FORMATION_EVENTS, handler); - } - /** * Saves console logs * @param logs The console logs to save @@ -138,4 +134,15 @@ export class SandboxClientService extends SocketClientService { } { return this.on(SOCKET_EVENTS.SAVED_CONSOLE_LOGS, handler); } + + /** + * Registers a handler for saved CloudFormation events + * @param handler The event handler + * @returns An object with an unsubscribe method + */ + public onSavedCloudFormationEvents( + handler: (events: DeploymentEvent[]) => void, + ): { unsubscribe: () => void } { + return this.on(SOCKET_EVENTS.SAVED_CLOUD_FORMATION_EVENTS, handler); + } } diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/sandbox_devtools_command.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/sandbox_devtools_command.ts index d0c872b06e3..9367b8ec2fe 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/sandbox_devtools_command.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/sandbox_devtools_command.ts @@ -181,7 +181,8 @@ export class SandboxDevToolsCommand implements CommandModule { storageManager: LocalStorageManager, ): void { // Listen for deployment started - sandbox.on('deploymentStarted', (data: { timestamp?: string }) => { void (async () => { + sandbox.on('deploymentStarted', (data: { timestamp?: string }) => { + void (async () => { this.printer.log('Deployment started', LogLevel.DEBUG); const currentState = await getSandboxState(); @@ -194,20 +195,20 @@ export class SandboxDevToolsCommand implements CommandModule { ); } - // Clear CloudFormation events when a new deployment starts - storageManager.clearCloudFormationEvents(); - // Reset CloudFormation timestamp to avoid showing old events - storageManager.clearCloudFormationTimestamp(); - // Save current timestamp to start tracking from now - storageManager.saveLastCloudFormationTimestamp(new Date()); - this.printer.log( - 'Cleared previous CloudFormation events and reset timestamp', - LogLevel.DEBUG, - ); - this.printer.log( - 'Cleared previous CloudFormation events', - LogLevel.DEBUG, - ); + // Clear CloudFormation events when a new deployment starts + storageManager.clearCloudFormationEvents(); + // Reset CloudFormation timestamp to avoid showing old events + storageManager.clearCloudFormationTimestamp(); + // Save current timestamp to start tracking from now + storageManager.saveLastCloudFormationTimestamp(new Date()); + this.printer.log( + 'Cleared previous CloudFormation events and reset timestamp', + LogLevel.DEBUG, + ); + this.printer.log( + 'Cleared previous CloudFormation events', + LogLevel.DEBUG, + ); const statusData: SandboxStatusData = { status: currentState, // This should be 'deploying' after deployment starts, identifier: backendId.name, @@ -246,20 +247,20 @@ export class SandboxDevToolsCommand implements CommandModule { const currentState = await getSandboxState(); // Clear CloudFormation events when a new deployment starts - storageManager.clearCloudFormationEvents(); - // Reset CloudFormation timestamp to avoid showing old events - storageManager.clearCloudFormationTimestamp(); - // Save current timestamp to start tracking from now - storageManager.saveLastCloudFormationTimestamp(new Date()); - this.printer.log( - 'Cleared previous CloudFormation events and reset timestamp', - LogLevel.DEBUG, - ); - this.printer.log( - 'Cleared previous CloudFormation events', - LogLevel.DEBUG, - ); - const statusData: SandboxStatusData = { + storageManager.clearCloudFormationEvents(); + // Reset CloudFormation timestamp to avoid showing old events + storageManager.clearCloudFormationTimestamp(); + // Save current timestamp to start tracking from now + storageManager.saveLastCloudFormationTimestamp(new Date()); + this.printer.log( + 'Cleared previous CloudFormation events and reset timestamp', + LogLevel.DEBUG, + ); + this.printer.log( + 'Cleared previous CloudFormation events', + LogLevel.DEBUG, + ); + const statusData: SandboxStatusData = { status: currentState, // This should be 'deleting' after deletion starts identifier: backendId.name, message: 'Deletion started', diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_resources.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_resources.ts index edcb4559c11..e726bf0219b 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_resources.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_resources.ts @@ -52,6 +52,68 @@ export class SocketHandlerResources { ); } + /** + * Handles the testLambdaFunction event + */ + public async handleTestLambdaFunction( + socket: Socket, + data: SocketEvents['testLambdaFunction'], + ): Promise { + if (!data?.resourceId || !data?.functionName) { + socket.emit(SOCKET_EVENTS.LAMBDA_TEST_RESULT, { + resourceId: data?.resourceId || 'unknown', + error: 'Invalid function information provided', + }); + return; + } + + try { + const { resourceId, functionName, input } = data; + + // Parse the input as JSON + let payload: Record; + try { + payload = input ? JSON.parse(input) : {}; + } catch (error) { + socket.emit(SOCKET_EVENTS.LAMBDA_TEST_RESULT, { + resourceId, + error: `Invalid JSON input: ${String(error)}`, + }); + return; + } + + // Invoke the Lambda function + const command = new InvokeCommand({ + FunctionName: functionName, + Payload: JSON.stringify(payload), + }); + + const response = await this.lambdaClient.send(command); + + // Parse the response payload + let result = ''; + if (response.Payload) { + const responseText = Buffer.from(response.Payload).toString('utf-8'); + result = responseText; + } + + // Send the result to the client + socket.emit(SOCKET_EVENTS.LAMBDA_TEST_RESULT, { + resourceId, + result, + }); + } catch (error) { + this.printer.log( + `Error testing Lambda function ${data.functionName}: ${String(error)}`, + LogLevel.ERROR, + ); + socket.emit(SOCKET_EVENTS.LAMBDA_TEST_RESULT, { + resourceId: data.resourceId, + error: String(error), + }); + } + } + /** * Handles the getSavedCloudFormationEvents event */ diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/shared/socket_events.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/shared/socket_events.ts index 99c3ad710cb..fe5c34936f1 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/shared/socket_events.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/shared/socket_events.ts @@ -93,6 +93,7 @@ export const SOCKET_EVENTS = { * Event received when log settings are sent from the server */ LOG_SETTINGS: 'logSettings', + /** * Event to test a Lambda function */ @@ -152,6 +153,7 @@ export const SOCKET_EVENTS = { * Event received when saved console logs are sent from the server */ SAVED_CONSOLE_LOGS: 'savedConsoleLogs', + /** * Event which triggers UI to show a deployment error * Contains error details like name, message, resolution, and timestamp.