diff --git a/packages/core/src/awsService/cloudWatchLogs/activation.ts b/packages/core/src/awsService/cloudWatchLogs/activation.ts index a186a8ba983..03760b158e7 100644 --- a/packages/core/src/awsService/cloudWatchLogs/activation.ts +++ b/packages/core/src/awsService/cloudWatchLogs/activation.ts @@ -19,6 +19,7 @@ import { searchLogGroup } from './commands/searchLogGroup' import { changeLogSearchParams } from './changeLogSearch' import { CloudWatchLogsNode } from './explorer/cloudWatchLogsNode' import { loadAndOpenInitialLogStreamFile, LogStreamCodeLensProvider } from './document/logStreamsCodeLensProvider' +import { tailLogGroup } from './commands/tailLogGroup' export async function activate(context: vscode.ExtensionContext, configuration: Settings): Promise { const registry = LogDataRegistry.instance @@ -89,6 +90,14 @@ export async function activate(context: vscode.ExtensionContext, configuration: Commands.register('aws.cwl.changeFilterPattern', async () => changeLogSearchParams(registry, 'filterPattern')), - Commands.register('aws.cwl.changeTimeFilter', async () => changeLogSearchParams(registry, 'timeFilter')) + Commands.register('aws.cwl.changeTimeFilter', async () => changeLogSearchParams(registry, 'timeFilter')), + + Commands.register('aws.cwl.tailLogGroup', async (node: LogGroupNode | CloudWatchLogsNode) => { + const logGroupInfo = + node instanceof LogGroupNode + ? { regionName: node.regionCode, groupName: node.logGroup.logGroupName! } + : undefined + await tailLogGroup(logGroupInfo) + }) ) } diff --git a/packages/core/src/awsService/cloudWatchLogs/commands/tailLogGroup.ts b/packages/core/src/awsService/cloudWatchLogs/commands/tailLogGroup.ts new file mode 100644 index 00000000000..53903f9610d --- /dev/null +++ b/packages/core/src/awsService/cloudWatchLogs/commands/tailLogGroup.ts @@ -0,0 +1,108 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as nls from 'vscode-nls' +import { DefaultCloudWatchLogsClient } from '../../../shared/clients/cloudWatchLogsClient' +import { createBackButton, createExitButton, createHelpButton } from '../../../shared/ui/buttons' +import { createInputBox } from '../../../shared/ui/inputPrompter' +import { DataQuickPickItem } from '../../../shared/ui/pickerPrompter' +import { Wizard } from '../../../shared/wizards/wizard' +import { CloudWatchLogsGroupInfo } from '../registry/logDataRegistry' +import { RegionSubmenu, RegionSubmenuResponse } from '../../../shared/ui/common/regionSubmenu' +import { CancellationError } from '../../../shared/utilities/timeoutUtils' +import { LogStreamFilterResponse, LogStreamFilterSubmenu } from '../liveTailLogStreamSubmenu' +import { getLogger, ToolkitError } from '../../../shared' +import { cwlFilterPatternHelpUrl } from '../../../shared/constants' + +const localize = nls.loadMessageBundle() + +export interface TailLogGroupWizardResponse { + regionLogGroupSubmenuResponse: RegionSubmenuResponse + logStreamFilter: LogStreamFilterResponse + filterPattern: string +} + +export async function tailLogGroup(logData?: { regionName: string; groupName: string }): Promise { + const wizard = new TailLogGroupWizard(logData) + const wizardResponse = await wizard.run() + if (!wizardResponse) { + throw new CancellationError('user') + } + + //TODO: Remove Log. For testing while we aren't yet consuming the wizardResponse. + getLogger().info(JSON.stringify(wizardResponse)) +} + +export class TailLogGroupWizard extends Wizard { + public constructor(logGroupInfo?: CloudWatchLogsGroupInfo) { + super({ + initState: { + regionLogGroupSubmenuResponse: logGroupInfo + ? { + data: logGroupInfo.groupName, + region: logGroupInfo.regionName, + } + : undefined, + }, + }) + this.form.regionLogGroupSubmenuResponse.bindPrompter(createRegionLogGroupSubmenu) + this.form.logStreamFilter.bindPrompter((state) => { + if (!state.regionLogGroupSubmenuResponse?.data) { + throw new ToolkitError('LogGroupName is null') + } + return new LogStreamFilterSubmenu( + state.regionLogGroupSubmenuResponse.data, + state.regionLogGroupSubmenuResponse.region + ) + }) + this.form.filterPattern.bindPrompter((state) => createFilterPatternPrompter()) + } +} + +export function createRegionLogGroupSubmenu(): RegionSubmenu { + return new RegionSubmenu( + getLogGroupQuickPickOptions, + { + title: localize('AWS.cwl.tailLogGroup.logGroupPromptTitle', 'Select Log Group to tail'), + buttons: [createExitButton()], + }, + { title: localize('AWS.cwl.tailLogGroup.regionPromptTitle', 'Select Region for Log Group') }, + 'LogGroups' + ) +} + +async function getLogGroupQuickPickOptions(regionCode: string): Promise[]> { + const client = new DefaultCloudWatchLogsClient(regionCode) + const logGroups = client.describeLogGroups() + + const logGroupsOptions: DataQuickPickItem[] = [] + + for await (const logGroupObject of logGroups) { + if (!logGroupObject.arn || !logGroupObject.logGroupName) { + throw new ToolkitError('LogGroupObject name or arn undefined') + } + + logGroupsOptions.push({ + label: logGroupObject.logGroupName, + data: formatLogGroupArn(logGroupObject.arn), + }) + } + + return logGroupsOptions +} + +function formatLogGroupArn(logGroupArn: string): string { + return logGroupArn.endsWith(':*') ? logGroupArn.substring(0, logGroupArn.length - 2) : logGroupArn +} + +export function createFilterPatternPrompter() { + const helpUri = cwlFilterPatternHelpUrl + return createInputBox({ + title: 'Provide log event filter pattern', + placeholder: 'filter pattern (case sensitive; empty matches all)', + prompt: 'Optional pattern to use to filter the results to include only log events that match the pattern.', + buttons: [createHelpButton(helpUri), createBackButton(), createExitButton()], + }) +} diff --git a/packages/core/src/awsService/cloudWatchLogs/liveTailLogStreamSubmenu.ts b/packages/core/src/awsService/cloudWatchLogs/liveTailLogStreamSubmenu.ts new file mode 100644 index 00000000000..e39cd1b8282 --- /dev/null +++ b/packages/core/src/awsService/cloudWatchLogs/liveTailLogStreamSubmenu.ts @@ -0,0 +1,166 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Prompter, PromptResult } from '../../shared/ui/prompter' +import { DefaultCloudWatchLogsClient } from '../../shared/clients/cloudWatchLogsClient' +import { createCommonButtons } from '../../shared/ui/buttons' +import { createInputBox, InputBoxPrompter } from '../../shared/ui/inputPrompter' +import { createQuickPick, DataQuickPickItem, QuickPickPrompter } from '../../shared/ui/pickerPrompter' +import { pageableToCollection } from '../../shared/utilities/collectionUtils' +import { CloudWatchLogs } from 'aws-sdk' +import { isValidResponse, StepEstimator } from '../../shared/wizards/wizard' +import { isNonNullable } from '../../shared/utilities/tsUtils' +import { + startLiveTailHelpUrl, + startLiveTailLogStreamNamesHelpUrl, + startLiveTailLogStreamPrefixHelpUrl, +} from '../../shared/constants' + +export type LogStreamFilterType = 'menu' | 'prefix' | 'specific' | 'all' + +export interface LogStreamFilterResponse { + readonly filter?: string + readonly type: LogStreamFilterType +} + +export class LogStreamFilterSubmenu extends Prompter { + private logStreamPrefixRegEx = /^[^:*]*$/ + private currentState: LogStreamFilterType = 'menu' + private steps?: [current: number, total: number] + private region: string + private logGroupArn: string + public defaultPrompter: QuickPickPrompter = this.createMenuPrompter() + + public constructor(logGroupArn: string, region: string) { + super() + this.region = region + this.logGroupArn = logGroupArn + } + + public createMenuPrompter() { + const helpUri = startLiveTailHelpUrl + const prompter = createQuickPick(this.menuOptions, { + title: 'Select LogStream filter type', + buttons: createCommonButtons(helpUri), + }) + return prompter + } + + private get menuOptions(): DataQuickPickItem[] { + const options: DataQuickPickItem[] = [] + options.push({ + label: 'All', + detail: 'Include log events from all LogStreams in the selected LogGroup', + data: 'all', + }) + options.push({ + label: 'Specific', + detail: 'Include log events from only a specific LogStream', + data: 'specific', + }) + options.push({ + label: 'Prefix', + detail: 'Include log events from LogStreams that begin with a provided prefix', + data: 'prefix', + }) + return options + } + + public createLogStreamPrefixBox(): InputBoxPrompter { + const helpUri = startLiveTailLogStreamPrefixHelpUrl + return createInputBox({ + title: 'Enter LogStream prefix', + placeholder: 'logStream prefix (case sensitive; empty matches all)', + prompt: 'Only log events in the LogStreams that have names that start with the prefix that you specify here are included in the Live Tail session', + validateInput: (input) => this.validateLogStreamPrefix(input), + buttons: createCommonButtons(helpUri), + }) + } + + public validateLogStreamPrefix(prefix: string) { + if (prefix.length > 512) { + return 'LogStream prefix cannot be longer than 512 characters' + } + + if (!this.logStreamPrefixRegEx.test(prefix)) { + return 'LogStream prefix must match pattern: [^:*]*' + } + } + + public createLogStreamSelector(): QuickPickPrompter { + const helpUri = startLiveTailLogStreamNamesHelpUrl + const client = new DefaultCloudWatchLogsClient(this.region) + const request: CloudWatchLogs.DescribeLogStreamsRequest = { + logGroupIdentifier: this.logGroupArn, + orderBy: 'LastEventTime', + descending: true, + } + const requester = (request: CloudWatchLogs.DescribeLogStreamsRequest) => client.describeLogStreams(request) + const collection = pageableToCollection(requester, request, 'nextToken', 'logStreams') + + const items = collection + .filter(isNonNullable) + .map((streams) => streams!.map((stream) => ({ data: stream.logStreamName!, label: stream.logStreamName! }))) + + return createQuickPick(items, { + title: 'Select LogStream', + buttons: createCommonButtons(helpUri), + }) + } + + private switchState(newState: LogStreamFilterType) { + this.currentState = newState + } + + protected async promptUser(): Promise> { + while (true) { + switch (this.currentState) { + case 'menu': { + const prompter = this.createMenuPrompter() + this.steps && prompter.setSteps(this.steps[0], this.steps[1]) + + const resp = await prompter.prompt() + if (resp === 'prefix') { + this.switchState('prefix') + } else if (resp === 'specific') { + this.switchState('specific') + } else if (resp === 'all') { + return { filter: undefined, type: resp } + } else { + return undefined + } + + break + } + case 'prefix': { + const resp = await this.createLogStreamPrefixBox().prompt() + if (isValidResponse(resp)) { + return { filter: resp, type: 'prefix' } + } + this.switchState('menu') + break + } + case 'specific': { + const resp = await this.createLogStreamSelector().prompt() + if (isValidResponse(resp)) { + return { filter: resp, type: 'specific' } + } + this.switchState('menu') + break + } + } + } + } + + public setSteps(current: number, total: number): void { + this.steps = [current, total] + } + + // Unused + public get recentItem(): any { + return + } + public set recentItem(response: any) {} + public setStepEstimator(estimator: StepEstimator): void {} +} diff --git a/packages/core/src/shared/constants.ts b/packages/core/src/shared/constants.ts index 1b2d3a5253e..8ffa616f9ee 100644 --- a/packages/core/src/shared/constants.ts +++ b/packages/core/src/shared/constants.ts @@ -113,6 +113,14 @@ export const ecsIamPermissionsUrl = vscode.Uri.parse( export const CLOUDWATCH_LOGS_SCHEME = 'aws-cwl' // eslint-disable-line @typescript-eslint/naming-convention export const AWS_SCHEME = 'aws' // eslint-disable-line @typescript-eslint/naming-convention +export const startLiveTailHelpUrl = + 'https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_StartLiveTail.html' +export const startLiveTailLogStreamPrefixHelpUrl = `${startLiveTailHelpUrl}#CWL-StartLiveTail-request-logStreamNamePrefixes` +export const startLiveTailLogStreamNamesHelpUrl = `${startLiveTailHelpUrl}#CWL-StartLiveTail-request-logStreamNames` + +export const cwlFilterPatternHelpUrl = + 'https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html' + export const lambdaPackageTypeImage = 'Image' // URLs for App Runner diff --git a/packages/core/src/test/awsService/cloudWatchLogs/commands/tailLogGroup.test.ts b/packages/core/src/test/awsService/cloudWatchLogs/commands/tailLogGroup.test.ts new file mode 100644 index 00000000000..4b2e382f38c --- /dev/null +++ b/packages/core/src/test/awsService/cloudWatchLogs/commands/tailLogGroup.test.ts @@ -0,0 +1,28 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TailLogGroupWizard } from '../../../../awsService/cloudWatchLogs/commands/tailLogGroup' +import { createWizardTester } from '../../../shared/wizards/wizardTestUtils' + +describe('TailLogGroupWizard', async function () { + it('prompts regionLogGroup submenu first if context not provided', async function () { + const wizard = new TailLogGroupWizard() + const tester = await createWizardTester(wizard) + tester.regionLogGroupSubmenuResponse.assertShowFirst() + tester.logStreamFilter.assertShowSecond() + tester.filterPattern.assertShowThird() + }) + + it('skips regionLogGroup submenu if context provided', async function () { + const wizard = new TailLogGroupWizard({ + groupName: 'test-groupName', + regionName: 'test-regionName', + }) + const tester = await createWizardTester(wizard) + tester.regionLogGroupSubmenuResponse.assertDoesNotShow() + tester.logStreamFilter.assertShowFirst() + tester.filterPattern.assertShowSecond() + }) +}) diff --git a/packages/core/src/test/awsService/cloudWatchLogs/liveTailLogStreamSubmenu.test.ts b/packages/core/src/test/awsService/cloudWatchLogs/liveTailLogStreamSubmenu.test.ts new file mode 100644 index 00000000000..0ac71141b7a --- /dev/null +++ b/packages/core/src/test/awsService/cloudWatchLogs/liveTailLogStreamSubmenu.test.ts @@ -0,0 +1,74 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import { LogStreamFilterSubmenu } from '../../../awsService/cloudWatchLogs/liveTailLogStreamSubmenu' +import { createQuickPickPrompterTester, QuickPickPrompterTester } from '../../shared/ui/testUtils' +import { getTestWindow } from '../../shared/vscode/window' + +describe('liveTailLogStreamSubmenu', async function () { + let logStreamFilterSubmenu: LogStreamFilterSubmenu + let logStreamMenuPrompter: QuickPickPrompterTester + const testRegion = 'us-east-1' + const testLogGroupArn = 'my-log-group-arn' + + beforeEach(async function () { + logStreamFilterSubmenu = new LogStreamFilterSubmenu(testRegion, testLogGroupArn) + logStreamMenuPrompter = createQuickPickPrompterTester(logStreamFilterSubmenu.defaultPrompter) + }) + + describe('Menu prompter', async function () { + it('gives option for each filter type', async function () { + logStreamMenuPrompter.assertContainsItems('All', 'Specific', 'Prefix') + logStreamMenuPrompter.acceptItem('All') + await logStreamMenuPrompter.result() + }) + }) + + describe('LogStream Prefix Submenu', function () { + it('accepts valid input', async function () { + const validInput = 'my-log-stream' + getTestWindow().onDidShowInputBox((input) => { + input.acceptValue(validInput) + }) + const inputBox = logStreamFilterSubmenu.createLogStreamPrefixBox() + const result = inputBox.prompt() + assert.strictEqual(await result, validInput) + }) + + it('rejects invalid input (:)', async function () { + const invalidInput = 'my-log-stream:' + getTestWindow().onDidShowInputBox((input) => { + input.acceptValue(invalidInput) + assert.deepEqual(input.validationMessage, 'LogStream prefix must match pattern: [^:*]*') + input.hide() + }) + const inputBox = logStreamFilterSubmenu.createLogStreamPrefixBox() + await inputBox.prompt() + }) + + it('rejects invalid input (*)', async function () { + const invalidInput = 'my-log-stream*' + getTestWindow().onDidShowInputBox((input) => { + input.acceptValue(invalidInput) + assert.deepEqual(input.validationMessage, 'LogStream prefix must match pattern: [^:*]*') + input.hide() + }) + const inputBox = logStreamFilterSubmenu.createLogStreamPrefixBox() + await inputBox.prompt() + }) + + it('rejects invalid input (length)', async function () { + const invalidInput = 'a'.repeat(520) + getTestWindow().onDidShowInputBox((input) => { + input.acceptValue(invalidInput) + assert.deepEqual(input.validationMessage, 'LogStream prefix cannot be longer than 512 characters') + input.hide() + }) + const inputBox = logStreamFilterSubmenu.createLogStreamPrefixBox() + await inputBox.prompt() + }) + }) +}) diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 640bbee70ad..3d5a837e772 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -1730,6 +1730,16 @@ "group": "inline@1", "when": "view == aws.explorer && viewItem =~ /^awsCloudWatchLogNode|awsCloudWatchLogParentNode$/" }, + { + "command": "aws.cwl.tailLogGroup", + "group": "0@1", + "when": "view == aws.explorer && viewItem =~ /^awsCloudWatchLogNode|awsCloudWatchLogParentNode$/" + }, + { + "command": "aws.cwl.tailLogGroup", + "group": "inline@1", + "when": "view == aws.explorer && viewItem =~ /^awsCloudWatchLogNode|awsCloudWatchLogParentNode$/" + }, { "command": "aws.apig.copyUrl", "when": "view == aws.explorer && viewItem =~ /^(awsApiGatewayNode)$/", @@ -3123,6 +3133,18 @@ } } }, + { + "command": "aws.cwl.tailLogGroup", + "title": "%AWS.command.cloudWatchLogs.tailLogGroup%", + "category": "%AWS.title%", + "enablement": "isCloud9 || !aws.isWebExtHost", + "icon": "$(notebook-execute)", + "cloud9": { + "cn": { + "category": "%AWS.title.cn%" + } + } + }, { "command": "aws.saveCurrentLogDataContent", "title": "%AWS.command.saveCurrentLogDataContent%",