From 1f67fd006e7ac2334ac1a28107907a521773a10d Mon Sep 17 00:00:00 2001 From: Chittibabu Terala <102535438+T-Chittibabu@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:56:57 +0530 Subject: [PATCH] [FEATURE] add CloudWatch plugin Add a CloudWatch plugin querying Amazon CloudWatch metrics through the CloudWatchProxy of the Perses server. The server signs the requests with its own AWS identity, optionally assuming a role, so the browser never receives AWS credentials and the datasource contains none. - CloudWatchDatasource: the region, and optionally the role to assume and the secret holding its external ID. The schema reuses the CloudWatchDatasourceSpec of perses/spec. - CloudWatchTimeSeriesQuery: metrics and metric math expressions sent in a single GetMetricData request, with metric discovery in the editor. The period of a metric is a minimum: it is increased to the step suggested by the panel, and so the series fit in the 10000 datapoints returned by the server (for example over 7 days). - CloudWatchDimensionValuesVariable: the values of a dimension in a namespace, discovered with ListMetrics. - Dashboard variables can be used in the namespaces, metric names, dimensions, expressions and legends. - Go SDK for the datasource, the query and the variable. The datasource doesn't define a health check path: the proxy only accepts POST requests, so the generic connection test doesn't apply. It requires the CloudWatch proxy model of perses/spec and the CloudWatch proxy of perses/perses. Co-authored-by: Jagath P <87551823+jagath25@users.noreply.github.com> Signed-off-by: Chittibabu Terala <102535438+T-Chittibabu@users.noreply.github.com> --- cloudwatch/README.md | 102 ++++++ cloudwatch/cue.mod/module.cue | 17 + cloudwatch/go.mod | 37 ++ cloudwatch/go.sum | 62 ++++ cloudwatch/package.json | 77 +++++ cloudwatch/rsbuild.config.ts | 37 ++ .../datasources/cloudwatch/cloudwatch.cue | 28 ++ .../invalid/external-id-without-role.json | 12 + .../tests/invalid/static-credentials.json | 12 + .../cloudwatch/tests/valid/assumed-role.json | 13 + .../tests/valid/default-identity.json | 11 + .../cloudwatch-time-series-query/query.cue | 50 +++ .../tests/invalid/metric-and-expression.json | 17 + .../tests/invalid/no-query.json | 6 + .../tests/invalid/unsupported-period.json | 16 + .../tests/valid/metric-math.json | 29 ++ .../cloudwatch-dimension-values.cue | 30 ++ .../tests/invalid/missing-dimension-key.json | 6 + .../tests/valid/instances.json | 11 + cloudwatch/sdk/go/datasource/datasource.go | 73 ++++ cloudwatch/sdk/go/datasource/options.go | 30 ++ .../sdk/go/query/time-series/options.go | 69 ++++ .../sdk/go/query/time-series/time-series.go | 77 +++++ cloudwatch/sdk/go/sdk_test.go | 81 +++++ .../dimension-values/dimension-values.go | 86 +++++ cloudwatch/src/bootstrap.tsx | 18 + .../src/components/DimensionsEditor.tsx | 89 +++++ .../CloudWatchDatasource.test.ts | 62 ++++ .../CloudWatchDatasource.tsx | 35 ++ .../CloudWatchDatasourceEditor.tsx | 104 ++++++ .../cloudwatch-datasource-types.ts | 43 +++ .../cloudwatch-datasource/index.ts | 16 + cloudwatch/src/datasources/index.ts | 14 + cloudwatch/src/env.d.ts | 14 + cloudwatch/src/getPluginModule.ts | 31 ++ cloudwatch/src/index-federation.ts | 14 + cloudwatch/src/index.ts | 18 + .../src/model/cloudwatch-client-types.ts | 75 ++++ .../src/model/cloudwatch-client.test.ts | 92 +++++ cloudwatch/src/model/cloudwatch-client.ts | 121 +++++++ cloudwatch/src/model/constants.ts | 24 ++ cloudwatch/src/model/index.ts | 17 + cloudwatch/src/model/period.test.ts | 57 ++++ cloudwatch/src/model/period.ts | 37 ++ .../CloudWatchTimeSeriesQuery.test.ts | 152 +++++++++ .../CloudWatchTimeSeriesQuery.tsx | 25 ++ .../CloudWatchTimeSeriesQueryEditor.tsx | 323 ++++++++++++++++++ .../MetricDiscovery.tsx | 128 +++++++ .../cloudwatch-time-series-query-types.ts | 22 ++ .../get-cloudwatch-time-series-data.ts | 109 ++++++ .../cloudwatch-time-series-query/index.ts | 18 + cloudwatch/src/queries/index.ts | 14 + cloudwatch/src/setup-tests.ts | 17 + .../CloudWatchDimensionValuesVariable.test.ts | 72 ++++ .../CloudWatchDimensionValuesVariable.tsx | 73 ++++ ...loudWatchDimensionValuesVariableEditor.tsx | 97 ++++++ ...udwatch-dimension-values-variable-types.ts | 26 ++ .../index.ts | 16 + cloudwatch/src/variables/index.ts | 14 + cloudwatch/tsconfig.build.json | 11 + cloudwatch/tsconfig.json | 4 + cloudwatch/vitest.config.ts | 19 ++ package-lock.json | 20 ++ package.json | 1 + 64 files changed, 3031 insertions(+) create mode 100644 cloudwatch/README.md create mode 100644 cloudwatch/cue.mod/module.cue create mode 100644 cloudwatch/go.mod create mode 100644 cloudwatch/go.sum create mode 100644 cloudwatch/package.json create mode 100644 cloudwatch/rsbuild.config.ts create mode 100644 cloudwatch/schemas/datasources/cloudwatch/cloudwatch.cue create mode 100644 cloudwatch/schemas/datasources/cloudwatch/tests/invalid/external-id-without-role.json create mode 100644 cloudwatch/schemas/datasources/cloudwatch/tests/invalid/static-credentials.json create mode 100644 cloudwatch/schemas/datasources/cloudwatch/tests/valid/assumed-role.json create mode 100644 cloudwatch/schemas/datasources/cloudwatch/tests/valid/default-identity.json create mode 100644 cloudwatch/schemas/queries/cloudwatch-time-series-query/query.cue create mode 100644 cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/invalid/metric-and-expression.json create mode 100644 cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/invalid/no-query.json create mode 100644 cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/invalid/unsupported-period.json create mode 100644 cloudwatch/schemas/queries/cloudwatch-time-series-query/tests/valid/metric-math.json create mode 100644 cloudwatch/schemas/variables/cloudwatch-dimension-values/cloudwatch-dimension-values.cue create mode 100644 cloudwatch/schemas/variables/cloudwatch-dimension-values/tests/invalid/missing-dimension-key.json create mode 100644 cloudwatch/schemas/variables/cloudwatch-dimension-values/tests/valid/instances.json create mode 100644 cloudwatch/sdk/go/datasource/datasource.go create mode 100644 cloudwatch/sdk/go/datasource/options.go create mode 100644 cloudwatch/sdk/go/query/time-series/options.go create mode 100644 cloudwatch/sdk/go/query/time-series/time-series.go create mode 100644 cloudwatch/sdk/go/sdk_test.go create mode 100644 cloudwatch/sdk/go/variable/dimension-values/dimension-values.go create mode 100644 cloudwatch/src/bootstrap.tsx create mode 100644 cloudwatch/src/components/DimensionsEditor.tsx create mode 100644 cloudwatch/src/datasources/cloudwatch-datasource/CloudWatchDatasource.test.ts create mode 100644 cloudwatch/src/datasources/cloudwatch-datasource/CloudWatchDatasource.tsx create mode 100644 cloudwatch/src/datasources/cloudwatch-datasource/CloudWatchDatasourceEditor.tsx create mode 100644 cloudwatch/src/datasources/cloudwatch-datasource/cloudwatch-datasource-types.ts create mode 100644 cloudwatch/src/datasources/cloudwatch-datasource/index.ts create mode 100644 cloudwatch/src/datasources/index.ts create mode 100644 cloudwatch/src/env.d.ts create mode 100644 cloudwatch/src/getPluginModule.ts create mode 100644 cloudwatch/src/index-federation.ts create mode 100644 cloudwatch/src/index.ts create mode 100644 cloudwatch/src/model/cloudwatch-client-types.ts create mode 100644 cloudwatch/src/model/cloudwatch-client.test.ts create mode 100644 cloudwatch/src/model/cloudwatch-client.ts create mode 100644 cloudwatch/src/model/constants.ts create mode 100644 cloudwatch/src/model/index.ts create mode 100644 cloudwatch/src/model/period.test.ts create mode 100644 cloudwatch/src/model/period.ts create mode 100644 cloudwatch/src/queries/cloudwatch-time-series-query/CloudWatchTimeSeriesQuery.test.ts create mode 100644 cloudwatch/src/queries/cloudwatch-time-series-query/CloudWatchTimeSeriesQuery.tsx create mode 100644 cloudwatch/src/queries/cloudwatch-time-series-query/CloudWatchTimeSeriesQueryEditor.tsx create mode 100644 cloudwatch/src/queries/cloudwatch-time-series-query/MetricDiscovery.tsx create mode 100644 cloudwatch/src/queries/cloudwatch-time-series-query/cloudwatch-time-series-query-types.ts create mode 100644 cloudwatch/src/queries/cloudwatch-time-series-query/get-cloudwatch-time-series-data.ts create mode 100644 cloudwatch/src/queries/cloudwatch-time-series-query/index.ts create mode 100644 cloudwatch/src/queries/index.ts create mode 100644 cloudwatch/src/setup-tests.ts create mode 100644 cloudwatch/src/variables/cloudwatch-dimension-values-variable/CloudWatchDimensionValuesVariable.test.ts create mode 100644 cloudwatch/src/variables/cloudwatch-dimension-values-variable/CloudWatchDimensionValuesVariable.tsx create mode 100644 cloudwatch/src/variables/cloudwatch-dimension-values-variable/CloudWatchDimensionValuesVariableEditor.tsx create mode 100644 cloudwatch/src/variables/cloudwatch-dimension-values-variable/cloudwatch-dimension-values-variable-types.ts create mode 100644 cloudwatch/src/variables/cloudwatch-dimension-values-variable/index.ts create mode 100644 cloudwatch/src/variables/index.ts create mode 100644 cloudwatch/tsconfig.build.json create mode 100644 cloudwatch/tsconfig.json create mode 100644 cloudwatch/vitest.config.ts 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",