diff --git a/.eslint_dictionary.json b/.eslint_dictionary.json index 7e524d2532b..3d810aa76ca 100644 --- a/.eslint_dictionary.json +++ b/.eslint_dictionary.json @@ -11,6 +11,7 @@ "anonymize", "ansi", "anthropic", + "apis", "apns", "apollo", "appleid", @@ -42,6 +43,7 @@ "cron", "ctor", "darwin", + "datasources", "datastore", "datasync", "debounce", @@ -147,6 +149,8 @@ "pathname", "pipelined", "pnpm", + "poller", + "pollers", "positionals", "posix", "postgres", diff --git a/package-lock.json b/package-lock.json index 496742330e7..2d8920bcdf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51861,6 +51861,7 @@ "@aws-amplify/schema-generator": "^1.4.0", "@aws-sdk/client-amplify": "^3.750.0", "@aws-sdk/client-cloudformation": "^3.750.0", + "@aws-sdk/client-cloudwatch-logs": "^3.750.0", "@aws-sdk/client-lambda": "^3.750.0", "@aws-sdk/client-s3": "^3.750.0", "@aws-sdk/client-sts": "^3.750.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 88f28dbcf6b..1358c0fe609 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -52,6 +52,7 @@ "@aws-sdk/client-amplify": "^3.750.0", "@aws-sdk/client-cloudformation": "^3.750.0", "@aws-sdk/client-lambda": "^3.750.0", + "@aws-sdk/client-cloudwatch-logs": "^3.750.0", "@aws-sdk/client-s3": "^3.750.0", "@aws-sdk/client-sts": "^3.750.0", "@aws-sdk/credential-provider-ini": "^3.750.0", diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/logging/log_group_extractor.test.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/logging/log_group_extractor.test.ts new file mode 100644 index 00000000000..c5a29871f6c --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/logging/log_group_extractor.test.ts @@ -0,0 +1,63 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { getLogGroupName } from './log_group_extractor.js'; + +void describe('getLogGroupName function', () => { + void it('returns correct log group name for Lambda functions', () => { + const resourceType = 'AWS::Lambda::Function'; + const resourceId = 'my-lambda-function'; + assert.strictEqual( + getLogGroupName(resourceType, resourceId), + '/aws/lambda/my-lambda-function', + ); + }); + + void it('returns correct log group name for API Gateway', () => { + const resourceType = 'AWS::ApiGateway::RestApi'; + const resourceId = 'abc123def'; + assert.strictEqual( + getLogGroupName(resourceType, resourceId), + // eslint-disable-next-line spellcheck/spell-checker + 'API-Gateway-Execution-Logs_abc123def', + ); + }); + + void it('returns correct log group name for AppSync APIs', () => { + const resourceType = 'AWS::AppSync::GraphQLApi'; + // eslint-disable-next-line spellcheck/spell-checker + const resourceId = 'xyz789'; + assert.strictEqual( + getLogGroupName(resourceType, resourceId), + // eslint-disable-next-line spellcheck/spell-checker + '/aws/appsync/apis/xyz789', + ); + }); + + void it('returns null for unsupported resource types', () => { + const resourceType = 'AWS::S3::Bucket'; + const resourceId = 'my-bucket'; + + const result = getLogGroupName(resourceType, resourceId); + + assert.strictEqual(result, null); + }); + + void it('handles resource IDs with special characters', () => { + const resourceType = 'AWS::Lambda::Function'; + const resourceId = 'my-function-with-special/chars'; + assert.strictEqual( + getLogGroupName(resourceType, resourceId), + '/aws/lambda/my-function-with-special/chars', + ); + }); + + void it('handles resource IDs with ARN format', () => { + const resourceType = 'AWS::Lambda::Function'; + const resourceId = + 'arn:aws:lambda:us-west-2:123456789012:function:my-function'; + assert.strictEqual( + getLogGroupName(resourceType, resourceId), + '/aws/lambda/arn:aws:lambda:us-west-2:123456789012:function:my-function', + ); + }); +}); diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/logging/log_group_extractor.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/logging/log_group_extractor.ts new file mode 100644 index 00000000000..9c318868297 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/logging/log_group_extractor.ts @@ -0,0 +1,22 @@ +/** + * Determines the CloudWatch Logs log group name for a given resource type and ID + * @param resourceType The AWS resource type + * @param resourceId The resource ID + * @returns The log group name or null if the resource type is not supported + */ +export const getLogGroupName = ( + resourceType: string, + resourceId: string, +): string | null => { + switch (resourceType) { + case 'AWS::Lambda::Function': + return `/aws/lambda/${resourceId}`; + case 'AWS::ApiGateway::RestApi': + return `API-Gateway-Execution-Logs_${resourceId}`; + case 'AWS::AppSync::GraphQLApi': + // eslint-disable-next-line spellcheck/spell-checker + return `/aws/appsync/apis/${resourceId}`; + default: + return null; + } +}; diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/ResourceConsole.tsx b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/ResourceConsole.tsx index 34461f5383c..45d770896f2 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/ResourceConsole.tsx +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/ResourceConsole.tsx @@ -1,7 +1,11 @@ import React, { useState, useMemo, useEffect } from 'react'; import { useResourceManager } from '../hooks/useResourceManager'; import { ResourceWithFriendlyName } from '../../../resource_console_functions'; -import { useResourceClientService } from '../contexts/socket_client_context'; +import { + useResourceClientService, + useLoggingClientService, +} from '../contexts/socket_client_context'; +import ResourceLogPanel from './ResourceLogPanel'; import '@cloudscape-design/global-styles/index.css'; import { Button, @@ -14,11 +18,13 @@ import { SpaceBetween, ExpandableSection, StatusIndicator, + Link, Input, FormField, Grid, Multiselect, SelectProps, + Badge, Modal, } from '@cloudscape-design/components'; import { SandboxStatus } from '@aws-amplify/sandbox'; @@ -42,10 +48,13 @@ const ResourceConsole: React.FC = ({ const [lastRefreshTime, setLastRefreshTime] = useState(0); const deploymentInProgress = sandboxStatus === 'deploying'; const [initializing, setInitializing] = useState(true); + const [selectedLogResource, setSelectedLogResource] = + useState(null); + const [showLogViewer, setShowLogViewer] = useState(false); const [editingResource, setEditingResource] = useState(null); const [editingFriendlyName, setEditingFriendlyName] = useState(''); - const REFRESH_COOLDOWN_MS = 5000; // 5 seconds minimum between refreshes + const REFRESH_COOLDOWN_MS = 2000; // 2 seconds minimum between refreshes // Use the resource manager hook const { @@ -56,19 +65,32 @@ const ResourceConsole: React.FC = ({ updateCustomFriendlyName, removeCustomFriendlyName, getResourceDisplayName, + toggleResourceLogging, + isLoggingActiveForResource, refreshResources: originalRefreshResources, } = useResourceManager(undefined, sandboxStatus); - // Get the resource client service + // Get the client services const resourceClientService = useResourceClientService(); + const loggingClientService = useLoggingClientService(); + + // Helper function to check if a resource supports logs + const supportsLogs = (resource: ResourceWithFriendlyName): boolean => { + return !!resource.logGroupName; + }; + + const handleEditFriendlyName = (resource: ResourceWithFriendlyName) => { + setEditingResource(resource); + setEditingFriendlyName(getResourceDisplayName(resource)); + }; - // Define column definitions for all tables const columnDefinitions = React.useMemo( () => [ { id: 'name', header: 'Resource Name', cell: (item: ResourceWithFriendlyName) => { + const isLogging = isLoggingActiveForResource(item.physicalResourceId); return (
@@ -84,6 +106,7 @@ const ResourceConsole: React.FC = ({ ariaLabel="Edit friendly name" />
+ {isLogging && Logging}
); }, @@ -124,6 +147,7 @@ const ResourceConsole: React.FC = ({ header: 'Actions', cell: (item: ResourceWithFriendlyName) => { const url = item.consoleUrl; + const isLogging = isLoggingActiveForResource(item.physicalResourceId); return ( @@ -133,16 +157,46 @@ const ResourceConsole: React.FC = ({ View in AWS Console (disabled during deployment) ) : ( + + View in AWS Console + + ))} + {supportsLogs(item) && ( + + {/* Toggle button for starting/stopping log recording */} - ))} + + {} + + + )} ); }, @@ -150,7 +204,16 @@ const ResourceConsole: React.FC = ({ minWidth: 250, }, ], - [deploymentInProgress], + [ + isLoggingActiveForResource, + getResourceDisplayName, + deploymentInProgress, + handleEditFriendlyName, + showLogViewer, + selectedLogResource, + toggleResourceLogging, + loggingClientService, + ], ); // Empty state for tables @@ -244,12 +307,6 @@ const ResourceConsole: React.FC = ({ return resourceType; }; - // Handle editing a resource's friendly name - const handleEditFriendlyName = (resource: ResourceWithFriendlyName) => { - setEditingResource(resource); - setEditingFriendlyName(getResourceDisplayName(resource)); - }; - const refreshFriendlyNames = () => { resourceClientService.getCustomFriendlyNames(); }; @@ -423,16 +480,54 @@ const ResourceConsole: React.FC = ({ - {/* Show resources if available, even during deployment */} - {resources && resources.length > 0 && ( - - )} + {/* Split view layout - show resources on left and logs on right when a log is being viewed */} + + {/* Left side - Resources */} +
+ {/* Show resources if available, even during deployment */} + {resources && resources.length > 0 && ( + + )} +
+ + {/* Right side - Log Viewer */} + {showLogViewer && selectedLogResource && ( + { + setShowLogViewer(false); + }} + deploymentInProgress={deploymentInProgress} + consoleUrl={selectedLogResource.consoleUrl} + isLoggingActive={isLoggingActiveForResource( + selectedLogResource.physicalResourceId, + )} + toggleResourceLogging={(_, __, startLogging) => + toggleResourceLogging(selectedLogResource, startLogging) + } + /> + )} +
); @@ -538,10 +633,19 @@ const ResourceConsole: React.FC = ({ )} - {/* Full width layout for resources */} - + {/* Split view layout - show resources on left and logs on right when a log is being viewed */} + {/* Left side - Resources */} -
+
= ({ )}
- {/* Log viewer will be added in PR 3 */} + {/* Right side - Log Viewer */} + {showLogViewer && selectedLogResource && ( + { + setShowLogViewer(false); + }} + deploymentInProgress={deploymentInProgress} + consoleUrl={selectedLogResource.consoleUrl} + isLoggingActive={isLoggingActiveForResource( + selectedLogResource.physicalResourceId, + )} + toggleResourceLogging={(_, __, startLogging) => + toggleResourceLogging(selectedLogResource, startLogging) + } + /> + )} diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/ResourceLogPanel.tsx b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/ResourceLogPanel.tsx new file mode 100644 index 00000000000..7809cba0577 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/components/ResourceLogPanel.tsx @@ -0,0 +1,482 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + LoggingClientService, +} from '../services/logging_client_service'; +import { + Button, + SpaceBetween, + StatusIndicator, + TextContent, + Input, + FormField, + Header, + Container, + Link, + Alert, +} from '@cloudscape-design/components'; +import { LambdaTestResult, LogEntry, LogStreamStatus } from '../../../shared/socket_types'; + +interface ResourceLogPanelProps { + loggingClientService: LoggingClientService; + resourceId: string; + resourceName: string; + resourceType: string; + onClose: () => void; + deploymentInProgress?: boolean; + consoleUrl?: string | null; + isLoggingActive: boolean; + toggleResourceLogging: ( + resourceId: string, + resourceType: string, + startLogging: boolean, + ) => void; +} + +// Module-level cache for logs to persist between component instances +const logCache: Record = {}; + +// Module-level cache for errors to persist between component instances +const errorCache: Record = {}; + +// Module-level cache for Lambda test state +const testStateStore: Record< + string, + { + isLoading: boolean; + testInput: string; + testOutput: string; + } +> = {}; + +const ResourceLogPanel: React.FC = ({ + loggingClientService, + resourceId, + resourceName, + resourceType, + onClose, + deploymentInProgress, + consoleUrl, + isLoggingActive, + toggleResourceLogging, +}) => { + const [logs, setLogs] = useState([]); + const isRecording = isLoggingActive; + const [error, setError] = useState( + errorCache[resourceId] || null, + ); + const [searchQuery, setSearchQuery] = useState(''); + const logContainerRef = useRef(null); + + // Force a re-render when test state changes + const [updateTrigger, setUpdateTrigger] = useState({}); + const forceUpdate = () => setUpdateTrigger({}); + + // Initialize test state for this resource if needed + if (!testStateStore[resourceId]) { + testStateStore[resourceId] = { + isLoading: false, + testInput: '{}', + testOutput: '', + }; + } + + // Current resource's test state + const testing = testStateStore[resourceId].isLoading; + const testInput = testStateStore[resourceId].testInput; + const testOutput = testStateStore[resourceId].testOutput; + + // State manipulation functions + const setLoading = (targetResourceId: string, isLoading: boolean) => { + if (testStateStore[targetResourceId]) { + testStateStore[targetResourceId].isLoading = isLoading; + } else { + testStateStore[targetResourceId] = { + isLoading, + testInput: '{}', + testOutput: '', + }; + } + forceUpdate(); + }; + + const setTestInput = (targetResourceId: string, input: string) => { + if (testStateStore[targetResourceId]) { + testStateStore[targetResourceId].testInput = input; + } else { + testStateStore[targetResourceId] = { + isLoading: false, + testInput: input, + testOutput: '', + }; + } + forceUpdate(); + }; + + const setTestOutput = (targetResourceId: string, output: string) => { + if (testStateStore[targetResourceId]) { + testStateStore[targetResourceId].testOutput = output; + } else { + testStateStore[targetResourceId] = { + isLoading: false, + testInput: '{}', + testOutput: output, + }; + } + forceUpdate(); + }; + + const isLambdaFunction = resourceType === 'AWS::Lambda::Function'; + const isLogGroupNotFoundError = error?.includes("log group doesn't exist"); + + // Auto-dismiss cached error after 10 seconds on initial load + useEffect(() => { + if (error && errorCache[resourceId]) { + const timer = setTimeout(() => { + errorCache[resourceId] = null; + setError(null); + }, 10000); + + return () => clearTimeout(timer); + } + }, [resourceId, error]); + + const formatLambdaOutput = (output: string): string => { + try { + const parsed = JSON.parse(output); + + if (parsed.errorType) { + return `Lambda Error: ${parsed.errorType} +Message: ${parsed.errorMessage} +Stack Trace: +${ + parsed.trace + ?.map((line: string, index: number) => ` ${index + 1}. ${line}`) + .join('\n') || 'No stack trace available' +}`; + } + + if (parsed.statusCode) { + return `Lambda Response (Status code: ${parsed.statusCode}): +${JSON.stringify(parsed.body, null, 2)}`; + } + + return JSON.stringify(parsed, null, 2); + } catch { + return output; + } + }; + + useEffect(() => { + // Use cached logs if available, otherwise use empty array + setLogs(logCache[resourceId] || []); + + // Request saved logs when panel opens + loggingClientService.viewResourceLogs(resourceId); + + // Set up a periodic refresh for logs when viewing + const refreshInterval = setInterval(() => { + loggingClientService.getSavedResourceLogs(resourceId); + }, 2000); // Refresh every 2 seconds to match server-side polling + + const handleResourceLogs = (data: { + resourceId: string; + logs: LogEntry[]; + }) => { + // Always update the cache for the resource in the data + const currentCachedLogs = logCache[data.resourceId] || []; + logCache[data.resourceId] = [...currentCachedLogs, ...data.logs]; + + // Only update the displayed logs if this is the active resource + if (data.resourceId === resourceId) { + setLogs(logCache[data.resourceId]); + } + }; + + const handleSavedResourceLogs = (data: { + resourceId: string; + logs: LogEntry[]; + }) => { + // Always update the cache for any resource + logCache[data.resourceId] = data.logs; + + // Only update the displayed logs if this is the active resource + if (data.resourceId === resourceId) { + setLogs(data.logs); + } + }; + + // Listen for errors - always cache errors regardless of active resource + const handleLogStreamError = (data: LogStreamStatus) => { + // Store error in cache for the specific resource + if (data.error) { + errorCache[data.resourceId] = data.error; + + // Only update UI if this is the active resource + if (data.resourceId === resourceId) { + setError(data.error); + + // Auto-dismiss error after 10 seconds + setTimeout(() => { + errorCache[data.resourceId] = null; + if (data.resourceId === resourceId) { + setError(null); + } + }, 10000); + } + } + }; + + // Listen for Lambda test results + const handleLambdaTestResult = (data: LambdaTestResult) => { + setLoading(data.resourceId, false); + + // Always save the output for this resource, regardless of which resource is currently displayed + if (data.error) { + setTestOutput(data.resourceId, `Error: ${data.error}`); + } else { + setTestOutput(data.resourceId, data.result || 'No output'); + } + }; + + const unsubscribeResourceLogs = + loggingClientService.onResourceLogs(handleResourceLogs); + const unsubscribeSavedResourceLogs = + loggingClientService.onSavedResourceLogs(handleSavedResourceLogs); + const unsubscribeLogStreamError = + loggingClientService.onLogStreamError(handleLogStreamError); + const unsubscribeLambdaTestResult = loggingClientService.onLambdaTestResult( + handleLambdaTestResult, + ); + + return () => { + // Clean up event listeners + unsubscribeResourceLogs.unsubscribe(); + unsubscribeSavedResourceLogs.unsubscribe(); + unsubscribeLogStreamError.unsubscribe(); + unsubscribeLambdaTestResult.unsubscribe(); + + // Clear the refresh interval + clearInterval(refreshInterval); + }; + }, [loggingClientService, resourceId, updateTrigger]); + + // Auto-scroll to bottom when new logs arrive + useEffect(() => { + if (logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [logs]); + + // Preserve logs when stopping recording + useEffect(() => { + if (!isRecording && logs.length > 0) { + loggingClientService.getSavedResourceLogs(resourceId); + } + }, [isRecording, resourceId, logs.length, loggingClientService]); + + const formatTimestamp = (timestamp: string) => { + try { + const date = new Date(timestamp); + return date.toLocaleTimeString(); + } catch { + return timestamp; + } + }; + + // Filter logs based on search query only + const filteredLogs = logs.filter((log) => { + // Filter by search query + return ( + searchQuery === '' || + log.message.toLowerCase().includes(searchQuery.toLowerCase()) || + formatTimestamp(log.timestamp) + .toLowerCase() + .includes(searchQuery.toLowerCase()) + ); + }); + + const toggleRecording = () => { + // Clear errors when toggling recording state + errorCache[resourceId] = null; + setError(null); + + toggleResourceLogging(resourceId, resourceType, !isRecording); + }; + + const handleTestFunction = () => { + if (!isLambdaFunction) return; + + // Set loading state for this specific resource only + setLoading(resourceId, true); + setTestOutput(resourceId, ''); + + // Clear any previous errors when testing + errorCache[resourceId] = null; + setError(null); + + loggingClientService.testLambdaFunction(resourceId, resourceId, testInput); + }; + + return ( + + {consoleUrl && + (deploymentInProgress ? ( + View in AWS Console + ) : ( + + View in AWS Console + + ))} + + + + } + > + {resourceName} Logs + + } + disableContentPaddings={false} + fitHeight + > + + {deploymentInProgress ? ( + + Deployment in progress - logging operations disabled + + ) : isRecording ? ( + Recording logs + ) : ( + Not recording logs + )} + + {isLogGroupNotFoundError ? ( + { + // Clear error from cache when dismissed + errorCache[resourceId] = null; + setError(null); + }} + > + This resource hasn't produced any logs yet. Try using the resource + first, then turn on logging again. + + ) : error ? ( + Error: {error} + ) : null} + + + setSearchQuery(detail.value)} + placeholder="Search in logs..." + /> + + + {isLambdaFunction && ( + + + + setTestInput(resourceId, detail.value) + } + placeholder='{"key": "value"}' + disabled={testing || deploymentInProgress} + /> + + + {testOutput && ( +
+ Test Output: +
+ {formatLambdaOutput(testOutput)} +
+ )} +
+ )} + +
+ {filteredLogs.length === 0 ? ( + +

+ {searchQuery ? 'No matching logs found' : 'No logs available'} +

+
+ ) : ( + filteredLogs.map((log, index) => ( +
+ + [{formatTimestamp(log.timestamp)}] + {' '} + {log.message} +
+ )) + )} +
+ + +

