diff --git a/.changeset/proud-ravens-post.md b/.changeset/proud-ravens-post.md new file mode 100644 index 0000000000..a845151cc8 --- /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 afb078589b..9ef5795391 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 483895556c..7f8191ffb7 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.test.tsx b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.test.tsx new file mode 100644 index 0000000000..a1e511df06 --- /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 new file mode 100644 index 0000000000..aea82a7b74 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/DeploymentProgress.tsx @@ -0,0 +1,553 @@ +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; +} + +const DeploymentProgress: React.FC = ({ + deploymentClientService, + visible, + status, +}) => { + const [events, setEvents] = useState([]); + const [resourceStatuses, setResourceStatuses] = useState< + Record + >({}); + 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 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); + + 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 + 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; + } + + // Sort events by timestamp (newest first) + const sortedEvents = cfnEvents.sort( + (a: DeploymentEvent, b: DeploymentEvent) => + 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]); + + // Separate useEffect for requesting events and polling + useEffect(() => { + if (status === 'deploying' || status === 'deleting') { + 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(); + } + // 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.resourceStatus).length > 0 && ( +
+ {events + .filter((event) => !event.resourceStatus) + .map((event, index) => ( +
+ {(status === 'deploying' || status === 'deleting') && + index === + events.filter((e) => !e.resourceStatus).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 464d3edc7d..d010225ecf 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 0000000000..e48e066088 --- /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 0000000000..7f37c1ec39 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/deployment_client_service.ts @@ -0,0 +1,59 @@ +import { SOCKET_EVENTS } from '../../../shared/socket_events'; +import { SocketClientService } from './socket_client_service'; + +export interface DeploymentEvent { + message: string; + timestamp: string; + resourceStatus?: ResourceStatus; +} + +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.test.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/sandbox_client_service.test.ts index ffe8188579..cda2b3c119 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 9748e10e6d..8447250a24 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,6 +5,7 @@ import { ConsoleLogEntry, DevToolsSandboxOptions, } from '../../../shared/socket_types'; +import { DeploymentEvent } from './deployment_client_service'; /** * Interface for log settings data @@ -64,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 @@ -126,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 276ac9ced6..9367b8ec2f 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 @@ -195,6 +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, + ); const statusData: SandboxStatusData = { status: currentState, // This should be 'deploying' after deployment starts, identifier: backendId.name, @@ -232,6 +246,20 @@ export class SandboxDevToolsCommand implements CommandModule { this.printer.log('Deletion started', LogLevel.DEBUG); 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 = { status: currentState, // This should be 'deleting' after deletion starts identifier: backendId.name, 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 61f42d67d4..e726bf0219 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,154 @@ export class SocketHandlerResources { } /** - * Handles the getSavedResources event + * Handles the getSavedCloudFormationEvents event */ - public handleGetSavedResources(socket: Socket): void { - const resources = this.storageManager.loadResources(); - socket.emit(SOCKET_EVENTS.SAVED_RESOURCES, resources || []); + 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 a24aa07ec4..fe5c34936f 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 @@ -104,6 +104,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 */