From 56ff8d7b4bad96adf8ef3f0a0a873ed0728976f6 Mon Sep 17 00:00:00 2001 From: Ana Danilochkina Date: Tue, 11 Jul 2023 23:54:17 +0100 Subject: [PATCH] feat(cloudwatch): dashboard variables (#26285) This change add the support for dashboard variables in CDK https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_dashboard_variables.html. It allows to reduce the number of repeated CloudWatch dashboards by unifying them into one managed with variables. Closes #26200 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- ...hboardVariablesIntegrationTest.assets.json | 19 ++ ...oardVariablesIntegrationTest.template.json | 55 ++++ .../cdk.out | 1 + ...efaultTestDeployAssertE08F481E.assets.json | 19 ++ ...aultTestDeployAssertE08F481E.template.json | 36 +++ .../integ.json | 12 + .../manifest.json | 111 ++++++++ .../tree.json | 136 ++++++++++ .../test/integ.dashboard-variables.ts | 64 +++++ packages/aws-cdk-lib/aws-cloudwatch/README.md | 81 ++++++ .../aws-cloudwatch/lib/dashboard.ts | 25 ++ .../aws-cdk-lib/aws-cloudwatch/lib/index.ts | 1 + .../aws-cloudwatch/lib/variable.ts | 249 ++++++++++++++++++ .../aws-cloudwatch/test/dashboard.test.ts | 224 +++++++++++++++- 14 files changed, 1031 insertions(+), 2 deletions(-) create mode 100644 packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/DashboardVariablesIntegrationTest.assets.json create mode 100644 packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/DashboardVariablesIntegrationTest.template.json create mode 100644 packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/cdk.out create mode 100644 packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.assets.json create mode 100644 packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.template.json create mode 100644 packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/integ.json create mode 100644 packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/manifest.json create mode 100644 packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/tree.json create mode 100644 packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.ts create mode 100644 packages/aws-cdk-lib/aws-cloudwatch/lib/variable.ts diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/DashboardVariablesIntegrationTest.assets.json b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/DashboardVariablesIntegrationTest.assets.json new file mode 100644 index 0000000000000..79207b48dce3d --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/DashboardVariablesIntegrationTest.assets.json @@ -0,0 +1,19 @@ +{ + "version": "32.0.0", + "files": { + "b633bcb492f09d5f22533ce8a0e06c6db179f3774bf3675afc4df8b1dccaa645": { + "source": { + "path": "DashboardVariablesIntegrationTest.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "b633bcb492f09d5f22533ce8a0e06c6db179f3774bf3675afc4df8b1dccaa645.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/DashboardVariablesIntegrationTest.template.json b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/DashboardVariablesIntegrationTest.template.json new file mode 100644 index 0000000000000..fdf68e64b8a29 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/DashboardVariablesIntegrationTest.template.json @@ -0,0 +1,55 @@ +{ + "Resources": { + "DashCCD7F836": { + "Type": "AWS::CloudWatch::Dashboard", + "Properties": { + "DashboardBody": { + "Fn::Join": [ + "", + [ + "{\"widgets\":[{\"type\":\"text\",\"width\":6,\"height\":2,\"x\":0,\"y\":0,\"properties\":{\"markdown\":\"The dashboard is showing RegionPlaceholder region\",\"background\":\"transparent\"}},{\"type\":\"metric\",\"width\":6,\"height\":6,\"x\":0,\"y\":2,\"properties\":{\"view\":\"timeSeries\",\"title\":\"My fancy graph\",\"region\":\"", + { + "Ref": "AWS::Region" + }, + "\",\"metrics\":[[\"AWS/S3\",\"BucketSizeBytes\",\"BucketName\",\"my-bucket\",\"StorageType\",\"StandardStorage\",{\"label\":\"[BucketName: ${PROP('Dim.BucketName')}] BucketSizeBytes\",\"stat\":\"Maximum\"}]],\"yAxis\":{}}}],\"variables\":[{\"pattern\":\"RegionPlaceholder\",\"type\":\"pattern\",\"inputType\":\"radio\",\"id\":\"region3\",\"defaultValue\":\"us-east-1\",\"visible\":true,\"label\":\"RegionPatternWithValues\",\"values\":[{\"label\":\"IAD\",\"value\":\"us-east-1\"},{\"label\":\"DUB\",\"value\":\"us-west-2\"}]},{\"property\":\"BucketName\",\"type\":\"property\",\"inputType\":\"select\",\"id\":\"BucketName\",\"defaultValue\":\"__FIRST\",\"visible\":true,\"label\":\"BucketName\",\"search\":\"{AWS/S3,BucketName,StorageType} MetricName=\\\"BucketSizeBytes\\\"\",\"populateFrom\":\"BucketName\"}]}" + ] + ] + } + } + } + }, + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/cdk.out b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/cdk.out new file mode 100644 index 0000000000000..f0b901e7c06e5 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/cdk.out @@ -0,0 +1 @@ +{"version":"32.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.assets.json b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.assets.json new file mode 100644 index 0000000000000..aca2924097b77 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.assets.json @@ -0,0 +1,19 @@ +{ + "version": "32.0.0", + "files": { + "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22": { + "source": { + "path": "cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.template.json", + "packaging": "file" + }, + "destinations": { + "current_account-current_region": { + "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", + "objectKey": "21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" + } + } + } + }, + "dockerImages": {} +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.template.json b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.template.json new file mode 100644 index 0000000000000..ad9d0fb73d1dd --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.template.json @@ -0,0 +1,36 @@ +{ + "Parameters": { + "BootstrapVersion": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/cdk-bootstrap/hnb659fds/version", + "Description": "Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]" + } + }, + "Rules": { + "CheckBootstrapVersion": { + "Assertions": [ + { + "Assert": { + "Fn::Not": [ + { + "Fn::Contains": [ + [ + "1", + "2", + "3", + "4", + "5" + ], + { + "Ref": "BootstrapVersion" + } + ] + } + ] + }, + "AssertDescription": "CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI." + } + ] + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/integ.json b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/integ.json new file mode 100644 index 0000000000000..46ba705f51b01 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/integ.json @@ -0,0 +1,12 @@ +{ + "version": "32.0.0", + "testCases": { + "cdk-integ-dashboard-with-variables/DefaultTest": { + "stacks": [ + "DashboardVariablesIntegrationTest" + ], + "assertionStack": "cdk-integ-dashboard-with-variables/DefaultTest/DeployAssert", + "assertionStackName": "cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/manifest.json b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/manifest.json new file mode 100644 index 0000000000000..ccae0a6497261 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/manifest.json @@ -0,0 +1,111 @@ +{ + "version": "32.0.0", + "artifacts": { + "DashboardVariablesIntegrationTest.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "DashboardVariablesIntegrationTest.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "DashboardVariablesIntegrationTest": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "DashboardVariablesIntegrationTest.template.json", + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/b633bcb492f09d5f22533ce8a0e06c6db179f3774bf3675afc4df8b1dccaa645.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "DashboardVariablesIntegrationTest.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "DashboardVariablesIntegrationTest.assets" + ], + "metadata": { + "/DashboardVariablesIntegrationTest/Dash/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "DashCCD7F836" + } + ], + "/DashboardVariablesIntegrationTest/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/DashboardVariablesIntegrationTest/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "DashboardVariablesIntegrationTest" + }, + "cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.assets": { + "type": "cdk:asset-manifest", + "properties": { + "file": "cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.assets.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.template.json", + "validateOnSynth": false, + "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}", + "cloudFormationExecutionRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}", + "stackTemplateAssetObjectUrl": "s3://cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}/21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22.json", + "requiresBootstrapStackVersion": 6, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version", + "additionalDependencies": [ + "cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.assets" + ], + "lookupRole": { + "arn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}", + "requiresBootstrapStackVersion": 8, + "bootstrapStackVersionSsmParameter": "/cdk-bootstrap/hnb659fds/version" + } + }, + "dependencies": [ + "cdkintegdashboardwithvariablesDefaultTestDeployAssertE08F481E.assets" + ], + "metadata": { + "/cdk-integ-dashboard-with-variables/DefaultTest/DeployAssert/BootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "BootstrapVersion" + } + ], + "/cdk-integ-dashboard-with-variables/DefaultTest/DeployAssert/CheckBootstrapVersion": [ + { + "type": "aws:cdk:logicalId", + "data": "CheckBootstrapVersion" + } + ] + }, + "displayName": "cdk-integ-dashboard-with-variables/DefaultTest/DeployAssert" + }, + "Tree": { + "type": "cdk:tree", + "properties": { + "file": "tree.json" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/tree.json b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/tree.json new file mode 100644 index 0000000000000..a88b13839ea86 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.js.snapshot/tree.json @@ -0,0 +1,136 @@ +{ + "version": "tree-0.1", + "tree": { + "id": "App", + "path": "", + "children": { + "DashboardVariablesIntegrationTest": { + "id": "DashboardVariablesIntegrationTest", + "path": "DashboardVariablesIntegrationTest", + "children": { + "Dash": { + "id": "Dash", + "path": "DashboardVariablesIntegrationTest/Dash", + "children": { + "Resource": { + "id": "Resource", + "path": "DashboardVariablesIntegrationTest/Dash/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::CloudWatch::Dashboard", + "aws:cdk:cloudformation:props": { + "dashboardBody": { + "Fn::Join": [ + "", + [ + "{\"widgets\":[{\"type\":\"text\",\"width\":6,\"height\":2,\"x\":0,\"y\":0,\"properties\":{\"markdown\":\"The dashboard is showing RegionPlaceholder region\",\"background\":\"transparent\"}},{\"type\":\"metric\",\"width\":6,\"height\":6,\"x\":0,\"y\":2,\"properties\":{\"view\":\"timeSeries\",\"title\":\"My fancy graph\",\"region\":\"", + { + "Ref": "AWS::Region" + }, + "\",\"metrics\":[[\"AWS/S3\",\"BucketSizeBytes\",\"BucketName\",\"my-bucket\",\"StorageType\",\"StandardStorage\",{\"label\":\"[BucketName: ${PROP('Dim.BucketName')}] BucketSizeBytes\",\"stat\":\"Maximum\"}]],\"yAxis\":{}}}],\"variables\":[{\"pattern\":\"RegionPlaceholder\",\"type\":\"pattern\",\"inputType\":\"radio\",\"id\":\"region3\",\"defaultValue\":\"us-east-1\",\"visible\":true,\"label\":\"RegionPatternWithValues\",\"values\":[{\"label\":\"IAD\",\"value\":\"us-east-1\"},{\"label\":\"DUB\",\"value\":\"us-west-2\"}]},{\"property\":\"BucketName\",\"type\":\"property\",\"inputType\":\"select\",\"id\":\"BucketName\",\"defaultValue\":\"__FIRST\",\"visible\":true,\"label\":\"BucketName\",\"search\":\"{AWS/S3,BucketName,StorageType} MetricName=\\\"BucketSizeBytes\\\"\",\"populateFrom\":\"BucketName\"}]}" + ] + ] + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_cloudwatch.CfnDashboard", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.aws_cloudwatch.Dashboard", + "version": "0.0.0" + } + }, + "BootstrapVersion": { + "id": "BootstrapVersion", + "path": "DashboardVariablesIntegrationTest/BootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnParameter", + "version": "0.0.0" + } + }, + "CheckBootstrapVersion": { + "id": "CheckBootstrapVersion", + "path": "DashboardVariablesIntegrationTest/CheckBootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnRule", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.Stack", + "version": "0.0.0" + } + }, + "cdk-integ-dashboard-with-variables": { + "id": "cdk-integ-dashboard-with-variables", + "path": "cdk-integ-dashboard-with-variables", + "children": { + "DefaultTest": { + "id": "DefaultTest", + "path": "cdk-integ-dashboard-with-variables/DefaultTest", + "children": { + "Default": { + "id": "Default", + "path": "cdk-integ-dashboard-with-variables/DefaultTest/Default", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.2.55" + } + }, + "DeployAssert": { + "id": "DeployAssert", + "path": "cdk-integ-dashboard-with-variables/DefaultTest/DeployAssert", + "children": { + "BootstrapVersion": { + "id": "BootstrapVersion", + "path": "cdk-integ-dashboard-with-variables/DefaultTest/DeployAssert/BootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnParameter", + "version": "0.0.0" + } + }, + "CheckBootstrapVersion": { + "id": "CheckBootstrapVersion", + "path": "cdk-integ-dashboard-with-variables/DefaultTest/DeployAssert/CheckBootstrapVersion", + "constructInfo": { + "fqn": "aws-cdk-lib.CfnRule", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.Stack", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests-alpha.IntegTestCase", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests-alpha.IntegTest", + "version": "0.0.0" + } + }, + "Tree": { + "id": "Tree", + "path": "Tree", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.2.55" + } + } + }, + "constructInfo": { + "fqn": "aws-cdk-lib.App", + "version": "0.0.0" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.ts b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.ts new file mode 100644 index 0000000000000..609f6d037f278 --- /dev/null +++ b/packages/@aws-cdk-testing/framework-integ/test/aws-cloudwatch/test/integ.dashboard-variables.ts @@ -0,0 +1,64 @@ +import { App, Stack, StackProps } from 'aws-cdk-lib'; +import { IntegTest } from '@aws-cdk/integ-tests-alpha'; +import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; + +class DashboardVariablesIntegrationTest extends Stack { + constructor(scope: App, id: string, props?: StackProps) { + super(scope, id, props); + + const dashboard = new cloudwatch.Dashboard(this, 'Dash', { + variables: [new cloudwatch.DashboardVariable({ + type: cloudwatch.VariableType.PATTERN, + value: 'RegionPlaceholder', + inputType: cloudwatch.VariableInputType.RADIO, + id: 'region3', + label: 'RegionPatternWithValues', + defaultValue: cloudwatch.DefaultValue.value('us-east-1'), + visible: true, + values: cloudwatch.Values.fromValues({ label: 'IAD', value: 'us-east-1' }, { label: 'DUB', value: 'us-west-2' }), + })], + }); + + dashboard.addWidgets(new cloudwatch.TextWidget({ + markdown: 'The dashboard is showing RegionPlaceholder region', + background: cloudwatch.TextWidgetBackground.TRANSPARENT, + })); + + const widget = new cloudwatch.GraphWidget({ + title: 'My fancy graph', + left: [ + new cloudwatch.Metric({ + namespace: 'AWS/S3', + metricName: 'BucketSizeBytes', + label: '[BucketName: ${PROP(\'Dim.BucketName\')}] BucketSizeBytes', + statistic: cloudwatch.Stats.MAXIMUM, + dimensionsMap: { StorageType: 'StandardStorage', BucketName: 'my-bucket' }, + }), + ], + }); + + // The dashboard variable which changes BucketName property on the dashboard + dashboard.addVariable(new cloudwatch.DashboardVariable({ + defaultValue: cloudwatch.DefaultValue.FIRST, + id: 'BucketName', + label: 'BucketName', + inputType: cloudwatch.VariableInputType.SELECT, + type: cloudwatch.VariableType.PROPERTY, + value: 'BucketName', + values: cloudwatch.Values.fromSearchComponents({ + namespace: 'AWS/S3', + dimensions: ['BucketName', 'StorageType'], + metricName: 'BucketSizeBytes', + populateFrom: 'BucketName', + }), + visible: true, + })); + + dashboard.addWidgets(widget); + } +} + +const app = new App(); +new IntegTest(app, 'cdk-integ-dashboard-with-variables', { + testCases: [new DashboardVariablesIntegrationTest(app, 'DashboardVariablesIntegrationTest')], +}); diff --git a/packages/aws-cdk-lib/aws-cloudwatch/README.md b/packages/aws-cdk-lib/aws-cloudwatch/README.md index e678c6a7b01dc..a5e26293b6960 100644 --- a/packages/aws-cdk-lib/aws-cloudwatch/README.md +++ b/packages/aws-cdk-lib/aws-cloudwatch/README.md @@ -723,3 +723,84 @@ const dashboard = new cw.Dashboard(this, 'Dash', { ``` Here, the dashboard would show the metrics for the last 7 days. + +### Dashboard variables + +Dashboard variables are a convenient way to create flexible dashboards that display different content depending +on the value of an input field within a dashboard. They create a dashboard on which it's possible to quickly switch between +different Lambda functions, Amazon EC2 instances, etc. + +You can learn more about Dashboard variables in the [Amazon Cloudwatch User Guide](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_dashboard_variables.html) + +There are two types of dashboard variables available: a property variable and a pattern variable. +- Property variables can change any JSON property in the JSON source of a dashboard like `region`. It can also change the dimension name for a metric. +- Pattern variables use a regular expression pattern to change all or part of a JSON property. + +A use case of a **property variable** is a dashboard with the ability to toggle the `region` property to see the same dashboard in different regions: + +```ts +import * as cw from 'aws-cdk-lib/aws-cloudwatch'; + +const dashboard = new cw.Dashboard(this, 'Dash', { + defaultInterval: Duration.days(7), + variables: [new cw.DashboardVariable({ + id: 'region', + type: cw.VariableType.PROPERTY, + label: 'Region', + inputType: cw.VariableInputType.RADIO, + value: 'region', + values: cw.Values.fromValues({ label: 'IAD', value: 'us-east-1' }, { label: 'DUB', value: 'us-west-2' }), + defaultValue: cw.DefaultValue.value('us-east-1'), + visible: true, + })], +}); +``` + +This example shows how to change `region` everywhere, assuming the current dashboard is showing region `us-east-1` already, by using **pattern variable** + +```ts +import * as cw from 'aws-cdk-lib/aws-cloudwatch'; + +const dashboard = new cw.Dashboard(this, 'Dash', { + defaultInterval: Duration.days(7), + variables: [new cw.DashboardVariable({ + id: 'region2', + type: cw.VariableType.PATTERN, + label: 'RegionPattern', + inputType: cw.VariableInputType.INPUT, + value: 'us-east-1', + defaultValue: cw.DefaultValue.value('us-east-1'), + visible: true, + })], +}); +``` + +The following example generates a Lambda function variable, with a radio button for each function. Functions are discovered by a metric query search. +The `values` with `cw.Values.fromSearchComponents` indicates that the values will be populated from `FunctionName` values retrieved from the search expression `{AWS/Lambda,FunctionName} MetricName=\"Duration\"`. +The `defaultValue` with `cw.DefaultValue.FIRST` indicates that the default value will be the first value returned from the search. + +```ts +import * as cw from 'aws-cdk-lib/aws-cloudwatch'; + +const dashboard = new cw.Dashboard(this, 'Dash', { + defaultInterval: Duration.days(7), + variables: [new cw.DashboardVariable({ + id: 'functionName', + type: cw.VariableType.PATTERN, + label: 'Function', + inputType: cw.VariableInputType.RADIO, + value: 'originalFuncNameInDashboard', + // equivalent to cw.Values.fromSearch('{AWS/Lambda,FunctionName} MetricName=\"Duration\"', 'FunctionName') + values: cw.Values.fromSearchComponents({ + namespace: 'AWS/Lambda', + dimensions: ['FunctionName'], + metricName: 'Duration', + populateFrom: 'FunctionName', + }), + defaultValue: cw.DefaultValue.FIRST, + visible: true, + })], +}); +``` + +You can add a variable after object instantiation with the method `dashboard.addVariable()`. diff --git a/packages/aws-cdk-lib/aws-cloudwatch/lib/dashboard.ts b/packages/aws-cdk-lib/aws-cloudwatch/lib/dashboard.ts index 919ddf643f88d..494a44d54de83 100644 --- a/packages/aws-cdk-lib/aws-cloudwatch/lib/dashboard.ts +++ b/packages/aws-cdk-lib/aws-cloudwatch/lib/dashboard.ts @@ -1,6 +1,7 @@ import { Construct } from 'constructs'; import { CfnDashboard } from './cloudwatch.generated'; import { Column, Row } from './layout'; +import { IVariable } from './variable'; import { IWidget } from './widget'; import { Lazy, Resource, Stack, Token, Annotations, Duration } from '../../core'; @@ -12,6 +13,7 @@ export enum PeriodOverride { * Period of all graphs on the dashboard automatically adapt to the time range of the dashboard. */ AUTO = 'auto', + /** * Period set for each graph will be used */ @@ -77,6 +79,15 @@ export interface DashboardProps { * @default - No widgets */ readonly widgets?: IWidget[][] + + /** + * A list of dashboard variables + * + * @see https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_dashboard_variables.html#cloudwatch_dashboard_variables_types + * + * @default - No variables + */ + readonly variables?: IVariable[]; } /** @@ -100,6 +111,8 @@ export class Dashboard extends Resource { private readonly rows: IWidget[] = []; + private readonly variables: IVariable[] = []; + constructor(scope: Construct, id: string, props: DashboardProps = {}) { super(scope, id, { physicalName: props.dashboardName, @@ -130,6 +143,7 @@ export class Dashboard extends Resource { end: props.defaultInterval !== undefined ? undefined : props.end, periodOverride: props.periodOverride, widgets: column.toJson(), + variables: this.variables.length > 0 ? this.variables.map(variable => variable.toJson()) : undefined, }); }, }), @@ -141,6 +155,8 @@ export class Dashboard extends Resource { this.addWidgets(...row); }); + (props.variables || []).forEach(variable => this.addVariable(variable)); + this.dashboardArn = Stack.of(this).formatArn({ service: 'cloudwatch', resource: 'dashboard', @@ -171,6 +187,15 @@ export class Dashboard extends Resource { const w = widgets.length > 1 ? new Row(...widgets) : widgets[0]; this.rows.push(w); } + + /** + * Add a variable to the dashboard. + * + * @see https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch_dashboard_variables.html + */ + public addVariable(variable: IVariable) { + this.variables.push(variable); + } } function allWidgetsDeep(ws: IWidget[]) { diff --git a/packages/aws-cdk-lib/aws-cloudwatch/lib/index.ts b/packages/aws-cdk-lib/aws-cloudwatch/lib/index.ts index 3a27d6be77458..1a97035f93b28 100644 --- a/packages/aws-cdk-lib/aws-cloudwatch/lib/index.ts +++ b/packages/aws-cdk-lib/aws-cloudwatch/lib/index.ts @@ -13,6 +13,7 @@ export * from './text'; export * from './widget'; export * from './alarm-status-widget'; export * from './stats'; +export * from './variable'; // AWS::CloudWatch CloudFormation Resources: export * from './cloudwatch.generated'; diff --git a/packages/aws-cdk-lib/aws-cloudwatch/lib/variable.ts b/packages/aws-cdk-lib/aws-cloudwatch/lib/variable.ts new file mode 100644 index 0000000000000..ef60b7eaefbdd --- /dev/null +++ b/packages/aws-cdk-lib/aws-cloudwatch/lib/variable.ts @@ -0,0 +1,249 @@ +export enum VariableInputType { + /** + * Freeform text input box + */ + INPUT = 'input', + + /** + * A dropdown of pre-defined values, or values filled in from a metric search query + */ + RADIO = 'radio', + + /** + * A set of pre-defined radio buttons, which can also be defined from a metric search query + */ + SELECT = 'select', +} + +export enum VariableType { + /** + * A property variable changes the values of all instances of a property in the list of widgets in the dashboard. + */ + PROPERTY = 'property', + + /** + * A pattern variable is one that changes a regex pattern across the dashboard JSON + */ + PATTERN = 'pattern', +} + +/** + * A single dashboard variable + */ +export interface IVariable { + /** + * Return the variable JSON for use in the dashboard + */ + toJson(): any; +} + +export interface VariableValue { + /** + * Optional label for the selected item + * + * @default - the variable's value + */ + readonly label?: string; + + /** + * Value of the selected item + */ + readonly value: string; +} + +/** + * Search components for use with {@link Values.fromSearchComponents} + */ +export interface SearchComponents { + /** + * The namespace to be used in the search expression + */ + readonly namespace: string, + + /** + * The list of dimensions to be used in the search expression + */ + readonly dimensions: string[], + + /** + * The metric name to be used in the search expression + */ + readonly metricName: string, + + /** + * The dimension name, that the search expression retrieves, whose values will be used to populate the values to choose from + */ + readonly populateFrom: string, +} + +/** + * A class for providing values for use with {@link VariableInputType.SELECT} and {@link VariableInputType.RADIO} dashboard variables + */ +export abstract class Values { + /** + * Create values from the components of search expression + */ + public static fromSearchComponents(components: SearchComponents): Values { + if (components.dimensions.length === 0) { + throw new Error('Empty dimensions provided. Please specify one dimension at least'); + } + if (!components.dimensions.includes(components.populateFrom)) { + throw new Error(`populateFrom (${components.populateFrom}) is not present in dimensions`); + } + const metricSchema = [components.namespace, ...components.dimensions]; + return Values.fromSearch(`{${metricSchema.join(',')}} MetricName=\"${components.metricName}\"`, components.populateFrom); + } + + /** + * Create values from a search expression + * + * @param expression search expression that specifies a namespace, dimension name(s) and a metric name. For example `{AWS/EC2,InstanceId} MetricName=\"CPUUtilization\"` + * @param populateFrom dimension the dimension name, that the search expression retrieves, whose values will be used to populate the values to choose from. For example `InstanceId` + */ + public static fromSearch(expression: string, populateFrom: string): Values { + return new SearchValues(expression, populateFrom); + } + + /** + * Create values from an array of possible variable values + */ + public static fromValues(...values: VariableValue[]): Values { + if (values.length == 0) { + throw new Error('Empty values is not allowed'); + } + return new StaticValues(values); + } + + public abstract toJson(): any; +} + +class StaticValues extends Values { + constructor(private readonly values: VariableValue[]) { + super(); + } + + toJson(): any { + return { + values: this.values.map(value => ({ label: value.label, value: value.value })), + }; + } +} + +class SearchValues extends Values { + /** + * Create a search expression for use + * + * @param expression search expression that specifies a namespace, dimension name(s) and a metric name. For example `{AWS/EC2,InstanceId} MetricName=\"CPUUtilization\"` + * @param populateFrom dimension the dimension name, that the search expression retrieves, whose values will be used to populate the values to choose from. For example `InstanceId` + */ + public constructor(public readonly expression: string, public readonly populateFrom: string) { + super(); + } + + toJson(): any { + return { + search: this.expression, + populateFrom: this.populateFrom, + }; + } +} + +/** + * Default value for use in {@link DashboardVariableOptions} + */ +export class DefaultValue { + /** + * A special value for use with search expressions to have the default value be the first value returned from search + */ + public static readonly FIRST = new DefaultValue('__FIRST'); + + /** + * Create a default value + * @param value the value to be used as default + */ + public static value(value: any) { + return new DefaultValue(value); + } + + private constructor(public readonly val: any) { } +} + +/** + * Options for {@link DashboardVariable} + */ +export interface DashboardVariableOptions { + /** + * Type of the variable + */ + readonly type: VariableType; + + /** + * The way the variable value is selected + */ + readonly inputType: VariableInputType; + + /** + * Pattern or property value to replace + */ + readonly value: string; + + /** + * Unique id + */ + readonly id: string; + + /** + * Optional label in the toolbar + * + * @default - the variable's value + */ + readonly label?: string; + + /** + * Optional values (required for {@link VariableInputType.RADIO} and {@link VariableInputType.SELECT} dashboard variables). + * + * @default - no values + */ + readonly values?: Values; + + /** + * Optional default value + * + * @default - no default value is set + */ + readonly defaultValue?: DefaultValue; + + /** + * Whether the variable is visible + * + * @default - true + */ + readonly visible?: boolean; +} + +/** + * Dashboard Variable + */ +export class DashboardVariable implements IVariable { + public constructor(private readonly options: DashboardVariableOptions) { + if (options.inputType !== VariableInputType.INPUT && !options.values) { + throw new Error(`Variable with inputType (${options.inputType}) requires values to be set`); + } + if (options.inputType == VariableInputType.INPUT && options.values) { + throw new Error('inputType INPUT cannot be combined with values. Please choose either SELECT or RADIO or remove \'values\' from options.'); + } + } + + toJson(): any { + return { + [this.options.type]: this.options.value, + type: this.options.type, + inputType: this.options.inputType, + id: this.options.id, + defaultValue: this.options.defaultValue?.val, + visible: this.options.visible, + label: this.options.label, + ...this.options.values?.toJson(), + }; + } +} diff --git a/packages/aws-cdk-lib/aws-cloudwatch/test/dashboard.test.ts b/packages/aws-cdk-lib/aws-cloudwatch/test/dashboard.test.ts index 8ffd7c62488b9..9c278ea600b37 100644 --- a/packages/aws-cdk-lib/aws-cloudwatch/test/dashboard.test.ts +++ b/packages/aws-cdk-lib/aws-cloudwatch/test/dashboard.test.ts @@ -1,6 +1,15 @@ -import { Template, Annotations, Match } from '../../assertions'; +import { Annotations, Match, Template } from '../../assertions'; import { App, Duration, Stack } from '../../core'; -import { Dashboard, GraphWidget, PeriodOverride, TextWidget, MathExpression, TextWidgetBackground } from '../lib'; +import { + Dashboard, DashboardVariable, DefaultValue, + GraphWidget, + MathExpression, + PeriodOverride, + TextWidget, + TextWidgetBackground, Values, + VariableInputType, + VariableType, +} from '../lib'; describe('Dashboard', () => { test('widgets in different adds are laid out underneath each other', () => { @@ -230,6 +239,202 @@ describe('Dashboard', () => { const template = Annotations.fromStack(stack); template.hasWarning('/MyStack/MyDashboard', Match.stringLikeRegexp("Math expression 'oops' references unknown identifiers")); }); + + test('dashboard has initial select/pattern variable', () => { + // GIVEN + const stack = new Stack(); + // WHEN + new Dashboard(stack, 'Dashboard', { + variables: [new DashboardVariable({ + type: VariableType.PATTERN, + value: 'us-east-1', + inputType: VariableInputType.SELECT, + id: 'region3', + label: 'RegionPatternWithValues', + defaultValue: DefaultValue.value('us-east-1'), + visible: true, + values: Values.fromValues({ label: 'IAD', value: 'us-east-1' }, { label: 'DUB', value: 'us-west-2' }), + })], + }); + + // THEN + const resources = Template.fromStack(stack).findResources('AWS::CloudWatch::Dashboard'); + expect(Object.keys(resources).length).toEqual(1); + const key = Object.keys(resources)[0]; + hasVariables(resources[key].Properties, [ + { defaultValue: 'us-east-1', id: 'region3', inputType: 'select', label: 'RegionPatternWithValues', pattern: 'us-east-1', type: 'pattern', values: [{ label: 'IAD', value: 'us-east-1' }, { label: 'DUB', value: 'us-west-2' }], visible: true }, + ]); + + }); + + test('dashboard has initial and added variable', () => { + // GIVEN + const stack = new Stack(); + const dashboard = new Dashboard(stack, 'Dashboard', { + variables: [new DashboardVariable({ + type: VariableType.PATTERN, + value: 'us-east-1', + inputType: VariableInputType.SELECT, + id: 'region3', + label: 'RegionPatternWithValues', + defaultValue: DefaultValue.value('us-east-1'), + visible: true, + values: Values.fromValues({ label: 'IAD', value: 'us-east-1' }, { label: 'DUB', value: 'us-west-2' }), + })], + }); + + dashboard.addVariable(new DashboardVariable({ + type: VariableType.PATTERN, + value: 'us-east-1', + inputType: VariableInputType.INPUT, + id: 'region2', + label: 'RegionPattern', + defaultValue: DefaultValue.value('us-east-1'), + visible: true, + })); + + // THEN + const resources = Template.fromStack(stack).findResources('AWS::CloudWatch::Dashboard'); + expect(Object.keys(resources).length).toEqual(1); + const key = Object.keys(resources)[0]; + hasVariables(resources[key].Properties, [ + { defaultValue: 'us-east-1', id: 'region3', inputType: 'select', label: 'RegionPatternWithValues', pattern: 'us-east-1', type: 'pattern', values: [{ label: 'IAD', value: 'us-east-1' }, { label: 'DUB', value: 'us-west-2' }], visible: true }, + { defaultValue: 'us-east-1', id: 'region2', inputType: 'input', label: 'RegionPattern', pattern: 'us-east-1', type: 'pattern', visible: true }, + ]); + + }); + + test('dashboard has property/select search variable', () => { + // GIVEN + const stack = new Stack(); + const dashboard = new Dashboard(stack, 'Dashboard'); + + // WHEN + dashboard.addVariable(new DashboardVariable({ + defaultValue: DefaultValue.FIRST, + id: 'InstanceId', + label: 'Instance', + inputType: VariableInputType.SELECT, + type: VariableType.PROPERTY, + value: 'InstanceId', + values: Values.fromSearchComponents({ + namespace: 'AWS/EC2', + dimensions: ['InstanceId'], + metricName: 'CPUUtilization', + populateFrom: 'InstanceId', + }), + visible: true, + })); + + // THEN + const resources = Template.fromStack(stack).findResources('AWS::CloudWatch::Dashboard'); + expect(Object.keys(resources).length).toEqual(1); + const key = Object.keys(resources)[0]; + hasVariables(resources[key].Properties, [ + { defaultValue: '__FIRST', id: 'InstanceId', inputType: 'select', label: 'Instance', populateFrom: 'InstanceId', property: 'InstanceId', search: '{AWS/EC2,InstanceId} MetricName="CPUUtilization"', type: 'property', visible: true }, + ]); + }); + + test('dashboard has input/pattern value variable', () => { + // GIVEN + const stack = new Stack(); + const dashboard = new Dashboard(stack, 'Dashboard'); + + // WHEN + dashboard.addVariable(new DashboardVariable({ + type: VariableType.PATTERN, + value: 'us-east-1', + inputType: VariableInputType.INPUT, + id: 'region2', + label: 'RegionPattern', + defaultValue: DefaultValue.value('us-east-1'), + visible: true, + })); + + // THEN + const resources = Template.fromStack(stack).findResources('AWS::CloudWatch::Dashboard'); + expect(Object.keys(resources).length).toEqual(1); + const key = Object.keys(resources)[0]; + hasVariables(resources[key].Properties, [ + { defaultValue: 'us-east-1', id: 'region2', inputType: 'input', label: 'RegionPattern', pattern: 'us-east-1', type: 'pattern', visible: true }, + ]); + }); + + test('dashboard has property/radio value variable', () => { + // GIVEN + const stack = new Stack(); + const dashboard = new Dashboard(stack, 'Dashboard'); + + // WHEN + dashboard.addVariable(new DashboardVariable({ + type: VariableType.PROPERTY, + value: 'region', + inputType: VariableInputType.RADIO, + id: 'region3', + label: 'RegionRadio', + defaultValue: DefaultValue.value('us-east-1'), + visible: true, + values: Values.fromValues({ label: 'IAD', value: 'us-east-1' }, { label: 'DUB', value: 'us-west-2' }), + })); + + // THEN + const resources = Template.fromStack(stack).findResources('AWS::CloudWatch::Dashboard'); + expect(Object.keys(resources).length).toEqual(1); + const key = Object.keys(resources)[0]; + hasVariables(resources[key].Properties, [ + { defaultValue: 'us-east-1', id: 'region3', inputType: 'radio', label: 'RegionRadio', property: 'region', type: 'property', values: [{ label: 'IAD', value: 'us-east-1' }, { label: 'DUB', value: 'us-west-2' }], visible: true }, + ]); + }); + + test('dashboard variable fails if unsupported input inputType', () => { + expect(() => new DashboardVariable({ + defaultValue: DefaultValue.FIRST, + id: 'InstanceId', + label: 'Instance', + inputType: VariableInputType.INPUT, + type: VariableType.PROPERTY, + value: 'InstanceId', + values: Values.fromSearchComponents({ + namespace: 'AWS/EC2', + dimensions: ['InstanceId'], + metricName: 'CPUUtilization', + populateFrom: 'InstanceId', + }), + visible: true, + })).toThrow(/inputType INPUT cannot be combined with values. Please choose either SELECT or RADIO or remove 'values' from options/); + }); + + test('dashboard variable fails if no values provided for select or radio inputType', () => { + [VariableInputType.SELECT, VariableInputType.RADIO].forEach(inputType => { + expect(() => new DashboardVariable({ + inputType, + type: VariableType.PATTERN, + value: 'us-east-1', + id: 'region3', + label: 'RegionPatternWithValues', + defaultValue: DefaultValue.value('us-east-1'), + visible: true, + })).toThrow(`Variable with inputType (${inputType}) requires values to be set`); + }); + }); + + test('search values fail if empty dimensions', () => { + expect(() => Values.fromSearchComponents({ + namespace: 'AWS/EC2', + dimensions: [], + metricName: 'CPUUtilization', + populateFrom: 'InstanceId', + })).toThrow(/Empty dimensions provided. Please specify one dimension at least/); + }); + + test('search values fail if populateFrom is not present in dimensions', () => { + expect(() => Values.fromSearchComponents({ + namespace: 'AWS/EC2', + dimensions: ['InstanceId'], + metricName: 'CPUUtilization', + populateFrom: 'DontExist', + })).toThrow('populateFrom (DontExist) is not present in dimensions'); + }); }); /** @@ -246,3 +451,18 @@ function hasWidgets(props: any, widgets: any[]) { } expect(actualWidgets).toEqual(expect.arrayContaining(widgets)); } + +/** + * Returns a property predicate that checks that the given Dashboard has the indicated variables + */ +function hasVariables(props: any, variables: any[]) { + let actualVariables: any[] = []; + try { + actualVariables = JSON.parse(props.DashboardBody).variables; + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error parsing', props); + throw e; + } + expect(actualVariables).toEqual(expect.arrayContaining(variables)); +}