+ {filteredLogs.length} log entries{' '} + {searchQuery && `(filtered from ${logs.length})`} +

+
+
+
+ ); +}; + +export default ResourceLogPanel; 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..91f2bd0db56 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 { LoggingClientService } from '../services/logging_client_service'; /** * Interface for socket client services @@ -10,6 +11,7 @@ interface SocketClientServices { socketClientService: SocketClientService; sandboxClientService: SandboxClientService; resourceClientService: ResourceClientService; + loggingClientService: LoggingClientService; } /** @@ -34,11 +36,13 @@ export const SocketClientProvider: React.FC = ({ const socketClientService = new SocketClientService(); const sandboxClientService = new SandboxClientService(); const resourceClientService = new ResourceClientService(); + const loggingClientService = new LoggingClientService(); const services: SocketClientServices = { socketClientService, sandboxClientService, resourceClientService, + loggingClientService, }; return ( @@ -89,3 +93,17 @@ export const useResourceClientService = (): ResourceClientService => { } return context.resourceClientService; }; + +/** + * Hook to access the logging client service + * @returns The logging client service + */ +export const useLoggingClientService = (): LoggingClientService => { + const context = useContext(SocketClientContext); + if (!context) { + throw new Error( + 'useLoggingClientService must be used within a SocketClientProvider', + ); + } + return context.loggingClientService; +}; diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/hooks/useResourceManager.test.tsx b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/hooks/useResourceManager.test.tsx new file mode 100644 index 00000000000..3ead2aff051 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/hooks/useResourceManager.test.tsx @@ -0,0 +1,517 @@ +import { describe, it, beforeEach, expect, vi, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useResourceManager } from './useResourceManager'; +import { BackendResourcesData } from '../../../shared/socket_types'; +import { ResourceClientService } from '../services/resource_client_service'; +import { LoggingClientService } from '../services/logging_client_service'; + +// Mock the context hooks +vi.mock('../contexts/socket_client_context', () => ({ + useResourceClientService: vi.fn(), + useLoggingClientService: vi.fn(), +})); + +// Import the mocked modules +import { + useResourceClientService, + useLoggingClientService, +} from '../contexts/socket_client_context'; +import { SandboxStatus } from '@aws-amplify/sandbox'; + +describe('useResourceManager hook', () => { + beforeEach(() => { + // Setup fake timers for all tests in this suite + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // Create mock client services with proper types + const mockResourceClientService = { + getCustomFriendlyNames: vi.fn(), + getDeployedBackendResources: vi.fn(), + onSavedResources: vi.fn(), + onDeployedBackendResources: vi.fn(), + onCustomFriendlyNames: vi.fn(), + onCustomFriendlyNameUpdated: vi.fn(), + onCustomFriendlyNameRemoved: vi.fn(), + onError: vi.fn(), + updateCustomFriendlyName: vi.fn(), + removeCustomFriendlyName: vi.fn(), + }; + + const mockLoggingClientService = { + getActiveLogStreams: vi.fn(), + onActiveLogStreams: vi.fn(), + onLogStreamStatus: vi.fn(), + onLogStreamError: vi.fn(), + toggleResourceLogging: vi.fn(), + }; + + // Set up the return values for subscription methods + beforeEach(() => { + mockResourceClientService.onSavedResources.mockReturnValue({ + unsubscribe: vi.fn(), + }); + mockResourceClientService.onDeployedBackendResources.mockReturnValue({ + unsubscribe: vi.fn(), + }); + mockResourceClientService.onCustomFriendlyNames.mockReturnValue({ + unsubscribe: vi.fn(), + }); + mockResourceClientService.onCustomFriendlyNameUpdated.mockReturnValue({ + unsubscribe: vi.fn(), + }); + mockResourceClientService.onCustomFriendlyNameRemoved.mockReturnValue({ + unsubscribe: vi.fn(), + }); + mockResourceClientService.onError.mockReturnValue({ unsubscribe: vi.fn() }); + mockLoggingClientService.onActiveLogStreams.mockReturnValue({ + unsubscribe: vi.fn(), + }); + mockLoggingClientService.onLogStreamStatus.mockReturnValue({ + unsubscribe: vi.fn(), + }); + mockLoggingClientService.onLogStreamError.mockReturnValue({ + unsubscribe: vi.fn(), + }); + }); + + beforeEach(() => { + vi.clearAllMocks(); + + // Set up the mock implementations + vi.mocked(useResourceClientService).mockReturnValue( + mockResourceClientService as unknown as ResourceClientService, + ); + vi.mocked(useLoggingClientService).mockReturnValue( + mockLoggingClientService as unknown as LoggingClientService, + ); + }); + + it('initializes with default values', () => { + const { result } = renderHook(() => useResourceManager()); + + expect(result.current.resources).toEqual([]); + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + expect(result.current.region).toBeNull(); + expect(result.current.customFriendlyNames).toEqual({}); + expect(result.current.activeLogStreams).toEqual([]); + }); + + it('calls getDeployedBackendResources and getCustomFriendlyNames on mount', () => { + renderHook(() => useResourceManager()); + + // Allow for the small delay in the hook's setTimeout + vi.advanceTimersByTime(500); + + expect( + mockResourceClientService.getDeployedBackendResources, + ).toHaveBeenCalled(); + expect(mockResourceClientService.getCustomFriendlyNames).toHaveBeenCalled(); + }); + + it('calls getActiveLogStreams on mount', () => { + renderHook(() => useResourceManager()); + + expect(mockLoggingClientService.getActiveLogStreams).toHaveBeenCalled(); + }); + + it('registers event handlers for resources and custom friendly names', () => { + renderHook(() => useResourceManager()); + + expect(mockResourceClientService.onSavedResources).toHaveBeenCalled(); + expect( + mockResourceClientService.onDeployedBackendResources, + ).toHaveBeenCalled(); + expect(mockResourceClientService.onCustomFriendlyNames).toHaveBeenCalled(); + expect( + mockResourceClientService.onCustomFriendlyNameUpdated, + ).toHaveBeenCalled(); + expect( + mockResourceClientService.onCustomFriendlyNameRemoved, + ).toHaveBeenCalled(); + expect(mockResourceClientService.onError).toHaveBeenCalled(); + }); + + it('registers event handlers for logging', () => { + renderHook(() => useResourceManager()); + + expect(mockLoggingClientService.onActiveLogStreams).toHaveBeenCalled(); + expect(mockLoggingClientService.onLogStreamStatus).toHaveBeenCalled(); + expect(mockLoggingClientService.onLogStreamError).toHaveBeenCalled(); + }); + + it('updates resources when onDeployedBackendResources handler is called', () => { + // Capture the handlers + let savedResourcesHandler: Function; + + // Store the handler function when it's called + mockResourceClientService.onDeployedBackendResources.mockImplementation( + function (handler) { + savedResourcesHandler = handler; + return { unsubscribe: vi.fn() }; + }, + ); + + const { result } = renderHook(() => useResourceManager()); + + // Simulate receiving backend resources + const mockData: BackendResourcesData = { + resources: [ + { + logicalResourceId: 'TestFunction', + physicalResourceId: 'lambda1', + resourceType: 'AWS::Lambda::Function', + resourceStatus: 'CREATE_COMPLETE', + logGroupName: '/aws/lambda/test-function', + consoleUrl: + 'https://console.aws.amazon.com/lambda/home#/functions/lambda1', + }, + ], + region: 'us-east-1', + name: 'test-backend', + status: 'running' as SandboxStatus, + }; + + act(() => { + // Call the handler with the mock data + savedResourcesHandler(mockData); + }); + + // Verify resources were updated + expect(result.current.resources).toEqual(mockData.resources); + expect(result.current.region).toEqual(mockData.region); + expect(result.current.backendName).toEqual(mockData.name); + }); + + it('updates custom friendly names when onCustomFriendlyNames handler is called', () => { + // Capture the handler + let customFriendlyNamesHandler: Function; + + mockResourceClientService.onCustomFriendlyNames.mockImplementation( + function (handler) { + customFriendlyNamesHandler = handler; + return { unsubscribe: vi.fn() }; + }, + ); + + const { result } = renderHook(() => useResourceManager()); + + // Simulate receiving custom friendly names + const mockFriendlyNames = { + lambda1: 'My Lambda Function', + dynamo1: 'My DynamoDB Table', + }; + + act(() => { + customFriendlyNamesHandler(mockFriendlyNames); + }); + + // Verify friendly names were updated + expect(result.current.customFriendlyNames).toEqual(mockFriendlyNames); + }); + + it('updates a custom friendly name when onCustomFriendlyNameUpdated handler is called', () => { + // Capture the handler + let customFriendlyNameUpdatedHandler: Function; + + mockResourceClientService.onCustomFriendlyNameUpdated.mockImplementation( + function (handler) { + customFriendlyNameUpdatedHandler = handler; + return { unsubscribe: vi.fn() }; + }, + ); + + const { result } = renderHook(() => useResourceManager()); + + // Simulate receiving an updated friendly name + act(() => { + customFriendlyNameUpdatedHandler({ + resourceId: 'lambda1', + friendlyName: 'My Lambda Function', + }); + }); + + // Verify friendly name was updated + expect(result.current.customFriendlyNames).toEqual({ + lambda1: 'My Lambda Function', + }); + }); + + it('removes a custom friendly name when onCustomFriendlyNameRemoved handler is called', () => { + // Capture the handlers + let customFriendlyNamesHandler: Function; + let customFriendlyNameRemovedHandler: Function; + + mockResourceClientService.onCustomFriendlyNames.mockImplementation( + function (handler) { + customFriendlyNamesHandler = handler; + return { unsubscribe: vi.fn() }; + }, + ); + + mockResourceClientService.onCustomFriendlyNameRemoved.mockImplementation( + function (handler) { + customFriendlyNameRemovedHandler = handler; + return { unsubscribe: vi.fn() }; + }, + ); + + const { result } = renderHook(() => useResourceManager()); + + // First set some friendly names + act(() => { + customFriendlyNamesHandler({ + lambda1: 'My Lambda Function', + dynamo1: 'My DynamoDB Table', + }); + }); + + // Then simulate removing one + act(() => { + customFriendlyNameRemovedHandler({ + resourceId: 'lambda1', + }); + }); + + // Verify the friendly name was removed + expect(result.current.customFriendlyNames).toEqual({ + dynamo1: 'My DynamoDB Table', + }); + }); + + it('updates active log streams when onActiveLogStreams handler is called', () => { + // Capture the handler + let activeLogStreamsHandler: Function; + + mockLoggingClientService.onActiveLogStreams.mockImplementation( + function (handler) { + activeLogStreamsHandler = handler; + return { unsubscribe: vi.fn() }; + }, + ); + + const { result } = renderHook(() => useResourceManager()); + + // Simulate receiving active log streams + act(() => { + activeLogStreamsHandler(['lambda1', 'lambda2']); + }); + + // Verify active log streams were updated + expect(result.current.activeLogStreams).toEqual(['lambda1', 'lambda2']); + }); + + it('updates active log streams when onLogStreamStatus handler is called', () => { + // Capture the handler + let logStreamStatusHandler: Function; + + mockLoggingClientService.onLogStreamStatus.mockImplementation( + function (handler) { + logStreamStatusHandler = handler; + return { unsubscribe: vi.fn() }; + }, + ); + + const { result } = renderHook(() => useResourceManager()); + + // Simulate activating a log stream + act(() => { + logStreamStatusHandler({ + resourceId: 'lambda1', + status: 'active', + }); + }); + + // Verify the resource was added to active streams + expect(result.current.activeLogStreams).toContain('lambda1'); + + // Simulate stopping a log stream + act(() => { + logStreamStatusHandler({ + resourceId: 'lambda1', + status: 'stopped', + }); + }); + + // Verify the resource was removed from active streams + expect(result.current.activeLogStreams).not.toContain('lambda1'); + }); + + it('handles errors from onError handler', () => { + // Capture the handler + let errorHandler: Function; + + mockResourceClientService.onError.mockImplementation(function (handler) { + errorHandler = handler; + return { unsubscribe: vi.fn() }; + }); + + const { result } = renderHook(() => useResourceManager()); + + // Simulate receiving an error + act(() => { + errorHandler({ message: 'Test error' }); + }); + + // Verify the error was set + expect(result.current.error).toEqual('Test error'); + expect(result.current.isLoading).toBe(false); + }); + + it('refreshResources calls getDeployedBackendResources', () => { + // Clear previous calls + mockResourceClientService.getDeployedBackendResources.mockClear(); + + const { result } = renderHook(() => useResourceManager()); + + // Call refreshResources + act(() => { + result.current.refreshResources(); + }); + + // Verify getDeployedBackendResources was called + expect( + mockResourceClientService.getDeployedBackendResources, + ).toHaveBeenCalledTimes(1); + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + }); + + it('updateCustomFriendlyName calls resourceClientService.updateCustomFriendlyName', () => { + const { result } = renderHook(() => useResourceManager()); + + // Call updateCustomFriendlyName + act(() => { + result.current.updateCustomFriendlyName('lambda1', 'My Lambda Function'); + }); + + // Verify updateCustomFriendlyName was called with the correct arguments + expect( + mockResourceClientService.updateCustomFriendlyName, + ).toHaveBeenCalledWith('lambda1', 'My Lambda Function'); + }); + + it('removeCustomFriendlyName calls resourceClientService.removeCustomFriendlyName', () => { + const { result } = renderHook(() => useResourceManager()); + + // Call removeCustomFriendlyName + act(() => { + result.current.removeCustomFriendlyName('lambda1'); + }); + + // Verify removeCustomFriendlyName was called with the correct argument + expect( + mockResourceClientService.removeCustomFriendlyName, + ).toHaveBeenCalledWith('lambda1'); + }); + + it('toggleResourceLogging calls loggingClientService.toggleResourceLogging with the correct arguments', () => { + const { result } = renderHook(() => useResourceManager()); + + const resource = { + logicalResourceId: 'TestFunction', + physicalResourceId: 'lambda1', + resourceType: 'AWS::Lambda::Function', + resourceStatus: 'CREATE_COMPLETE', + logGroupName: '/aws/lambda/test-function', + consoleUrl: + 'https://console.aws.amazon.com/lambda/home#/functions/lambda1', + }; + + // Call toggleResourceLogging + act(() => { + result.current.toggleResourceLogging(resource, true); + }); + + // Verify toggleResourceLogging was called with the correct arguments + expect(mockLoggingClientService.toggleResourceLogging).toHaveBeenCalledWith( + 'lambda1', + 'AWS::Lambda::Function', + true, + ); + }); + + it('isLoggingActiveForResource returns true when resource ID is in activeLogStreams', () => { + // Capture the handler + let activeLogStreamsHandler: Function; + + mockLoggingClientService.onActiveLogStreams.mockImplementation( + function (handler) { + activeLogStreamsHandler = handler; + return { unsubscribe: vi.fn() }; + }, + ); + + const { result } = renderHook(() => useResourceManager()); + + // Set up active log streams + act(() => { + activeLogStreamsHandler(['lambda1', 'lambda2']); + }); + + // Check if a resource is active + expect(result.current.isLoggingActiveForResource('lambda1')).toBe(true); + expect(result.current.isLoggingActiveForResource('lambda3')).toBe(false); + }); + + it('unsubscribes from events on unmount', () => { + const unsubscribeSavedResources = vi.fn(); + const unsubscribeDeployedBackendResources = vi.fn(); + const unsubscribeCustomFriendlyNames = vi.fn(); + const unsubscribeCustomFriendlyNameUpdated = vi.fn(); + const unsubscribeCustomFriendlyNameRemoved = vi.fn(); + const unsubscribeError = vi.fn(); + const unsubscribeActiveLogStreams = vi.fn(); + const unsubscribeLogStreamStatus = vi.fn(); + const unsubscribeLogStreamError = vi.fn(); + + mockResourceClientService.onSavedResources.mockReturnValue({ + unsubscribe: unsubscribeSavedResources, + }); + mockResourceClientService.onDeployedBackendResources.mockReturnValue({ + unsubscribe: unsubscribeDeployedBackendResources, + }); + mockResourceClientService.onCustomFriendlyNames.mockReturnValue({ + unsubscribe: unsubscribeCustomFriendlyNames, + }); + mockResourceClientService.onCustomFriendlyNameUpdated.mockReturnValue({ + unsubscribe: unsubscribeCustomFriendlyNameUpdated, + }); + mockResourceClientService.onCustomFriendlyNameRemoved.mockReturnValue({ + unsubscribe: unsubscribeCustomFriendlyNameRemoved, + }); + mockResourceClientService.onError.mockReturnValue({ + unsubscribe: unsubscribeError, + }); + mockLoggingClientService.onActiveLogStreams.mockReturnValue({ + unsubscribe: unsubscribeActiveLogStreams, + }); + mockLoggingClientService.onLogStreamStatus.mockReturnValue({ + unsubscribe: unsubscribeLogStreamStatus, + }); + mockLoggingClientService.onLogStreamError.mockReturnValue({ + unsubscribe: unsubscribeLogStreamError, + }); + + const { unmount } = renderHook(() => useResourceManager()); + + // Unmount the hook + unmount(); + + // Verify all unsubscribe functions were called + expect(unsubscribeSavedResources).toHaveBeenCalled(); + expect(unsubscribeDeployedBackendResources).toHaveBeenCalled(); + expect(unsubscribeCustomFriendlyNames).toHaveBeenCalled(); + expect(unsubscribeCustomFriendlyNameUpdated).toHaveBeenCalled(); + expect(unsubscribeCustomFriendlyNameRemoved).toHaveBeenCalled(); + expect(unsubscribeError).toHaveBeenCalled(); + expect(unsubscribeActiveLogStreams).toHaveBeenCalled(); + expect(unsubscribeLogStreamStatus).toHaveBeenCalled(); + expect(unsubscribeLogStreamError).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/hooks/useResourceManager.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/hooks/useResourceManager.ts index b0939563806..8ee41999222 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/hooks/useResourceManager.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/hooks/useResourceManager.ts @@ -1,8 +1,14 @@ import { useState, useEffect, useCallback } from 'react'; import { SandboxStatus } from '@aws-amplify/sandbox'; -import { useResourceClientService } from '../contexts/socket_client_context'; -import { ResourceWithFriendlyName } from '../services/../../../resource_console_functions'; -import { BackendResourcesData } from '../../../shared/socket_types'; +import { + useResourceClientService, + useLoggingClientService, +} from '../contexts/socket_client_context'; +import { + BackendResourcesData, + LogStreamStatus, +} from '../../../shared/socket_types'; +import { ResourceWithFriendlyName } from '../../../resource_console_functions'; /** * Hook for managing backend resources @@ -15,18 +21,20 @@ export const useResourceManager = ( sandboxStatus?: SandboxStatus, ) => { const resourceClientService = useResourceClientService(); + const loggingClientService = useLoggingClientService(); const [resources, setResources] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [customFriendlyNames, setCustomFriendlyNames] = useState< Record >({}); const [region, setRegion] = useState(null); const [backendName, setBackendName] = useState(''); + const [activeLogStreams, setActiveLogStreams] = useState([]); useEffect(() => { // Add a small random delay on initial load to prevent all tabs from requesting at the same time - const initialDelay = Math.random() * 2000; // Random delay between 0-2000ms + const initialDelay = Math.random() * 500; // Random delay between 0-500ms const loadResources = () => { setIsLoading(true); @@ -39,6 +47,38 @@ export const useResourceManager = ( // Apply initial delay to prevent thundering herd problem when multiple tabs reconnect setTimeout(loadResources, initialDelay); + // Get active log streams + loggingClientService.getActiveLogStreams(); + + // Listen for active log streams + const handleActiveLogStreams = (streams: string[]) => { + setActiveLogStreams(streams || []); + }; + + // Listen for log stream status changes for immediate UI updates + const handleLogStreamStatus = (data: { + resourceId: string; + status: string; + }) => { + if (data.status === 'active' || data.status === 'already-active') { + setActiveLogStreams((prev) => + prev.includes(data.resourceId) ? prev : [...prev, data.resourceId], + ); + } else if (data.status === 'stopped') { + setActiveLogStreams((prev) => + prev.filter((id) => id !== data.resourceId), + ); + } + }; + + // Listen for log stream errors to revert optimistic updates + const handleLogStreamError = (data: LogStreamStatus) => { + // Revert optimistic update on error + setActiveLogStreams((prev) => + prev.filter((id) => id !== data.resourceId), + ); + }; + const handleSavedResources = (data: BackendResourcesData) => { if (data && data.resources) { setResources(data.resources); @@ -117,6 +157,14 @@ export const useResourceManager = ( handleCustomFriendlyNameRemoved, ); const unsubscribeError = resourceClientService.onError(handleError); + const unsubscribeActiveLogStreams = loggingClientService.onActiveLogStreams( + handleActiveLogStreams, + ); + const unsubscribeLogStreamStatus = loggingClientService.onLogStreamStatus( + handleLogStreamStatus, + ); + const unsubscribeLogStreamError = + loggingClientService.onLogStreamError(handleLogStreamError); // Cleanup function to unsubscribe from events return () => { @@ -126,8 +174,16 @@ export const useResourceManager = ( unsubscribeCustomFriendlyNameUpdated.unsubscribe(); unsubscribeCustomFriendlyNameRemoved.unsubscribe(); unsubscribeError.unsubscribe(); + unsubscribeActiveLogStreams.unsubscribe(); + unsubscribeLogStreamStatus.unsubscribe(); + unsubscribeLogStreamError.unsubscribe(); }; - }, [resourceClientService, sandboxStatus, onResourcesLoaded]); + }, [ + resourceClientService, + loggingClientService, + sandboxStatus, + onResourcesLoaded, + ]); /** * Updates a custom friendly name for a resource @@ -176,6 +232,49 @@ export const useResourceManager = ( resourceClientService.getDeployedBackendResources(); }; + /** + * Checks if a resource has active logging + * @param resourceId The resource ID + * @returns True if logging is active for the resource, false otherwise + */ + const isLoggingActiveForResource = useCallback( + (resourceId: string): boolean => { + return activeLogStreams.includes(resourceId); + }, + [activeLogStreams], + ); + + /** + * Toggles logging for a resource + * @param resource The resource to toggle logging for + * @param startLogging Whether to start or stop logging + */ + const toggleResourceLogging = ( + resource: ResourceWithFriendlyName, + startLogging: boolean, + ) => { + if (resource.logGroupName) { + // Optimistic update for immediate UI feedback + if (startLogging) { + setActiveLogStreams((prev) => + prev.includes(resource.physicalResourceId) + ? prev + : [...prev, resource.physicalResourceId], + ); + } else { + setActiveLogStreams((prev) => + prev.filter((id) => id !== resource.physicalResourceId), + ); + } + + loggingClientService.toggleResourceLogging( + resource.physicalResourceId, + resource.resourceType, + startLogging, + ); + } + }; + return { resources, isLoading, @@ -187,5 +286,8 @@ export const useResourceManager = ( removeCustomFriendlyName, getResourceDisplayName, refreshResources, + isLoggingActiveForResource, + toggleResourceLogging, + activeLogStreams, }; }; diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/logging_client_service.test.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/logging_client_service.test.ts new file mode 100644 index 00000000000..74f86215a29 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/logging_client_service.test.ts @@ -0,0 +1,222 @@ +import { describe, it, beforeEach, mock } from 'node:test'; +import assert from 'node:assert'; +import { LoggingClientService } from './logging_client_service'; +import { SOCKET_EVENTS } from '../../../shared/socket_events'; +import { createMockSocket } from './test_helpers'; +import { LogStreamStatus } from '../../../shared/socket_types'; + +void describe('LoggingClientService', () => { + let service: LoggingClientService; + let mockSocket: ReturnType; + + beforeEach(() => { + mockSocket = createMockSocket(); + + // Create service with mocked socket + service = new LoggingClientService(); + }); + + void describe('toggleResourceLogging', () => { + void it('emits TOGGLE_RESOURCE_LOGGING event with correct parameters', () => { + service.toggleResourceLogging('resource123', 'lambda', true); + + assert.strictEqual(mockSocket.mockEmit.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockEmit.mock.calls[0].arguments[0], + SOCKET_EVENTS.TOGGLE_RESOURCE_LOGGING, + ); + assert.deepStrictEqual(mockSocket.mockEmit.mock.calls[0].arguments[1], { + resourceId: 'resource123', + resourceType: 'lambda', + startLogging: true, + }); + }); + }); + + void describe('viewResourceLogs', () => { + void it('emits VIEW_RESOURCE_LOGS event with correct parameters', () => { + service.viewResourceLogs('resource123'); + + assert.strictEqual(mockSocket.mockEmit.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockEmit.mock.calls[0].arguments[0], + SOCKET_EVENTS.VIEW_RESOURCE_LOGS, + ); + assert.deepStrictEqual(mockSocket.mockEmit.mock.calls[0].arguments[1], { + resourceId: 'resource123', + }); + }); + }); + + void describe('getSavedResourceLogs', () => { + void it('emits GET_SAVED_RESOURCE_LOGS event with correct parameters', () => { + service.getSavedResourceLogs('resource123'); + + assert.strictEqual(mockSocket.mockEmit.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockEmit.mock.calls[0].arguments[0], + SOCKET_EVENTS.GET_SAVED_RESOURCE_LOGS, + ); + assert.deepStrictEqual(mockSocket.mockEmit.mock.calls[0].arguments[1], { + resourceId: 'resource123', + }); + }); + }); + + void describe('getActiveLogStreams', () => { + void it('emits GET_ACTIVE_LOG_STREAMS event', () => { + service.getActiveLogStreams(); + + assert.strictEqual(mockSocket.mockEmit.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockEmit.mock.calls[0].arguments[0], + SOCKET_EVENTS.GET_ACTIVE_LOG_STREAMS, + ); + }); + }); + + void describe('getLogSettings', () => { + void it('emits GET_LOG_SETTINGS event', () => { + service.getLogSettings(); + + assert.strictEqual(mockSocket.mockEmit.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockEmit.mock.calls[0].arguments[0], + SOCKET_EVENTS.GET_LOG_SETTINGS, + ); + }); + }); + + void describe('saveLogSettings', () => { + void it('emits SAVE_LOG_SETTINGS event with correct parameters', () => { + const settings = { maxLogSizeMB: 10 }; + service.saveLogSettings(settings); + + assert.strictEqual(mockSocket.mockEmit.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockEmit.mock.calls[0].arguments[0], + SOCKET_EVENTS.SAVE_LOG_SETTINGS, + ); + assert.deepStrictEqual( + mockSocket.mockEmit.mock.calls[0].arguments[1], + settings, + ); + }); + }); + + void describe('testLambdaFunction', () => { + void it('emits TEST_LAMBDA_FUNCTION event with correct parameters', () => { + service.testLambdaFunction( + 'resource123', + 'myFunction', + '{"test": "input"}', + ); + + assert.strictEqual(mockSocket.mockEmit.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockEmit.mock.calls[0].arguments[0], + SOCKET_EVENTS.TEST_LAMBDA_FUNCTION, + ); + assert.deepStrictEqual(mockSocket.mockEmit.mock.calls[0].arguments[1], { + resourceId: 'resource123', + functionName: 'myFunction', + input: '{"test": "input"}', + }); + }); + }); + + void describe('event handlers', () => { + void it('registers onLogStreamStatus handler correctly', () => { + const mockHandler = mock.fn(); + + service.onLogStreamStatus(mockHandler); + + assert.strictEqual(mockSocket.mockOn.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockOn.mock.calls[0].arguments[0], + SOCKET_EVENTS.LOG_STREAM_STATUS, + ); + + // Call the registered handler + const registeredHandler = mockSocket.mockOn.mock.calls[0] + .arguments[1] as (data: LogStreamStatus) => void; + const testData = { resourceId: 'resource123', status: 'active' }; + registeredHandler(testData); + + assert.strictEqual(mockHandler.mock.callCount(), 1); + assert.deepStrictEqual(mockHandler.mock.calls[0].arguments[0], testData); + }); + + void it('registers onActiveLogStreams handler correctly', () => { + const mockHandler = mock.fn(); + + service.onActiveLogStreams(mockHandler); + + assert.strictEqual(mockSocket.mockOn.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockOn.mock.calls[0].arguments[0], + SOCKET_EVENTS.ACTIVE_LOG_STREAMS, + ); + }); + + void it('registers onResourceLogs handler correctly', () => { + const mockHandler = mock.fn(); + + service.onResourceLogs(mockHandler); + + assert.strictEqual(mockSocket.mockOn.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockOn.mock.calls[0].arguments[0], + SOCKET_EVENTS.RESOURCE_LOGS, + ); + }); + + void it('registers onSavedResourceLogs handler correctly', () => { + const mockHandler = mock.fn(); + + service.onSavedResourceLogs(mockHandler); + + assert.strictEqual(mockSocket.mockOn.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockOn.mock.calls[0].arguments[0], + SOCKET_EVENTS.SAVED_RESOURCE_LOGS, + ); + }); + + void it('registers onLogStreamError handler correctly', () => { + const mockHandler = mock.fn(); + + service.onLogStreamError(mockHandler); + + assert.strictEqual(mockSocket.mockOn.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockOn.mock.calls[0].arguments[0], + SOCKET_EVENTS.LOG_STREAM_ERROR, + ); + }); + + void it('registers onLambdaTestResult handler correctly', () => { + const mockHandler = mock.fn(); + + service.onLambdaTestResult(mockHandler); + + assert.strictEqual(mockSocket.mockOn.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockOn.mock.calls[0].arguments[0], + SOCKET_EVENTS.LAMBDA_TEST_RESULT, + ); + }); + + void it('registers onLogSettings handler correctly', () => { + const mockHandler = mock.fn(); + + service.onLogSettings(mockHandler); + + assert.strictEqual(mockSocket.mockOn.mock.callCount(), 1); + assert.strictEqual( + mockSocket.mockOn.mock.calls[0].arguments[0], + SOCKET_EVENTS.LOG_SETTINGS, + ); + }); + }); +}); diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/logging_client_service.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/logging_client_service.ts new file mode 100644 index 00000000000..3227de9e572 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/react-app/src/services/logging_client_service.ts @@ -0,0 +1,173 @@ +import { SocketClientService } from './socket_client_service'; +import { SOCKET_EVENTS } from '../../../shared/socket_events'; +import { + LogSettings, + LogStreamStatus, + LambdaTestResult, + ResourceIdentifier, + ResourceLogs, + ResourceLoggingToggle, +} from '../../../shared/socket_types'; + +/** + * Service for handling logging-related socket communication + */ +export class LoggingClientService extends SocketClientService { + /** + * Toggles logging for a resource + * @param resourceId The resource ID + * @param resourceType The resource type + * @param startLogging Whether to start or stop logging + */ + public toggleResourceLogging( + resourceId: string, + resourceType: string, + startLogging: boolean, + ): void { + const payload: ResourceLoggingToggle = { + resourceId, + resourceType, + startLogging, + }; + this.emit(SOCKET_EVENTS.TOGGLE_RESOURCE_LOGGING, payload, true); + } + + /** + * Requests to view logs for a resource + * @param resourceId The resource ID + */ + public viewResourceLogs(resourceId: string): void { + const payload: ResourceIdentifier = { resourceId }; + this.emit(SOCKET_EVENTS.VIEW_RESOURCE_LOGS, payload); + } + + /** + * Requests saved logs for a resource + * @param resourceId The resource ID + */ + public getSavedResourceLogs(resourceId: string): void { + const payload: ResourceIdentifier = { resourceId }; + this.emit(SOCKET_EVENTS.GET_SAVED_RESOURCE_LOGS, payload); + } + + /** + * Requests active log streams + */ + public getActiveLogStreams(): void { + this.emit(SOCKET_EVENTS.GET_ACTIVE_LOG_STREAMS); + } + + /** + * Requests log settings + */ + public getLogSettings(): void { + this.emit(SOCKET_EVENTS.GET_LOG_SETTINGS); + } + + /** + * Saves log settings + * @param settings The log settings to save + */ + public saveLogSettings(settings: LogSettings): void { + this.emit(SOCKET_EVENTS.SAVE_LOG_SETTINGS, settings); + } + + /** + * Tests a Lambda function + * @param resourceId The resource ID + * @param functionName The function name + * @param input The test input + */ + public testLambdaFunction( + resourceId: string, + functionName: string, + input: string, + ): void { + this.emit( + SOCKET_EVENTS.TEST_LAMBDA_FUNCTION, + { + resourceId, + functionName, + input, + }, + true, + ); + } + + /** + * Registers a handler for log stream status events + * @param handler The event handler + * @returns An object with an unsubscribe method + */ + public onLogStreamStatus(handler: (data: LogStreamStatus) => void): { + unsubscribe: () => void; + } { + return this.on(SOCKET_EVENTS.LOG_STREAM_STATUS, handler); + } + + /** + * Registers a handler for active log streams events + * @param handler The event handler + * @returns An object with an unsubscribe method + */ + public onActiveLogStreams(handler: (streams: string[]) => void): { + unsubscribe: () => void; + } { + return this.on(SOCKET_EVENTS.ACTIVE_LOG_STREAMS, handler); + } + + /** + * Registers a handler for resource logs events + * @param handler The event handler + * @returns An object with an unsubscribe method + */ + public onResourceLogs(handler: (data: ResourceLogs) => void): { + unsubscribe: () => void; + } { + return this.on(SOCKET_EVENTS.RESOURCE_LOGS, handler); + } + + /** + * Registers a handler for saved resource logs events + * @param handler The event handler + * @returns An object with an unsubscribe method + */ + public onSavedResourceLogs(handler: (data: ResourceLogs) => void): { + unsubscribe: () => void; + } { + return this.on(SOCKET_EVENTS.SAVED_RESOURCE_LOGS, handler); + } + + /** + * Registers a handler for log stream error events + * @param handler The event handler + * @returns An object with an unsubscribe method + */ + public onLogStreamError(handler: (data: LogStreamStatus) => void): { + unsubscribe: () => void; + } { + return this.on(SOCKET_EVENTS.LOG_STREAM_ERROR, handler); + } + + /** + * Registers a handler for Lambda test result events + * @param handler The event handler + * @returns An object with an unsubscribe method + */ + public onLambdaTestResult(handler: (data: LambdaTestResult) => void): { + unsubscribe: () => void; + } { + return this.on(SOCKET_EVENTS.LAMBDA_TEST_RESULT, handler); + } + + /** + * Registers a handler for log settings events + * @param handler The event handler + * @returns An object with an unsubscribe method + */ + public onLogSettings(handler: (data: LogSettings) => void): { + unsubscribe: () => void; + } { + return this.on(SOCKET_EVENTS.LOG_SETTINGS, handler); + } +} diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/resource_console_functions.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/resource_console_functions.ts index da53e68cbfd..748f47fe039 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/resource_console_functions.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/resource_console_functions.ts @@ -1,5 +1,3 @@ -/* eslint-disable no-case-declarations */ -/* eslint-disable spellcheck/spell-checker */ /** * Utility functions for AWS console resource handling * These functions are used by both the React component and test files @@ -16,6 +14,7 @@ export type ResourceWithFriendlyName = DeployedBackendResource & { resourceStatus: string; friendlyName?: string; consoleUrl?: string | null; + logGroupName?: string | null; }; /** @@ -79,6 +78,7 @@ export const canProvideConsoleLink = ( */ export const isGlobalService = (service?: string): boolean => { if (!service) return false; + // eslint-disable-next-line spellcheck/spell-checker return ['iam', 'cloudfront', 'route53'].includes(service.toLowerCase()); }; @@ -117,24 +117,28 @@ export const getAwsConsoleUrl = ( : `https://${region}.console.aws.amazon.com`; switch (resourceType) { - case 'AWS::Lambda::Function': + case 'AWS::Lambda::Function': { // Check if physicalId is an ARN and extract function name if needed const lambdaFunctionName = physicalId.includes(':function:') ? physicalId.split(':function:')[1] : physicalId; return `${baseUrl}/lambda/home?region=${region}#/functions/${lambdaFunctionName}`; + } - case 'AWS::DynamoDB::Table': + case 'AWS::DynamoDB::Table': { // Check if physicalId is an ARN and extract table name if needed const dynamoTableName = physicalId.includes(':table/') ? physicalId.split(':table/')[1] : physicalId; + // eslint-disable-next-line spellcheck/spell-checker return `${baseUrl}/dynamodbv2/home?region=${region}#table?name=${encodeURIComponent(dynamoTableName)}`; + } case 'AWS::S3::Bucket': return `${baseUrl}/s3/buckets/${encodeURIComponent(physicalId)}?region=${region}`; case 'AWS::ApiGateway::RestApi': + // eslint-disable-next-line spellcheck/spell-checker return `${baseUrl}/apigateway/main/apis/${physicalId}/resources?api=${physicalId}®ion=${region}`; case 'AWS::IAM::Role': @@ -142,12 +146,13 @@ export const getAwsConsoleUrl = ( // Even though IAM is a global service, the console URL includes the region (why?) return `https://${region}.console.aws.amazon.com/iam/home#/roles/details/${encodeURIComponent(physicalId)}?section=permissions`; - case 'AWS::Cognito::UserPool': + case 'AWS::Cognito::UserPool': { // Check if physicalId is an ARN and extract pool ID if needed const userPoolId = physicalId.includes(':userpool/') ? physicalId.split(':userpool/')[1] : physicalId; return `${baseUrl}/cognito/v2/idp/user-pools/${userPoolId}/users?region=${region}`; + } case 'AWS::Cognito::UserPoolGroup': // For Cognito user pool groups, we need both the user pool ID and group name @@ -160,7 +165,9 @@ export const getAwsConsoleUrl = ( case 'AWS::AppSync::GraphQLApi': // Extract API ID from ARN if available + // eslint-disable-next-line spellcheck/spell-checker if (physicalId.includes(':apis/')) { + // eslint-disable-next-line spellcheck/spell-checker const apiId = physicalId.split(':apis/')[1]; return `${baseUrl}/appsync/home?region=${region}#/${apiId}/v1/`; } @@ -170,20 +177,23 @@ export const getAwsConsoleUrl = ( return `${baseUrl}/cloudwatch/home?region=${region}#alarmsV2:alarm/${physicalId}`; case 'AWS::StepFunctions::StateMachine': + // eslint-disable-next-line spellcheck/spell-checker return `${baseUrl}/states/home?region=${region}#/statemachines/view/${physicalId}`; case 'AWS::SecretsManager::Secret': + // eslint-disable-next-line spellcheck/spell-checker return `${baseUrl}/secretsmanager/home?region=${region}#/secret?name=${physicalId}`; case 'AWS::Logs::LogGroup': return `${baseUrl}/cloudwatch/home?region=${region}#logsV2:log-groups/log-group/${encodeURIComponent(physicalId)}`; - case 'AWS::Cognito::IdentityPool': + case 'AWS::Cognito::IdentityPool': { // Check if physicalId is an ARN and extract identity pool ID if needed const identityPoolId = physicalId.includes(':identitypool/') ? physicalId.split(':identitypool/')[1] : physicalId; return `${baseUrl}/cognito/v2/identity/identity-pools/${encodeURIComponent(identityPoolId)}/?region=${region}`; + } case 'AWS::Lambda::LayerVersion': // For Lambda layers, the physical ID is typically the layer ARN @@ -255,6 +265,7 @@ export const getAwsConsoleUrl = ( case 'Custom::AmplifyDynamoDBTable': //For the amplify specific dynamo DB table + // eslint-disable-next-line spellcheck/spell-checker return `${baseUrl}/dynamodbv2/home?region=${region}#table?name=${encodeURIComponent(physicalId)}`; default: 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..987a88942ca 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 @@ -516,6 +516,8 @@ export class SandboxDevToolsCommand implements CommandModule { shutdownService, resourceService, storageManager, + undefined, + undefined, devToolsLogger, ); diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/services/resource_service.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/services/resource_service.ts index 8bfbf8ed8d1..5ebcc3614cf 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/services/resource_service.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/services/resource_service.ts @@ -14,6 +14,7 @@ import { isCompleteResource, } from '../resource_console_functions.js'; import { SandboxStatus } from '@aws-amplify/sandbox'; +import { getLogGroupName } from '../logging/log_group_extractor.js'; /** * Type for deployed backend resources response @@ -147,6 +148,13 @@ export class ResourceService { region, ); + // Add log group name (if this resource type supports logs) + const logGroupName = getLogGroupName( + resourceType, + resource.physicalResourceId, + ); + resourceWithFriendlyName.logGroupName = logGroupName; + return resourceWithFriendlyName; }); diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers.test.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers.test.ts index 465ba40682d..505636fd278 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers.test.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers.test.ts @@ -92,6 +92,8 @@ void describe('SocketHandlerService', () => { mockShutdownService, mockResourceService, mockStorageManager, + undefined, // No active log pollers for this test + undefined, // No toggle start times for this test mockPrinter, ); }); @@ -106,9 +108,15 @@ void describe('SocketHandlerService', () => { // Verify all expected handlers are registered const expectedHandlers = [ + SOCKET_EVENTS.TOGGLE_RESOURCE_LOGGING, + SOCKET_EVENTS.VIEW_RESOURCE_LOGS, + SOCKET_EVENTS.GET_SAVED_RESOURCE_LOGS, + SOCKET_EVENTS.GET_LOG_SETTINGS, + SOCKET_EVENTS.SAVE_LOG_SETTINGS, SOCKET_EVENTS.TEST_LAMBDA_FUNCTION, SOCKET_EVENTS.GET_SANDBOX_STATUS, SOCKET_EVENTS.GET_DEPLOYED_BACKEND_RESOURCES, + SOCKET_EVENTS.GET_ACTIVE_LOG_STREAMS, SOCKET_EVENTS.GET_CUSTOM_FRIENDLY_NAMES, SOCKET_EVENTS.UPDATE_CUSTOM_FRIENDLY_NAME, SOCKET_EVENTS.REMOVE_CUSTOM_FRIENDLY_NAME, @@ -116,6 +124,8 @@ void describe('SocketHandlerService', () => { SOCKET_EVENTS.STOP_SANDBOX, SOCKET_EVENTS.DELETE_SANDBOX, SOCKET_EVENTS.STOP_DEV_TOOLS, + SOCKET_EVENTS.SAVE_CONSOLE_LOGS, + SOCKET_EVENTS.LOAD_CONSOLE_LOGS, ]; expectedHandlers.forEach((handler) => { @@ -180,6 +190,8 @@ void describe('SocketHandlerService', () => { mockShutdownService, mockResourceService, mockStorageManager, + undefined, // No active log pollers for this test + undefined, // No toggle start times for this test mockPrinter, ); @@ -402,6 +414,8 @@ void describe('SocketHandlerService', () => { mockShutdownService, mockResourceService, mockStorageManager, + undefined, // No active log pollers for this test + undefined, // No toggle start times for this test mockPrinter, ); diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers.ts index c1727b6d674..2df7eea28cf 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers.ts @@ -10,31 +10,34 @@ import { LambdaClient } from '@aws-sdk/client-lambda'; import { ResourceService } from './resource_service.js'; import { SOCKET_EVENTS } from '../shared/socket_events.js'; import { + BackendResourcesData, ConsoleLogEntry, DevToolsSandboxOptions, + FriendlyNameUpdate, + LogSettings, + ResourceIdentifier, + ResourceLoggingToggle, SandboxStatusData, } from '../shared/socket_types.js'; import { ShutdownService } from './shutdown_service.js'; import { LocalStorageManager } from '../local_storage_manager.js'; import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import { SocketHandlerLogging } from './socket_handlers_logging.js'; import { SocketHandlerResources } from './socket_handlers_resources.js'; /** * Interface for socket event data types */ export type SocketEvents = { + toggleResourceLogging: ResourceLoggingToggle; + viewResourceLogs: ResourceIdentifier; + getSavedResourceLogs: ResourceIdentifier; + getActiveLogStreams: void; getLogSettings: void; - saveLogSettings: { - maxLogSizeMB: number; - }; + saveLogSettings: LogSettings; getCustomFriendlyNames: void; - updateCustomFriendlyName: { - resourceId: string; - friendlyName: string; - }; - removeCustomFriendlyName: { - resourceId: string; - }; + updateCustomFriendlyName: FriendlyNameUpdate; + removeCustomFriendlyName: ResourceIdentifier; getSandboxStatus: void; deploymentInProgress: { @@ -46,14 +49,13 @@ export type SocketEvents = { stopSandbox: void; deleteSandbox: void; stopDevTools: void; - getSavedResources: void; + getSavedCloudFormationEvents: void; testLambdaFunction: { resourceId: string; functionName: string; input: string; }; - getCloudFormationEvents: void; saveConsoleLogs: { logs: ConsoleLogEntry[]; }; @@ -64,6 +66,7 @@ export type SocketEvents = { * Service for handling socket events */ export class SocketHandlerService { + private loggingHandler: SocketHandlerLogging; private resourcesHandler: SocketHandlerResources; /** @@ -77,9 +80,22 @@ export class SocketHandlerService { private shutdownService: ShutdownService, private resourceService: ResourceService, storageManager: LocalStorageManager, + // eslint-disable-next-line spellcheck/spell-checker + activeLogPollers = new Map(), + // Track when logging was toggled on for each resource + toggleStartTimes = new Map(), private printer: Printer = printerUtil, // Optional printer, defaults to cli-core printer lambdaClient: LambdaClient = new LambdaClient({}), ) { + // Initialize specialized handlers + this.loggingHandler = new SocketHandlerLogging( + io, + storageManager, + activeLogPollers, + toggleStartTimes, + printer, + ); + this.resourcesHandler = new SocketHandlerResources( io, storageManager, @@ -90,11 +106,62 @@ export class SocketHandlerService { ); } + /** + * Gets the resources handler + * @returns The socket handler for resources + */ + public getResourcesHandler(): SocketHandlerResources { + return this.resourcesHandler; + } + /** * Sets up all socket event handlers * @param socket The socket connection */ public setupSocketHandlers(socket: Socket): void { + // Resource logs handlers + socket.on( + SOCKET_EVENTS.TOGGLE_RESOURCE_LOGGING, + this.loggingHandler.handleToggleResourceLogging.bind( + this.loggingHandler, + socket, + ), + ); + socket.on( + SOCKET_EVENTS.VIEW_RESOURCE_LOGS, + this.loggingHandler.handleViewResourceLogs.bind( + this.loggingHandler, + socket, + ), + ); + socket.on( + SOCKET_EVENTS.GET_SAVED_RESOURCE_LOGS, + this.loggingHandler.handleGetSavedResourceLogs.bind( + this.loggingHandler, + socket, + ), + ); + socket.on( + SOCKET_EVENTS.GET_ACTIVE_LOG_STREAMS, + this.loggingHandler.handleGetActiveLogStreams.bind( + this.loggingHandler, + socket, + ), + ); + socket.on( + SOCKET_EVENTS.GET_LOG_SETTINGS, + this.loggingHandler.handleGetLogSettings.bind( + this.loggingHandler, + socket, + ), + ); + socket.on( + SOCKET_EVENTS.SAVE_LOG_SETTINGS, + this.loggingHandler.handleSaveLogSettings.bind( + this.loggingHandler, + socket, + ), + ); socket.on( SOCKET_EVENTS.TEST_LAMBDA_FUNCTION, this.resourcesHandler.handleTestLambdaFunction.bind( @@ -152,6 +219,19 @@ export class SocketHandlerService { // DevTools handlers socket.on(SOCKET_EVENTS.STOP_DEV_TOOLS, this.handleStopDevTools.bind(this)); + + // Console logs handlers + socket.on( + SOCKET_EVENTS.SAVE_CONSOLE_LOGS, + this.loggingHandler.handleSaveConsoleLogs.bind(this.loggingHandler), + ); + socket.on( + SOCKET_EVENTS.LOAD_CONSOLE_LOGS, + this.loggingHandler.handleLoadConsoleLogs.bind( + this.loggingHandler, + socket, + ), + ); } /** @@ -204,28 +284,31 @@ export class SocketHandlerService { LogLevel.ERROR, ); - socket.emit(SOCKET_EVENTS.DEPLOYED_BACKEND_RESOURCES, { + const errorResponse: BackendResourcesData = { name: this.backendId.name, status: 'error', resources: [], region: null, message: `Error fetching resources: ${errorMessage}`, error: errorMessage, - }); + }; + socket.emit(SOCKET_EVENTS.DEPLOYED_BACKEND_RESOURCES, errorResponse); } } catch (error) { this.printer.log( `Error in handleGetDeployedBackendResources: ${String(error)}`, LogLevel.ERROR, ); - socket.emit(SOCKET_EVENTS.DEPLOYED_BACKEND_RESOURCES, { + const errorMessage = String(error); + const errorResponse: BackendResourcesData = { name: this.backendId.name, status: 'error', resources: [], region: null, - message: `Error checking sandbox status: ${String(error)}`, - error: String(error), - }); + message: `Error checking sandbox status: ${errorMessage}`, + error: errorMessage, + }; + socket.emit(SOCKET_EVENTS.DEPLOYED_BACKEND_RESOURCES, errorResponse); } } @@ -361,6 +444,8 @@ export class SocketHandlerService { * Handles the stopDevTools event */ private async handleStopDevTools(): Promise { + // Stop all active log pollers before shutting down + this.loggingHandler.stopAllLogPollers(); await this.shutdownService.shutdown('user request', true); } } diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_logging.test.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_logging.test.ts new file mode 100644 index 00000000000..c1e47b772fb --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_logging.test.ts @@ -0,0 +1,357 @@ +import { beforeEach, describe, it, mock } from 'node:test'; +import assert from 'node:assert'; +import { SocketHandlerLogging } from './socket_handlers_logging.js'; +import { Printer, printer } from '@aws-amplify/cli-core'; +import type { Server, Socket } from 'socket.io'; +import { SOCKET_EVENTS } from '../shared/socket_events.js'; +import { LocalStorageManager } from '../local_storage_manager.js'; +import { CloudWatchLogsClient } from '@aws-sdk/client-cloudwatch-logs'; + +// Define the return type of mock.fn() +type MockFn = ReturnType; + +// Mock call type with more specific typing +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type MockCall = { + arguments: readonly unknown[]; +}; + +void describe('SocketHandlerLogging', () => { + let handler: SocketHandlerLogging; + let mockIo: Server; + let mockSocket: Socket; + let mockPrinter: Printer; + let mockStorageManager: LocalStorageManager; + + beforeEach(() => { + mock.reset(); + mockPrinter = { print: mock.fn(), log: mock.fn() } as unknown as Printer; + mock.method(printer, 'log'); + + mockIo = { emit: mock.fn() } as unknown as Server; + mockSocket = { on: mock.fn(), emit: mock.fn() } as unknown as Socket; + mockStorageManager = { + loadCloudWatchLogs: mock.fn(() => []), + appendCloudWatchLog: mock.fn(), + saveResourceLoggingState: mock.fn(), + getResourcesWithActiveLogging: mock.fn(() => []), + getLogsSizeInMB: mock.fn(() => 10), + setMaxLogSize: mock.fn(), + saveConsoleLogs: mock.fn(), + loadConsoleLogs: mock.fn(() => []), + maxLogSizeMB: 50, + } as unknown as LocalStorageManager; + + handler = new SocketHandlerLogging( + mockIo, + mockStorageManager, + undefined, // No active log pollers for this test + undefined, // No toggle start times for this test + mockPrinter, + ); + }); + + void describe('Resource Logging Handlers', () => { + void describe('handleToggleResourceLogging', () => { + void it('starts logging for valid resource', async () => { + // Mock findLatestLogStream to return a valid stream name + handler.findLatestLogStream = mock.fn(() => + Promise.resolve({ logStreamName: 'test-stream' }), + ); + + // Mock setupAdaptiveLogPolling to do nothing + handler.setupAdaptiveLogPolling = mock.fn(); + + await handler.handleToggleResourceLogging(mockSocket, { + resourceId: 'test-resource', + resourceType: 'AWS::Lambda::Function', + startLogging: true, + }); + + const mockEmitFn = mockSocket.emit as unknown as MockFn; + assert.ok(mockEmitFn.mock.callCount() > 0); + }); + + void it('stops logging for resource', async () => { + await handler.handleToggleResourceLogging(mockSocket, { + resourceId: 'test-resource', + resourceType: 'AWS::Lambda::Function', + startLogging: false, + }); + + const mockEmitFn = mockSocket.emit as unknown as MockFn; + assert.ok(mockEmitFn.mock.callCount() > 0); + }); + + void it('handles missing resource type', async () => { + await handler.handleToggleResourceLogging(mockSocket, { + resourceId: 'test-resource', + resourceType: '', + startLogging: true, + }); + + const mockEmitFn = mockSocket.emit as unknown as MockFn; + const errorCall = mockEmitFn.mock.calls.find( + (call: MockCall) => + call.arguments[0] === SOCKET_EVENTS.LOG_STREAM_ERROR, + ); + assert.ok(errorCall); + }); + }); + + void describe('handleViewResourceLogs', () => { + void it('returns saved logs for resource', () => { + const mockLogs = [{ timestamp: Date.now(), message: 'test log' }]; + ( + mockStorageManager.loadCloudWatchLogs as unknown as MockFn + ).mock.mockImplementation(() => mockLogs); + + handler.handleViewResourceLogs(mockSocket, { + resourceId: 'test-resource', + }); + + const mockEmitFn = mockSocket.emit as unknown as MockFn; + const savedLogsCall = mockEmitFn.mock.calls.find( + (call: MockCall) => + call.arguments[0] === SOCKET_EVENTS.SAVED_RESOURCE_LOGS, + ); + assert.ok(savedLogsCall); + }); + + void it('handles invalid resource ID', () => { + handler.handleViewResourceLogs(mockSocket, { resourceId: '' }); + + const mockEmitFn = mockSocket.emit as unknown as MockFn; + const errorCall = mockEmitFn.mock.calls.find( + (call: MockCall) => + call.arguments[0] === SOCKET_EVENTS.LOG_STREAM_ERROR, + ); + assert.ok(errorCall); + }); + }); + + void describe('handleGetSavedResourceLogs', () => { + void it('returns saved logs for resource', () => { + const mockLogs = [{ timestamp: Date.now(), message: 'test log' }]; + ( + mockStorageManager.loadCloudWatchLogs as unknown as MockFn + ).mock.mockImplementation(() => mockLogs); + + handler.handleGetSavedResourceLogs(mockSocket, { + resourceId: 'test-resource', + }); + + const mockEmitFn = mockSocket.emit as unknown as MockFn; + const savedLogsCall = mockEmitFn.mock.calls.find( + (call: MockCall) => + call.arguments[0] === SOCKET_EVENTS.SAVED_RESOURCE_LOGS, + ); + assert.ok(savedLogsCall); + }); + + void it('handles invalid resource ID', () => { + handler.handleGetSavedResourceLogs(mockSocket, { resourceId: '' }); + + const mockEmitFn = mockSocket.emit as unknown as MockFn; + const errorCall = mockEmitFn.mock.calls.find( + (call: MockCall) => + call.arguments[0] === SOCKET_EVENTS.LOG_STREAM_ERROR, + ); + assert.ok(errorCall); + }); + }); + + void describe('handleGetActiveLogStreams', () => { + void it('returns active log streams', () => { + const mockActiveStreams = ['resource1', 'resource2']; + ( + mockStorageManager.getResourcesWithActiveLogging as unknown as MockFn + ).mock.mockImplementation(() => mockActiveStreams); + + handler.handleGetActiveLogStreams(mockSocket); + + const mockEmitFn = mockSocket.emit as unknown as MockFn; + const activeStreamsCall = mockEmitFn.mock.calls.find( + (call: MockCall) => + call.arguments[0] === SOCKET_EVENTS.ACTIVE_LOG_STREAMS, + ); + assert.ok(activeStreamsCall); + assert.deepStrictEqual( + activeStreamsCall.arguments[1], + mockActiveStreams, + ); + }); + }); + + void describe('handleGetLogSettings', () => { + void it('returns log settings', () => { + handler.handleGetLogSettings(mockSocket); + + const mockEmitFn = mockSocket.emit as unknown as MockFn; + const settingsCall = mockEmitFn.mock.calls.find( + (call: MockCall) => call.arguments[0] === SOCKET_EVENTS.LOG_SETTINGS, + ); + assert.ok(settingsCall); + const settings = settingsCall.arguments[1] as { + maxLogSizeMB: number; + currentSizeMB: number; + }; + assert.strictEqual(settings.maxLogSizeMB, 50); + assert.strictEqual(settings.currentSizeMB, 10); + }); + }); + + void describe('handleSaveLogSettings', () => { + void it('saves valid log settings', () => { + handler.handleSaveLogSettings(mockSocket, { maxLogSizeMB: 200 }); + + const mockSetMaxLogSize = + mockStorageManager.setMaxLogSize as unknown as MockFn; + assert.strictEqual(mockSetMaxLogSize.mock.callCount(), 1); + assert.strictEqual(mockSetMaxLogSize.mock.calls[0].arguments[0], 200); + }); + + void it('validates minimum log size', () => { + handler.handleSaveLogSettings(mockSocket, { maxLogSizeMB: 0 }); + + const mockSetMaxLogSize = + mockStorageManager.setMaxLogSize as unknown as MockFn; + assert.strictEqual(mockSetMaxLogSize.mock.calls[0].arguments[0], 1); + }); + + void it('validates maximum log size', () => { + handler.handleSaveLogSettings(mockSocket, { maxLogSizeMB: 1000 }); + + const mockSetMaxLogSize = + mockStorageManager.setMaxLogSize as unknown as MockFn; + assert.strictEqual(mockSetMaxLogSize.mock.calls[0].arguments[0], 500); + }); + + void it('handles invalid settings', () => { + handler.handleSaveLogSettings( + mockSocket, + null as unknown as { maxLogSizeMB: number; currentSizeMB?: number }, + ); + + const mockEmitFn = mockSocket.emit as unknown as MockFn; + const errorCall = mockEmitFn.mock.calls.find( + (call: MockCall) => call.arguments[0] === SOCKET_EVENTS.ERROR, + ); + assert.ok(errorCall); + }); + }); + + void it('handles log group not found error', async () => { + // Mock the notifyLogStreamStatus method to track calls + const mockNotifyLogStreamStatus = mock.fn(); + handler['notifyLogStreamStatus'] = mockNotifyLogStreamStatus; + + // Replace the CloudWatch client with one that throws the right error + handler['cwLogsClient'] = { + send: mock.fn(() => { + throw new Error( + 'ResourceNotFoundException: The specified log group does not exist', + ); + }), + } as unknown as CloudWatchLogsClient; + + // Reset the socket emit mock to track only new calls + (mockSocket.emit as unknown as MockFn).mock.resetCalls(); + + // Call handler with request to start logging for a resource + await handler.handleToggleResourceLogging(mockSocket, { + resourceId: 'test-resource', + resourceType: 'AWS::Lambda::Function', + startLogging: true, + }); + + // Verify notifyLogStreamStatus was called with 'starting' + assert.strictEqual(mockNotifyLogStreamStatus.mock.callCount(), 2); + assert.strictEqual( + mockNotifyLogStreamStatus.mock.calls[0].arguments[0], + 'test-resource', + ); + assert.strictEqual( + mockNotifyLogStreamStatus.mock.calls[0].arguments[1], + 'starting', + ); + assert.strictEqual( + mockNotifyLogStreamStatus.mock.calls[1].arguments[0], + 'test-resource', + ); + assert.strictEqual( + mockNotifyLogStreamStatus.mock.calls[1].arguments[1], + 'stopped', + ); + + // Check for LOG_STREAM_ERROR event + const mockEmitFn = mockSocket.emit as unknown as MockFn; + const errorCall = mockEmitFn.mock.calls.find( + (call: MockCall) => + call.arguments[0] === SOCKET_EVENTS.LOG_STREAM_ERROR, + ); + assert.ok(errorCall, 'Should emit LOG_STREAM_ERROR event'); + + // Verify the error message + const errorData = errorCall.arguments[1] as { + resourceId: string; + error: string; + }; + assert.strictEqual(errorData.resourceId, 'test-resource'); + assert.ok( + errorData.error.includes("log group doesn't exist yet"), + 'Error should mention log group not existing', + ); + + // Should call saveResourceLoggingState with false + const saveStateFn = + mockStorageManager.saveResourceLoggingState as unknown as MockFn; + assert.strictEqual(saveStateFn.mock.callCount(), 1); + assert.strictEqual( + saveStateFn.mock.calls[0].arguments[0], + 'test-resource', + ); + assert.strictEqual(saveStateFn.mock.calls[0].arguments[1], false); + }); + }); + + void describe('Console Log Handlers', () => { + void describe('handleSaveConsoleLogs', () => { + void it('saves console logs', () => { + const testLogs = [ + { id: '1', timestamp: '2023-01-01', level: 'INFO', message: 'test' }, + ]; + handler.handleSaveConsoleLogs({ logs: testLogs }); + + const mockSaveConsoleLogs = + mockStorageManager.saveConsoleLogs as unknown as MockFn; + assert.strictEqual(mockSaveConsoleLogs.mock.callCount(), 1); + assert.deepStrictEqual( + mockSaveConsoleLogs.mock.calls[0].arguments[0], + testLogs, + ); + }); + }); + + void describe('handleLoadConsoleLogs', () => { + void it('loads and emits console logs', () => { + const mockLogs = [ + { id: '1', timestamp: '2023-01-01', level: 'INFO', message: 'test' }, + ]; + ( + mockStorageManager.loadConsoleLogs as unknown as MockFn + ).mock.mockImplementation(() => mockLogs); + + handler.handleLoadConsoleLogs(mockSocket); + + const mockEmitFn = mockSocket.emit as unknown as MockFn; + const savedLogsCall = mockEmitFn.mock.calls.find( + (call: MockCall) => + call.arguments[0] === SOCKET_EVENTS.SAVED_CONSOLE_LOGS, + ); + assert.ok(savedLogsCall); + assert.deepStrictEqual(savedLogsCall.arguments[1], mockLogs); + }); + }); + }); +}); diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_logging.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_logging.ts new file mode 100644 index 00000000000..363102ae923 --- /dev/null +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/services/socket_handlers_logging.ts @@ -0,0 +1,742 @@ +import { + LogLevel, + Printer, + printer as printerUtil, +} from '@aws-amplify/cli-core'; +import { Server, Socket } from 'socket.io'; +import { + CloudWatchLogsClient, + DescribeLogStreamsCommand, + GetLogEventsCommand, +} from '@aws-sdk/client-cloudwatch-logs'; +import { SOCKET_EVENTS } from '../shared/socket_events.js'; +import { LocalStorageManager } from '../local_storage_manager.js'; +import { getLogGroupName } from '../logging/log_group_extractor.js'; +import { + LogStreamStatus, + ResourceIdentifier, + ResourceLoggingToggle, +} from '../shared/socket_types.js'; +import { SocketEvents } from './socket_handlers.js'; + +/** + * Service for handling socket events related to logging + */ +export class SocketHandlerLogging { + /** + * CloudWatch log polling configuration - all values subjectively tuned + * AWS limits: GetLogEvents (10 req/s), DescribeLogStreams (5 req/s) + */ + private static readonly pollingConfig = { + // How often to poll for new logs when first starting - balances responsiveness vs API usage + // Lower = more responsive but more API calls, Higher = less responsive but fewer API calls + // Tune: Decrease for faster log appearance, increase to reduce AWS costs + INITIAL_POLL_INTERVAL_MS: 2000, + + // Maximum polling interval when no logs are found - prevents excessive slowdown + // Tune: Increase to further reduce API calls, decrease for better responsiveness + MAX_POLL_INTERVAL_MS: 10000, + + // How often to check if Lambda created a new log stream - Lambda creates new streams periodically + // Lambda rotates streams on cold starts and daily, so we need periodic checks + // Tune: Increase to reduce API calls, decrease to catch new streams faster + STREAM_CHECK_INTERVAL_MS: 30000, + + // Number of consecutive empty API responses before slowing down polling + // Prevents immediate slowdown from brief quiet periods but responds to sustained inactivity + // Tune: Increase to delay slowdown longer, decrease to slow down sooner + EMPTY_POLLS_THRESHOLD: 3, + + // Exponential back off multiplier - 1.5 is conservative to avoid overshooting max interval + // Tune: Higher values reach max interval faster but risk overshooting optimal frequency + BACKOFF_MULTIPLIER: 1.5, + } as const; + + private cwLogsClient: CloudWatchLogsClient; + // Prevent overlapping async polling requests + private pollingInProgress = new Map(); + + /** + * Creates a new SocketHandlerLogging + */ + constructor( + private io: Server, + private storageManager: LocalStorageManager, + private activeLogPollers = new Map(), + // Track when logging was toggled on for each resource + private toggleStartTimes = new Map(), + private printer: Printer = printerUtil, // Optional printer, defaults to cli-core printer + ) { + // Initialize AWS clients + this.cwLogsClient = new CloudWatchLogsClient({}); + } + + /** + * Handles the toggleResourceLogging event + */ + public async handleToggleResourceLogging( + socket: Socket, + data: ResourceLoggingToggle, + ): Promise { + this.printer.log( + `Toggle logging for ${data.resourceId}, startLogging=${data.startLogging}`, + LogLevel.DEBUG, + ); + + if (data.startLogging) { + // Start logging if not already active + if (!this.activeLogPollers.has(data.resourceId)) { + try { + // Check if resource type is defined + if (!data.resourceType) { + this.handleLogError( + data.resourceId, + 'Resource type is undefined. Cannot determine log group.', + socket, + ); + return; + } + + // Determine log group name based on resource type + const logGroupName = getLogGroupName( + data.resourceType, + data.resourceId, + ); + if (!logGroupName) { + this.handleLogError( + data.resourceId, + `Unsupported resource type for logs: ${data.resourceType}`, + socket, + ); + return; + } + + // Notify client that we're starting to record logs + this.notifyLogStreamStatus(data.resourceId, 'starting', socket); + + this.toggleStartTimes.set(data.resourceId, Date.now()); + + // Using polling-based logs directly + this.printer.log( + `Setting up polling-based logs for ${data.resourceId}`, + LogLevel.INFO, + ); + + // Find the latest log stream + const streamResult = await this.findLatestLogStream(logGroupName); + + // Set up adaptive log polling + this.setupAdaptiveLogPolling( + data.resourceId, + logGroupName, + streamResult.logStreamName!, + socket, + ); + + // Only save state after polling is successfully set up + this.storageManager.saveResourceLoggingState(data.resourceId, true); + this.notifyLogStreamStatus(data.resourceId, 'active', socket); + + const activeStreams = + this.storageManager.getResourcesWithActiveLogging(); + this.io.emit(SOCKET_EVENTS.ACTIVE_LOG_STREAMS, activeStreams); + } catch (error) { + try { + this.handleResourceNotFoundException( + data.resourceId, + error, + socket, + ); + } catch { + this.handleLogError(data.resourceId, error, socket); + } + } + } else { + // Already recording logs + this.notifyLogStreamStatus(data.resourceId, 'already-active', socket); + } + } else { + // Stop logging - first verify the log group is valid + const logGroupName = getLogGroupName(data.resourceType, data.resourceId); + if (!logGroupName) { + this.handleLogError( + data.resourceId, + `Unsupported resource type for logs: ${data.resourceType}`, + socket, + ); + return; + } + + // Stop the logging + this.stopLoggingForResource(data.resourceId, socket); + + // Send updated active log streams to all clients + const activeStreams = this.storageManager.getResourcesWithActiveLogging(); + this.io.emit(SOCKET_EVENTS.ACTIVE_LOG_STREAMS, activeStreams); + } + } + + /** + * Finds the latest log stream for a resource + * @param logGroupName Log group name + * @returns Object containing log stream name or error + */ + public async findLatestLogStream( + logGroupName: string, + ): Promise<{ logStreamName?: string; error?: string }> { + const describeStreamsResponse = await this.cwLogsClient.send( + new DescribeLogStreamsCommand({ + logGroupName, + orderBy: 'LastEventTime', + descending: true, + limit: 1, + }), + ); + + if ( + !describeStreamsResponse.logStreams || + describeStreamsResponse.logStreams.length === 0 + ) { + throw new Error('No log streams found for this resource'); + } + + return { + logStreamName: describeStreamsResponse.logStreams[0].logStreamName, + }; + } + + /** + * 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) { + // First notify that logging is stopped to reset UI state + this.notifyLogStreamStatus(resourceId, 'stopped', socket); + + // Remove from toggle start times + this.toggleStartTimes.delete(resourceId); + + // Update storage to show logging is not active + this.storageManager.saveResourceLoggingState(resourceId, false); + + // 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 log errors + * @param resourceId The resource ID + * @param error The error object + * @param socket Optional socket to emit errors to + */ + public handleLogError( + resourceId: string, + error: unknown, + socket?: Socket, + ): void { + this.printer.log( + `Error with logs for ${resourceId}: ${String(error)}`, + LogLevel.ERROR, + ); + + if (socket) { + socket.emit(SOCKET_EVENTS.LOG_STREAM_ERROR, { + resourceId, + error: String(error), + }); + // First notify that logging is stopped to reset UI state + this.notifyLogStreamStatus(resourceId, 'stopped', socket); + + // Remove from toggle start times + this.toggleStartTimes.delete(resourceId); + + // Update storage to show logging is not active + this.storageManager.saveResourceLoggingState(resourceId, false); + } + } + + /** + * Notifies clients about log stream status changes + * @param resourceId The resource ID + * @param status The status to notify about + * @param socket Optional socket for targeted notification + */ + public notifyLogStreamStatus( + resourceId: string, + status: 'starting' | 'active' | 'already-active' | 'stopped', + socket?: Socket, + ): void { + const statusPayload: LogStreamStatus = { + resourceId, + status, + }; + + // Notify specific client if provided + if (socket) { + socket.emit(SOCKET_EVENTS.LOG_STREAM_STATUS, statusPayload); + } + + // For certain status types, also broadcast to all clients + if (status === 'active' || status === 'stopped') { + this.io.emit(SOCKET_EVENTS.LOG_STREAM_STATUS, statusPayload); + } + } + + /** + * Stops logging for a resource + * @param resourceId The resource ID + * @param socket Socket to notify about the change + */ + public stopLoggingForResource(resourceId: string, socket: Socket): void { + const pollingInterval = this.activeLogPollers.get(resourceId); + + if (pollingInterval) { + // Stop polling + clearInterval(pollingInterval); + this.activeLogPollers.delete(resourceId); + + this.printer.log( + `Stopped log polling for resource ${resourceId}`, + LogLevel.DEBUG, + ); + } + + // Get existing logs + const existingLogs = this.storageManager.loadCloudWatchLogs(resourceId); + + // Update storage + this.storageManager.saveResourceLoggingState(resourceId, false); + + // Remove toggle start time + this.toggleStartTimes.delete(resourceId); + + // Notify client that logs are no longer being recorded + this.notifyLogStreamStatus(resourceId, 'stopped', socket); + + // Send the saved logs back to the client to ensure they're not lost + socket.emit(SOCKET_EVENTS.SAVED_RESOURCE_LOGS, { + resourceId, + logs: existingLogs, + }); + + this.printer.log( + `Stopped logging for resource ${resourceId}`, + LogLevel.INFO, + ); + } + + /** + * Handles the viewResourceLogs event + */ + public handleViewResourceLogs( + socket: Socket, + data: ResourceIdentifier, + ): void { + this.printer.log( + `Viewing logs for resource ${data.resourceId}`, + LogLevel.DEBUG, + ); + + if (!data?.resourceId) { + socket.emit(SOCKET_EVENTS.LOG_STREAM_ERROR, { + resourceId: 'unknown', + error: 'Invalid resource ID provided', + } as LogStreamStatus); + return; + } + + try { + const { resourceId } = data; + + const cloudWatchLogs = + this.storageManager.loadCloudWatchLogs(resourceId) || []; + + // Convert CloudWatchLogEntry to the format expected by the client + const logs = cloudWatchLogs.map((log) => ({ + timestamp: new Date(log.timestamp).toISOString(), + message: log.message, + })); + + socket.emit(SOCKET_EVENTS.SAVED_RESOURCE_LOGS, { + resourceId, + logs: logs || [], // Ensure we always send an array, even if no logs exist + }); + } catch (error) { + this.printer.log( + `Error viewing logs for ${data.resourceId}: ${String(error)}`, + LogLevel.ERROR, + ); + socket.emit(SOCKET_EVENTS.LOG_STREAM_ERROR, { + resourceId: data.resourceId, + error: `Error loading logs: ${String(error)}`, + } as LogStreamStatus); + } + } + + /** + * Handles the getSavedResourceLogs event + */ + public handleGetSavedResourceLogs( + socket: Socket, + data: ResourceIdentifier, + ): void { + if (!data?.resourceId) { + socket.emit(SOCKET_EVENTS.LOG_STREAM_ERROR, { + resourceId: 'unknown', + error: 'Invalid resource ID provided', + }); + return; + } + + try { + const { resourceId } = data; + + const cloudWatchLogs = + this.storageManager.loadCloudWatchLogs(resourceId) || []; + + // Convert CloudWatchLogEntry to the format expected by the client + const logs = cloudWatchLogs.map((log) => ({ + timestamp: new Date(log.timestamp).toISOString(), + message: log.message, + })); + + // Send the logs to the client + socket.emit(SOCKET_EVENTS.SAVED_RESOURCE_LOGS, { + resourceId, + logs, + }); + } catch (error) { + this.printer.log( + `Error getting saved logs for ${data.resourceId}: ${String(error)}`, + LogLevel.ERROR, + ); + socket.emit(SOCKET_EVENTS.LOG_STREAM_ERROR, { + resourceId: data.resourceId, + error: String(error), + }); + } + } + + /** + * Handles the getActiveLogStreams event + */ + public handleGetActiveLogStreams(socket: Socket): void { + // Get active log streams from storage + const activeStreams = this.storageManager.getResourcesWithActiveLogging(); + socket.emit(SOCKET_EVENTS.ACTIVE_LOG_STREAMS, activeStreams); + } + + /** + * Handles the getLogSettings event + */ + public handleGetLogSettings(socket: Socket): void { + try { + // Get current max log size + const maxLogSizeMB = this.storageManager.maxLogSizeMB; + const currentSizeMB = this.storageManager.getLogsSizeInMB(); + + // Send the settings to the client + socket.emit(SOCKET_EVENTS.LOG_SETTINGS, { + maxLogSizeMB, + currentSizeMB, + }); + } catch (error) { + this.printer.log( + `Error getting log settings: ${String(error)}`, + LogLevel.ERROR, + ); + } + } + + /** + * Handles the saveLogSettings event + */ + public handleSaveLogSettings( + socket: Socket, + settings: SocketEvents['saveLogSettings'], + ): void { + try { + if (!settings || typeof settings.maxLogSizeMB !== 'number') { + socket.emit(SOCKET_EVENTS.ERROR, 'Invalid log settings provided'); + return; + } + + // Validate the settings + if (settings.maxLogSizeMB < 1) { + settings.maxLogSizeMB = 1; // Minimum of 1 MB + } else if (settings.maxLogSizeMB > 500) { + settings.maxLogSizeMB = 500; // Maximum of 500 MB + } + + // Save the settings to storage + this.storageManager.setMaxLogSize(settings.maxLogSizeMB); + + // Send the updated settings to all connected clients + this.io.emit(SOCKET_EVENTS.LOG_SETTINGS, settings); + } catch (error) { + this.printer.log( + `Error saving log settings: ${String(error)}`, + LogLevel.ERROR, + ); + socket.emit( + SOCKET_EVENTS.ERROR, + `Error saving log settings: ${String(error)}`, + ); + } + } + + /** + * Sets up adaptive log polling for a resource that adjusts frequency based on activity + * @param resourceId The resource ID + * @param logGroupName The log group name + * @param logStreamName The log stream name + * @param socket Optional socket to emit errors to + */ + public setupAdaptiveLogPolling( + resourceId: string, + logGroupName: string, + logStreamName: string, + socket?: Socket, + ): void { + let nextToken: string | undefined = undefined; + let currentLogStreamName = logStreamName; + let lastStreamCheckTime = Date.now(); + let consecutiveEmptyPolls = 0; + let currentInterval: number = + SocketHandlerLogging.pollingConfig.INITIAL_POLL_INTERVAL_MS; + + // Helper function to handle empty poll results + const handleEmptyPoll = () => { + consecutiveEmptyPolls++; + if ( + consecutiveEmptyPolls > + SocketHandlerLogging.pollingConfig.EMPTY_POLLS_THRESHOLD && + currentInterval < + SocketHandlerLogging.pollingConfig.MAX_POLL_INTERVAL_MS + ) { + // Slow down polling after threshold empty polls + currentInterval = Math.min( + currentInterval * + SocketHandlerLogging.pollingConfig.BACKOFF_MULTIPLIER, + SocketHandlerLogging.pollingConfig.MAX_POLL_INTERVAL_MS, + ); + pollingInterval = this.updatePollingInterval( + resourceId, + () => void fetchLogs(), + currentInterval, + pollingInterval, + ); + + this.printer.log( + `Reduced polling frequency for ${resourceId} to ${currentInterval}ms after ${consecutiveEmptyPolls} empty polls`, + LogLevel.DEBUG, + ); + } + }; + + const fetchLogs = async (): Promise => { + // Prevent overlapping async requests + if (this.pollingInProgress.get(resourceId)) { + return; + } + + this.pollingInProgress.set(resourceId, true); + + try { + // Periodically check for newer log streams + if ( + Date.now() - lastStreamCheckTime > + SocketHandlerLogging.pollingConfig.STREAM_CHECK_INTERVAL_MS + ) { + try { + const streamResult = await this.findLatestLogStream(logGroupName); + if ( + streamResult.logStreamName && + streamResult.logStreamName !== currentLogStreamName + ) { + this.printer.log( + `Found newer log stream for ${resourceId}: ${streamResult.logStreamName}`, + LogLevel.INFO, + ); + currentLogStreamName = streamResult.logStreamName; + nextToken = undefined; // Reset token when switching to a new stream + } + lastStreamCheckTime = Date.now(); + } catch (error) { + // Continue with current stream if there's an error finding a new one + this.printer.log( + `Error checking for new log streams: ${String(error)}`, + LogLevel.DEBUG, + ); + } + } + + const getLogsResponse = await this.cwLogsClient.send( + new GetLogEventsCommand({ + logGroupName, + logStreamName: currentLogStreamName, + nextToken, + startFromHead: true, + }), + ); + + // Update next token for next poll + nextToken = getLogsResponse.nextForwardToken; + + // Process and save logs + if (getLogsResponse.events && getLogsResponse.events.length > 0) { + // Get the toggle start time for this resource + const toggleStartTime = this.toggleStartTimes.get(resourceId) || 0; + + // Filter logs based on toggle start time + const logs = getLogsResponse.events + .filter((event) => (event.timestamp || 0) > toggleStartTime) + .map((event) => ({ + timestamp: event.timestamp || Date.now(), + message: event.message || '', + })); + + // Only save and emit if we have logs after filtering + if (logs.length > 0) { + // Save logs to local storage + logs.forEach((log: { timestamp: number; message: string }) => { + this.storageManager.appendCloudWatchLog(resourceId, log); + }); + + // Emit logs to all clients + this.io.emit(SOCKET_EVENTS.RESOURCE_LOGS, { + resourceId, + logs, + }); + + // Reset consecutive empty polls and speed up polling if needed + consecutiveEmptyPolls = 0; + if ( + currentInterval > + SocketHandlerLogging.pollingConfig.INITIAL_POLL_INTERVAL_MS + ) { + // Speed up polling if we're getting logs + currentInterval = + SocketHandlerLogging.pollingConfig.INITIAL_POLL_INTERVAL_MS; + pollingInterval = this.updatePollingInterval( + resourceId, + () => void fetchLogs(), + currentInterval, + pollingInterval, + ); + } + } else { + // No new logs after filtering + handleEmptyPoll(); + } + } else { + // No logs at all + handleEmptyPoll(); + } + } catch (error) { + try { + clearInterval(pollingInterval); + this.activeLogPollers.delete(resourceId); + this.handleResourceNotFoundException(resourceId, error, socket); + } catch { + this.handleLogError(resourceId, error, socket); + } + } finally { + // Always reset the polling flag + this.pollingInProgress.set(resourceId, false); + } + }; + + let pollingInterval = setInterval(() => void fetchLogs(), currentInterval); + this.activeLogPollers.set(resourceId, pollingInterval); + + // Fetch logs immediately after setting up the interval + void fetchLogs(); + } + + /** + * Stops all log pollers + */ + public stopAllLogPollers(): void { + for (const [resourceId, poller] of this.activeLogPollers.entries()) { + clearInterval(poller); + this.printer.log(`Stopping log poller for ${resourceId}`, LogLevel.DEBUG); + } + this.activeLogPollers.clear(); + this.toggleStartTimes.clear(); + } + + /** + * Handles the saveConsoleLogs event + */ + public handleSaveConsoleLogs(data: SocketEvents['saveConsoleLogs']): void { + try { + this.storageManager.saveConsoleLogs(data.logs); + } catch (error) { + this.printer.log( + `Error saving console logs: ${String(error)}`, + LogLevel.ERROR, + ); + } + } + + /** + * Handles the loadConsoleLogs event + */ + public handleLoadConsoleLogs(socket: Socket): void { + try { + const logs = this.storageManager.loadConsoleLogs(); + socket.emit(SOCKET_EVENTS.SAVED_CONSOLE_LOGS, logs); + } catch (error) { + this.printer.log( + `Error loading console logs: ${String(error)}`, + LogLevel.ERROR, + ); + socket.emit(SOCKET_EVENTS.SAVED_CONSOLE_LOGS, []); + } + } + + /** + * Helper method to update polling interval - centralizes interval management logic + * @param resourceId The resource ID + * @param fetchLogsFn The fetch function to call on interval + * @param newInterval The new interval in milliseconds + * @param pollingInterval The current polling interval to update + * @returns The new polling interval + */ + private updatePollingInterval( + resourceId: string, + fetchLogsFn: () => void, + newInterval: number, + pollingInterval: NodeJS.Timeout, + ): NodeJS.Timeout { + clearInterval(pollingInterval); + const newPoller = setInterval(fetchLogsFn, newInterval); + this.activeLogPollers.set(resourceId, newPoller); + return newPoller; + } +} 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..356c4c2a16f 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 @@ -94,6 +94,61 @@ export const SOCKET_EVENTS = { */ LOG_SETTINGS: 'logSettings', + /** + * Event to toggle resource logging + */ + TOGGLE_RESOURCE_LOGGING: 'toggleResourceLogging', + + /** + * Event to view logs for a specific resource + */ + VIEW_RESOURCE_LOGS: 'viewResourceLogs', + + /** + * Event to get saved logs for a resource + */ + GET_SAVED_RESOURCE_LOGS: 'getSavedResourceLogs', + + /** + * Event to get active log streams + */ + GET_ACTIVE_LOG_STREAMS: 'getActiveLogStreams', + + /** + * Event received when active log streams are sent from the server. + * Contains the current list of resource IDs that have active logging. + * Used for: UI state synchronization, showing which resources are being logged. + * Triggered by: Client requests, bulk state updates after logging changes. + */ + ACTIVE_LOG_STREAMS: 'activeLogStreams', + + /** + * Event received when log stream status changes for a specific resource. + * Contains status transitions like 'starting', 'active', 'stopped', 'already-active'. + * Used for: Real-time user feedback during logging state transitions. + * Triggered by: Individual logging toggle actions, immediate status updates. + * + * Note: This is different from ACTIVE_LOG_STREAMS which provides the current + * list of all active resources, while this provides transition feedback for + * individual resources. + */ + LOG_STREAM_STATUS: 'logStreamStatus', + + /** + * Event received when resource logs are sent from the server + */ + RESOURCE_LOGS: 'resourceLogs', + + /** + * Event received when saved resource logs are sent from the server + */ + SAVED_RESOURCE_LOGS: 'savedResourceLogs', + + /** + * Event received when a log stream error occurs + */ + LOG_STREAM_ERROR: 'logStreamError', + /** * Event to test a Lambda function */ diff --git a/packages/cli/src/commands/sandbox/sandbox-devtools/shared/socket_types.ts b/packages/cli/src/commands/sandbox/sandbox-devtools/shared/socket_types.ts index 0c81ce47432..3040d23f808 100644 --- a/packages/cli/src/commands/sandbox/sandbox-devtools/shared/socket_types.ts +++ b/packages/cli/src/commands/sandbox/sandbox-devtools/shared/socket_types.ts @@ -4,6 +4,16 @@ import { ResourceWithFriendlyName } from '../resource_console_functions.js'; * Shared types for socket communication between client and server */ +/** + * Type for toggling resource logging - used in both client and server components + * for controlling the logging state of AWS resources + */ +export type ResourceLoggingToggle = { + resourceId: string; + resourceType: string; + startLogging: boolean; +}; + /** * Type for identifying a specific resource - used in multiple events * like viewResourceLogs, getSavedResourceLogs, and removeCustomFriendlyName @@ -33,6 +43,15 @@ export type BackendResourcesData = { error?: string; }; +/** + * Type for log settings configuration + * used in saveLogSettings and getLogSettings events + */ +export type LogSettings = { + maxLogSizeMB: number; + currentSizeMB?: number; +}; + /** * Type for console log entries used in both the frontend and backend */ @@ -84,6 +103,40 @@ export type SandboxStatusData = { deploymentCompleted?: boolean; }; +/** + * Interface for log stream status + */ +export type LogStreamStatus = { + resourceId: string; + status: string; + error?: string; +}; + +/** + * Interface for log entry data + */ +export type LogEntry = { + timestamp: string; + message: string; +}; + +/** + * Interface for resource logs + */ +export type ResourceLogs = { + resourceId: string; + logs: LogEntry[]; +}; + +/** + * Interface for Lambda test result + */ +export type LambdaTestResult = { + resourceId: string; + result?: string; + error?: string; +}; + /** * Interface for DevTools Sandbox options * diff --git a/packages/create-amplify/src/default_packages.json b/packages/create-amplify/src/default_packages.json index 4502bc76d41..2f92fb71cc5 100644 --- a/packages/create-amplify/src/default_packages.json +++ b/packages/create-amplify/src/default_packages.json @@ -2,7 +2,7 @@ "defaultDevPackages": [ "@aws-amplify/backend", "@aws-amplify/backend-cli", - "aws-cdk-lib@2.204.0", + "aws-cdk-lib@2.201.0", "constructs@^10.0.0", "typescript@^5.0.0", "tsx",