diff --git a/cloudwatch/README.md b/cloudwatch/README.md
new file mode 100644
index 000000000..81e9e2218
--- /dev/null
+++ b/cloudwatch/README.md
@@ -0,0 +1,102 @@
+# CloudWatch Plugin for Perses
+
+## Overview
+
+The CloudWatch plugin queries [Amazon CloudWatch](https://aws.amazon.com/cloudwatch/) metrics through the
+CloudWatch proxy of the Perses server. The server signs the requests with its own AWS identity, optionally assuming
+an IAM role: the browser never receives AWS credentials, and the datasource contains none.
+
+The CloudWatch proxy is disabled by default. A Perses administrator must enable it and allow the regions, accounts and
+roles the datasources can use. See the CloudWatch proxy documentation of Perses.
+
+## Features
+
+- **CloudWatch Datasource**: the region, and optionally the IAM role to assume and the secret holding its external ID.
+- **CloudWatch Time Series Query**: metrics (namespace, metric name, dimensions, statistic, period) and metric math
+ expressions referencing the other queries, with metric discovery in the editor.
+- **CloudWatch Dimension Values Variable**: the values of a dimension in a namespace, for example every `InstanceId`.
+- **Variable Support**: dashboard variables can be used in the namespaces, metric names, dimensions, expressions and
+ legends.
+
+## Datasource
+
+```yaml
+kind: Datasource
+metadata:
+ name: cloudwatch
+ project: production
+spec:
+ plugin:
+ kind: CloudWatchDatasource
+ spec:
+ proxy:
+ kind: CloudWatchProxy
+ spec:
+ region: us-east-1
+ # Optional: the IAM role assumed by the Perses server. Without it, the server uses its own AWS identity.
+ roleArn: arn:aws:iam::123456789012:role/perses-cloudwatch-read
+ # Optional, only with roleArn: the name of the secret holding the external ID in its authorization credentials.
+ externalIdSecret: cloudwatch-external-id
+```
+
+CloudWatch datasources are project or global datasources: the Perses server doesn't allow them in a dashboard.
+
+## Time Series Query
+
+```yaml
+kind: CloudWatchTimeSeriesQuery
+spec:
+ datasource:
+ kind: CloudWatchDatasource
+ name: cloudwatch
+ queries:
+ - id: m1
+ returnData: false
+ metric:
+ namespace: AWS/EC2
+ name: CPUUtilization
+ dimensions:
+ InstanceId: $instance
+ statistic: Average
+ period: 60
+ - id: e1
+ label: CPU x2
+ expression: m1 * 2
+```
+
+- `statistic` is `Average`, `Sum`, `Minimum`, `Maximum`, `SampleCount` or a percentile from `p0` to `p100`.
+- `period` is the minimum period in seconds, a multiple of 60. Like the minimum step of a Prometheus query, it is
+ increased to the step suggested by the panel, and so the series fit in the 10000 datapoints the Perses server
+ returns. For example, a single metric over 7 days is queried with a period of at least 2 minutes.
+- `returnData: false` hides a series only used by an expression.
+- Expressions support the arithmetic and comparison operators and the functions `ABS`, `CEIL`, `FLOOR`, `IF`, `FILL`,
+ `RATE`, `DIFF`, `DIFF_TIME`, `MIN`, `MAX`, `SUM` and `AVG`. `SEARCH`, Metrics Insights queries and Lambda functions
+ are not supported.
+- At most 20 queries, over at most 31 days.
+
+A dimension takes a single value, so use single-value variables in the dimensions: a multi-value variable is replaced by
+a single string, by default in the `(a|b)` format.
+
+## Dimension Values Variable
+
+```yaml
+kind: CloudWatchDimensionValuesVariable
+spec:
+ datasource:
+ kind: CloudWatchDatasource
+ name: cloudwatch
+ namespace: AWS/EC2
+ metricName: CPUUtilization # optional
+ dimensionKey: InstanceId
+ dimensions: # optional filters
+ AutoScalingGroupName: $asg
+```
+
+The options come from `ListMetrics`, which only returns the metrics with data in the last two weeks. The Perses server
+returns at most 1000 metrics: use `metricName` and `dimensions` to narrow the discovery on large accounts.
+
+## Connection test
+
+The CloudWatch proxy only accepts the `GetMetricData` and `ListMetrics` actions sent as `POST` requests, so the generic
+connection test of the datasource editor is not available. Use the metric discovery of the query editor to check the
+datasource.
diff --git a/cloudwatch/cue.mod/module.cue b/cloudwatch/cue.mod/module.cue
new file mode 100644
index 000000000..3a05c44ad
--- /dev/null
+++ b/cloudwatch/cue.mod/module.cue
@@ -0,0 +1,17 @@
+module: "github.com/perses/plugins/cloudwatch@v0"
+language: {
+ version: "v0.17.1"
+}
+source: {
+ kind: "git"
+}
+deps: {
+ "github.com/perses/shared/cue@v0": {
+ v: "v0.55.0-beta.15"
+ default: true
+ }
+ "github.com/perses/spec/cue@v0": {
+ v: "v0.3.0-beta.10"
+ default: true
+ }
+}
diff --git a/cloudwatch/go.mod b/cloudwatch/go.mod
new file mode 100644
index 000000000..2ed23f5b8
--- /dev/null
+++ b/cloudwatch/go.mod
@@ -0,0 +1,37 @@
+module github.com/perses/plugins/cloudwatch
+
+go 1.27.1
+
+require (
+ github.com/perses/perses v0.55.0-beta.3
+ github.com/perses/spec v0.3.0-beta.10
+ github.com/stretchr/testify v1.12.1
+)
+
+require (
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/go-jose/go-jose/v4 v4.1.5 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jpillora/backoff v1.0.0 // indirect
+ github.com/muhlemmer/gu v0.3.1 // indirect
+ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f // indirect
+ github.com/perses/common v0.31.2 // indirect
+ github.com/prometheus/client_golang v1.24.1 // indirect
+ github.com/prometheus/client_model v0.6.3 // indirect
+ github.com/prometheus/common v0.71.0 // indirect
+ github.com/prometheus/procfs v0.21.1 // indirect
+ github.com/zitadel/oidc/v3 v3.51.3 // indirect
+ github.com/zitadel/schema v1.3.2 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
+ go.yaml.in/yaml/v3 v3.0.5 // indirect
+ golang.org/x/net v0.59.0 // indirect
+ golang.org/x/oauth2 v0.37.0 // indirect
+ golang.org/x/sys v0.48.0 // indirect
+ golang.org/x/text v0.42.0 // indirect
+ google.golang.org/protobuf v1.36.12 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
diff --git a/cloudwatch/go.sum b/cloudwatch/go.sum
new file mode 100644
index 000000000..ad0f63fad
--- /dev/null
+++ b/cloudwatch/go.sum
@@ -0,0 +1,62 @@
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
+github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
+github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
+github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
+github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU=
+github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/nexucis/lamenv v0.5.2 h1:tK/u3XGhCq9qIoVNcXsK9LZb8fKopm0A5weqSRvHd7M=
+github.com/nexucis/lamenv v0.5.2/go.mod h1:HusJm6ltmmT7FMG8A750mOLuME6SHCsr2iFYxp5fFi0=
+github.com/perses/common v0.31.2 h1:klsl0KfWn6wVVG4rDJvsTvFO8Owf5ed4nj2VjbQST60=
+github.com/perses/common v0.31.2/go.mod h1:KgLB0ojBFzg93UwTNK8uAE1yuGexBiwqHiAvTFcHRDI=
+github.com/perses/perses v0.55.0-beta.3 h1:LMTE3/SnPGDpKMc58MVUufxTH9tehvCFJtx3E5d9F74=
+github.com/perses/perses v0.55.0-beta.3/go.mod h1:0UJkSSi6uH2DssOjY8BHnn3kjvnbj3ukAblF1R8K0Lw=
+github.com/perses/spec v0.3.0-beta.10 h1:DWqHHP3TIq0ekDZpdK6lqV394K6tWC5ZdYq1nO25spw=
+github.com/perses/spec v0.3.0-beta.10/go.mod h1:p08A4KJtH/7yP/nWdf7As/l0RE74kTYLKIP/8mvqC9E=
+github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
+github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
+github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
+github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
+github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8=
+github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
+github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
+github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
+github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
+github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
+github.com/zitadel/oidc/v3 v3.51.3 h1:jkEQ2k60amW6vwvzMwrGO7mw7vGxNp6BSsEH0AH6Gas=
+github.com/zitadel/oidc/v3 v3.51.3/go.mod h1:KQmYte+zOePAeVwR3LM153dxWBC9orWZAIa4w9r1ZhU=
+github.com/zitadel/schema v1.3.2 h1:gfJvt7dOMfTmxzhscZ9KkapKo3Nei3B6cAxjav+lyjI=
+github.com/zitadel/schema v1.3.2/go.mod h1:IZmdfF9Wu62Zu6tJJTH3UsArevs3Y4smfJIj3L8fzxw=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
+golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
+golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
+golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
+golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
+golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
+google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
+google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/cloudwatch/package.json b/cloudwatch/package.json
new file mode 100644
index 000000000..2aa5144b7
--- /dev/null
+++ b/cloudwatch/package.json
@@ -0,0 +1,77 @@
+{
+ "name": "@perses-dev/cloudwatch-plugin",
+ "version": "0.1.0-beta.0",
+ "license": "Apache-2.0",
+ "homepage": "https://github.com/perses/plugins/blob/main/README.md",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/perses/plugins.git"
+ },
+ "bugs": {
+ "url": "https://github.com/perses/plugins/issues"
+ },
+ "scripts": {
+ "dev": "rsbuild dev",
+ "build": "npm run build-mf && concurrently \"npm:build:*\"",
+ "build-mf": "rsbuild build",
+ "build:esm": "swc ./src -d dist/lib --strip-leading-paths --config-file ../.swcrc",
+ "build:types": "tsc --project tsconfig.build.json",
+ "lint": "oxlint src",
+ "test": "cross-env LC_ALL=C TZ=UTC vitest run",
+ "type-check": "tsc --noEmit"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "module": "lib/index.js",
+ "types": "lib/index.d.ts",
+ "dependencies": {},
+ "peerDependencies": {
+ "@perses-dev/client": "^0.55.0-beta.15",
+ "@perses-dev/components": "^0.55.0-beta.15",
+ "@perses-dev/plugin-system": "^0.55.0-beta.15",
+ "@perses-dev/spec": "^0.3.0-beta.10",
+ "immer": "^10.1.1",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0"
+ },
+ "devDependencies": {},
+ "files": [
+ "lib/**/*",
+ "__mf/**/*",
+ "mf-manifest.json",
+ "mf-stats.json"
+ ],
+ "perses": {
+ "moduleName": "CloudWatch",
+ "schemasPath": "schemas",
+ "plugins": [
+ {
+ "kind": "Datasource",
+ "spec": {
+ "display": {
+ "name": "CloudWatch Datasource"
+ },
+ "name": "CloudWatchDatasource"
+ }
+ },
+ {
+ "kind": "TimeSeriesQuery",
+ "spec": {
+ "display": {
+ "name": "CloudWatch Time Series Query"
+ },
+ "name": "CloudWatchTimeSeriesQuery"
+ }
+ },
+ {
+ "kind": "Variable",
+ "spec": {
+ "display": {
+ "name": "CloudWatch Dimension Values Variable"
+ },
+ "name": "CloudWatchDimensionValuesVariable"
+ }
+ }
+ ]
+ }
+}
diff --git a/cloudwatch/rsbuild.config.ts b/cloudwatch/rsbuild.config.ts
new file mode 100644
index 000000000..745d6f284
--- /dev/null
+++ b/cloudwatch/rsbuild.config.ts
@@ -0,0 +1,37 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { pluginReact } from '@rsbuild/plugin-react';
+
+import { createConfigForPlugin } from '../rsbuild.shared';
+
+export default createConfigForPlugin({
+ name: 'CloudWatch',
+ rsbuildConfig: {
+ server: { port: 3136 },
+ plugins: [pluginReact()],
+ },
+ moduleFederation: {
+ exposes: {
+ './CloudWatchDatasource': './src/datasources/cloudwatch-datasource',
+ './CloudWatchTimeSeriesQuery': './src/queries/cloudwatch-time-series-query',
+ './CloudWatchDimensionValuesVariable': './src/variables/cloudwatch-dimension-values-variable',
+ },
+ shared: {
+ react: { requiredVersion: '18.2.0', singleton: true },
+ immer: { singleton: true },
+ '@perses-dev/components': { singleton: true },
+ '@perses-dev/plugin-system': { singleton: true },
+ },
+ },
+});
diff --git a/cloudwatch/schemas/datasources/cloudwatch/cloudwatch.cue b/cloudwatch/schemas/datasources/cloudwatch/cloudwatch.cue
new file mode 100644
index 000000000..6bb4f65bb
--- /dev/null
+++ b/cloudwatch/schemas/datasources/cloudwatch/cloudwatch.cue
@@ -0,0 +1,28 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the \"License\");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an \"AS IS\" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package model
+
+import (
+ "github.com/perses/shared/cue/common"
+ "github.com/perses/spec/cue/datasource"
+)
+
+#kind: "CloudWatchDatasource"
+
+kind: #kind
+spec: {
+ datasource.#CloudWatchDatasourceSpec
+}
+
+#selector: common.#datasourceSelector & {_kind: #kind}
diff --git a/cloudwatch/schemas/datasources/cloudwatch/tests/invalid/external-id-without-role.json b/cloudwatch/schemas/datasources/cloudwatch/tests/invalid/external-id-without-role.json
new file mode 100644
index 000000000..b077a6659
--- /dev/null
+++ b/cloudwatch/schemas/datasources/cloudwatch/tests/invalid/external-id-without-role.json
@@ -0,0 +1,12 @@
+{
+ "kind": "CloudWatchDatasource",
+ "spec": {
+ "proxy": {
+ "kind": "CloudWatchProxy",
+ "spec": {
+ "region": "us-east-1",
+ "externalIdSecret": "cloudwatch-external-id"
+ }
+ }
+ }
+}
diff --git a/cloudwatch/schemas/datasources/cloudwatch/tests/invalid/static-credentials.json b/cloudwatch/schemas/datasources/cloudwatch/tests/invalid/static-credentials.json
new file mode 100644
index 000000000..c9bcbf531
--- /dev/null
+++ b/cloudwatch/schemas/datasources/cloudwatch/tests/invalid/static-credentials.json
@@ -0,0 +1,12 @@
+{
+ "kind": "CloudWatchDatasource",
+ "spec": {
+ "proxy": {
+ "kind": "CloudWatchProxy",
+ "spec": {
+ "region": "us-east-1",
+ "accessKeyId": "AKIAEXAMPLE"
+ }
+ }
+ }
+}
diff --git a/cloudwatch/schemas/datasources/cloudwatch/tests/valid/assumed-role.json b/cloudwatch/schemas/datasources/cloudwatch/tests/valid/assumed-role.json
new file mode 100644
index 000000000..f66941a0f
--- /dev/null
+++ b/cloudwatch/schemas/datasources/cloudwatch/tests/valid/assumed-role.json
@@ -0,0 +1,13 @@
+{
+ "kind": "CloudWatchDatasource",
+ "spec": {
+ "proxy": {
+ "kind": "CloudWatchProxy",
+ "spec": {
+ "region": "eu-west-3",
+ "roleArn": "arn:aws:iam::123456789012:role/perses-cloudwatch-read",
+ "externalIdSecret": "cloudwatch-external-id"
+ }
+ }
+ }
+}
diff --git a/cloudwatch/schemas/datasources/cloudwatch/tests/valid/default-identity.json b/cloudwatch/schemas/datasources/cloudwatch/tests/valid/default-identity.json
new file mode 100644
index 000000000..0db577f0a
--- /dev/null
+++ b/cloudwatch/schemas/datasources/cloudwatch/tests/valid/default-identity.json
@@ -0,0 +1,11 @@
+{
+ "kind": "CloudWatchDatasource",
+ "spec": {
+ "proxy": {
+ "kind": "CloudWatchProxy",
+ "spec": {
+ "region": "us-east-1"
+ }
+ }
+ }
+}
diff --git a/cloudwatch/schemas/queries/cloudwatch-time-series-query/query.cue b/cloudwatch/schemas/queries/cloudwatch-time-series-query/query.cue
new file mode 100644
index 000000000..09c851328
--- /dev/null
+++ b/cloudwatch/schemas/queries/cloudwatch-time-series-query/query.cue
@@ -0,0 +1,50 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the \"License\");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an \"AS IS\" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package model
+
+import (
+ "list"
+ "math"
+ "strings"
+ ds "github.com/perses/plugins/cloudwatch/schemas/datasources/cloudwatch:model"
+)
+
+kind: "CloudWatchTimeSeriesQuery"
+spec: close({
+ ds.#selector
+ queries: list.MinItems(1) & list.MaxItems(20) & [...#query]
+})
+
+#name: strings.MinRunes(1) & strings.MaxRunes(255)
+
+#query: close({
+ id: =~"^[a-z][a-zA-Z0-9_]{0,63}$"
+ label?: strings.MaxRunes(255)
+ returnData?: bool
+ metric?: close({
+ namespace: #name
+ name: #name
+ dimensions?: {[#name]: #name}
+ statistic: =~"^(Average|Sum|Minimum|Maximum|SampleCount|p([0-9]|[1-9][0-9])(\\.[0-9]{1,2})?|p100)$"
+ // period is the minimum period in seconds. It is increased for long time ranges.
+ period: int & >=60 & <=86400 & math.MultipleOf(60)
+ })
+ expression?: strings.MinRunes(1) & strings.MaxRunes(1024)
+ if metric == _|_ {
+ expression!: _
+ }
+ if metric != _|_ {
+ expression?: _|_
+ }
+})
diff --git a/cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/invalid/metric-and-expression.json b/cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/invalid/metric-and-expression.json
new file mode 100644
index 000000000..1f706428f
--- /dev/null
+++ b/cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/invalid/metric-and-expression.json
@@ -0,0 +1,17 @@
+{
+ "kind": "CloudWatchTimeSeriesQuery",
+ "spec": {
+ "queries": [
+ {
+ "id": "m1",
+ "expression": "m2 * 2",
+ "metric": {
+ "namespace": "AWS/EC2",
+ "name": "CPUUtilization",
+ "statistic": "Average",
+ "period": 60
+ }
+ }
+ ]
+ }
+}
diff --git a/cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/invalid/no-query.json b/cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/invalid/no-query.json
new file mode 100644
index 000000000..e2c4ae548
--- /dev/null
+++ b/cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/invalid/no-query.json
@@ -0,0 +1,6 @@
+{
+ "kind": "CloudWatchTimeSeriesQuery",
+ "spec": {
+ "queries": []
+ }
+}
diff --git a/cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/invalid/unsupported-period.json b/cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/invalid/unsupported-period.json
new file mode 100644
index 000000000..b3b2c8aa0
--- /dev/null
+++ b/cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/invalid/unsupported-period.json
@@ -0,0 +1,16 @@
+{
+ "kind": "CloudWatchTimeSeriesQuery",
+ "spec": {
+ "queries": [
+ {
+ "id": "m1",
+ "metric": {
+ "namespace": "AWS/EC2",
+ "name": "CPUUtilization",
+ "statistic": "Average",
+ "period": 90
+ }
+ }
+ ]
+ }
+}
diff --git a/cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/valid/metric-math.json b/cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/valid/metric-math.json
new file mode 100644
index 000000000..0d48b06c4
--- /dev/null
+++ b/cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/valid/metric-math.json
@@ -0,0 +1,29 @@
+{
+ "kind": "CloudWatchTimeSeriesQuery",
+ "spec": {
+ "datasource": {
+ "kind": "CloudWatchDatasource",
+ "name": "cloudwatch"
+ },
+ "queries": [
+ {
+ "id": "m1",
+ "returnData": false,
+ "metric": {
+ "namespace": "AWS/EC2",
+ "name": "CPUUtilization",
+ "dimensions": {
+ "InstanceId": "$instance"
+ },
+ "statistic": "p99.5",
+ "period": 300
+ }
+ },
+ {
+ "id": "e1",
+ "label": "CPU x2",
+ "expression": "m1 * 2"
+ }
+ ]
+ }
+}
diff --git a/cloudwatch/schemas/variables/cloudwatch-dimension-values/cloudwatch-dimension-values.cue b/cloudwatch/schemas/variables/cloudwatch-dimension-values/cloudwatch-dimension-values.cue
new file mode 100644
index 000000000..e9e073c07
--- /dev/null
+++ b/cloudwatch/schemas/variables/cloudwatch-dimension-values/cloudwatch-dimension-values.cue
@@ -0,0 +1,30 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the \"License\");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an \"AS IS\" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package model
+
+import (
+ "strings"
+ ds "github.com/perses/plugins/cloudwatch/schemas/datasources/cloudwatch:model"
+)
+
+#name: strings.MinRunes(1) & strings.MaxRunes(255)
+
+kind: "CloudWatchDimensionValuesVariable"
+spec: close({
+ ds.#selector
+ namespace: #name
+ metricName?: #name
+ dimensionKey: #name
+ dimensions?: {[#name]: #name}
+})
diff --git a/cloudwatch/schemas/variables/cloudwatch-dimension-values/tests/invalid/missing-dimension-key.json b/cloudwatch/schemas/variables/cloudwatch-dimension-values/tests/invalid/missing-dimension-key.json
new file mode 100644
index 000000000..a724c3fc9
--- /dev/null
+++ b/cloudwatch/schemas/variables/cloudwatch-dimension-values/tests/invalid/missing-dimension-key.json
@@ -0,0 +1,6 @@
+{
+ "kind": "CloudWatchDimensionValuesVariable",
+ "spec": {
+ "namespace": "AWS/EC2"
+ }
+}
diff --git a/cloudwatch/schemas/variables/cloudwatch-dimension-values/tests/valid/instances.json b/cloudwatch/schemas/variables/cloudwatch-dimension-values/tests/valid/instances.json
new file mode 100644
index 000000000..67dfbef03
--- /dev/null
+++ b/cloudwatch/schemas/variables/cloudwatch-dimension-values/tests/valid/instances.json
@@ -0,0 +1,11 @@
+{
+ "kind": "CloudWatchDimensionValuesVariable",
+ "spec": {
+ "namespace": "AWS/EC2",
+ "metricName": "CPUUtilization",
+ "dimensionKey": "InstanceId",
+ "dimensions": {
+ "AutoScalingGroupName": "$asg"
+ }
+ }
+}
diff --git a/cloudwatch/sdk/go/datasource/datasource.go b/cloudwatch/sdk/go/datasource/datasource.go
new file mode 100644
index 000000000..f46f7b7f9
--- /dev/null
+++ b/cloudwatch/sdk/go/datasource/datasource.go
@@ -0,0 +1,73 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package datasource
+
+import (
+ "github.com/perses/perses/go-sdk/datasource"
+ datasourceSpec "github.com/perses/spec/go/datasource"
+ "github.com/perses/spec/go/datasource/proxy/cloudwatch"
+)
+
+const (
+ PluginKind = "CloudWatchDatasource"
+ ProxyKind = "CloudWatchProxy"
+)
+
+type Option func(plugin *Builder) error
+
+func create(region string, options ...Option) (Builder, error) {
+ builder := &Builder{
+ Config: cloudwatch.Config{Region: region},
+ }
+
+ for _, opt := range options {
+ if err := opt(builder); err != nil {
+ return *builder, err
+ }
+ }
+
+ if err := builder.Config.Validate(); err != nil {
+ return *builder, err
+ }
+
+ return *builder, nil
+}
+
+type Builder struct {
+ Config cloudwatch.Config
+}
+
+// CloudWatch defines a datasource querying CloudWatch in the given region through the Perses server.
+// The datasource contains no AWS credential: the server signs the requests, optionally assuming a role.
+func CloudWatch(region string, options ...Option) datasource.Option {
+ return func(builder *datasource.Builder) error {
+ plugin, err := create(region, options...)
+ if err != nil {
+ return err
+ }
+
+ builder.Spec.Plugin.Kind = PluginKind
+ builder.Spec.Plugin.Spec = datasourceSpec.CloudWatchDatasourceSpec{
+ Proxy: &cloudwatch.Proxy{Kind: ProxyKind, Spec: plugin.Config},
+ }
+ return nil
+ }
+}
+
+func Selector(datasourceName string) *datasource.Selector {
+ return &datasource.Selector{
+ Kind: PluginKind,
+ Name: datasourceName,
+ }
+}
diff --git a/cloudwatch/sdk/go/datasource/options.go b/cloudwatch/sdk/go/datasource/options.go
new file mode 100644
index 000000000..2b3b0380b
--- /dev/null
+++ b/cloudwatch/sdk/go/datasource/options.go
@@ -0,0 +1,30 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package datasource
+
+// RoleARN sets the IAM role assumed by the Perses server to query CloudWatch.
+func RoleARN(roleARN string) Option {
+ return func(builder *Builder) error {
+ builder.Config.RoleARN = roleARN
+ return nil
+ }
+}
+
+// ExternalIDSecret sets the name of the secret holding the external ID used to assume the role.
+func ExternalIDSecret(secretName string) Option {
+ return func(builder *Builder) error {
+ builder.Config.ExternalIDSecret = secretName
+ return nil
+ }
+}
diff --git a/cloudwatch/sdk/go/query/time-series/options.go b/cloudwatch/sdk/go/query/time-series/options.go
new file mode 100644
index 000000000..9dab8c2c2
--- /dev/null
+++ b/cloudwatch/sdk/go/query/time-series/options.go
@@ -0,0 +1,69 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package timeseries
+
+import (
+ "fmt"
+
+ cloudwatchDatasource "github.com/perses/plugins/cloudwatch/sdk/go/datasource"
+)
+
+func Datasource(datasourceName string) Option {
+ return func(builder *Builder) error {
+ builder.Datasource = cloudwatchDatasource.Selector(datasourceName)
+ return nil
+ }
+}
+
+// Metric adds a query returning a CloudWatch metric.
+func Metric(id string, metric MetricStat) Option {
+ return func(builder *Builder) error {
+ if metric.Period < 60 || metric.Period%60 != 0 {
+ return fmt.Errorf("the period of query %q must be a multiple of 60 seconds", id)
+ }
+ builder.Queries = append(builder.Queries, Query{ID: id, Metric: &metric})
+ return nil
+ }
+}
+
+// Expression adds a metric math query, referencing the other queries by ID.
+func Expression(id string, expression string) Option {
+ return func(builder *Builder) error {
+ builder.Queries = append(builder.Queries, Query{ID: id, Expression: expression})
+ return nil
+ }
+}
+
+// Label sets the legend of the last added query.
+func Label(label string) Option {
+ return func(builder *Builder) error {
+ if len(builder.Queries) == 0 {
+ return fmt.Errorf("add a query before setting its label")
+ }
+ builder.Queries[len(builder.Queries)-1].Label = label
+ return nil
+ }
+}
+
+// Hidden hides the series of the last added query, for example when it is only used by an expression.
+func Hidden() Option {
+ return func(builder *Builder) error {
+ if len(builder.Queries) == 0 {
+ return fmt.Errorf("add a query before hiding it")
+ }
+ returnData := false
+ builder.Queries[len(builder.Queries)-1].ReturnData = &returnData
+ return nil
+ }
+}
diff --git a/cloudwatch/sdk/go/query/time-series/time-series.go b/cloudwatch/sdk/go/query/time-series/time-series.go
new file mode 100644
index 000000000..d49dfb9eb
--- /dev/null
+++ b/cloudwatch/sdk/go/query/time-series/time-series.go
@@ -0,0 +1,77 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package timeseries
+
+import (
+ "github.com/perses/perses/go-sdk/datasource"
+ "github.com/perses/perses/go-sdk/query"
+ "github.com/perses/spec/go/plugin"
+)
+
+const PluginKind = "CloudWatchTimeSeriesQuery"
+
+type MetricStat struct {
+ Namespace string `json:"namespace" yaml:"namespace"`
+ Name string `json:"name" yaml:"name"`
+ Dimensions map[string]string `json:"dimensions,omitempty" yaml:"dimensions,omitempty"`
+ Statistic string `json:"statistic" yaml:"statistic"`
+ // Period is the minimum period in seconds, a multiple of 60. It is increased for long time ranges.
+ Period int `json:"period" yaml:"period"`
+}
+
+// Query is either a metric or a metric math expression referencing the IDs of the other queries.
+type Query struct {
+ ID string `json:"id" yaml:"id"`
+ Metric *MetricStat `json:"metric,omitempty" yaml:"metric,omitempty"`
+ Expression string `json:"expression,omitempty" yaml:"expression,omitempty"`
+ Label string `json:"label,omitempty" yaml:"label,omitempty"`
+ ReturnData *bool `json:"returnData,omitempty" yaml:"returnData,omitempty"`
+}
+
+type PluginSpec struct {
+ Datasource *datasource.Selector `json:"datasource,omitempty" yaml:"datasource,omitempty"`
+ Queries []Query `json:"queries" yaml:"queries"`
+}
+
+type Option func(plugin *Builder) error
+
+func create(options ...Option) (Builder, error) {
+ builder := &Builder{
+ PluginSpec: PluginSpec{},
+ }
+
+ for _, opt := range options {
+ if err := opt(builder); err != nil {
+ return *builder, err
+ }
+ }
+
+ return *builder, nil
+}
+
+type Builder struct {
+ PluginSpec `json:",inline" yaml:",inline"`
+}
+
+func CloudWatchTimeSeriesQuery(options ...Option) query.Option {
+ plg, err := create(options...)
+ return query.Option{
+ Kind: plugin.KindTimeSeriesQuery,
+ Plugin: plugin.Plugin{
+ Kind: PluginKind,
+ Spec: plg,
+ },
+ Error: err,
+ }
+}
diff --git a/cloudwatch/sdk/go/sdk_test.go b/cloudwatch/sdk/go/sdk_test.go
new file mode 100644
index 000000000..053308ba2
--- /dev/null
+++ b/cloudwatch/sdk/go/sdk_test.go
@@ -0,0 +1,81 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package sdk
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/perses/perses/go-sdk/dashboard"
+ "github.com/perses/perses/go-sdk/panel"
+ panelgroup "github.com/perses/perses/go-sdk/panel-group"
+ listvariable "github.com/perses/perses/go-sdk/variable/list-variable"
+ cloudwatchDatasource "github.com/perses/plugins/cloudwatch/sdk/go/datasource"
+ timeseries "github.com/perses/plugins/cloudwatch/sdk/go/query/time-series"
+ dimensionvalues "github.com/perses/plugins/cloudwatch/sdk/go/variable/dimension-values"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDashboard(t *testing.T) {
+ builder, err := dashboard.New("cloudwatch",
+ dashboard.ProjectName("production"),
+ dashboard.AddDatasource("cloudwatch", cloudwatchDatasource.CloudWatch("eu-west-3",
+ cloudwatchDatasource.RoleARN("arn:aws:iam::123456789012:role/perses-cloudwatch-read"),
+ cloudwatchDatasource.ExternalIDSecret("cloudwatch-external-id"),
+ )),
+ dashboard.AddVariable("instance", listvariable.List(
+ dimensionvalues.CloudWatchDimensionValues("AWS/EC2", "InstanceId",
+ dimensionvalues.Datasource("cloudwatch"),
+ dimensionvalues.MetricName("CPUUtilization"),
+ ),
+ listvariable.DisplayName("Instance"),
+ )),
+ dashboard.AddPanelGroup("EC2",
+ panelgroup.AddPanel("CPU",
+ panel.AddQuery(timeseries.CloudWatchTimeSeriesQuery(
+ timeseries.Datasource("cloudwatch"),
+ timeseries.Metric("m1", timeseries.MetricStat{
+ Namespace: "AWS/EC2",
+ Name: "CPUUtilization",
+ Dimensions: map[string]string{"InstanceId": "$instance"},
+ Statistic: "Average",
+ Period: 60,
+ }),
+ timeseries.Hidden(),
+ timeseries.Expression("e1", "m1 * 2"),
+ timeseries.Label("CPU x2"),
+ )),
+ ),
+ ),
+ )
+ require.NoError(t, err)
+ data, err := json.Marshal(builder.Dashboard)
+ require.NoError(t, err)
+ result := string(data)
+ assert.Contains(t, result, `"proxy":{"kind":"CloudWatchProxy","spec":{"region":"eu-west-3","roleArn":"arn:aws:iam::123456789012:role/perses-cloudwatch-read","externalIdSecret":"cloudwatch-external-id"}}`)
+ assert.Contains(t, result, `"kind":"CloudWatchDimensionValuesVariable","spec":{"datasource":{"kind":"CloudWatchDatasource","name":"cloudwatch"},"namespace":"AWS/EC2","metricName":"CPUUtilization","dimensionKey":"InstanceId"}`)
+ assert.Contains(t, result, `"queries":[{"id":"m1","metric":{"namespace":"AWS/EC2","name":"CPUUtilization","dimensions":{"InstanceId":"$instance"},"statistic":"Average","period":60},"returnData":false},{"id":"e1","expression":"m1 * 2","label":"CPU x2"}]`)
+}
+
+func TestInvalidOptions(t *testing.T) {
+ _, err := dashboard.New("cloudwatch", dashboard.AddDatasource("cloudwatch", cloudwatchDatasource.CloudWatch("https://example.com")))
+ assert.Error(t, err)
+ _, err = dashboard.New("cloudwatch", dashboard.AddDatasource("cloudwatch", cloudwatchDatasource.CloudWatch("us-east-1",
+ cloudwatchDatasource.ExternalIDSecret("cloudwatch-external-id"))))
+ assert.Error(t, err)
+ _, err = dashboard.New("cloudwatch", dashboard.AddPanelGroup("EC2", panelgroup.AddPanel("CPU",
+ panel.AddQuery(timeseries.CloudWatchTimeSeriesQuery(timeseries.Metric("m1", timeseries.MetricStat{Namespace: "AWS/EC2", Name: "CPUUtilization", Statistic: "Average", Period: 90}))))))
+ assert.Error(t, err)
+}
diff --git a/cloudwatch/sdk/go/variable/dimension-values/dimension-values.go b/cloudwatch/sdk/go/variable/dimension-values/dimension-values.go
new file mode 100644
index 000000000..2805a3e9c
--- /dev/null
+++ b/cloudwatch/sdk/go/variable/dimension-values/dimension-values.go
@@ -0,0 +1,86 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package dimensionvalues
+
+import (
+ "github.com/perses/perses/go-sdk/datasource"
+ listvariable "github.com/perses/perses/go-sdk/variable/list-variable"
+ cloudwatchDatasource "github.com/perses/plugins/cloudwatch/sdk/go/datasource"
+)
+
+const PluginKind = "CloudWatchDimensionValuesVariable"
+
+type PluginSpec struct {
+ Datasource *datasource.Selector `json:"datasource,omitempty" yaml:"datasource,omitempty"`
+ Namespace string `json:"namespace" yaml:"namespace"`
+ MetricName string `json:"metricName,omitempty" yaml:"metricName,omitempty"`
+ DimensionKey string `json:"dimensionKey" yaml:"dimensionKey"`
+ Dimensions map[string]string `json:"dimensions,omitempty" yaml:"dimensions,omitempty"`
+}
+
+type Option func(plugin *Builder) error
+
+type Builder struct {
+ PluginSpec `json:",inline" yaml:",inline"`
+}
+
+func create(namespace string, dimensionKey string, options ...Option) (Builder, error) {
+ builder := &Builder{
+ PluginSpec: PluginSpec{Namespace: namespace, DimensionKey: dimensionKey},
+ }
+
+ for _, opt := range options {
+ if err := opt(builder); err != nil {
+ return *builder, err
+ }
+ }
+
+ return *builder, nil
+}
+
+// CloudWatchDimensionValues defines a list variable whose options are the values of a dimension in a namespace.
+func CloudWatchDimensionValues(namespace string, dimensionKey string, options ...Option) listvariable.Option {
+ return func(builder *listvariable.Builder) error {
+ plg, err := create(namespace, dimensionKey, options...)
+ if err != nil {
+ return err
+ }
+ builder.ListVariableSpec.Plugin.Kind = PluginKind
+ builder.ListVariableSpec.Plugin.Spec = plg
+ return nil
+ }
+}
+
+func Datasource(datasourceName string) Option {
+ return func(builder *Builder) error {
+ builder.Datasource = cloudwatchDatasource.Selector(datasourceName)
+ return nil
+ }
+}
+
+// MetricName limits the discovery to a metric.
+func MetricName(metricName string) Option {
+ return func(builder *Builder) error {
+ builder.MetricName = metricName
+ return nil
+ }
+}
+
+// Dimensions limits the discovery to the metrics having these dimension values. The values can use variables.
+func Dimensions(dimensions map[string]string) Option {
+ return func(builder *Builder) error {
+ builder.Dimensions = dimensions
+ return nil
+ }
+}
diff --git a/cloudwatch/src/bootstrap.tsx b/cloudwatch/src/bootstrap.tsx
new file mode 100644
index 000000000..b8814442e
--- /dev/null
+++ b/cloudwatch/src/bootstrap.tsx
@@ -0,0 +1,18 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// This file is used for development mode only
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+const root = ReactDOM.createRoot(document.getElementById('root')!);
+root.render();
diff --git a/cloudwatch/src/components/DimensionsEditor.tsx b/cloudwatch/src/components/DimensionsEditor.tsx
new file mode 100644
index 000000000..f94abf3fe
--- /dev/null
+++ b/cloudwatch/src/components/DimensionsEditor.tsx
@@ -0,0 +1,89 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { TextField } from '@mui/material';
+import type { ChangeEvent, ReactElement } from 'react';
+import { useCallback, useMemo, useState } from 'react';
+
+import type { CloudWatchDimensions } from '../model';
+
+const MAX_DIMENSIONS = 30;
+
+/**
+ * Parses a JSON object of dimension names and values. Returns undefined when the text is not a valid object of
+ * at most 30 non-empty string values.
+ */
+export function parseDimensions(text: string): CloudWatchDimensions | undefined {
+ if (text.trim() === '') {
+ return {};
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text);
+ } catch {
+ return undefined;
+ }
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+ return undefined;
+ }
+ const entries = Object.entries(parsed);
+ if (
+ entries.length > MAX_DIMENSIONS ||
+ entries.some(([name, value]) => name === '' || typeof value !== 'string' || value === '')
+ ) {
+ return undefined;
+ }
+ return Object.fromEntries(entries) as CloudWatchDimensions;
+}
+
+export interface DimensionsEditorProps {
+ label: string;
+ value?: CloudWatchDimensions;
+ onChange: (dimensions?: CloudWatchDimensions) => void;
+ readOnly?: boolean;
+}
+
+export function DimensionsEditor({ label, value, onChange, readOnly }: DimensionsEditorProps): ReactElement {
+ const [draft, setDraft] = useState(value && Object.keys(value).length > 0 ? JSON.stringify(value) : '');
+ const [error, setError] = useState(false);
+ const readOnlyProps = useMemo(() => ({ input: { readOnly } }), [readOnly]);
+
+ const handleChange = useCallback(
+ (event: ChangeEvent): void => {
+ setDraft(event.target.value);
+ const dimensions = parseDimensions(event.target.value);
+ setError(dimensions === undefined);
+ if (dimensions !== undefined) {
+ onChange(Object.keys(dimensions).length > 0 ? dimensions : undefined);
+ }
+ },
+ [onChange],
+ );
+
+ return (
+
+ );
+}
diff --git a/cloudwatch/src/datasources/cloudwatch-datasource/CloudWatchDatasource.test.ts b/cloudwatch/src/datasources/cloudwatch-datasource/CloudWatchDatasource.test.ts
new file mode 100644
index 000000000..18e1c5824
--- /dev/null
+++ b/cloudwatch/src/datasources/cloudwatch-datasource/CloudWatchDatasource.test.ts
@@ -0,0 +1,62 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { parseDimensions } from '../../components/DimensionsEditor';
+import { validateCloudWatchProxySpec } from './cloudwatch-datasource-types';
+import { CloudWatchDatasource } from './CloudWatchDatasource';
+
+describe('CloudWatchDatasource', () => {
+ it('creates a client for the server proxy only', () => {
+ const spec = CloudWatchDatasource.createInitialOptions();
+ expect(
+ CloudWatchDatasource.createClient(spec, { proxyUrl: '/proxy/globaldatasources/cw' }).options.datasourceUrl,
+ ).toBe('/proxy/globaldatasources/cw');
+ expect(() => CloudWatchDatasource.createClient(spec, {})).toThrow('Perses server proxy');
+ });
+
+ it('creates a valid initial datasource', () => {
+ expect(validateCloudWatchProxySpec(CloudWatchDatasource.createInitialOptions().proxy.spec)).toEqual({});
+ });
+});
+
+describe('validateCloudWatchProxySpec', () => {
+ it('accepts a role with an external ID', () => {
+ expect(
+ validateCloudWatchProxySpec({
+ region: 'eu-west-3',
+ roleArn: 'arn:aws:iam::123456789012:role/monitoring/perses',
+ externalIdSecret: 'cw-external-id',
+ }),
+ ).toEqual({});
+ });
+
+ it('reports the invalid fields', () => {
+ expect(validateCloudWatchProxySpec({ region: 'us_east_1', roleArn: 'arn:aws:iam::123456789012:user/bob' })).toEqual(
+ { region: expect.any(String), roleArn: expect.any(String) },
+ );
+ expect(validateCloudWatchProxySpec({ region: 'us-east-1', externalIdSecret: 'cw-external-id' })).toEqual({
+ externalIdSecret: expect.any(String),
+ });
+ });
+});
+
+describe('parseDimensions', () => {
+ it('parses an object of string values', () => {
+ expect(parseDimensions('{"InstanceId": "$instance"}')).toEqual({ InstanceId: '$instance' });
+ expect(parseDimensions(' ')).toEqual({});
+ });
+
+ it.each(['not json', '[]', '{"InstanceId": 1}', '{"InstanceId": ""}'])('rejects %s', (text) => {
+ expect(parseDimensions(text)).toBeUndefined();
+ });
+});
diff --git a/cloudwatch/src/datasources/cloudwatch-datasource/CloudWatchDatasource.tsx b/cloudwatch/src/datasources/cloudwatch-datasource/CloudWatchDatasource.tsx
new file mode 100644
index 000000000..3b3f37f0e
--- /dev/null
+++ b/cloudwatch/src/datasources/cloudwatch-datasource/CloudWatchDatasource.tsx
@@ -0,0 +1,35 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type { DatasourcePlugin } from '@perses-dev/plugin-system';
+
+import type { CloudWatchClient } from '../../model';
+import { createCloudWatchClient } from '../../model';
+import type { CloudWatchDatasourceSpec } from './cloudwatch-datasource-types';
+import { CloudWatchDatasourceEditor } from './CloudWatchDatasourceEditor';
+
+const createClient: DatasourcePlugin['createClient'] = (_spec, options) => {
+ const { proxyUrl, fetchJson } = options;
+ if (proxyUrl === undefined) {
+ throw new Error('CloudWatch can only be queried through the Perses server proxy.');
+ }
+ return createCloudWatchClient({ datasourceUrl: proxyUrl, fetchJson });
+};
+
+// healthCheckPath is not set: the CloudWatch proxy only accepts POST requests, so the generic connection test
+// (a GET on the health check path) doesn't apply. The connection is checked by a query or a metric discovery.
+export const CloudWatchDatasource: DatasourcePlugin = {
+ createClient,
+ OptionsEditorComponent: CloudWatchDatasourceEditor,
+ createInitialOptions: () => ({ proxy: { kind: 'CloudWatchProxy', spec: { region: 'us-east-1' } } }),
+};
diff --git a/cloudwatch/src/datasources/cloudwatch-datasource/CloudWatchDatasourceEditor.tsx b/cloudwatch/src/datasources/cloudwatch-datasource/CloudWatchDatasourceEditor.tsx
new file mode 100644
index 000000000..fb3a13a91
--- /dev/null
+++ b/cloudwatch/src/datasources/cloudwatch-datasource/CloudWatchDatasourceEditor.tsx
@@ -0,0 +1,104 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Stack, TextField, Typography } from '@mui/material';
+import type { DatasourceEditorProps } from '@perses-dev/plugin-system';
+import type { CloudWatchProxySpec } from '@perses-dev/spec';
+import type { ChangeEvent, ReactElement } from 'react';
+import { useCallback, useMemo } from 'react';
+
+import type { CloudWatchDatasourceSpec } from './cloudwatch-datasource-types';
+import { validateCloudWatchProxySpec } from './cloudwatch-datasource-types';
+
+type OptionalField = 'roleArn' | 'externalIdSecret';
+
+export function CloudWatchDatasourceEditor({
+ value,
+ onChange,
+ isReadonly,
+}: DatasourceEditorProps): ReactElement {
+ const spec = value.proxy.spec;
+ const errors = useMemo(() => validateCloudWatchProxySpec(spec), [spec]);
+ const readOnlyProps = useMemo(() => ({ input: { readOnly: isReadonly } }), [isReadonly]);
+
+ const update = useCallback(
+ (newSpec: CloudWatchProxySpec): void => {
+ onChange({ ...value, proxy: { kind: 'CloudWatchProxy', spec: newSpec } });
+ },
+ [onChange, value],
+ );
+
+ const handleRegionChange = useCallback(
+ (event: ChangeEvent): void => update({ ...spec, region: event.target.value.trim() }),
+ [spec, update],
+ );
+
+ const handleOptionalChange = useCallback(
+ (field: OptionalField) =>
+ (event: ChangeEvent): void => {
+ const newSpec = { ...spec };
+ const input = event.target.value.trim();
+ if (input === '') {
+ delete newSpec[field];
+ } else {
+ newSpec[field] = input;
+ }
+ update(newSpec);
+ },
+ [spec, update],
+ );
+
+ return (
+
+
+ The Perses server signs the CloudWatch requests with its own AWS identity, optionally assuming a role. The
+ server administrator must enable CloudWatch and allow the region, the account and the role. AWS access keys are
+ never entered here.
+
+
+
+
+
+ );
+}
diff --git a/cloudwatch/src/datasources/cloudwatch-datasource/cloudwatch-datasource-types.ts b/cloudwatch/src/datasources/cloudwatch-datasource/cloudwatch-datasource-types.ts
new file mode 100644
index 000000000..2b7dff3c2
--- /dev/null
+++ b/cloudwatch/src/datasources/cloudwatch-datasource/cloudwatch-datasource-types.ts
@@ -0,0 +1,43 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type { CloudWatchProxy, CloudWatchProxySpec } from '@perses-dev/spec';
+
+// The datasource contains no AWS credential: the Perses server signs the requests, optionally assuming a role.
+export interface CloudWatchDatasourceSpec {
+ proxy: CloudWatchProxy;
+}
+
+// Same rules as the CloudWatchProxy model of perses/spec.
+const REGION_REGEXP = /^[a-z]{2}(-[a-z]+)+-[0-9]{1,2}$/;
+const ROLE_ARN_REGEXP = /^arn:aws(-[a-z]+)*:iam::[0-9]{12}:role\/[A-Za-z0-9+=,.@_/-]{1,576}$/;
+
+export interface CloudWatchProxySpecErrors {
+ region?: string;
+ roleArn?: string;
+ externalIdSecret?: string;
+}
+
+export function validateCloudWatchProxySpec(spec: CloudWatchProxySpec): CloudWatchProxySpecErrors {
+ const errors: CloudWatchProxySpecErrors = {};
+ if (!REGION_REGEXP.test(spec.region)) {
+ errors.region = 'Enter an AWS region, for example us-east-1.';
+ }
+ if (spec.roleArn !== undefined && !ROLE_ARN_REGEXP.test(spec.roleArn)) {
+ errors.roleArn = 'Enter an IAM role ARN, for example arn:aws:iam::123456789012:role/perses-cloudwatch-read.';
+ }
+ if (spec.externalIdSecret !== undefined && spec.roleArn === undefined) {
+ errors.externalIdSecret = 'An external ID can only be used to assume a role.';
+ }
+ return errors;
+}
diff --git a/cloudwatch/src/datasources/cloudwatch-datasource/index.ts b/cloudwatch/src/datasources/cloudwatch-datasource/index.ts
new file mode 100644
index 000000000..88d5f35a6
--- /dev/null
+++ b/cloudwatch/src/datasources/cloudwatch-datasource/index.ts
@@ -0,0 +1,16 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export * from './CloudWatchDatasource';
+export * from './CloudWatchDatasourceEditor';
+export * from './cloudwatch-datasource-types';
diff --git a/cloudwatch/src/datasources/index.ts b/cloudwatch/src/datasources/index.ts
new file mode 100644
index 000000000..7325f426c
--- /dev/null
+++ b/cloudwatch/src/datasources/index.ts
@@ -0,0 +1,14 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export * from './cloudwatch-datasource';
diff --git a/cloudwatch/src/env.d.ts b/cloudwatch/src/env.d.ts
new file mode 100644
index 000000000..ab76749f8
--- /dev/null
+++ b/cloudwatch/src/env.d.ts
@@ -0,0 +1,14 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+///
diff --git a/cloudwatch/src/getPluginModule.ts b/cloudwatch/src/getPluginModule.ts
new file mode 100644
index 000000000..6dee4f848
--- /dev/null
+++ b/cloudwatch/src/getPluginModule.ts
@@ -0,0 +1,31 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type { PluginModuleResource, PluginModuleSpec } from '@perses-dev/plugin-system';
+
+import packageJson from '../package.json' with { type: 'json' };
+
+/**
+ * Returns the plugin module information from package.json
+ */
+export function getPluginModule(): PluginModuleResource {
+ const { name, version, perses } = packageJson;
+ return {
+ kind: 'PluginModule',
+ metadata: {
+ name,
+ version,
+ },
+ spec: perses as PluginModuleSpec,
+ };
+}
diff --git a/cloudwatch/src/index-federation.ts b/cloudwatch/src/index-federation.ts
new file mode 100644
index 000000000..df7f0bd82
--- /dev/null
+++ b/cloudwatch/src/index-federation.ts
@@ -0,0 +1,14 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import('./bootstrap');
diff --git a/cloudwatch/src/index.ts b/cloudwatch/src/index.ts
new file mode 100644
index 000000000..0baab8421
--- /dev/null
+++ b/cloudwatch/src/index.ts
@@ -0,0 +1,18 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export { getPluginModule } from './getPluginModule';
+export * from './model';
+export * from './datasources';
+export * from './queries';
+export * from './variables';
diff --git a/cloudwatch/src/model/cloudwatch-client-types.ts b/cloudwatch/src/model/cloudwatch-client-types.ts
new file mode 100644
index 000000000..d87426a10
--- /dev/null
+++ b/cloudwatch/src/model/cloudwatch-client-types.ts
@@ -0,0 +1,75 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export type CloudWatchDimensions = Record;
+
+export interface CloudWatchMetricStat {
+ namespace: string;
+ name: string;
+ dimensions?: CloudWatchDimensions;
+ // statistic is Average, Sum, Minimum, Maximum, SampleCount or a percentile like p99.
+ statistic: string;
+ // period in seconds, a multiple of 60.
+ period: number;
+}
+
+// CloudWatchMetricDataQuery is either a metric or a metric math expression referencing the IDs of other queries.
+export interface CloudWatchMetricDataQuery {
+ id: string;
+ metric?: CloudWatchMetricStat;
+ expression?: string;
+ label?: string;
+ // returnData is true by default. Set it to false to hide a series only used by an expression.
+ returnData?: boolean;
+}
+
+export interface GetMetricDataParams {
+ startTime: string;
+ endTime: string;
+ queries: CloudWatchMetricDataQuery[];
+}
+
+export interface ListMetricsParams {
+ namespace: string;
+ metricName?: string;
+ dimensions?: CloudWatchDimensions;
+}
+
+export interface MetricDataResult {
+ Id: string;
+ Label: string;
+ Timestamps: string[];
+ Values: number[];
+ StatusCode: string;
+}
+
+export interface GetMetricDataResponse {
+ MetricDataResults: MetricDataResult[];
+}
+
+export interface CloudWatchDimension {
+ Name: string;
+ Value: string;
+}
+
+export interface CloudWatchMetricInfo {
+ Namespace: string;
+ MetricName: string;
+ Dimensions: CloudWatchDimension[];
+}
+
+export interface ListMetricsResponse {
+ Metrics: CloudWatchMetricInfo[];
+ // Truncated is true when more metrics match the filters than the returned ones.
+ Truncated: boolean;
+}
diff --git a/cloudwatch/src/model/cloudwatch-client.test.ts b/cloudwatch/src/model/cloudwatch-client.test.ts
new file mode 100644
index 000000000..3710eca66
--- /dev/null
+++ b/cloudwatch/src/model/cloudwatch-client.test.ts
@@ -0,0 +1,92 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { createCloudWatchClient, parseGetMetricDataResponse, parseListMetricsResponse } from './cloudwatch-client';
+
+describe('createCloudWatchClient', () => {
+ it('sends the actions as a POST on the datasource proxy', async () => {
+ const fetchJson = vi.fn().mockResolvedValue({ Metrics: [], Truncated: true });
+ const client = createCloudWatchClient({ datasourceUrl: '/proxy/projects/p/datasources/cw', fetchJson });
+
+ const response = await client.listMetrics({ namespace: 'AWS/EC2', metricName: 'CPUUtilization' });
+
+ expect(response).toEqual({ Metrics: [], Truncated: true });
+ expect(fetchJson).toHaveBeenCalledWith('/proxy/projects/p/datasources/cw', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ action: 'ListMetrics', namespace: 'AWS/EC2', metricName: 'CPUUtilization' }),
+ signal: undefined,
+ });
+ });
+
+ it('sends GetMetricData requests', async () => {
+ const fetchJson = vi.fn().mockResolvedValue({ MetricDataResults: [] });
+ const client = createCloudWatchClient({ datasourceUrl: '/proxy', fetchJson });
+ const queries = [{ id: 'e1', expression: 'm1 * 2' }];
+
+ await client.getMetricData({ startTime: '2026-09-30T10:00:00.000Z', endTime: '2026-09-30T11:00:00.000Z', queries });
+
+ const body = JSON.parse(fetchJson.mock.calls[0]?.[1].body);
+ expect(body).toEqual({
+ action: 'GetMetricData',
+ startTime: '2026-09-30T10:00:00.000Z',
+ endTime: '2026-09-30T11:00:00.000Z',
+ queries,
+ });
+ });
+});
+
+describe('parseGetMetricDataResponse', () => {
+ it('accepts complete results', () => {
+ const response = {
+ MetricDataResults: [
+ { Id: 'm1', Label: 'CPU', Timestamps: ['2026-09-30T10:00:00Z'], Values: [42], StatusCode: 'Complete' },
+ ],
+ };
+ expect(parseGetMetricDataResponse(response)).toEqual(response);
+ });
+
+ it.each([
+ ['a missing list', {}],
+ [
+ 'mismatched values',
+ { MetricDataResults: [{ Id: 'm1', Label: '', Timestamps: ['t'], Values: [], StatusCode: 'Complete' }] },
+ ],
+ [
+ 'a non-finite value',
+ { MetricDataResults: [{ Id: 'm1', Label: '', Timestamps: ['t'], Values: [null], StatusCode: 'Complete' }] },
+ ],
+ ['a missing ID', { MetricDataResults: [{ Label: '', Timestamps: [], Values: [], StatusCode: 'Complete' }] }],
+ ])('rejects %s', (_, response) => {
+ expect(() => parseGetMetricDataResponse(response)).toThrow('Unexpected CloudWatch GetMetricData response');
+ });
+});
+
+describe('parseListMetricsResponse', () => {
+ it('accepts metrics, with Truncated false by default', () => {
+ const metric = {
+ Namespace: 'AWS/EC2',
+ MetricName: 'CPUUtilization',
+ Dimensions: [{ Name: 'InstanceId', Value: 'i-1' }],
+ };
+ expect(parseListMetricsResponse({ Metrics: [metric] })).toEqual({ Metrics: [metric], Truncated: false });
+ });
+
+ it('rejects malformed dimensions', () => {
+ expect(() =>
+ parseListMetricsResponse({
+ Metrics: [{ Namespace: 'AWS/EC2', MetricName: 'CPUUtilization', Dimensions: [{ Name: 1 }] }],
+ }),
+ ).toThrow('Unexpected CloudWatch ListMetrics response');
+ });
+});
diff --git a/cloudwatch/src/model/cloudwatch-client.ts b/cloudwatch/src/model/cloudwatch-client.ts
new file mode 100644
index 000000000..8d2c36a49
--- /dev/null
+++ b/cloudwatch/src/model/cloudwatch-client.ts
@@ -0,0 +1,121 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { fetchJson } from '@perses-dev/client';
+import type { DatasourceClient } from '@perses-dev/plugin-system';
+
+import type {
+ CloudWatchMetricInfo,
+ GetMetricDataParams,
+ GetMetricDataResponse,
+ ListMetricsParams,
+ ListMetricsResponse,
+ MetricDataResult,
+} from './cloudwatch-client-types';
+
+export interface CloudWatchClientOptions {
+ // datasourceUrl is the URL of the datasource proxy of the Perses server. CloudWatch can't be queried directly.
+ datasourceUrl: string;
+ fetchJson?: typeof fetchJson;
+}
+
+export interface CloudWatchClient extends DatasourceClient {
+ options: CloudWatchClientOptions;
+ getMetricData: (params: GetMetricDataParams, abortSignal?: AbortSignal) => Promise;
+ listMetrics: (params: ListMetricsParams, abortSignal?: AbortSignal) => Promise;
+}
+
+function post(options: CloudWatchClientOptions, body: object, signal?: AbortSignal): Promise {
+ const doFetchJson = options.fetchJson ?? fetchJson;
+ return doFetchJson(options.datasourceUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ signal,
+ });
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function isStringArray(value: unknown): value is string[] {
+ return Array.isArray(value) && value.every((item) => typeof item === 'string');
+}
+
+function isMetricDataResult(value: unknown): value is MetricDataResult {
+ return (
+ isRecord(value) &&
+ typeof value.Id === 'string' &&
+ typeof value.Label === 'string' &&
+ isStringArray(value.Timestamps) &&
+ Array.isArray(value.Values) &&
+ value.Values.every((item) => typeof item === 'number' && Number.isFinite(item)) &&
+ value.Values.length === value.Timestamps.length
+ );
+}
+
+function isMetricInfo(value: unknown): value is CloudWatchMetricInfo {
+ return (
+ isRecord(value) &&
+ typeof value.Namespace === 'string' &&
+ typeof value.MetricName === 'string' &&
+ Array.isArray(value.Dimensions) &&
+ value.Dimensions.every(
+ (dimension) => isRecord(dimension) && typeof dimension.Name === 'string' && typeof dimension.Value === 'string',
+ )
+ );
+}
+
+export function parseGetMetricDataResponse(response: unknown): GetMetricDataResponse {
+ if (
+ !isRecord(response) ||
+ !Array.isArray(response.MetricDataResults) ||
+ !response.MetricDataResults.every(isMetricDataResult)
+ ) {
+ throw new Error('Unexpected CloudWatch GetMetricData response');
+ }
+ return { MetricDataResults: response.MetricDataResults };
+}
+
+export function parseListMetricsResponse(response: unknown): ListMetricsResponse {
+ if (!isRecord(response) || !Array.isArray(response.Metrics) || !response.Metrics.every(isMetricInfo)) {
+ throw new Error('Unexpected CloudWatch ListMetrics response');
+ }
+ return { Metrics: response.Metrics, Truncated: response.Truncated === true };
+}
+
+export async function getMetricData(
+ params: GetMetricDataParams,
+ options: CloudWatchClientOptions,
+ abortSignal?: AbortSignal,
+): Promise {
+ return parseGetMetricDataResponse(await post(options, { action: 'GetMetricData', ...params }, abortSignal));
+}
+
+export async function listMetrics(
+ params: ListMetricsParams,
+ options: CloudWatchClientOptions,
+ abortSignal?: AbortSignal,
+): Promise {
+ return parseListMetricsResponse(await post(options, { action: 'ListMetrics', ...params }, abortSignal));
+}
+
+export function createCloudWatchClient(options: CloudWatchClientOptions): CloudWatchClient {
+ return {
+ kind: 'CloudWatchDatasource',
+ options,
+ getMetricData: (params, abortSignal) => getMetricData(params, options, abortSignal),
+ listMetrics: (params, abortSignal) => listMetrics(params, options, abortSignal),
+ };
+}
diff --git a/cloudwatch/src/model/constants.ts b/cloudwatch/src/model/constants.ts
new file mode 100644
index 000000000..6e8bb1e45
--- /dev/null
+++ b/cloudwatch/src/model/constants.ts
@@ -0,0 +1,24 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type { DatasourceSelector } from '@perses-dev/spec';
+
+export const CLOUDWATCH_DATASOURCE_KIND = 'CloudWatchDatasource';
+
+export const DEFAULT_CLOUDWATCH: DatasourceSelector = { kind: CLOUDWATCH_DATASOURCE_KIND };
+
+// Limits enforced by the CloudWatch proxy of the Perses server.
+export const CLOUDWATCH_MAX_QUERIES = 20;
+export const CLOUDWATCH_MAX_DATAPOINTS = 10000;
+export const CLOUDWATCH_MIN_PERIOD = 60;
+export const CLOUDWATCH_MAX_PERIOD = 86400;
diff --git a/cloudwatch/src/model/index.ts b/cloudwatch/src/model/index.ts
new file mode 100644
index 000000000..529c49ef4
--- /dev/null
+++ b/cloudwatch/src/model/index.ts
@@ -0,0 +1,17 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export * from './cloudwatch-client';
+export * from './cloudwatch-client-types';
+export * from './constants';
+export * from './period';
diff --git a/cloudwatch/src/model/period.test.ts b/cloudwatch/src/model/period.test.ts
new file mode 100644
index 000000000..b83fbfe6d
--- /dev/null
+++ b/cloudwatch/src/model/period.test.ts
@@ -0,0 +1,57 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { CLOUDWATCH_MAX_DATAPOINTS } from './constants';
+import { getEffectivePeriod } from './period';
+
+const HOUR = 3600;
+const DAY = 24 * HOUR;
+
+describe('getEffectivePeriod', () => {
+ it('keeps the period of the query when it fits', () => {
+ expect(getEffectivePeriod(60, HOUR, 1)).toBe(60);
+ expect(getEffectivePeriod(300, DAY, 1)).toBe(300);
+ });
+
+ it('increases the period so a long time range fits in the datapoint limit', () => {
+ // 7 days at 60s would be 10080 datapoints.
+ expect(getEffectivePeriod(60, 7 * DAY, 1)).toBe(120);
+ expect(getEffectivePeriod(60, 31 * DAY, 1)).toBe(300);
+ });
+
+ it('shares the datapoint limit between the returned series', () => {
+ // 7 series over a day at 60s would be 10080 datapoints.
+ expect(getEffectivePeriod(60, DAY, 7)).toBe(120);
+ expect(getEffectivePeriod(60, DAY, 6)).toBe(60);
+ });
+
+ it('follows the step suggested by the panel, rounded up to a multiple of 60', () => {
+ expect(getEffectivePeriod(60, DAY, 1, 90_000)).toBe(120);
+ expect(getEffectivePeriod(300, DAY, 1, 30_000)).toBe(300);
+ });
+
+ it('stays within the CloudWatch periods', () => {
+ expect(getEffectivePeriod(0, 60, 1)).toBe(60);
+ expect(getEffectivePeriod(60, 31 * DAY, 20, 7 * DAY * 1000)).toBe(DAY);
+ });
+
+ it('never exceeds the datapoint limit of the proxy', () => {
+ for (const range of [HOUR, DAY, 7 * DAY, 14 * DAY, 31 * DAY]) {
+ for (const series of [1, 3, 7, 20]) {
+ const period = getEffectivePeriod(60, range, series);
+ expect(Math.ceil(range / period) * series).toBeLessThanOrEqual(CLOUDWATCH_MAX_DATAPOINTS);
+ expect(period % 60).toBe(0);
+ }
+ }
+ });
+});
diff --git a/cloudwatch/src/model/period.ts b/cloudwatch/src/model/period.ts
new file mode 100644
index 000000000..5d70b8bbb
--- /dev/null
+++ b/cloudwatch/src/model/period.ts
@@ -0,0 +1,37 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { CLOUDWATCH_MAX_DATAPOINTS, CLOUDWATCH_MAX_PERIOD, CLOUDWATCH_MIN_PERIOD } from './constants';
+
+/**
+ * Returns the period (in seconds) to query a metric.
+ * The period of the query is a minimum, like the minimum step of a Prometheus query. It is increased:
+ * - to the step suggested by the panel, as more datapoints than pixels are not useful,
+ * - so all the returned series fit in the datapoint limit of the CloudWatch proxy, for example over a long time range.
+ * The result is rounded up to a multiple of 60 seconds, as required by CloudWatch for standard resolution metrics.
+ */
+export function getEffectivePeriod(
+ period: number,
+ rangeSeconds: number,
+ returnedSeries: number,
+ suggestedStepMs?: number,
+): number {
+ const pointsPerSeries = Math.max(1, Math.floor(CLOUDWATCH_MAX_DATAPOINTS / Math.max(1, returnedSeries)));
+ const minimum = Math.max(
+ period,
+ CLOUDWATCH_MIN_PERIOD,
+ rangeSeconds / pointsPerSeries,
+ (suggestedStepMs ?? 0) / 1000,
+ );
+ return Math.min(CLOUDWATCH_MAX_PERIOD, Math.ceil(minimum / CLOUDWATCH_MIN_PERIOD) * CLOUDWATCH_MIN_PERIOD);
+}
diff --git a/cloudwatch/src/queries/cloudwatch-time-series-query/CloudWatchTimeSeriesQuery.test.ts b/cloudwatch/src/queries/cloudwatch-time-series-query/CloudWatchTimeSeriesQuery.test.ts
new file mode 100644
index 000000000..381c198b2
--- /dev/null
+++ b/cloudwatch/src/queries/cloudwatch-time-series-query/CloudWatchTimeSeriesQuery.test.ts
@@ -0,0 +1,152 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type { TimeSeriesQueryContext, VariableStateMap } from '@perses-dev/plugin-system';
+
+import type { CloudWatchClient } from '../../model';
+import type { CloudWatchTimeSeriesQuerySpec } from './cloudwatch-time-series-query-types';
+import { CloudWatchTimeSeriesQuery } from './CloudWatchTimeSeriesQuery';
+import { validateQueries } from './CloudWatchTimeSeriesQueryEditor';
+
+const variables: VariableStateMap = {
+ instance: { value: 'i-0123', loading: false, options: [] },
+ namespace: { value: 'AWS/EC2', loading: false, options: [] },
+};
+
+const spec: CloudWatchTimeSeriesQuerySpec = {
+ queries: [
+ {
+ id: 'm1',
+ returnData: false,
+ metric: {
+ namespace: '$namespace',
+ name: 'CPUUtilization',
+ dimensions: { InstanceId: '$instance' },
+ statistic: 'Average',
+ period: 60,
+ },
+ },
+ { id: 'e1', expression: 'm1 * 2', label: 'CPU of $instance' },
+ ],
+};
+
+function createContext(
+ client: Partial,
+ days: number,
+ suggestedStepMs?: number,
+): TimeSeriesQueryContext {
+ const end = new Date('2026-09-30T00:00:00Z');
+ return {
+ timeRange: { start: new Date(end.getTime() - days * 24 * 3600 * 1000), end },
+ suggestedStepMs,
+ variableState: variables,
+ datasourceStore: { getDatasourceClient: vi.fn().mockResolvedValue(client) },
+ } as unknown as TimeSeriesQueryContext;
+}
+
+describe('CloudWatchTimeSeriesQuery', () => {
+ it('replaces the variables and converts the results to time series', async () => {
+ const getMetricData = vi.fn().mockResolvedValue({
+ MetricDataResults: [
+ {
+ Id: 'e1',
+ Label: 'CPU of i-0123',
+ Timestamps: ['2026-09-29T23:58:00Z', '2026-09-29T23:59:00Z'],
+ Values: [1, 2],
+ StatusCode: 'Complete',
+ },
+ ],
+ });
+
+ const result = await CloudWatchTimeSeriesQuery.getTimeSeriesData(spec, createContext({ getMetricData }, 1));
+
+ const request = getMetricData.mock.calls[0]?.[0];
+ expect(request.startTime).toBe('2026-09-29T00:00:00.000Z');
+ expect(request.endTime).toBe('2026-09-30T00:00:00.000Z');
+ expect(request.queries[0].metric).toEqual({
+ namespace: 'AWS/EC2',
+ name: 'CPUUtilization',
+ dimensions: { InstanceId: 'i-0123' },
+ statistic: 'Average',
+ period: 60,
+ });
+ expect(request.queries[1]).toEqual({ id: 'e1', expression: 'm1 * 2', label: 'CPU of i-0123' });
+ expect(result.series).toEqual([
+ {
+ name: 'CPU of i-0123',
+ values: [
+ [Date.parse('2026-09-29T23:58:00Z'), 1],
+ [Date.parse('2026-09-29T23:59:00Z'), 2],
+ ],
+ },
+ ]);
+ expect(result.stepMs).toBe(60_000);
+ });
+
+ it('increases the period of a long time range', async () => {
+ const getMetricData = vi.fn().mockResolvedValue({ MetricDataResults: [] });
+
+ const result = await CloudWatchTimeSeriesQuery.getTimeSeriesData(spec, createContext({ getMetricData }, 7));
+
+ expect(getMetricData.mock.calls[0]?.[0].queries[0].metric.period).toBe(120);
+ expect(result.stepMs).toBe(120_000);
+ });
+
+ it('uses the step suggested by the panel', async () => {
+ const getMetricData = vi.fn().mockResolvedValue({ MetricDataResults: [] });
+
+ await CloudWatchTimeSeriesQuery.getTimeSeriesData(spec, createContext({ getMetricData }, 1, 300_000));
+
+ expect(getMetricData.mock.calls[0]?.[0].queries[0].metric.period).toBe(300);
+ });
+
+ it('lists the variables the queries depend on', () => {
+ const dependsOn = CloudWatchTimeSeriesQuery.dependsOn?.(spec, {} as TimeSeriesQueryContext);
+ expect(dependsOn?.variables).toEqual(expect.arrayContaining(['instance', 'namespace']));
+ expect(dependsOn?.variables).toHaveLength(2);
+ });
+
+ it('creates a valid initial query', () => {
+ expect(validateQueries(CloudWatchTimeSeriesQuery.createInitialOptions().queries)).toBeUndefined();
+ });
+});
+
+describe('validateQueries', () => {
+ it('accepts metrics and expressions', () => {
+ expect(validateQueries(spec.queries)).toBeUndefined();
+ });
+
+ it.each([
+ ['no query', []],
+ [
+ 'duplicate IDs',
+ [
+ { id: 'e1', expression: 'm1' },
+ { id: 'e1', expression: 'm1' },
+ ],
+ ],
+ ['an invalid ID', [{ id: 'M1', expression: 'm1' }]],
+ ['a metric and an expression', [{ id: 'm1', expression: 'm1', metric: spec.queries[0]?.metric }]],
+ [
+ 'an unsupported statistic',
+ [{ id: 'm1', metric: { namespace: 'AWS/EC2', name: 'CPU', statistic: 'avg', period: 60 } }],
+ ],
+ [
+ 'a period not multiple of 60',
+ [{ id: 'm1', metric: { namespace: 'AWS/EC2', name: 'CPU', statistic: 'Sum', period: 90 } }],
+ ],
+ ['no returned data', [{ id: 'm1', returnData: false, expression: 'm2' }]],
+ ])('rejects %s', (_, queries) => {
+ expect(validateQueries(queries)).toBeDefined();
+ });
+});
diff --git a/cloudwatch/src/queries/cloudwatch-time-series-query/CloudWatchTimeSeriesQuery.tsx b/cloudwatch/src/queries/cloudwatch-time-series-query/CloudWatchTimeSeriesQuery.tsx
new file mode 100644
index 000000000..f48740d1f
--- /dev/null
+++ b/cloudwatch/src/queries/cloudwatch-time-series-query/CloudWatchTimeSeriesQuery.tsx
@@ -0,0 +1,25 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type { TimeSeriesQueryPlugin } from '@perses-dev/plugin-system';
+
+import type { CloudWatchTimeSeriesQuerySpec } from './cloudwatch-time-series-query-types';
+import { CloudWatchTimeSeriesQueryEditor, newMetricQuery } from './CloudWatchTimeSeriesQueryEditor';
+import { getCloudWatchTimeSeriesData, getQueryVariables } from './get-cloudwatch-time-series-data';
+
+export const CloudWatchTimeSeriesQuery: TimeSeriesQueryPlugin = {
+ getTimeSeriesData: getCloudWatchTimeSeriesData,
+ OptionsEditorComponent: CloudWatchTimeSeriesQueryEditor,
+ createInitialOptions: () => ({ queries: [newMetricQuery('m1')] }),
+ dependsOn: (spec) => ({ variables: getQueryVariables(spec) }),
+};
diff --git a/cloudwatch/src/queries/cloudwatch-time-series-query/CloudWatchTimeSeriesQueryEditor.tsx b/cloudwatch/src/queries/cloudwatch-time-series-query/CloudWatchTimeSeriesQueryEditor.tsx
new file mode 100644
index 000000000..a5f503e09
--- /dev/null
+++ b/cloudwatch/src/queries/cloudwatch-time-series-query/CloudWatchTimeSeriesQueryEditor.tsx
@@ -0,0 +1,323 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Alert, Button, Divider, FormControlLabel, Stack, Switch, TextField } from '@mui/material';
+import type { DatasourceSelectProps, OptionsEditorProps } from '@perses-dev/plugin-system';
+import { DatasourceSelect, isVariableDatasource } from '@perses-dev/plugin-system';
+import type { ChangeEvent, FocusEvent, ReactElement } from 'react';
+import { useCallback, useMemo } from 'react';
+
+import { DimensionsEditor } from '../../components/DimensionsEditor';
+import type { CloudWatchDimensions, CloudWatchMetricDataQuery, CloudWatchMetricStat } from '../../model';
+import {
+ CLOUDWATCH_DATASOURCE_KIND,
+ CLOUDWATCH_MAX_PERIOD,
+ CLOUDWATCH_MAX_QUERIES,
+ CLOUDWATCH_MIN_PERIOD,
+ DEFAULT_CLOUDWATCH,
+} from '../../model';
+import type { CloudWatchTimeSeriesQuerySpec } from './cloudwatch-time-series-query-types';
+import { MetricDiscovery } from './MetricDiscovery';
+
+const ID_REGEXP = /^[a-z][a-zA-Z0-9_]{0,63}$/;
+const STATISTIC_REGEXP = /^(Average|Sum|Minimum|Maximum|SampleCount|p([0-9]|[1-9][0-9])(\.[0-9]{1,2})?|p100)$/;
+
+/**
+ * Returns the first problem of the queries, or undefined when they are valid.
+ */
+export function validateQueries(queries: CloudWatchMetricDataQuery[]): string | undefined {
+ if (queries.length === 0 || queries.length > CLOUDWATCH_MAX_QUERIES) {
+ return `Define between 1 and ${CLOUDWATCH_MAX_QUERIES} queries.`;
+ }
+ const ids = new Set();
+ for (const query of queries) {
+ if (!ID_REGEXP.test(query.id) || ids.has(query.id)) {
+ return `Query ID "${query.id}" must be unique, start with a lower-case letter and contain only letters, digits and underscores.`;
+ }
+ ids.add(query.id);
+ if ((query.metric === undefined) === (query.expression === undefined)) {
+ return `Query "${query.id}" must define either a metric or an expression.`;
+ }
+ const metric = query.metric;
+ if (metric !== undefined) {
+ if (metric.namespace === '' || metric.name === '') {
+ return `Query "${query.id}" needs a namespace and a metric name.`;
+ }
+ if (!STATISTIC_REGEXP.test(metric.statistic)) {
+ return `Query "${query.id}" has an unsupported statistic.`;
+ }
+ if (
+ !Number.isInteger(metric.period) ||
+ metric.period < CLOUDWATCH_MIN_PERIOD ||
+ metric.period > CLOUDWATCH_MAX_PERIOD ||
+ metric.period % CLOUDWATCH_MIN_PERIOD !== 0
+ ) {
+ return `The period of query "${query.id}" must be a multiple of 60 between 60 and 86400 seconds.`;
+ }
+ } else if (query.expression === '') {
+ return `Query "${query.id}" needs an expression.`;
+ }
+ }
+ if (queries.every((query) => query.returnData === false)) {
+ return 'At least one query must return data.';
+ }
+ return undefined;
+}
+
+function nextID(queries: CloudWatchMetricDataQuery[], prefix: string): string {
+ let index = 1;
+ while (queries.some((query) => query.id === `${prefix}${index}`)) {
+ index++;
+ }
+ return `${prefix}${index}`;
+}
+
+export function newMetricQuery(id: string, metric?: CloudWatchMetricStat): CloudWatchMetricDataQuery {
+ return {
+ id,
+ metric: metric ?? {
+ namespace: 'AWS/EC2',
+ name: 'CPUUtilization',
+ statistic: 'Average',
+ period: CLOUDWATCH_MIN_PERIOD,
+ },
+ };
+}
+
+type MetricField = 'namespace' | 'name' | 'statistic';
+
+interface QueryEditorProps {
+ query: CloudWatchMetricDataQuery;
+ isReadonly?: boolean;
+ onChange: (id: string, query: CloudWatchMetricDataQuery) => void;
+ onRemove: (id: string) => void;
+}
+
+function QueryEditor({ query, isReadonly, onChange, onRemove }: QueryEditorProps): ReactElement {
+ const readOnlyProps = useMemo(() => ({ input: { readOnly: isReadonly } }), [isReadonly]);
+
+ // The ID is applied on blur, so a partial ID doesn't rename the query (and its React key) at each keystroke.
+ const handleIdBlur = useCallback(
+ (event: FocusEvent): void =>
+ onChange(query.id, { ...query, id: event.target.value.trim() }),
+ [onChange, query],
+ );
+ const handleLabelChange = useCallback(
+ (event: ChangeEvent): void =>
+ onChange(query.id, { ...query, label: event.target.value || undefined }),
+ [onChange, query],
+ );
+ const handleExpressionChange = useCallback(
+ (event: ChangeEvent): void => onChange(query.id, { ...query, expression: event.target.value }),
+ [onChange, query],
+ );
+ const handleMetricChange = useCallback(
+ (field: MetricField) =>
+ (event: ChangeEvent): void => {
+ if (query.metric) {
+ onChange(query.id, { ...query, metric: { ...query.metric, [field]: event.target.value } });
+ }
+ },
+ [onChange, query],
+ );
+ const handlePeriodChange = useCallback(
+ (event: ChangeEvent): void => {
+ if (query.metric) {
+ onChange(query.id, { ...query, metric: { ...query.metric, period: Number(event.target.value) } });
+ }
+ },
+ [onChange, query],
+ );
+ const handleDimensionsChange = useCallback(
+ (dimensions?: CloudWatchDimensions): void => {
+ if (query.metric) {
+ onChange(query.id, { ...query, metric: { ...query.metric, dimensions } });
+ }
+ },
+ [onChange, query],
+ );
+ const handleReturnDataChange = useCallback(
+ (event: ChangeEvent): void =>
+ onChange(query.id, { ...query, returnData: event.target.checked ? undefined : false }),
+ [onChange, query],
+ );
+ const handleRemove = useCallback(() => onRemove(query.id), [onRemove, query.id]);
+
+ const returnDataSwitch = useMemo(
+ () => ,
+ [query.returnData, isReadonly, handleReturnDataChange],
+ );
+
+ return (
+
+
+
+
+
+ {query.metric ? (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+ {!isReadonly && (
+
+ )}
+
+
+
+ );
+}
+
+export function CloudWatchTimeSeriesQueryEditor({
+ value,
+ onChange,
+ isReadonly,
+}: OptionsEditorProps): ReactElement {
+ const datasource = value.datasource ?? DEFAULT_CLOUDWATCH;
+ const error = useMemo(() => validateQueries(value.queries), [value.queries]);
+ const canAdd = !isReadonly && value.queries.length < CLOUDWATCH_MAX_QUERIES;
+
+ const handleDatasourceChange = useCallback(
+ (next) => {
+ if (!isVariableDatasource(next) && next.kind === CLOUDWATCH_DATASOURCE_KIND) {
+ onChange({ ...value, datasource: next });
+ }
+ },
+ [onChange, value],
+ );
+ const handleQueryChange = useCallback(
+ (id: string, updated: CloudWatchMetricDataQuery): void =>
+ onChange({ ...value, queries: value.queries.map((query) => (query.id === id ? updated : query)) }),
+ [onChange, value],
+ );
+ const handleQueryRemove = useCallback(
+ (id: string): void => onChange({ ...value, queries: value.queries.filter((query) => query.id !== id) }),
+ [onChange, value],
+ );
+ const handleMetricSelect = useCallback(
+ (metric: CloudWatchMetricStat): void =>
+ onChange({ ...value, queries: [...value.queries, newMetricQuery(nextID(value.queries, 'm'), metric)] }),
+ [onChange, value],
+ );
+ const handleAddMetric = useCallback(
+ (): void => onChange({ ...value, queries: [...value.queries, newMetricQuery(nextID(value.queries, 'm'))] }),
+ [onChange, value],
+ );
+ const handleAddExpression = useCallback(
+ (): void => onChange({ ...value, queries: [...value.queries, { id: nextID(value.queries, 'e'), expression: '' }] }),
+ [onChange, value],
+ );
+
+ return (
+
+
+ {canAdd && }
+ {error && {error}}
+ {value.queries.map((query) => (
+
+ ))}
+ {canAdd && (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/cloudwatch/src/queries/cloudwatch-time-series-query/MetricDiscovery.tsx b/cloudwatch/src/queries/cloudwatch-time-series-query/MetricDiscovery.tsx
new file mode 100644
index 000000000..02a02781f
--- /dev/null
+++ b/cloudwatch/src/queries/cloudwatch-time-series-query/MetricDiscovery.tsx
@@ -0,0 +1,128 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Alert, Button, Stack, TextField } from '@mui/material';
+import { useDatasourceClient } from '@perses-dev/plugin-system';
+import type { DatasourceSelector } from '@perses-dev/spec';
+import type { ChangeEvent, ReactElement } from 'react';
+import { useCallback, useState } from 'react';
+
+import type { CloudWatchClient, CloudWatchMetricInfo, CloudWatchMetricStat, ListMetricsResponse } from '../../model';
+import { CLOUDWATCH_MIN_PERIOD } from '../../model';
+
+export function toMetricStat(metric: CloudWatchMetricInfo): CloudWatchMetricStat {
+ const dimensions = Object.fromEntries(metric.Dimensions.map((dimension) => [dimension.Name, dimension.Value]));
+ return {
+ namespace: metric.Namespace,
+ name: metric.MetricName,
+ dimensions: Object.keys(dimensions).length > 0 ? dimensions : undefined,
+ statistic: 'Average',
+ period: CLOUDWATCH_MIN_PERIOD,
+ };
+}
+
+const DISCOVERED_METRIC_SX = { justifyContent: 'flex-start', textTransform: 'none' };
+
+function metricKey(metric: CloudWatchMetricInfo): string {
+ return `${metric.Namespace}/${metric.MetricName}/${JSON.stringify(metric.Dimensions)}`;
+}
+
+interface DiscoveredMetricProps {
+ metric: CloudWatchMetricInfo;
+ onSelect: (metric: CloudWatchMetricStat) => void;
+}
+
+function DiscoveredMetric({ metric, onSelect }: DiscoveredMetricProps): ReactElement {
+ const handleClick = useCallback(() => onSelect(toMetricStat(metric)), [metric, onSelect]);
+ const dimensions = metric.Dimensions.map((dimension) => `${dimension.Name}=${dimension.Value}`).join(', ');
+ return (
+
+ );
+}
+
+export interface MetricDiscoveryProps {
+ datasource: DatasourceSelector;
+ onSelect: (metric: CloudWatchMetricStat) => void;
+}
+
+/**
+ * Lists the metrics of a namespace with ListMetrics, so the user can add one to the queries.
+ */
+export function MetricDiscovery({ datasource, onSelect }: MetricDiscoveryProps): ReactElement {
+ const { data: client } = useDatasourceClient(datasource);
+ const [namespace, setNamespace] = useState('AWS/EC2');
+ const [metricName, setMetricName] = useState('');
+ const [response, setResponse] = useState();
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState();
+
+ const handleNamespaceChange = useCallback(
+ (event: ChangeEvent) => setNamespace(event.target.value),
+ [],
+ );
+ const handleMetricNameChange = useCallback(
+ (event: ChangeEvent) => setMetricName(event.target.value),
+ [],
+ );
+
+ const discover = useCallback(async (): Promise => {
+ if (client === undefined) {
+ return;
+ }
+ setLoading(true);
+ setError(undefined);
+ setResponse(undefined);
+ try {
+ setResponse(
+ await client.listMetrics({ namespace: namespace.trim(), metricName: metricName.trim() || undefined }),
+ );
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Metric discovery failed.');
+ } finally {
+ setLoading(false);
+ }
+ }, [client, namespace, metricName]);
+
+ return (
+
+
+
+
+
+
+ {error && {error}}
+ {response?.Metrics.length === 0 && (
+
+ No metric found. ListMetrics only returns the metrics with data in the last two weeks.
+
+ )}
+ {response?.Truncated && (
+
+ Only the first {response.Metrics.length} metrics are listed. Enter a metric name to narrow the discovery.
+
+ )}
+ {response?.Metrics.map((metric) => (
+
+ ))}
+
+ );
+}
diff --git a/cloudwatch/src/queries/cloudwatch-time-series-query/cloudwatch-time-series-query-types.ts b/cloudwatch/src/queries/cloudwatch-time-series-query/cloudwatch-time-series-query-types.ts
new file mode 100644
index 000000000..c4049e4e5
--- /dev/null
+++ b/cloudwatch/src/queries/cloudwatch-time-series-query/cloudwatch-time-series-query-types.ts
@@ -0,0 +1,22 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type { DatasourceSelector } from '@perses-dev/spec';
+
+import type { CloudWatchMetricDataQuery } from '../../model';
+
+export interface CloudWatchTimeSeriesQuerySpec {
+ datasource?: DatasourceSelector;
+ // queries are sent together, so metric math expressions can reference the other queries by ID.
+ queries: CloudWatchMetricDataQuery[];
+}
diff --git a/cloudwatch/src/queries/cloudwatch-time-series-query/get-cloudwatch-time-series-data.ts b/cloudwatch/src/queries/cloudwatch-time-series-query/get-cloudwatch-time-series-data.ts
new file mode 100644
index 000000000..d8098ffcb
--- /dev/null
+++ b/cloudwatch/src/queries/cloudwatch-time-series-query/get-cloudwatch-time-series-data.ts
@@ -0,0 +1,109 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type { TimeSeriesQueryPlugin, VariableStateMap } from '@perses-dev/plugin-system';
+import { parseVariables, replaceVariables } from '@perses-dev/plugin-system';
+import type { TimeSeries } from '@perses-dev/spec';
+
+import type { CloudWatchClient, CloudWatchDimensions, CloudWatchMetricDataQuery } from '../../model';
+import { CLOUDWATCH_MIN_PERIOD, DEFAULT_CLOUDWATCH, getEffectivePeriod } from '../../model';
+import type { CloudWatchTimeSeriesQuerySpec } from './cloudwatch-time-series-query-types';
+
+function replaceDimensions(
+ dimensions: CloudWatchDimensions | undefined,
+ variables: VariableStateMap,
+): CloudWatchDimensions | undefined {
+ if (dimensions === undefined) {
+ return undefined;
+ }
+ return Object.fromEntries(
+ Object.entries(dimensions).map(([name, value]) => [
+ replaceVariables(name, variables),
+ replaceVariables(value, variables),
+ ]),
+ );
+}
+
+/**
+ * Replaces the variables of a query and adapts its period to the time range.
+ */
+export function resolveQuery(
+ query: CloudWatchMetricDataQuery,
+ variables: VariableStateMap,
+ rangeSeconds: number,
+ returnedSeries: number,
+ suggestedStepMs?: number,
+): CloudWatchMetricDataQuery {
+ const resolved: CloudWatchMetricDataQuery = { ...query };
+ if (query.label !== undefined) {
+ resolved.label = replaceVariables(query.label, variables);
+ }
+ if (query.expression !== undefined) {
+ resolved.expression = replaceVariables(query.expression, variables);
+ }
+ if (query.metric !== undefined) {
+ resolved.metric = {
+ ...query.metric,
+ namespace: replaceVariables(query.metric.namespace, variables),
+ name: replaceVariables(query.metric.name, variables),
+ dimensions: replaceDimensions(query.metric.dimensions, variables),
+ period: getEffectivePeriod(query.metric.period, rangeSeconds, returnedSeries, suggestedStepMs),
+ };
+ }
+ return resolved;
+}
+
+/**
+ * Returns the names of the variables used by the queries.
+ */
+export function getQueryVariables(spec: CloudWatchTimeSeriesQuerySpec): string[] {
+ const texts = spec.queries.flatMap((query) => [
+ query.label ?? '',
+ query.expression ?? '',
+ query.metric?.namespace ?? '',
+ query.metric?.name ?? '',
+ ...Object.entries(query.metric?.dimensions ?? {}).flat(),
+ ]);
+ return [...new Set(texts.flatMap((text) => parseVariables(text)))];
+}
+
+export const getCloudWatchTimeSeriesData: TimeSeriesQueryPlugin['getTimeSeriesData'] =
+ async (spec, context, abortSignal) => {
+ const { start, end } = context.timeRange;
+ if (spec.queries.length === 0) {
+ return { series: [], timeRange: { start, end }, stepMs: CLOUDWATCH_MIN_PERIOD * 1000 };
+ }
+
+ const rangeSeconds = (end.getTime() - start.getTime()) / 1000;
+ const returnedSeries = spec.queries.filter((query) => query.returnData !== false).length;
+ const queries = spec.queries.map((query) =>
+ resolveQuery(query, context.variableState, rangeSeconds, returnedSeries, context.suggestedStepMs),
+ );
+
+ const client = await context.datasourceStore.getDatasourceClient(
+ spec.datasource ?? DEFAULT_CLOUDWATCH,
+ );
+ const response = await client.getMetricData(
+ { startTime: start.toISOString(), endTime: end.toISOString(), queries },
+ abortSignal,
+ );
+
+ const series: TimeSeries[] = response.MetricDataResults.map((result) => ({
+ name: result.Label || result.Id,
+ values: result.Timestamps.map((timestamp, index) => [Date.parse(timestamp), result.Values[index] ?? null]),
+ }));
+ const periods = queries.flatMap((query) => (query.metric ? [query.metric.period] : []));
+ const stepSeconds = periods.length > 0 ? Math.min(...periods) : CLOUDWATCH_MIN_PERIOD;
+
+ return { series, timeRange: { start, end }, stepMs: stepSeconds * 1000 };
+ };
diff --git a/cloudwatch/src/queries/cloudwatch-time-series-query/index.ts b/cloudwatch/src/queries/cloudwatch-time-series-query/index.ts
new file mode 100644
index 000000000..35a8a779b
--- /dev/null
+++ b/cloudwatch/src/queries/cloudwatch-time-series-query/index.ts
@@ -0,0 +1,18 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export * from './CloudWatchTimeSeriesQuery';
+export * from './CloudWatchTimeSeriesQueryEditor';
+export * from './MetricDiscovery';
+export * from './cloudwatch-time-series-query-types';
+export * from './get-cloudwatch-time-series-data';
diff --git a/cloudwatch/src/queries/index.ts b/cloudwatch/src/queries/index.ts
new file mode 100644
index 000000000..d1a0e3ed8
--- /dev/null
+++ b/cloudwatch/src/queries/index.ts
@@ -0,0 +1,14 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export * from './cloudwatch-time-series-query';
diff --git a/cloudwatch/src/setup-tests.ts b/cloudwatch/src/setup-tests.ts
new file mode 100644
index 000000000..78af56004
--- /dev/null
+++ b/cloudwatch/src/setup-tests.ts
@@ -0,0 +1,17 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom/vitest';
+
+// Always mock e-charts during tests since we don't have a proper canvas in jsdom
+vi.mock('echarts/core');
diff --git a/cloudwatch/src/variables/cloudwatch-dimension-values-variable/CloudWatchDimensionValuesVariable.test.ts b/cloudwatch/src/variables/cloudwatch-dimension-values-variable/CloudWatchDimensionValuesVariable.test.ts
new file mode 100644
index 000000000..d0fde9c7d
--- /dev/null
+++ b/cloudwatch/src/variables/cloudwatch-dimension-values-variable/CloudWatchDimensionValuesVariable.test.ts
@@ -0,0 +1,72 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type { GetVariableOptionsContext } from '@perses-dev/plugin-system';
+
+import type { CloudWatchMetricInfo } from '../../model';
+import { CloudWatchDimensionValuesVariable, getDimensionValues } from './CloudWatchDimensionValuesVariable';
+
+const metrics: CloudWatchMetricInfo[] = [
+ { Namespace: 'AWS/EC2', MetricName: 'CPUUtilization', Dimensions: [{ Name: 'InstanceId', Value: 'i-2' }] },
+ {
+ Namespace: 'AWS/EC2',
+ MetricName: 'CPUUtilization',
+ Dimensions: [
+ { Name: 'AutoScalingGroupName', Value: 'web' },
+ { Name: 'InstanceId', Value: 'i-1' },
+ ],
+ },
+ { Namespace: 'AWS/EC2', MetricName: 'NetworkIn', Dimensions: [{ Name: 'InstanceId', Value: 'i-2' }] },
+ { Namespace: 'AWS/EC2', MetricName: 'CPUUtilization', Dimensions: [] },
+];
+
+describe('getDimensionValues', () => {
+ it('returns the sorted distinct values of the dimension', () => {
+ expect(getDimensionValues(metrics, 'InstanceId')).toEqual([
+ { value: 'i-1', label: 'i-1' },
+ { value: 'i-2', label: 'i-2' },
+ ]);
+ expect(getDimensionValues(metrics, 'Unknown')).toEqual([]);
+ });
+});
+
+describe('CloudWatchDimensionValuesVariable', () => {
+ const spec = {
+ namespace: 'AWS/EC2',
+ metricName: 'CPUUtilization',
+ dimensionKey: 'InstanceId',
+ dimensions: { AutoScalingGroupName: '$asg' },
+ };
+
+ it('discovers the dimension values with the variables replaced', async () => {
+ const listMetrics = vi.fn().mockResolvedValue({ Metrics: metrics, Truncated: false });
+ const ctx = {
+ variables: { asg: { value: 'web', loading: false, options: [] } },
+ datasourceStore: { getDatasourceClient: vi.fn().mockResolvedValue({ listMetrics }) },
+ } as unknown as GetVariableOptionsContext;
+
+ const { data } = await CloudWatchDimensionValuesVariable.getVariableOptions(spec, ctx);
+
+ expect(listMetrics).toHaveBeenCalledWith(
+ { namespace: 'AWS/EC2', metricName: 'CPUUtilization', dimensions: { AutoScalingGroupName: 'web' } },
+ undefined,
+ );
+ expect(data.map((option) => option.value)).toEqual(['i-1', 'i-2']);
+ });
+
+ it('depends on the variables of the filters', () => {
+ expect(CloudWatchDimensionValuesVariable.dependsOn?.(spec, {} as GetVariableOptionsContext)).toEqual({
+ variables: ['asg'],
+ });
+ });
+});
diff --git a/cloudwatch/src/variables/cloudwatch-dimension-values-variable/CloudWatchDimensionValuesVariable.tsx b/cloudwatch/src/variables/cloudwatch-dimension-values-variable/CloudWatchDimensionValuesVariable.tsx
new file mode 100644
index 000000000..480213cf0
--- /dev/null
+++ b/cloudwatch/src/variables/cloudwatch-dimension-values-variable/CloudWatchDimensionValuesVariable.tsx
@@ -0,0 +1,73 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type { VariableOption, VariablePlugin } from '@perses-dev/plugin-system';
+import { parseVariables, replaceVariables } from '@perses-dev/plugin-system';
+
+import type { CloudWatchClient, CloudWatchDimensions, CloudWatchMetricInfo } from '../../model';
+import { DEFAULT_CLOUDWATCH } from '../../model';
+import type { CloudWatchDimensionValuesVariableSpec } from './cloudwatch-dimension-values-variable-types';
+import { CloudWatchDimensionValuesVariableEditor } from './CloudWatchDimensionValuesVariableEditor';
+
+/**
+ * Returns the sorted distinct values of a dimension in the discovered metrics.
+ */
+export function getDimensionValues(metrics: CloudWatchMetricInfo[], dimensionKey: string): VariableOption[] {
+ const values = new Set();
+ for (const metric of metrics) {
+ for (const dimension of metric.Dimensions) {
+ if (dimension.Name === dimensionKey) {
+ values.add(dimension.Value);
+ }
+ }
+ }
+ return [...values].toSorted().map((value) => ({ value, label: value }));
+}
+
+export const CloudWatchDimensionValuesVariable: VariablePlugin = {
+ getVariableOptions: async (spec, ctx, abortSignal) => {
+ const client = await ctx.datasourceStore.getDatasourceClient(
+ spec.datasource ?? DEFAULT_CLOUDWATCH,
+ );
+ let dimensions: CloudWatchDimensions | undefined;
+ if (spec.dimensions !== undefined) {
+ dimensions = Object.fromEntries(
+ Object.entries(spec.dimensions).map(([name, value]) => [
+ replaceVariables(name, ctx.variables),
+ replaceVariables(value, ctx.variables),
+ ]),
+ );
+ }
+ const dimensionKey = replaceVariables(spec.dimensionKey, ctx.variables);
+ const response = await client.listMetrics(
+ {
+ namespace: replaceVariables(spec.namespace, ctx.variables),
+ metricName: spec.metricName ? replaceVariables(spec.metricName, ctx.variables) : undefined,
+ dimensions,
+ },
+ abortSignal,
+ );
+ return { data: getDimensionValues(response.Metrics, dimensionKey) };
+ },
+ dependsOn: (spec) => {
+ const texts = [
+ spec.namespace,
+ spec.metricName ?? '',
+ spec.dimensionKey,
+ ...Object.entries(spec.dimensions ?? {}).flat(),
+ ];
+ return { variables: [...new Set(texts.flatMap((text) => parseVariables(text)))] };
+ },
+ OptionsEditorComponent: CloudWatchDimensionValuesVariableEditor,
+ createInitialOptions: () => ({ namespace: 'AWS/EC2', dimensionKey: 'InstanceId' }),
+};
diff --git a/cloudwatch/src/variables/cloudwatch-dimension-values-variable/CloudWatchDimensionValuesVariableEditor.tsx b/cloudwatch/src/variables/cloudwatch-dimension-values-variable/CloudWatchDimensionValuesVariableEditor.tsx
new file mode 100644
index 000000000..8cde6fcf2
--- /dev/null
+++ b/cloudwatch/src/variables/cloudwatch-dimension-values-variable/CloudWatchDimensionValuesVariableEditor.tsx
@@ -0,0 +1,97 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Stack, TextField } from '@mui/material';
+import type { DatasourceSelectProps, OptionsEditorProps } from '@perses-dev/plugin-system';
+import { DatasourceSelect, isVariableDatasource } from '@perses-dev/plugin-system';
+import type { ChangeEvent, ReactElement } from 'react';
+import { useCallback, useMemo } from 'react';
+
+import { DimensionsEditor } from '../../components/DimensionsEditor';
+import type { CloudWatchDimensions } from '../../model';
+import { CLOUDWATCH_DATASOURCE_KIND, DEFAULT_CLOUDWATCH } from '../../model';
+import type { CloudWatchDimensionValuesVariableSpec } from './cloudwatch-dimension-values-variable-types';
+
+export function CloudWatchDimensionValuesVariableEditor({
+ value,
+ onChange,
+ isReadonly,
+}: OptionsEditorProps): ReactElement {
+ const readOnlyProps = useMemo(() => ({ input: { readOnly: isReadonly } }), [isReadonly]);
+
+ const handleDatasourceChange = useCallback(
+ (next) => {
+ if (!isVariableDatasource(next) && next.kind === CLOUDWATCH_DATASOURCE_KIND) {
+ onChange({ ...value, datasource: next });
+ }
+ },
+ [onChange, value],
+ );
+ const handleNamespaceChange = useCallback(
+ (event: ChangeEvent): void => onChange({ ...value, namespace: event.target.value }),
+ [onChange, value],
+ );
+ const handleMetricNameChange = useCallback(
+ (event: ChangeEvent): void => onChange({ ...value, metricName: event.target.value || undefined }),
+ [onChange, value],
+ );
+ const handleDimensionKeyChange = useCallback(
+ (event: ChangeEvent): void => onChange({ ...value, dimensionKey: event.target.value }),
+ [onChange, value],
+ );
+ const handleDimensionsChange = useCallback(
+ (dimensions?: CloudWatchDimensions): void => onChange({ ...value, dimensions }),
+ [onChange, value],
+ );
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/cloudwatch/src/variables/cloudwatch-dimension-values-variable/cloudwatch-dimension-values-variable-types.ts b/cloudwatch/src/variables/cloudwatch-dimension-values-variable/cloudwatch-dimension-values-variable-types.ts
new file mode 100644
index 000000000..6c5caccf5
--- /dev/null
+++ b/cloudwatch/src/variables/cloudwatch-dimension-values-variable/cloudwatch-dimension-values-variable-types.ts
@@ -0,0 +1,26 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import type { DatasourceSelector } from '@perses-dev/spec';
+
+import type { CloudWatchDimensions } from '../../model';
+
+export interface CloudWatchDimensionValuesVariableSpec {
+ datasource?: DatasourceSelector;
+ namespace: string;
+ metricName?: string;
+ // dimensionKey is the dimension whose values are the options of the variable, for example InstanceId.
+ dimensionKey: string;
+ // dimensions filters the metrics on other dimension values, for example {"AutoScalingGroupName": "$asg"}.
+ dimensions?: CloudWatchDimensions;
+}
diff --git a/cloudwatch/src/variables/cloudwatch-dimension-values-variable/index.ts b/cloudwatch/src/variables/cloudwatch-dimension-values-variable/index.ts
new file mode 100644
index 000000000..94ff098e0
--- /dev/null
+++ b/cloudwatch/src/variables/cloudwatch-dimension-values-variable/index.ts
@@ -0,0 +1,16 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export * from './CloudWatchDimensionValuesVariable';
+export * from './CloudWatchDimensionValuesVariableEditor';
+export * from './cloudwatch-dimension-values-variable-types';
diff --git a/cloudwatch/src/variables/index.ts b/cloudwatch/src/variables/index.ts
new file mode 100644
index 000000000..f223519e1
--- /dev/null
+++ b/cloudwatch/src/variables/index.ts
@@ -0,0 +1,14 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export * from './cloudwatch-dimension-values-variable';
diff --git a/cloudwatch/tsconfig.build.json b/cloudwatch/tsconfig.build.json
new file mode 100644
index 000000000..e1e5de66b
--- /dev/null
+++ b/cloudwatch/tsconfig.build.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "outDir": "./dist/lib",
+ "rootDir": "./src",
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "declarationMap": true
+ }
+}
diff --git a/cloudwatch/tsconfig.json b/cloudwatch/tsconfig.json
new file mode 100644
index 000000000..4bd6962d4
--- /dev/null
+++ b/cloudwatch/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "../tsconfig.base.json",
+ "include": ["src"]
+}
diff --git a/cloudwatch/vitest.config.ts b/cloudwatch/vitest.config.ts
new file mode 100644
index 000000000..19928bdd5
--- /dev/null
+++ b/cloudwatch/vitest.config.ts
@@ -0,0 +1,19 @@
+// Copyright The Perses Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { definePackageVitestConfig } from '../vitest.shared.ts';
+
+export default definePackageVitestConfig({
+ packageDir: import.meta.dirname,
+ setupFiles: ['src/setup-tests.ts'],
+});
diff --git a/package-lock.json b/package-lock.json
index 8b1eb67ba..f866afb23 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"barchart",
"canvas",
"clickhouse",
+ "cloudwatch",
"datasourcevariable",
"flamechart",
"gaugechart",
@@ -181,6 +182,21 @@
"use-resize-observer": "^9.0.0"
}
},
+ "cloudwatch": {
+ "name": "@perses-dev/cloudwatch-plugin",
+ "version": "0.1.0-beta.0",
+ "license": "Apache-2.0",
+ "devDependencies": {},
+ "peerDependencies": {
+ "@perses-dev/client": "^0.55.0-beta.15",
+ "@perses-dev/components": "^0.55.0-beta.15",
+ "@perses-dev/plugin-system": "^0.55.0-beta.15",
+ "@perses-dev/spec": "^0.3.0-beta.10",
+ "immer": "^10.1.1",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0"
+ }
+ },
"datasourcevariable": {
"name": "@perses-dev/datasource-variable-plugin",
"version": "0.7.0-beta.7",
@@ -3295,6 +3311,10 @@
"react": "^18.3.0"
}
},
+ "node_modules/@perses-dev/cloudwatch-plugin": {
+ "resolved": "cloudwatch",
+ "link": true
+ },
"node_modules/@perses-dev/components": {
"version": "0.55.0-beta.15",
"resolved": "https://registry.npmjs.org/@perses-dev/components/-/components-0.55.0-beta.15.tgz",
diff --git a/package.json b/package.json
index e80c5b4a0..1f65fcd64 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"barchart",
"canvas",
"clickhouse",
+ "cloudwatch",
"datasourcevariable",
"flamechart",
"gaugechart",