mirror of https://github.com/grafana/grafana
CloudWatch: Datasource improvements (#20268)
* CloudWatch: Datasource improvements * Add statistic as template variale * Add wildcard to list of values * Template variable intercept dimension key * Return row specific errors when transformation error occured * Add meta feedback * Make it possible to retrieve values without known metrics * Add curated dashboard for EC2 * Fix broken tests * Use correct dashboard name * Display alert in case multi template var is being used for some certain props in the cloudwatch query * Minor fixes after feedback * Update dashboard json * Update snapshot test * Make sure region default is intercepted in cloudwatch link * Update dashboards * Include ec2 dashboard in ds * Do not include ec2 dashboard in beta1 * Display actual regionpull/20378/head
parent
1f018adbf3
commit
00bef917ee
@ -0,0 +1,62 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"strings" |
||||
) |
||||
|
||||
type cloudWatchQuery struct { |
||||
RefId string |
||||
Region string |
||||
Id string |
||||
Namespace string |
||||
MetricName string |
||||
Stats string |
||||
Expression string |
||||
ReturnData bool |
||||
Dimensions map[string][]string |
||||
Period int |
||||
Alias string |
||||
Identifier string |
||||
HighResolution bool |
||||
MatchExact bool |
||||
UsedExpression string |
||||
RequestExceededMaxLimit bool |
||||
} |
||||
|
||||
func (q *cloudWatchQuery) isMathExpression() bool { |
||||
return q.Expression != "" && !q.isUserDefinedSearchExpression() |
||||
} |
||||
|
||||
func (q *cloudWatchQuery) isSearchExpression() bool { |
||||
return q.isUserDefinedSearchExpression() || q.isInferredSearchExpression() |
||||
} |
||||
|
||||
func (q *cloudWatchQuery) isUserDefinedSearchExpression() bool { |
||||
return strings.Contains(q.Expression, "SEARCH(") |
||||
} |
||||
|
||||
func (q *cloudWatchQuery) isInferredSearchExpression() bool { |
||||
if len(q.Dimensions) == 0 { |
||||
return !q.MatchExact |
||||
} |
||||
|
||||
if !q.MatchExact { |
||||
return true |
||||
} |
||||
|
||||
for _, values := range q.Dimensions { |
||||
if len(values) > 1 { |
||||
return true |
||||
} |
||||
for _, v := range values { |
||||
if v == "*" { |
||||
return true |
||||
} |
||||
} |
||||
} |
||||
return false |
||||
} |
||||
|
||||
func (q *cloudWatchQuery) isMetricStat() bool { |
||||
return !q.isSearchExpression() && !q.isMathExpression() |
||||
} |
@ -0,0 +1,175 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
. "github.com/smartystreets/goconvey/convey" |
||||
) |
||||
|
||||
func TestCloudWatchQuery(t *testing.T) { |
||||
Convey("TestCloudWatchQuery", t, func() { |
||||
Convey("and SEARCH(someexpression) was specified in the query editor", func() { |
||||
query := &cloudWatchQuery{ |
||||
RefId: "A", |
||||
Region: "us-east-1", |
||||
Expression: "SEARCH(someexpression)", |
||||
Stats: "Average", |
||||
Period: 300, |
||||
Id: "id1", |
||||
Identifier: "id1", |
||||
} |
||||
|
||||
Convey("it is a search expression", func() { |
||||
So(query.isSearchExpression(), ShouldBeTrue) |
||||
}) |
||||
|
||||
Convey("it is not math expressions", func() { |
||||
So(query.isMathExpression(), ShouldBeFalse) |
||||
}) |
||||
}) |
||||
|
||||
Convey("and no expression, no multi dimension key values and no * was used", func() { |
||||
query := &cloudWatchQuery{ |
||||
RefId: "A", |
||||
Region: "us-east-1", |
||||
Expression: "", |
||||
Stats: "Average", |
||||
Period: 300, |
||||
Id: "id1", |
||||
Identifier: "id1", |
||||
MatchExact: true, |
||||
Dimensions: map[string][]string{ |
||||
"InstanceId": {"i-12345678"}, |
||||
}, |
||||
} |
||||
|
||||
Convey("it is not a search expression", func() { |
||||
So(query.isSearchExpression(), ShouldBeFalse) |
||||
}) |
||||
|
||||
Convey("it is not math expressions", func() { |
||||
So(query.isMathExpression(), ShouldBeFalse) |
||||
}) |
||||
}) |
||||
|
||||
Convey("and no expression but multi dimension key values exist", func() { |
||||
query := &cloudWatchQuery{ |
||||
RefId: "A", |
||||
Region: "us-east-1", |
||||
Expression: "", |
||||
Stats: "Average", |
||||
Period: 300, |
||||
Id: "id1", |
||||
Identifier: "id1", |
||||
Dimensions: map[string][]string{ |
||||
"InstanceId": {"i-12345678", "i-34562312"}, |
||||
}, |
||||
} |
||||
|
||||
Convey("it is a search expression", func() { |
||||
So(query.isSearchExpression(), ShouldBeTrue) |
||||
}) |
||||
|
||||
Convey("it is not math expressions", func() { |
||||
So(query.isMathExpression(), ShouldBeFalse) |
||||
}) |
||||
}) |
||||
|
||||
Convey("and no expression but dimension values has *", func() { |
||||
query := &cloudWatchQuery{ |
||||
RefId: "A", |
||||
Region: "us-east-1", |
||||
Expression: "", |
||||
Stats: "Average", |
||||
Period: 300, |
||||
Id: "id1", |
||||
Identifier: "id1", |
||||
Dimensions: map[string][]string{ |
||||
"InstanceId": {"i-12345678", "*"}, |
||||
"InstanceType": {"abc", "def"}, |
||||
}, |
||||
} |
||||
|
||||
Convey("it is not a search expression", func() { |
||||
So(query.isSearchExpression(), ShouldBeTrue) |
||||
}) |
||||
|
||||
Convey("it is not math expressions", func() { |
||||
So(query.isMathExpression(), ShouldBeFalse) |
||||
}) |
||||
}) |
||||
|
||||
Convey("and no dimensions were added", func() { |
||||
query := &cloudWatchQuery{ |
||||
RefId: "A", |
||||
Region: "us-east-1", |
||||
Expression: "", |
||||
Stats: "Average", |
||||
Period: 300, |
||||
Id: "id1", |
||||
MatchExact: false, |
||||
Identifier: "id1", |
||||
Dimensions: make(map[string][]string), |
||||
} |
||||
Convey("and match exact is false", func() { |
||||
query.MatchExact = false |
||||
Convey("it is a search expression", func() { |
||||
So(query.isSearchExpression(), ShouldBeTrue) |
||||
}) |
||||
|
||||
Convey("it is not math expressions", func() { |
||||
So(query.isMathExpression(), ShouldBeFalse) |
||||
}) |
||||
|
||||
Convey("it is not metric stat", func() { |
||||
So(query.isMetricStat(), ShouldBeFalse) |
||||
}) |
||||
|
||||
}) |
||||
|
||||
Convey("and match exact is true", func() { |
||||
query.MatchExact = true |
||||
Convey("it is a search expression", func() { |
||||
So(query.isSearchExpression(), ShouldBeFalse) |
||||
}) |
||||
|
||||
Convey("it is not math expressions", func() { |
||||
So(query.isMathExpression(), ShouldBeFalse) |
||||
}) |
||||
|
||||
Convey("it is a metric stat", func() { |
||||
So(query.isMetricStat(), ShouldBeTrue) |
||||
}) |
||||
|
||||
}) |
||||
}) |
||||
|
||||
Convey("and match exact is", func() { |
||||
query := &cloudWatchQuery{ |
||||
RefId: "A", |
||||
Region: "us-east-1", |
||||
Expression: "", |
||||
Stats: "Average", |
||||
Period: 300, |
||||
Id: "id1", |
||||
Identifier: "id1", |
||||
MatchExact: false, |
||||
Dimensions: map[string][]string{ |
||||
"InstanceId": {"i-12345678"}, |
||||
}, |
||||
} |
||||
|
||||
Convey("it is a search expression", func() { |
||||
So(query.isSearchExpression(), ShouldBeTrue) |
||||
}) |
||||
|
||||
Convey("it is not math expressions", func() { |
||||
So(query.isMathExpression(), ShouldBeFalse) |
||||
}) |
||||
|
||||
Convey("it is not metric stat", func() { |
||||
So(query.isMetricStat(), ShouldBeFalse) |
||||
}) |
||||
}) |
||||
}) |
||||
} |
@ -1,35 +0,0 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/models" |
||||
"github.com/grafana/grafana/pkg/tsdb" |
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson" |
||||
. "github.com/smartystreets/goconvey/convey" |
||||
) |
||||
|
||||
func TestCloudWatch(t *testing.T) { |
||||
Convey("CloudWatch", t, func() { |
||||
|
||||
Convey("executeQuery", func() { |
||||
e := &CloudWatchExecutor{ |
||||
DataSource: &models.DataSource{ |
||||
JsonData: simplejson.New(), |
||||
}, |
||||
} |
||||
|
||||
Convey("End time before start time should result in error", func() { |
||||
_, err := e.executeQuery(context.Background(), &CloudWatchQuery{}, &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-2h")}) |
||||
So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time") |
||||
}) |
||||
|
||||
Convey("End time equals start time should result in error", func() { |
||||
_, err := e.executeQuery(context.Background(), &CloudWatchQuery{}, &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-1h")}) |
||||
So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time") |
||||
}) |
||||
}) |
||||
}) |
||||
} |
@ -1,30 +0,0 @@ |
||||
package cloudwatch |
||||
|
||||
var cloudwatchUnitMappings = map[string]string{ |
||||
"Seconds": "s", |
||||
"Microseconds": "µs", |
||||
"Milliseconds": "ms", |
||||
"Bytes": "bytes", |
||||
"Kilobytes": "kbytes", |
||||
"Megabytes": "mbytes", |
||||
"Gigabytes": "gbytes", |
||||
//"Terabytes": "",
|
||||
"Bits": "bits", |
||||
//"Kilobits": "",
|
||||
//"Megabits": "",
|
||||
//"Gigabits": "",
|
||||
//"Terabits": "",
|
||||
"Percent": "percent", |
||||
//"Count": "",
|
||||
"Bytes/Second": "Bps", |
||||
"Kilobytes/Second": "KBs", |
||||
"Megabytes/Second": "MBs", |
||||
"Gigabytes/Second": "GBs", |
||||
//"Terabytes/Second": "",
|
||||
"Bits/Second": "bps", |
||||
"Kilobits/Second": "Kbits", |
||||
"Megabits/Second": "Mbits", |
||||
"Gigabits/Second": "Gbits", |
||||
//"Terabits/Second": "",
|
||||
//"Count/Second": "",
|
||||
} |
@ -1,171 +0,0 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
"github.com/aws/aws-sdk-go/service/cloudwatch" |
||||
"github.com/grafana/grafana/pkg/components/null" |
||||
"github.com/grafana/grafana/pkg/components/simplejson" |
||||
"github.com/grafana/grafana/pkg/infra/metrics" |
||||
"github.com/grafana/grafana/pkg/tsdb" |
||||
) |
||||
|
||||
func (e *CloudWatchExecutor) executeGetMetricDataQuery(ctx context.Context, region string, queries map[string]*CloudWatchQuery, queryContext *tsdb.TsdbQuery) ([]*tsdb.QueryResult, error) { |
||||
queryResponses := make([]*tsdb.QueryResult, 0) |
||||
|
||||
client, err := e.getClient(region) |
||||
if err != nil { |
||||
return queryResponses, err |
||||
} |
||||
|
||||
params, err := parseGetMetricDataQuery(queries, queryContext) |
||||
if err != nil { |
||||
return queryResponses, err |
||||
} |
||||
|
||||
nextToken := "" |
||||
mdr := make(map[string]map[string]*cloudwatch.MetricDataResult) |
||||
for { |
||||
if nextToken != "" { |
||||
params.NextToken = aws.String(nextToken) |
||||
} |
||||
resp, err := client.GetMetricDataWithContext(ctx, params) |
||||
if err != nil { |
||||
return queryResponses, err |
||||
} |
||||
metrics.MAwsCloudWatchGetMetricData.Add(float64(len(params.MetricDataQueries))) |
||||
|
||||
for _, r := range resp.MetricDataResults { |
||||
if _, ok := mdr[*r.Id]; !ok { |
||||
mdr[*r.Id] = make(map[string]*cloudwatch.MetricDataResult) |
||||
mdr[*r.Id][*r.Label] = r |
||||
} else if _, ok := mdr[*r.Id][*r.Label]; !ok { |
||||
mdr[*r.Id][*r.Label] = r |
||||
} else { |
||||
mdr[*r.Id][*r.Label].Timestamps = append(mdr[*r.Id][*r.Label].Timestamps, r.Timestamps...) |
||||
mdr[*r.Id][*r.Label].Values = append(mdr[*r.Id][*r.Label].Values, r.Values...) |
||||
} |
||||
} |
||||
|
||||
if resp.NextToken == nil || *resp.NextToken == "" { |
||||
break |
||||
} |
||||
nextToken = *resp.NextToken |
||||
} |
||||
|
||||
for id, lr := range mdr { |
||||
queryRes, err := parseGetMetricDataResponse(lr, queries[id]) |
||||
if err != nil { |
||||
return queryResponses, err |
||||
} |
||||
queryResponses = append(queryResponses, queryRes) |
||||
} |
||||
|
||||
return queryResponses, nil |
||||
} |
||||
|
||||
func parseGetMetricDataQuery(queries map[string]*CloudWatchQuery, queryContext *tsdb.TsdbQuery) (*cloudwatch.GetMetricDataInput, error) { |
||||
// validate query
|
||||
for _, query := range queries { |
||||
if !(len(query.Statistics) == 1 && len(query.ExtendedStatistics) == 0) && |
||||
!(len(query.Statistics) == 0 && len(query.ExtendedStatistics) == 1) { |
||||
return nil, errors.New("Statistics count should be 1") |
||||
} |
||||
} |
||||
|
||||
startTime, err := queryContext.TimeRange.ParseFrom() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
endTime, err := queryContext.TimeRange.ParseTo() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
params := &cloudwatch.GetMetricDataInput{ |
||||
StartTime: aws.Time(startTime), |
||||
EndTime: aws.Time(endTime), |
||||
ScanBy: aws.String("TimestampAscending"), |
||||
} |
||||
for _, query := range queries { |
||||
// 1 minutes resolution metrics is stored for 15 days, 15 * 24 * 60 = 21600
|
||||
if query.HighResolution && (((endTime.Unix() - startTime.Unix()) / int64(query.Period)) > 21600) { |
||||
return nil, errors.New("too long query period") |
||||
} |
||||
|
||||
mdq := &cloudwatch.MetricDataQuery{ |
||||
Id: aws.String(query.Id), |
||||
ReturnData: aws.Bool(query.ReturnData), |
||||
} |
||||
if query.Expression != "" { |
||||
mdq.Expression = aws.String(query.Expression) |
||||
} else { |
||||
mdq.MetricStat = &cloudwatch.MetricStat{ |
||||
Metric: &cloudwatch.Metric{ |
||||
Namespace: aws.String(query.Namespace), |
||||
MetricName: aws.String(query.MetricName), |
||||
}, |
||||
Period: aws.Int64(int64(query.Period)), |
||||
} |
||||
for _, d := range query.Dimensions { |
||||
mdq.MetricStat.Metric.Dimensions = append(mdq.MetricStat.Metric.Dimensions, |
||||
&cloudwatch.Dimension{ |
||||
Name: d.Name, |
||||
Value: d.Value, |
||||
}) |
||||
} |
||||
if len(query.Statistics) == 1 { |
||||
mdq.MetricStat.Stat = query.Statistics[0] |
||||
} else { |
||||
mdq.MetricStat.Stat = query.ExtendedStatistics[0] |
||||
} |
||||
} |
||||
params.MetricDataQueries = append(params.MetricDataQueries, mdq) |
||||
} |
||||
return params, nil |
||||
} |
||||
|
||||
func parseGetMetricDataResponse(lr map[string]*cloudwatch.MetricDataResult, query *CloudWatchQuery) (*tsdb.QueryResult, error) { |
||||
queryRes := tsdb.NewQueryResult() |
||||
queryRes.RefId = query.RefId |
||||
|
||||
for label, r := range lr { |
||||
if *r.StatusCode != "Complete" { |
||||
return queryRes, fmt.Errorf("Part of query is failed: %s", *r.StatusCode) |
||||
} |
||||
|
||||
series := tsdb.TimeSeries{ |
||||
Tags: map[string]string{}, |
||||
Points: make([]tsdb.TimePoint, 0), |
||||
} |
||||
for _, d := range query.Dimensions { |
||||
series.Tags[*d.Name] = *d.Value |
||||
} |
||||
s := "" |
||||
if len(query.Statistics) == 1 { |
||||
s = *query.Statistics[0] |
||||
} else { |
||||
s = *query.ExtendedStatistics[0] |
||||
} |
||||
series.Name = formatAlias(query, s, series.Tags, label) |
||||
|
||||
for j, t := range r.Timestamps { |
||||
if j > 0 { |
||||
expectedTimestamp := r.Timestamps[j-1].Add(time.Duration(query.Period) * time.Second) |
||||
if expectedTimestamp.Before(*t) { |
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), float64(expectedTimestamp.Unix()*1000))) |
||||
} |
||||
} |
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(*r.Values[j]), float64((*t).Unix())*1000)) |
||||
} |
||||
|
||||
queryRes.Series = append(queryRes.Series, &series) |
||||
queryRes.Meta = simplejson.New() |
||||
} |
||||
return queryRes, nil |
||||
} |
@ -0,0 +1,34 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
"github.com/aws/aws-sdk-go/service/cloudwatch" |
||||
"github.com/grafana/grafana/pkg/infra/metrics" |
||||
) |
||||
|
||||
func (e *CloudWatchExecutor) executeRequest(ctx context.Context, client cloudWatchClient, metricDataInput *cloudwatch.GetMetricDataInput) ([]*cloudwatch.GetMetricDataOutput, error) { |
||||
mdo := make([]*cloudwatch.GetMetricDataOutput, 0) |
||||
|
||||
nextToken := "" |
||||
for { |
||||
if nextToken != "" { |
||||
metricDataInput.NextToken = aws.String(nextToken) |
||||
} |
||||
resp, err := client.GetMetricDataWithContext(ctx, metricDataInput) |
||||
if err != nil { |
||||
return mdo, err |
||||
} |
||||
|
||||
mdo = append(mdo, resp) |
||||
metrics.MAwsCloudWatchGetMetricData.Add(float64(len(metricDataInput.MetricDataQueries))) |
||||
|
||||
if resp.NextToken == nil || *resp.NextToken == "" { |
||||
break |
||||
} |
||||
nextToken = *resp.NextToken |
||||
} |
||||
|
||||
return mdo, nil |
||||
} |
@ -0,0 +1,50 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
"github.com/aws/aws-sdk-go/aws/request" |
||||
"github.com/aws/aws-sdk-go/service/cloudwatch" |
||||
. "github.com/smartystreets/goconvey/convey" |
||||
) |
||||
|
||||
var counter = 1 |
||||
|
||||
type cloudWatchFakeClient struct { |
||||
} |
||||
|
||||
func (client *cloudWatchFakeClient) GetMetricDataWithContext(ctx aws.Context, input *cloudwatch.GetMetricDataInput, opts ...request.Option) (*cloudwatch.GetMetricDataOutput, error) { |
||||
nextToken := "next" |
||||
res := []*cloudwatch.MetricDataResult{{ |
||||
Values: []*float64{aws.Float64(12.3), aws.Float64(23.5)}, |
||||
}} |
||||
if counter == 0 { |
||||
nextToken = "" |
||||
res = []*cloudwatch.MetricDataResult{{ |
||||
Values: []*float64{aws.Float64(100)}, |
||||
}} |
||||
} |
||||
counter-- |
||||
return &cloudwatch.GetMetricDataOutput{ |
||||
MetricDataResults: res, |
||||
NextToken: aws.String(nextToken), |
||||
}, nil |
||||
} |
||||
|
||||
func TestGetMetricDataExecutorTest(t *testing.T) { |
||||
Convey("TestGetMetricDataExecutorTest", t, func() { |
||||
Convey("pagination works", func() { |
||||
executor := &CloudWatchExecutor{} |
||||
inputs := &cloudwatch.GetMetricDataInput{MetricDataQueries: []*cloudwatch.MetricDataQuery{}} |
||||
res, err := executor.executeRequest(context.Background(), &cloudWatchFakeClient{}, inputs) |
||||
So(err, ShouldBeNil) |
||||
So(len(res), ShouldEqual, 2) |
||||
So(len(res[0].MetricDataResults[0].Values), ShouldEqual, 2) |
||||
So(*res[0].MetricDataResults[0].Values[1], ShouldEqual, 23.5) |
||||
So(*res[1].MetricDataResults[0].Values[0], ShouldEqual, 100) |
||||
}) |
||||
}) |
||||
|
||||
} |
@ -1,116 +0,0 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
"github.com/aws/aws-sdk-go/service/cloudwatch" |
||||
"github.com/grafana/grafana/pkg/components/null" |
||||
"github.com/grafana/grafana/pkg/tsdb" |
||||
. "github.com/smartystreets/goconvey/convey" |
||||
) |
||||
|
||||
func TestCloudWatchGetMetricData(t *testing.T) { |
||||
Convey("CloudWatchGetMetricData", t, func() { |
||||
|
||||
Convey("can parse cloudwatch GetMetricData query", func() { |
||||
queries := map[string]*CloudWatchQuery{ |
||||
"id1": { |
||||
RefId: "A", |
||||
Region: "us-east-1", |
||||
Namespace: "AWS/EC2", |
||||
MetricName: "CPUUtilization", |
||||
Dimensions: []*cloudwatch.Dimension{ |
||||
{ |
||||
Name: aws.String("InstanceId"), |
||||
Value: aws.String("i-12345678"), |
||||
}, |
||||
}, |
||||
Statistics: []*string{aws.String("Average")}, |
||||
Period: 300, |
||||
Id: "id1", |
||||
Expression: "", |
||||
}, |
||||
"id2": { |
||||
RefId: "B", |
||||
Region: "us-east-1", |
||||
Statistics: []*string{aws.String("Average")}, |
||||
Id: "id2", |
||||
Expression: "id1 * 2", |
||||
}, |
||||
} |
||||
|
||||
queryContext := &tsdb.TsdbQuery{ |
||||
TimeRange: tsdb.NewFakeTimeRange("5m", "now", time.Now()), |
||||
} |
||||
res, err := parseGetMetricDataQuery(queries, queryContext) |
||||
So(err, ShouldBeNil) |
||||
|
||||
for _, v := range res.MetricDataQueries { |
||||
if *v.Id == "id1" { |
||||
So(*v.MetricStat.Metric.Namespace, ShouldEqual, "AWS/EC2") |
||||
So(*v.MetricStat.Metric.MetricName, ShouldEqual, "CPUUtilization") |
||||
So(*v.MetricStat.Metric.Dimensions[0].Name, ShouldEqual, "InstanceId") |
||||
So(*v.MetricStat.Metric.Dimensions[0].Value, ShouldEqual, "i-12345678") |
||||
So(*v.MetricStat.Period, ShouldEqual, 300) |
||||
So(*v.MetricStat.Stat, ShouldEqual, "Average") |
||||
So(*v.Id, ShouldEqual, "id1") |
||||
} else { |
||||
So(*v.Id, ShouldEqual, "id2") |
||||
So(*v.Expression, ShouldEqual, "id1 * 2") |
||||
} |
||||
} |
||||
}) |
||||
|
||||
Convey("can parse cloudwatch response", func() { |
||||
timestamp := time.Unix(0, 0) |
||||
resp := map[string]*cloudwatch.MetricDataResult{ |
||||
"label": { |
||||
Id: aws.String("id1"), |
||||
Label: aws.String("label"), |
||||
Timestamps: []*time.Time{ |
||||
aws.Time(timestamp), |
||||
aws.Time(timestamp.Add(60 * time.Second)), |
||||
aws.Time(timestamp.Add(180 * time.Second)), |
||||
}, |
||||
Values: []*float64{ |
||||
aws.Float64(10), |
||||
aws.Float64(20), |
||||
aws.Float64(30), |
||||
}, |
||||
StatusCode: aws.String("Complete"), |
||||
}, |
||||
} |
||||
query := &CloudWatchQuery{ |
||||
RefId: "refId1", |
||||
Region: "us-east-1", |
||||
Namespace: "AWS/ApplicationELB", |
||||
MetricName: "TargetResponseTime", |
||||
Dimensions: []*cloudwatch.Dimension{ |
||||
{ |
||||
Name: aws.String("LoadBalancer"), |
||||
Value: aws.String("lb"), |
||||
}, |
||||
{ |
||||
Name: aws.String("TargetGroup"), |
||||
Value: aws.String("tg"), |
||||
}, |
||||
}, |
||||
Statistics: []*string{aws.String("Average")}, |
||||
Period: 60, |
||||
Alias: "{{namespace}}_{{metric}}_{{stat}}", |
||||
} |
||||
queryRes, err := parseGetMetricDataResponse(resp, query) |
||||
So(err, ShouldBeNil) |
||||
So(queryRes.RefId, ShouldEqual, "refId1") |
||||
So(queryRes.Series[0].Name, ShouldEqual, "AWS/ApplicationELB_TargetResponseTime_Average") |
||||
So(queryRes.Series[0].Tags["LoadBalancer"], ShouldEqual, "lb") |
||||
So(queryRes.Series[0].Tags["TargetGroup"], ShouldEqual, "tg") |
||||
So(queryRes.Series[0].Points[0][0].String(), ShouldEqual, null.FloatFrom(10.0).String()) |
||||
So(queryRes.Series[0].Points[1][0].String(), ShouldEqual, null.FloatFrom(20.0).String()) |
||||
So(queryRes.Series[0].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String()) |
||||
So(queryRes.Series[0].Points[3][0].String(), ShouldEqual, null.FloatFrom(30.0).String()) |
||||
}) |
||||
}) |
||||
} |
@ -1,276 +0,0 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"fmt" |
||||
"regexp" |
||||
"sort" |
||||
"strconv" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
"github.com/aws/aws-sdk-go/aws/request" |
||||
"github.com/aws/aws-sdk-go/service/cloudwatch" |
||||
"github.com/grafana/grafana/pkg/components/null" |
||||
"github.com/grafana/grafana/pkg/components/simplejson" |
||||
"github.com/grafana/grafana/pkg/infra/metrics" |
||||
"github.com/grafana/grafana/pkg/setting" |
||||
"github.com/grafana/grafana/pkg/tsdb" |
||||
) |
||||
|
||||
func (e *CloudWatchExecutor) executeQuery(ctx context.Context, query *CloudWatchQuery, queryContext *tsdb.TsdbQuery) (*tsdb.QueryResult, error) { |
||||
client, err := e.getClient(query.Region) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
startTime, err := queryContext.TimeRange.ParseFrom() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
endTime, err := queryContext.TimeRange.ParseTo() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if !startTime.Before(endTime) { |
||||
return nil, fmt.Errorf("Invalid time range: Start time must be before end time") |
||||
} |
||||
|
||||
params := &cloudwatch.GetMetricStatisticsInput{ |
||||
Namespace: aws.String(query.Namespace), |
||||
MetricName: aws.String(query.MetricName), |
||||
Dimensions: query.Dimensions, |
||||
Period: aws.Int64(int64(query.Period)), |
||||
} |
||||
if len(query.Statistics) > 0 { |
||||
params.Statistics = query.Statistics |
||||
} |
||||
if len(query.ExtendedStatistics) > 0 { |
||||
params.ExtendedStatistics = query.ExtendedStatistics |
||||
} |
||||
|
||||
// 1 minutes resolution metrics is stored for 15 days, 15 * 24 * 60 = 21600
|
||||
if query.HighResolution && (((endTime.Unix() - startTime.Unix()) / int64(query.Period)) > 21600) { |
||||
return nil, errors.New("too long query period") |
||||
} |
||||
var resp *cloudwatch.GetMetricStatisticsOutput |
||||
for startTime.Before(endTime) { |
||||
params.StartTime = aws.Time(startTime) |
||||
if query.HighResolution { |
||||
startTime = startTime.Add(time.Duration(1440*query.Period) * time.Second) |
||||
} else { |
||||
startTime = endTime |
||||
} |
||||
params.EndTime = aws.Time(startTime) |
||||
|
||||
if setting.Env == setting.DEV { |
||||
plog.Debug("CloudWatch query", "raw query", params) |
||||
} |
||||
|
||||
partResp, err := client.GetMetricStatisticsWithContext(ctx, params, request.WithResponseReadTimeout(10*time.Second)) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if resp != nil { |
||||
resp.Datapoints = append(resp.Datapoints, partResp.Datapoints...) |
||||
} else { |
||||
resp = partResp |
||||
|
||||
} |
||||
metrics.MAwsCloudWatchGetMetricStatistics.Inc() |
||||
} |
||||
|
||||
queryRes, err := parseResponse(resp, query) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return queryRes, nil |
||||
} |
||||
|
||||
func parseQuery(model *simplejson.Json) (*CloudWatchQuery, error) { |
||||
region, err := model.Get("region").String() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
namespace, err := model.Get("namespace").String() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
metricName, err := model.Get("metricName").String() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
id := model.Get("id").MustString("") |
||||
expression := model.Get("expression").MustString("") |
||||
|
||||
dimensions, err := parseDimensions(model) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
statistics, extendedStatistics, err := parseStatistics(model) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
p := model.Get("period").MustString("") |
||||
if p == "" { |
||||
if namespace == "AWS/EC2" { |
||||
p = "300" |
||||
} else { |
||||
p = "60" |
||||
} |
||||
} |
||||
|
||||
var period int |
||||
if regexp.MustCompile(`^\d+$`).Match([]byte(p)) { |
||||
period, err = strconv.Atoi(p) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
} else { |
||||
d, err := time.ParseDuration(p) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
period = int(d.Seconds()) |
||||
} |
||||
|
||||
alias := model.Get("alias").MustString() |
||||
|
||||
returnData := !model.Get("hide").MustBool(false) |
||||
queryType := model.Get("type").MustString() |
||||
if queryType == "" { |
||||
// If no type is provided we assume we are called by alerting service, which requires to return data!
|
||||
// Note, this is sort of a hack, but the official Grafana interfaces do not carry the information
|
||||
// who (which service) called the TsdbQueryEndpoint.Query(...) function.
|
||||
returnData = true |
||||
} |
||||
highResolution := model.Get("highResolution").MustBool(false) |
||||
|
||||
return &CloudWatchQuery{ |
||||
Region: region, |
||||
Namespace: namespace, |
||||
MetricName: metricName, |
||||
Dimensions: dimensions, |
||||
Statistics: aws.StringSlice(statistics), |
||||
ExtendedStatistics: aws.StringSlice(extendedStatistics), |
||||
Period: period, |
||||
Alias: alias, |
||||
Id: id, |
||||
Expression: expression, |
||||
ReturnData: returnData, |
||||
HighResolution: highResolution, |
||||
}, nil |
||||
} |
||||
|
||||
func parseDimensions(model *simplejson.Json) ([]*cloudwatch.Dimension, error) { |
||||
var result []*cloudwatch.Dimension |
||||
|
||||
for k, v := range model.Get("dimensions").MustMap() { |
||||
kk := k |
||||
if vv, ok := v.(string); ok { |
||||
result = append(result, &cloudwatch.Dimension{ |
||||
Name: &kk, |
||||
Value: &vv, |
||||
}) |
||||
} else { |
||||
return nil, errors.New("failed to parse") |
||||
} |
||||
} |
||||
|
||||
sort.Slice(result, func(i, j int) bool { |
||||
return *result[i].Name < *result[j].Name |
||||
}) |
||||
return result, nil |
||||
} |
||||
|
||||
func parseStatistics(model *simplejson.Json) ([]string, []string, error) { |
||||
var statistics []string |
||||
var extendedStatistics []string |
||||
|
||||
for _, s := range model.Get("statistics").MustArray() { |
||||
if ss, ok := s.(string); ok { |
||||
if _, isStandard := standardStatistics[ss]; isStandard { |
||||
statistics = append(statistics, ss) |
||||
} else { |
||||
extendedStatistics = append(extendedStatistics, ss) |
||||
} |
||||
} else { |
||||
return nil, nil, errors.New("failed to parse") |
||||
} |
||||
} |
||||
|
||||
return statistics, extendedStatistics, nil |
||||
} |
||||
|
||||
func parseResponse(resp *cloudwatch.GetMetricStatisticsOutput, query *CloudWatchQuery) (*tsdb.QueryResult, error) { |
||||
queryRes := tsdb.NewQueryResult() |
||||
|
||||
queryRes.RefId = query.RefId |
||||
var value float64 |
||||
for _, s := range append(query.Statistics, query.ExtendedStatistics...) { |
||||
series := tsdb.TimeSeries{ |
||||
Tags: map[string]string{}, |
||||
Points: make([]tsdb.TimePoint, 0), |
||||
} |
||||
for _, d := range query.Dimensions { |
||||
series.Tags[*d.Name] = *d.Value |
||||
} |
||||
series.Name = formatAlias(query, *s, series.Tags, "") |
||||
|
||||
lastTimestamp := make(map[string]time.Time) |
||||
sort.Slice(resp.Datapoints, func(i, j int) bool { |
||||
return (*resp.Datapoints[i].Timestamp).Before(*resp.Datapoints[j].Timestamp) |
||||
}) |
||||
for _, v := range resp.Datapoints { |
||||
switch *s { |
||||
case "Average": |
||||
value = *v.Average |
||||
case "Maximum": |
||||
value = *v.Maximum |
||||
case "Minimum": |
||||
value = *v.Minimum |
||||
case "Sum": |
||||
value = *v.Sum |
||||
case "SampleCount": |
||||
value = *v.SampleCount |
||||
default: |
||||
if strings.Index(*s, "p") == 0 && v.ExtendedStatistics[*s] != nil { |
||||
value = *v.ExtendedStatistics[*s] |
||||
} |
||||
} |
||||
|
||||
// terminate gap of data points
|
||||
timestamp := *v.Timestamp |
||||
if _, ok := lastTimestamp[*s]; ok { |
||||
nextTimestampFromLast := lastTimestamp[*s].Add(time.Duration(query.Period) * time.Second) |
||||
for timestamp.After(nextTimestampFromLast) { |
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), float64(nextTimestampFromLast.Unix()*1000))) |
||||
nextTimestampFromLast = nextTimestampFromLast.Add(time.Duration(query.Period) * time.Second) |
||||
} |
||||
} |
||||
lastTimestamp[*s] = timestamp |
||||
|
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(value), float64(timestamp.Unix()*1000))) |
||||
} |
||||
|
||||
queryRes.Series = append(queryRes.Series, &series) |
||||
queryRes.Meta = simplejson.New() |
||||
if len(resp.Datapoints) > 0 && resp.Datapoints[0].Unit != nil { |
||||
if unit, ok := cloudwatchUnitMappings[*resp.Datapoints[0].Unit]; ok { |
||||
queryRes.Meta.Set("unit", unit) |
||||
} |
||||
} |
||||
} |
||||
|
||||
return queryRes, nil |
||||
} |
@ -1,187 +0,0 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
"github.com/aws/aws-sdk-go/service/cloudwatch" |
||||
"github.com/grafana/grafana/pkg/components/null" |
||||
"github.com/grafana/grafana/pkg/components/simplejson" |
||||
. "github.com/smartystreets/goconvey/convey" |
||||
) |
||||
|
||||
func TestCloudWatchGetMetricStatistics(t *testing.T) { |
||||
Convey("CloudWatchGetMetricStatistics", t, func() { |
||||
|
||||
Convey("can parse cloudwatch json model", func() { |
||||
json := ` |
||||
{ |
||||
"region": "us-east-1", |
||||
"namespace": "AWS/ApplicationELB", |
||||
"metricName": "TargetResponseTime", |
||||
"dimensions": { |
||||
"LoadBalancer": "lb", |
||||
"TargetGroup": "tg" |
||||
}, |
||||
"statistics": [ |
||||
"Average", |
||||
"Maximum", |
||||
"p50.00", |
||||
"p90.00" |
||||
], |
||||
"period": "60", |
||||
"highResolution": false, |
||||
"alias": "{{metric}}_{{stat}}" |
||||
} |
||||
` |
||||
modelJson, err := simplejson.NewJson([]byte(json)) |
||||
So(err, ShouldBeNil) |
||||
|
||||
res, err := parseQuery(modelJson) |
||||
So(err, ShouldBeNil) |
||||
So(res.Region, ShouldEqual, "us-east-1") |
||||
So(res.Namespace, ShouldEqual, "AWS/ApplicationELB") |
||||
So(res.MetricName, ShouldEqual, "TargetResponseTime") |
||||
So(len(res.Dimensions), ShouldEqual, 2) |
||||
So(*res.Dimensions[0].Name, ShouldEqual, "LoadBalancer") |
||||
So(*res.Dimensions[0].Value, ShouldEqual, "lb") |
||||
So(*res.Dimensions[1].Name, ShouldEqual, "TargetGroup") |
||||
So(*res.Dimensions[1].Value, ShouldEqual, "tg") |
||||
So(len(res.Statistics), ShouldEqual, 2) |
||||
So(*res.Statistics[0], ShouldEqual, "Average") |
||||
So(*res.Statistics[1], ShouldEqual, "Maximum") |
||||
So(len(res.ExtendedStatistics), ShouldEqual, 2) |
||||
So(*res.ExtendedStatistics[0], ShouldEqual, "p50.00") |
||||
So(*res.ExtendedStatistics[1], ShouldEqual, "p90.00") |
||||
So(res.Period, ShouldEqual, 60) |
||||
So(res.Alias, ShouldEqual, "{{metric}}_{{stat}}") |
||||
}) |
||||
|
||||
Convey("can parse cloudwatch response", func() { |
||||
timestamp := time.Unix(0, 0) |
||||
resp := &cloudwatch.GetMetricStatisticsOutput{ |
||||
Label: aws.String("TargetResponseTime"), |
||||
Datapoints: []*cloudwatch.Datapoint{ |
||||
{ |
||||
Timestamp: aws.Time(timestamp), |
||||
Average: aws.Float64(10.0), |
||||
Maximum: aws.Float64(20.0), |
||||
ExtendedStatistics: map[string]*float64{ |
||||
"p50.00": aws.Float64(30.0), |
||||
"p90.00": aws.Float64(40.0), |
||||
}, |
||||
Unit: aws.String("Seconds"), |
||||
}, |
||||
}, |
||||
} |
||||
query := &CloudWatchQuery{ |
||||
Region: "us-east-1", |
||||
Namespace: "AWS/ApplicationELB", |
||||
MetricName: "TargetResponseTime", |
||||
Dimensions: []*cloudwatch.Dimension{ |
||||
{ |
||||
Name: aws.String("LoadBalancer"), |
||||
Value: aws.String("lb"), |
||||
}, |
||||
{ |
||||
Name: aws.String("TargetGroup"), |
||||
Value: aws.String("tg"), |
||||
}, |
||||
}, |
||||
Statistics: []*string{aws.String("Average"), aws.String("Maximum")}, |
||||
ExtendedStatistics: []*string{aws.String("p50.00"), aws.String("p90.00")}, |
||||
Period: 60, |
||||
Alias: "{{namespace}}_{{metric}}_{{stat}}", |
||||
} |
||||
|
||||
queryRes, err := parseResponse(resp, query) |
||||
So(err, ShouldBeNil) |
||||
So(queryRes.Series[0].Name, ShouldEqual, "AWS/ApplicationELB_TargetResponseTime_Average") |
||||
So(queryRes.Series[0].Tags["LoadBalancer"], ShouldEqual, "lb") |
||||
So(queryRes.Series[0].Tags["TargetGroup"], ShouldEqual, "tg") |
||||
So(queryRes.Series[0].Points[0][0].String(), ShouldEqual, null.FloatFrom(10.0).String()) |
||||
So(queryRes.Series[1].Points[0][0].String(), ShouldEqual, null.FloatFrom(20.0).String()) |
||||
So(queryRes.Series[2].Points[0][0].String(), ShouldEqual, null.FloatFrom(30.0).String()) |
||||
So(queryRes.Series[3].Points[0][0].String(), ShouldEqual, null.FloatFrom(40.0).String()) |
||||
So(queryRes.Meta.Get("unit").MustString(), ShouldEqual, "s") |
||||
}) |
||||
|
||||
Convey("terminate gap of data points", func() { |
||||
timestamp := time.Unix(0, 0) |
||||
resp := &cloudwatch.GetMetricStatisticsOutput{ |
||||
Label: aws.String("TargetResponseTime"), |
||||
Datapoints: []*cloudwatch.Datapoint{ |
||||
{ |
||||
Timestamp: aws.Time(timestamp), |
||||
Average: aws.Float64(10.0), |
||||
Maximum: aws.Float64(20.0), |
||||
ExtendedStatistics: map[string]*float64{ |
||||
"p50.00": aws.Float64(30.0), |
||||
"p90.00": aws.Float64(40.0), |
||||
}, |
||||
Unit: aws.String("Seconds"), |
||||
}, |
||||
{ |
||||
Timestamp: aws.Time(timestamp.Add(60 * time.Second)), |
||||
Average: aws.Float64(20.0), |
||||
Maximum: aws.Float64(30.0), |
||||
ExtendedStatistics: map[string]*float64{ |
||||
"p50.00": aws.Float64(40.0), |
||||
"p90.00": aws.Float64(50.0), |
||||
}, |
||||
Unit: aws.String("Seconds"), |
||||
}, |
||||
{ |
||||
Timestamp: aws.Time(timestamp.Add(180 * time.Second)), |
||||
Average: aws.Float64(30.0), |
||||
Maximum: aws.Float64(40.0), |
||||
ExtendedStatistics: map[string]*float64{ |
||||
"p50.00": aws.Float64(50.0), |
||||
"p90.00": aws.Float64(60.0), |
||||
}, |
||||
Unit: aws.String("Seconds"), |
||||
}, |
||||
}, |
||||
} |
||||
query := &CloudWatchQuery{ |
||||
Region: "us-east-1", |
||||
Namespace: "AWS/ApplicationELB", |
||||
MetricName: "TargetResponseTime", |
||||
Dimensions: []*cloudwatch.Dimension{ |
||||
{ |
||||
Name: aws.String("LoadBalancer"), |
||||
Value: aws.String("lb"), |
||||
}, |
||||
{ |
||||
Name: aws.String("TargetGroup"), |
||||
Value: aws.String("tg"), |
||||
}, |
||||
}, |
||||
Statistics: []*string{aws.String("Average"), aws.String("Maximum")}, |
||||
ExtendedStatistics: []*string{aws.String("p50.00"), aws.String("p90.00")}, |
||||
Period: 60, |
||||
Alias: "{{namespace}}_{{metric}}_{{stat}}", |
||||
} |
||||
|
||||
queryRes, err := parseResponse(resp, query) |
||||
So(err, ShouldBeNil) |
||||
So(queryRes.Series[0].Points[0][0].String(), ShouldEqual, null.FloatFrom(10.0).String()) |
||||
So(queryRes.Series[1].Points[0][0].String(), ShouldEqual, null.FloatFrom(20.0).String()) |
||||
So(queryRes.Series[2].Points[0][0].String(), ShouldEqual, null.FloatFrom(30.0).String()) |
||||
So(queryRes.Series[3].Points[0][0].String(), ShouldEqual, null.FloatFrom(40.0).String()) |
||||
So(queryRes.Series[0].Points[1][0].String(), ShouldEqual, null.FloatFrom(20.0).String()) |
||||
So(queryRes.Series[1].Points[1][0].String(), ShouldEqual, null.FloatFrom(30.0).String()) |
||||
So(queryRes.Series[2].Points[1][0].String(), ShouldEqual, null.FloatFrom(40.0).String()) |
||||
So(queryRes.Series[3].Points[1][0].String(), ShouldEqual, null.FloatFrom(50.0).String()) |
||||
So(queryRes.Series[0].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String()) |
||||
So(queryRes.Series[1].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String()) |
||||
So(queryRes.Series[2].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String()) |
||||
So(queryRes.Series[3].Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String()) |
||||
So(queryRes.Series[0].Points[3][0].String(), ShouldEqual, null.FloatFrom(30.0).String()) |
||||
So(queryRes.Series[1].Points[3][0].String(), ShouldEqual, null.FloatFrom(40.0).String()) |
||||
So(queryRes.Series[2].Points[3][0].String(), ShouldEqual, null.FloatFrom(50.0).String()) |
||||
So(queryRes.Series[3].Points[3][0].String(), ShouldEqual, null.FloatFrom(60.0).String()) |
||||
}) |
||||
}) |
||||
} |
@ -0,0 +1,46 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
"github.com/aws/aws-sdk-go/service/cloudwatch" |
||||
"github.com/grafana/grafana/pkg/tsdb" |
||||
) |
||||
|
||||
func (e *CloudWatchExecutor) buildMetricDataInput(queryContext *tsdb.TsdbQuery, queries map[string]*cloudWatchQuery) (*cloudwatch.GetMetricDataInput, error) { |
||||
startTime, err := queryContext.TimeRange.ParseFrom() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
endTime, err := queryContext.TimeRange.ParseTo() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if !startTime.Before(endTime) { |
||||
return nil, fmt.Errorf("Invalid time range: Start time must be before end time") |
||||
} |
||||
|
||||
metricDataInput := &cloudwatch.GetMetricDataInput{ |
||||
StartTime: aws.Time(startTime), |
||||
EndTime: aws.Time(endTime), |
||||
ScanBy: aws.String("TimestampAscending"), |
||||
} |
||||
for _, query := range queries { |
||||
// 1 minutes resolution metrics is stored for 15 days, 15 * 24 * 60 = 21600
|
||||
if query.HighResolution && (((endTime.Unix() - startTime.Unix()) / int64(query.Period)) > 21600) { |
||||
return nil, &queryError{errors.New("too long query period"), query.RefId} |
||||
} |
||||
|
||||
metricDataQuery, err := e.buildMetricDataQuery(query) |
||||
if err != nil { |
||||
return nil, &queryError{err, query.RefId} |
||||
} |
||||
metricDataInput.MetricDataQueries = append(metricDataInput.MetricDataQueries, metricDataQuery) |
||||
} |
||||
|
||||
return metricDataInput, nil |
||||
} |
@ -0,0 +1,28 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/tsdb" |
||||
|
||||
. "github.com/smartystreets/goconvey/convey" |
||||
) |
||||
|
||||
func TestMetricDataInputBuilder(t *testing.T) { |
||||
Convey("TestMetricDataInputBuilder", t, func() { |
||||
executor := &CloudWatchExecutor{} |
||||
query := make(map[string]*cloudWatchQuery) |
||||
|
||||
Convey("Time range is valid", func() { |
||||
Convey("End time before start time should result in error", func() { |
||||
_, err := executor.buildMetricDataInput(&tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-2h")}, query) |
||||
So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time") |
||||
}) |
||||
|
||||
Convey("End time equals start time should result in error", func() { |
||||
_, err := executor.buildMetricDataInput(&tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-1h")}, query) |
||||
So(err.Error(), ShouldEqual, "Invalid time range: Start time must be before end time") |
||||
}) |
||||
}) |
||||
}) |
||||
} |
@ -0,0 +1,139 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"fmt" |
||||
"sort" |
||||
"strconv" |
||||
"strings" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
"github.com/aws/aws-sdk-go/service/cloudwatch" |
||||
) |
||||
|
||||
func (e *CloudWatchExecutor) buildMetricDataQuery(query *cloudWatchQuery) (*cloudwatch.MetricDataQuery, error) { |
||||
mdq := &cloudwatch.MetricDataQuery{ |
||||
Id: aws.String(query.Id), |
||||
ReturnData: aws.Bool(query.ReturnData), |
||||
} |
||||
|
||||
if query.Expression != "" { |
||||
mdq.Expression = aws.String(query.Expression) |
||||
} else { |
||||
if query.isSearchExpression() { |
||||
mdq.Expression = aws.String(buildSearchExpression(query, query.Stats)) |
||||
} else { |
||||
mdq.MetricStat = &cloudwatch.MetricStat{ |
||||
Metric: &cloudwatch.Metric{ |
||||
Namespace: aws.String(query.Namespace), |
||||
MetricName: aws.String(query.MetricName), |
||||
Dimensions: make([]*cloudwatch.Dimension, 0), |
||||
}, |
||||
Period: aws.Int64(int64(query.Period)), |
||||
} |
||||
for key, values := range query.Dimensions { |
||||
mdq.MetricStat.Metric.Dimensions = append(mdq.MetricStat.Metric.Dimensions, |
||||
&cloudwatch.Dimension{ |
||||
Name: aws.String(key), |
||||
Value: aws.String(values[0]), |
||||
}) |
||||
} |
||||
mdq.MetricStat.Stat = aws.String(query.Stats) |
||||
} |
||||
} |
||||
|
||||
if mdq.Expression != nil { |
||||
query.UsedExpression = *mdq.Expression |
||||
} else { |
||||
query.UsedExpression = "" |
||||
} |
||||
|
||||
return mdq, nil |
||||
} |
||||
|
||||
func buildSearchExpression(query *cloudWatchQuery, stat string) string { |
||||
knownDimensions := make(map[string][]string) |
||||
dimensionNames := []string{} |
||||
dimensionNamesWithoutKnownValues := []string{} |
||||
|
||||
for key, values := range query.Dimensions { |
||||
dimensionNames = append(dimensionNames, key) |
||||
hasWildcard := false |
||||
for _, value := range values { |
||||
if value == "*" { |
||||
hasWildcard = true |
||||
break |
||||
} |
||||
} |
||||
if hasWildcard { |
||||
dimensionNamesWithoutKnownValues = append(dimensionNamesWithoutKnownValues, key) |
||||
} else { |
||||
knownDimensions[key] = values |
||||
} |
||||
} |
||||
|
||||
searchTerm := fmt.Sprintf(`MetricName="%s"`, query.MetricName) |
||||
keys := []string{} |
||||
for k := range knownDimensions { |
||||
keys = append(keys, k) |
||||
} |
||||
sort.Strings(keys) |
||||
for _, key := range keys { |
||||
values := escape(knownDimensions[key]) |
||||
valueExpression := join(values, " OR ", `"`, `"`) |
||||
if len(knownDimensions[key]) > 1 { |
||||
valueExpression = fmt.Sprintf(`(%s)`, valueExpression) |
||||
} |
||||
keyFilter := fmt.Sprintf(`"%s"=%s`, key, valueExpression) |
||||
searchTerm = appendSearch(searchTerm, keyFilter) |
||||
} |
||||
|
||||
if query.MatchExact { |
||||
schema := query.Namespace |
||||
if len(dimensionNames) > 0 { |
||||
sort.Strings(dimensionNames) |
||||
schema += fmt.Sprintf(",%s", join(dimensionNames, ",", "", "")) |
||||
} |
||||
|
||||
return fmt.Sprintf("REMOVE_EMPTY(SEARCH('{%s} %s', '%s', %s))", schema, searchTerm, stat, strconv.Itoa(query.Period)) |
||||
} |
||||
|
||||
sort.Strings(dimensionNamesWithoutKnownValues) |
||||
searchTerm = appendSearch(searchTerm, join(dimensionNamesWithoutKnownValues, " ", `"`, `"`)) |
||||
return fmt.Sprintf(`REMOVE_EMPTY(SEARCH('Namespace="%s" %s', '%s', %s))`, query.Namespace, searchTerm, stat, strconv.Itoa(query.Period)) |
||||
} |
||||
|
||||
func escape(arr []string) []string { |
||||
result := []string{} |
||||
for _, value := range arr { |
||||
value = strings.ReplaceAll(value, `\`, `\\`) |
||||
value = strings.ReplaceAll(value, ")", `\)`) |
||||
value = strings.ReplaceAll(value, "(", `\(`) |
||||
value = strings.ReplaceAll(value, `"`, `\"`) |
||||
result = append(result, value) |
||||
} |
||||
|
||||
return result |
||||
} |
||||
|
||||
func join(arr []string, delimiter string, valuePrefix string, valueSuffix string) string { |
||||
result := "" |
||||
for index, value := range arr { |
||||
result += valuePrefix + value + valueSuffix |
||||
if index+1 != len(arr) { |
||||
result += delimiter |
||||
} |
||||
} |
||||
|
||||
return result |
||||
} |
||||
|
||||
func appendSearch(target string, value string) string { |
||||
if value != "" { |
||||
if target == "" { |
||||
return value |
||||
} |
||||
return fmt.Sprintf("%v %v", target, value) |
||||
} |
||||
|
||||
return target |
||||
} |
@ -0,0 +1,215 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
. "github.com/smartystreets/goconvey/convey" |
||||
) |
||||
|
||||
func TestMetricDataQueryBuilder(t *testing.T) { |
||||
Convey("TestMetricDataQueryBuilder", t, func() { |
||||
Convey("buildSearchExpression", func() { |
||||
Convey("and query should be matched exact", func() { |
||||
matchExact := true |
||||
Convey("and query has three dimension values for a given dimension key", func() { |
||||
query := &cloudWatchQuery{ |
||||
Namespace: "AWS/EC2", |
||||
MetricName: "CPUUtilization", |
||||
Dimensions: map[string][]string{ |
||||
"LoadBalancer": {"lb1", "lb2", "lb3"}, |
||||
}, |
||||
Period: 300, |
||||
Identifier: "id1", |
||||
Expression: "", |
||||
MatchExact: matchExact, |
||||
} |
||||
|
||||
res := buildSearchExpression(query, "Average") |
||||
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('{AWS/EC2,LoadBalancer} MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3")', 'Average', 300))`) |
||||
}) |
||||
|
||||
Convey("and query has three dimension values for two given dimension keys", func() { |
||||
|
||||
query := &cloudWatchQuery{ |
||||
Namespace: "AWS/EC2", |
||||
MetricName: "CPUUtilization", |
||||
Dimensions: map[string][]string{ |
||||
"LoadBalancer": {"lb1", "lb2", "lb3"}, |
||||
"InstanceId": {"i-123", "i-456", "i-789"}, |
||||
}, |
||||
Period: 300, |
||||
Identifier: "id1", |
||||
Expression: "", |
||||
MatchExact: matchExact, |
||||
} |
||||
|
||||
res := buildSearchExpression(query, "Average") |
||||
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId,LoadBalancer} MetricName="CPUUtilization" "InstanceId"=("i-123" OR "i-456" OR "i-789") "LoadBalancer"=("lb1" OR "lb2" OR "lb3")', 'Average', 300))`) |
||||
}) |
||||
|
||||
Convey("and no OR operator was added if a star was used for dimension value", func() { |
||||
query := &cloudWatchQuery{ |
||||
Namespace: "AWS/EC2", |
||||
MetricName: "CPUUtilization", |
||||
Dimensions: map[string][]string{ |
||||
"LoadBalancer": {"*"}, |
||||
}, |
||||
Period: 300, |
||||
Identifier: "id1", |
||||
Expression: "", |
||||
MatchExact: matchExact, |
||||
} |
||||
|
||||
res := buildSearchExpression(query, "Average") |
||||
So(res, ShouldNotContainSubstring, "OR") |
||||
}) |
||||
|
||||
Convey("and query has one dimension key with a * value", func() { |
||||
query := &cloudWatchQuery{ |
||||
Namespace: "AWS/EC2", |
||||
MetricName: "CPUUtilization", |
||||
Dimensions: map[string][]string{ |
||||
"LoadBalancer": {"*"}, |
||||
}, |
||||
Period: 300, |
||||
Identifier: "id1", |
||||
Expression: "", |
||||
MatchExact: matchExact, |
||||
} |
||||
|
||||
res := buildSearchExpression(query, "Average") |
||||
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('{AWS/EC2,LoadBalancer} MetricName="CPUUtilization"', 'Average', 300))`) |
||||
}) |
||||
|
||||
Convey("and query has three dimension values for two given dimension keys, and one value is a star", func() { |
||||
query := &cloudWatchQuery{ |
||||
Namespace: "AWS/EC2", |
||||
MetricName: "CPUUtilization", |
||||
Dimensions: map[string][]string{ |
||||
"LoadBalancer": {"lb1", "lb2", "lb3"}, |
||||
"InstanceId": {"i-123", "*", "i-789"}, |
||||
}, |
||||
Period: 300, |
||||
Identifier: "id1", |
||||
Expression: "", |
||||
MatchExact: matchExact, |
||||
} |
||||
|
||||
res := buildSearchExpression(query, "Average") |
||||
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId,LoadBalancer} MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3")', 'Average', 300))`) |
||||
}) |
||||
}) |
||||
|
||||
Convey("and query should not be matched exact", func() { |
||||
matchExact := false |
||||
Convey("and query has three dimension values for a given dimension key", func() { |
||||
query := &cloudWatchQuery{ |
||||
Namespace: "AWS/EC2", |
||||
MetricName: "CPUUtilization", |
||||
Dimensions: map[string][]string{ |
||||
"LoadBalancer": {"lb1", "lb2", "lb3"}, |
||||
}, |
||||
Period: 300, |
||||
Identifier: "id1", |
||||
Expression: "", |
||||
MatchExact: matchExact, |
||||
} |
||||
|
||||
res := buildSearchExpression(query, "Average") |
||||
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3")', 'Average', 300))`) |
||||
}) |
||||
|
||||
Convey("and query has three dimension values for two given dimension keys", func() { |
||||
query := &cloudWatchQuery{ |
||||
Namespace: "AWS/EC2", |
||||
MetricName: "CPUUtilization", |
||||
Dimensions: map[string][]string{ |
||||
"LoadBalancer": {"lb1", "lb2", "lb3"}, |
||||
"InstanceId": {"i-123", "i-456", "i-789"}, |
||||
}, |
||||
Period: 300, |
||||
Identifier: "id1", |
||||
Expression: "", |
||||
MatchExact: matchExact, |
||||
} |
||||
|
||||
res := buildSearchExpression(query, "Average") |
||||
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "InstanceId"=("i-123" OR "i-456" OR "i-789") "LoadBalancer"=("lb1" OR "lb2" OR "lb3")', 'Average', 300))`) |
||||
}) |
||||
|
||||
Convey("and query has one dimension key with a * value", func() { |
||||
query := &cloudWatchQuery{ |
||||
Namespace: "AWS/EC2", |
||||
MetricName: "CPUUtilization", |
||||
Dimensions: map[string][]string{ |
||||
"LoadBalancer": {"*"}, |
||||
}, |
||||
Period: 300, |
||||
Identifier: "id1", |
||||
Expression: "", |
||||
MatchExact: matchExact, |
||||
} |
||||
|
||||
res := buildSearchExpression(query, "Average") |
||||
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "LoadBalancer"', 'Average', 300))`) |
||||
}) |
||||
|
||||
Convey("and query has three dimension values for two given dimension keys, and one value is a star", func() { |
||||
query := &cloudWatchQuery{ |
||||
Namespace: "AWS/EC2", |
||||
MetricName: "CPUUtilization", |
||||
Dimensions: map[string][]string{ |
||||
"LoadBalancer": {"lb1", "lb2", "lb3"}, |
||||
"InstanceId": {"i-123", "*", "i-789"}, |
||||
}, |
||||
Period: 300, |
||||
Identifier: "id1", |
||||
Expression: "", |
||||
MatchExact: matchExact, |
||||
} |
||||
|
||||
res := buildSearchExpression(query, "Average") |
||||
So(res, ShouldEqual, `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization" "LoadBalancer"=("lb1" OR "lb2" OR "lb3") "InstanceId"', 'Average', 300))`) |
||||
}) |
||||
|
||||
}) |
||||
}) |
||||
|
||||
Convey("and query has has invalid characters in dimension values", func() { |
||||
query := &cloudWatchQuery{ |
||||
Namespace: "AWS/EC2", |
||||
MetricName: "CPUUtilization", |
||||
Dimensions: map[string][]string{ |
||||
"lb1": {`lb\1\`}, |
||||
"lb2": {`)lb2`}, |
||||
"lb3": {`l(b3`}, |
||||
"lb4": {`lb4""`}, |
||||
"lb5": {`l\(b5"`}, |
||||
"lb6": {`l\\(b5"`}, |
||||
}, |
||||
Period: 300, |
||||
Identifier: "id1", |
||||
Expression: "", |
||||
MatchExact: true, |
||||
} |
||||
res := buildSearchExpression(query, "Average") |
||||
|
||||
Convey("it should escape backslash", func() { |
||||
So(res, ShouldContainSubstring, `"lb1"="lb\\1\\"`) |
||||
}) |
||||
|
||||
Convey("it should escape closing parenthesis", func() { |
||||
So(res, ShouldContainSubstring, `"lb2"="\)lb2"`) |
||||
}) |
||||
|
||||
Convey("it should escape open parenthesis", func() { |
||||
So(res, ShouldContainSubstring, `"lb3"="l\(b3"`) |
||||
}) |
||||
|
||||
Convey("it should escape double quotes", func() { |
||||
So(res, ShouldContainSubstring, `"lb6"="l\\\\\(b5\""`) |
||||
}) |
||||
|
||||
}) |
||||
}) |
||||
} |
@ -0,0 +1,103 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"fmt" |
||||
"sort" |
||||
"strings" |
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson" |
||||
"github.com/grafana/grafana/pkg/tsdb" |
||||
) |
||||
|
||||
// returns a map of queries with query id as key. In the case a q request query
|
||||
// has more than one statistic defined, one cloudwatchQuery will be created for each statistic.
|
||||
// If the query doesn't have an Id defined by the user, we'll give it an with format `query[RefId]`. In the case
|
||||
// the incoming query had more than one stat, it will ge an id like `query[RefId]_[StatName]`, eg queryC_Average
|
||||
func (e *CloudWatchExecutor) transformRequestQueriesToCloudWatchQueries(requestQueries []*requestQuery) (map[string]*cloudWatchQuery, error) { |
||||
cloudwatchQueries := make(map[string]*cloudWatchQuery) |
||||
for _, requestQuery := range requestQueries { |
||||
for _, stat := range requestQuery.Statistics { |
||||
id := requestQuery.Id |
||||
if id == "" { |
||||
id = fmt.Sprintf("query%s", requestQuery.RefId) |
||||
} |
||||
if len(requestQuery.Statistics) > 1 { |
||||
id = fmt.Sprintf("%s_%v", id, strings.ReplaceAll(*stat, ".", "_")) |
||||
} |
||||
|
||||
query := &cloudWatchQuery{ |
||||
Id: id, |
||||
RefId: requestQuery.RefId, |
||||
Region: requestQuery.Region, |
||||
Namespace: requestQuery.Namespace, |
||||
MetricName: requestQuery.MetricName, |
||||
Dimensions: requestQuery.Dimensions, |
||||
Stats: *stat, |
||||
Period: requestQuery.Period, |
||||
Alias: requestQuery.Alias, |
||||
Expression: requestQuery.Expression, |
||||
ReturnData: requestQuery.ReturnData, |
||||
HighResolution: requestQuery.HighResolution, |
||||
MatchExact: requestQuery.MatchExact, |
||||
} |
||||
|
||||
if _, ok := cloudwatchQueries[id]; ok { |
||||
return nil, fmt.Errorf("Error in query %s. Query id %s is not unique", query.RefId, query.Id) |
||||
} |
||||
|
||||
cloudwatchQueries[id] = query |
||||
} |
||||
} |
||||
|
||||
return cloudwatchQueries, nil |
||||
} |
||||
|
||||
func (e *CloudWatchExecutor) transformQueryResponseToQueryResult(cloudwatchResponses []*cloudwatchResponse) map[string]*tsdb.QueryResult { |
||||
results := make(map[string]*tsdb.QueryResult) |
||||
responsesByRefID := make(map[string][]*cloudwatchResponse) |
||||
|
||||
for _, res := range cloudwatchResponses { |
||||
if _, ok := responsesByRefID[res.RefId]; ok { |
||||
responsesByRefID[res.RefId] = append(responsesByRefID[res.RefId], res) |
||||
} else { |
||||
responsesByRefID[res.RefId] = []*cloudwatchResponse{res} |
||||
} |
||||
} |
||||
|
||||
for refID, responses := range responsesByRefID { |
||||
queryResult := tsdb.NewQueryResult() |
||||
queryResult.RefId = refID |
||||
queryResult.Meta = simplejson.New() |
||||
queryResult.Series = tsdb.TimeSeriesSlice{} |
||||
timeSeries := make(tsdb.TimeSeriesSlice, 0) |
||||
|
||||
requestExceededMaxLimit := false |
||||
queryMeta := []struct { |
||||
Expression, ID string |
||||
}{} |
||||
|
||||
for _, response := range responses { |
||||
timeSeries = append(timeSeries, *response.series...) |
||||
requestExceededMaxLimit = requestExceededMaxLimit || response.RequestExceededMaxLimit |
||||
queryMeta = append(queryMeta, struct { |
||||
Expression, ID string |
||||
}{ |
||||
Expression: response.Expression, |
||||
ID: response.Id, |
||||
}) |
||||
} |
||||
|
||||
sort.Slice(timeSeries, func(i, j int) bool { |
||||
return timeSeries[i].Name < timeSeries[j].Name |
||||
}) |
||||
|
||||
if requestExceededMaxLimit { |
||||
queryResult.ErrorString = "Cloudwatch GetMetricData error: Maximum number of allowed metrics exceeded. Your search may have been limited." |
||||
} |
||||
queryResult.Series = append(queryResult.Series, timeSeries...) |
||||
queryResult.Meta.Set("gmdMeta", queryMeta) |
||||
results[refID] = queryResult |
||||
} |
||||
|
||||
return results |
||||
} |
@ -0,0 +1,167 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
. "github.com/smartystreets/goconvey/convey" |
||||
) |
||||
|
||||
func TestQueryTransformer(t *testing.T) { |
||||
Convey("TestQueryTransformer", t, func() { |
||||
Convey("when transforming queries", func() { |
||||
|
||||
executor := &CloudWatchExecutor{} |
||||
Convey("one cloudwatchQuery is generated when its request query has one stat", func() { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average"}), |
||||
Period: 600, |
||||
Id: "", |
||||
HighResolution: false, |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
So(err, ShouldBeNil) |
||||
So(len(res), ShouldEqual, 1) |
||||
}) |
||||
|
||||
Convey("two cloudwatchQuery is generated when there's two stats", func() { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average", "Sum"}), |
||||
Period: 600, |
||||
Id: "", |
||||
HighResolution: false, |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
So(err, ShouldBeNil) |
||||
So(len(res), ShouldEqual, 2) |
||||
}) |
||||
Convey("and id is given by user", func() { |
||||
Convey("that id will be used in the cloudwatch query", func() { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average"}), |
||||
Period: 600, |
||||
Id: "myid", |
||||
HighResolution: false, |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
So(err, ShouldBeNil) |
||||
So(len(res), ShouldEqual, 1) |
||||
So(res, ShouldContainKey, "myid") |
||||
}) |
||||
}) |
||||
|
||||
Convey("and id is not given by user", func() { |
||||
Convey("id will be generated based on ref id if query only has one stat", func() { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average"}), |
||||
Period: 600, |
||||
Id: "", |
||||
HighResolution: false, |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
So(err, ShouldBeNil) |
||||
So(len(res), ShouldEqual, 1) |
||||
So(res, ShouldContainKey, "queryD") |
||||
}) |
||||
|
||||
Convey("id will be generated based on ref and stat name if query has two stats", func() { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average", "Sum"}), |
||||
Period: 600, |
||||
Id: "", |
||||
HighResolution: false, |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
So(err, ShouldBeNil) |
||||
So(len(res), ShouldEqual, 2) |
||||
So(res, ShouldContainKey, "queryD_Sum") |
||||
So(res, ShouldContainKey, "queryD_Average") |
||||
}) |
||||
}) |
||||
|
||||
Convey("dot should be removed when query has more than one stat and one of them is a percentile", func() { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average", "p46.32"}), |
||||
Period: 600, |
||||
Id: "", |
||||
HighResolution: false, |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
So(err, ShouldBeNil) |
||||
So(len(res), ShouldEqual, 2) |
||||
So(res, ShouldContainKey, "queryD_p46_32") |
||||
}) |
||||
|
||||
Convey("should return an error if two queries have the same id", func() { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average", "p46.32"}), |
||||
Period: 600, |
||||
Id: "myId", |
||||
HighResolution: false, |
||||
}, |
||||
{ |
||||
RefId: "E", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average", "p46.32"}), |
||||
Period: 600, |
||||
Id: "myId", |
||||
HighResolution: false, |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
So(res, ShouldBeNil) |
||||
So(err, ShouldNotBeNil) |
||||
}) |
||||
}) |
||||
}) |
||||
} |
@ -0,0 +1,162 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"errors" |
||||
"regexp" |
||||
"sort" |
||||
"strconv" |
||||
"time" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
"github.com/grafana/grafana/pkg/components/simplejson" |
||||
"github.com/grafana/grafana/pkg/tsdb" |
||||
) |
||||
|
||||
// Parses the json queries and returns a requestQuery. The requstQuery has a 1 to 1 mapping to a query editor row
|
||||
func (e *CloudWatchExecutor) parseQueries(queryContext *tsdb.TsdbQuery) (map[string][]*requestQuery, error) { |
||||
requestQueries := make(map[string][]*requestQuery) |
||||
|
||||
for i, model := range queryContext.Queries { |
||||
queryType := model.Model.Get("type").MustString() |
||||
if queryType != "timeSeriesQuery" && queryType != "" { |
||||
continue |
||||
} |
||||
|
||||
RefID := queryContext.Queries[i].RefId |
||||
query, err := parseRequestQuery(queryContext.Queries[i].Model, RefID) |
||||
if err != nil { |
||||
return nil, &queryError{err, RefID} |
||||
} |
||||
if _, exist := requestQueries[query.Region]; !exist { |
||||
requestQueries[query.Region] = make([]*requestQuery, 0) |
||||
} |
||||
requestQueries[query.Region] = append(requestQueries[query.Region], query) |
||||
} |
||||
|
||||
return requestQueries, nil |
||||
} |
||||
|
||||
func parseRequestQuery(model *simplejson.Json, refId string) (*requestQuery, error) { |
||||
region, err := model.Get("region").String() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
namespace, err := model.Get("namespace").String() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
metricName, err := model.Get("metricName").String() |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
dimensions, err := parseDimensions(model) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
statistics, err := parseStatistics(model) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
p := model.Get("period").MustString("") |
||||
if p == "" { |
||||
if namespace == "AWS/EC2" { |
||||
p = "300" |
||||
} else { |
||||
p = "60" |
||||
} |
||||
} |
||||
|
||||
var period int |
||||
if regexp.MustCompile(`^\d+$`).Match([]byte(p)) { |
||||
period, err = strconv.Atoi(p) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
} else { |
||||
d, err := time.ParseDuration(p) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
period = int(d.Seconds()) |
||||
} |
||||
|
||||
id := model.Get("id").MustString("") |
||||
expression := model.Get("expression").MustString("") |
||||
alias := model.Get("alias").MustString() |
||||
returnData := !model.Get("hide").MustBool(false) |
||||
queryType := model.Get("type").MustString() |
||||
if queryType == "" { |
||||
// If no type is provided we assume we are called by alerting service, which requires to return data!
|
||||
// Note, this is sort of a hack, but the official Grafana interfaces do not carry the information
|
||||
// who (which service) called the TsdbQueryEndpoint.Query(...) function.
|
||||
returnData = true |
||||
} |
||||
|
||||
highResolution := model.Get("highResolution").MustBool(false) |
||||
matchExact := model.Get("matchExact").MustBool(true) |
||||
|
||||
return &requestQuery{ |
||||
RefId: refId, |
||||
Region: region, |
||||
Namespace: namespace, |
||||
MetricName: metricName, |
||||
Dimensions: dimensions, |
||||
Statistics: aws.StringSlice(statistics), |
||||
Period: period, |
||||
Alias: alias, |
||||
Id: id, |
||||
Expression: expression, |
||||
ReturnData: returnData, |
||||
HighResolution: highResolution, |
||||
MatchExact: matchExact, |
||||
}, nil |
||||
} |
||||
|
||||
func parseStatistics(model *simplejson.Json) ([]string, error) { |
||||
var statistics []string |
||||
|
||||
for _, s := range model.Get("statistics").MustArray() { |
||||
statistics = append(statistics, s.(string)) |
||||
} |
||||
|
||||
return statistics, nil |
||||
} |
||||
|
||||
func parseDimensions(model *simplejson.Json) (map[string][]string, error) { |
||||
parsedDimensions := make(map[string][]string) |
||||
for k, v := range model.Get("dimensions").MustMap() { |
||||
// This is for backwards compatibility. Before 6.5 dimensions values were stored as strings and not arrays
|
||||
if value, ok := v.(string); ok { |
||||
parsedDimensions[k] = []string{value} |
||||
} else if values, ok := v.([]interface{}); ok { |
||||
for _, value := range values { |
||||
parsedDimensions[k] = append(parsedDimensions[k], value.(string)) |
||||
} |
||||
} else { |
||||
return nil, errors.New("failed to parse dimensions") |
||||
} |
||||
} |
||||
|
||||
sortedDimensions := sortDimensions(parsedDimensions) |
||||
|
||||
return sortedDimensions, nil |
||||
} |
||||
|
||||
func sortDimensions(dimensions map[string][]string) map[string][]string { |
||||
sortedDimensions := make(map[string][]string) |
||||
var keys []string |
||||
for k := range dimensions { |
||||
keys = append(keys, k) |
||||
} |
||||
sort.Strings(keys) |
||||
|
||||
for _, k := range keys { |
||||
sortedDimensions[k] = dimensions[k] |
||||
} |
||||
return sortedDimensions |
||||
} |
@ -0,0 +1,87 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson" |
||||
. "github.com/smartystreets/goconvey/convey" |
||||
) |
||||
|
||||
func TestRequestParser(t *testing.T) { |
||||
Convey("TestRequestParser", t, func() { |
||||
Convey("when parsing query editor row json", func() { |
||||
Convey("using new dimensions structure", func() { |
||||
query := simplejson.NewFromAny(map[string]interface{}{ |
||||
"refId": "ref1", |
||||
"region": "us-east-1", |
||||
"namespace": "ec2", |
||||
"metricName": "CPUUtilization", |
||||
"id": "", |
||||
"expression": "", |
||||
"dimensions": map[string]interface{}{ |
||||
"InstanceId": []interface{}{"test"}, |
||||
"InstanceType": []interface{}{"test2", "test3"}, |
||||
}, |
||||
"statistics": []interface{}{"Average"}, |
||||
"period": "600", |
||||
"hide": false, |
||||
"highResolution": false, |
||||
}) |
||||
|
||||
res, err := parseRequestQuery(query, "ref1") |
||||
So(err, ShouldBeNil) |
||||
So(res.Region, ShouldEqual, "us-east-1") |
||||
So(res.RefId, ShouldEqual, "ref1") |
||||
So(res.Namespace, ShouldEqual, "ec2") |
||||
So(res.MetricName, ShouldEqual, "CPUUtilization") |
||||
So(res.Id, ShouldEqual, "") |
||||
So(res.Expression, ShouldEqual, "") |
||||
So(res.Period, ShouldEqual, 600) |
||||
So(res.ReturnData, ShouldEqual, true) |
||||
So(res.HighResolution, ShouldEqual, false) |
||||
So(len(res.Dimensions), ShouldEqual, 2) |
||||
So(len(res.Dimensions["InstanceId"]), ShouldEqual, 1) |
||||
So(len(res.Dimensions["InstanceType"]), ShouldEqual, 2) |
||||
So(res.Dimensions["InstanceType"][1], ShouldEqual, "test3") |
||||
So(len(res.Statistics), ShouldEqual, 1) |
||||
So(*res.Statistics[0], ShouldEqual, "Average") |
||||
}) |
||||
|
||||
Convey("using old dimensions structure (backwards compatibility)", func() { |
||||
query := simplejson.NewFromAny(map[string]interface{}{ |
||||
"refId": "ref1", |
||||
"region": "us-east-1", |
||||
"namespace": "ec2", |
||||
"metricName": "CPUUtilization", |
||||
"id": "", |
||||
"expression": "", |
||||
"dimensions": map[string]interface{}{ |
||||
"InstanceId": "test", |
||||
"InstanceType": "test2", |
||||
}, |
||||
"statistics": []interface{}{"Average"}, |
||||
"period": "600", |
||||
"hide": false, |
||||
"highResolution": false, |
||||
}) |
||||
|
||||
res, err := parseRequestQuery(query, "ref1") |
||||
So(err, ShouldBeNil) |
||||
So(res.Region, ShouldEqual, "us-east-1") |
||||
So(res.RefId, ShouldEqual, "ref1") |
||||
So(res.Namespace, ShouldEqual, "ec2") |
||||
So(res.MetricName, ShouldEqual, "CPUUtilization") |
||||
So(res.Id, ShouldEqual, "") |
||||
So(res.Expression, ShouldEqual, "") |
||||
So(res.Period, ShouldEqual, 600) |
||||
So(res.ReturnData, ShouldEqual, true) |
||||
So(res.HighResolution, ShouldEqual, false) |
||||
So(len(res.Dimensions), ShouldEqual, 2) |
||||
So(len(res.Dimensions["InstanceId"]), ShouldEqual, 1) |
||||
So(len(res.Dimensions["InstanceType"]), ShouldEqual, 1) |
||||
So(res.Dimensions["InstanceType"][0], ShouldEqual, "test2") |
||||
So(*res.Statistics[0], ShouldEqual, "Average") |
||||
}) |
||||
}) |
||||
}) |
||||
} |
@ -0,0 +1,155 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"fmt" |
||||
"strconv" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/aws/aws-sdk-go/service/cloudwatch" |
||||
"github.com/grafana/grafana/pkg/components/null" |
||||
"github.com/grafana/grafana/pkg/tsdb" |
||||
) |
||||
|
||||
func (e *CloudWatchExecutor) parseResponse(metricDataOutputs []*cloudwatch.GetMetricDataOutput, queries map[string]*cloudWatchQuery) ([]*cloudwatchResponse, error) { |
||||
mdr := make(map[string]map[string]*cloudwatch.MetricDataResult) |
||||
|
||||
for _, mdo := range metricDataOutputs { |
||||
requestExceededMaxLimit := false |
||||
for _, message := range mdo.Messages { |
||||
if *message.Code == "MaxMetricsExceeded" { |
||||
requestExceededMaxLimit = true |
||||
} |
||||
} |
||||
|
||||
for _, r := range mdo.MetricDataResults { |
||||
if _, exists := mdr[*r.Id]; !exists { |
||||
mdr[*r.Id] = make(map[string]*cloudwatch.MetricDataResult) |
||||
mdr[*r.Id][*r.Label] = r |
||||
} else if _, exists := mdr[*r.Id][*r.Label]; !exists { |
||||
mdr[*r.Id][*r.Label] = r |
||||
} else { |
||||
mdr[*r.Id][*r.Label].Timestamps = append(mdr[*r.Id][*r.Label].Timestamps, r.Timestamps...) |
||||
mdr[*r.Id][*r.Label].Values = append(mdr[*r.Id][*r.Label].Values, r.Values...) |
||||
} |
||||
queries[*r.Id].RequestExceededMaxLimit = requestExceededMaxLimit |
||||
} |
||||
} |
||||
|
||||
cloudWatchResponses := make([]*cloudwatchResponse, 0) |
||||
for id, lr := range mdr { |
||||
response := &cloudwatchResponse{} |
||||
series, err := parseGetMetricDataTimeSeries(lr, queries[id]) |
||||
if err != nil { |
||||
return cloudWatchResponses, err |
||||
} |
||||
|
||||
response.series = series |
||||
response.Expression = queries[id].UsedExpression |
||||
response.RefId = queries[id].RefId |
||||
response.Id = queries[id].Id |
||||
response.RequestExceededMaxLimit = queries[id].RequestExceededMaxLimit |
||||
|
||||
cloudWatchResponses = append(cloudWatchResponses, response) |
||||
} |
||||
|
||||
return cloudWatchResponses, nil |
||||
} |
||||
|
||||
func parseGetMetricDataTimeSeries(metricDataResults map[string]*cloudwatch.MetricDataResult, query *cloudWatchQuery) (*tsdb.TimeSeriesSlice, error) { |
||||
result := tsdb.TimeSeriesSlice{} |
||||
for label, metricDataResult := range metricDataResults { |
||||
if *metricDataResult.StatusCode != "Complete" { |
||||
return nil, fmt.Errorf("too many datapoint requested in query %s. Please try to reduce the time range", query.RefId) |
||||
} |
||||
|
||||
for _, message := range metricDataResult.Messages { |
||||
if *message.Code == "ArithmeticError" { |
||||
return nil, fmt.Errorf("ArithmeticError in query %s: %s", query.RefId, *message.Value) |
||||
} |
||||
} |
||||
|
||||
series := tsdb.TimeSeries{ |
||||
Tags: make(map[string]string), |
||||
Points: make([]tsdb.TimePoint, 0), |
||||
} |
||||
|
||||
for key, values := range query.Dimensions { |
||||
if len(values) == 1 && values[0] != "*" { |
||||
series.Tags[key] = values[0] |
||||
} else { |
||||
for _, value := range values { |
||||
if value == label || value == "*" { |
||||
series.Tags[key] = label |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
series.Name = formatAlias(query, query.Stats, series.Tags, label) |
||||
|
||||
for j, t := range metricDataResult.Timestamps { |
||||
if j > 0 { |
||||
expectedTimestamp := metricDataResult.Timestamps[j-1].Add(time.Duration(query.Period) * time.Second) |
||||
if expectedTimestamp.Before(*t) { |
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFromPtr(nil), float64(expectedTimestamp.Unix()*1000))) |
||||
} |
||||
} |
||||
series.Points = append(series.Points, tsdb.NewTimePoint(null.FloatFrom(*metricDataResult.Values[j]), float64((*t).Unix())*1000)) |
||||
} |
||||
result = append(result, &series) |
||||
} |
||||
return &result, nil |
||||
} |
||||
|
||||
func formatAlias(query *cloudWatchQuery, stat string, dimensions map[string]string, label string) string { |
||||
region := query.Region |
||||
namespace := query.Namespace |
||||
metricName := query.MetricName |
||||
period := strconv.Itoa(query.Period) |
||||
|
||||
if query.isUserDefinedSearchExpression() { |
||||
pIndex := strings.LastIndex(query.Expression, ",") |
||||
period = strings.Trim(query.Expression[pIndex+1:], " )") |
||||
sIndex := strings.LastIndex(query.Expression[:pIndex], ",") |
||||
stat = strings.Trim(query.Expression[sIndex+1:pIndex], " '") |
||||
} |
||||
|
||||
if len(query.Alias) == 0 && query.isMathExpression() { |
||||
return query.Id |
||||
} |
||||
|
||||
if len(query.Alias) == 0 && query.isInferredSearchExpression() { |
||||
return label |
||||
} |
||||
|
||||
data := map[string]string{} |
||||
data["region"] = region |
||||
data["namespace"] = namespace |
||||
data["metric"] = metricName |
||||
data["stat"] = stat |
||||
data["period"] = period |
||||
if len(label) != 0 { |
||||
data["label"] = label |
||||
} |
||||
for k, v := range dimensions { |
||||
data[k] = v |
||||
} |
||||
|
||||
result := aliasFormat.ReplaceAllFunc([]byte(query.Alias), func(in []byte) []byte { |
||||
labelName := strings.Replace(string(in), "{{", "", 1) |
||||
labelName = strings.Replace(labelName, "}}", "", 1) |
||||
labelName = strings.TrimSpace(labelName) |
||||
if val, exists := data[labelName]; exists { |
||||
return []byte(val) |
||||
} |
||||
|
||||
return in |
||||
}) |
||||
|
||||
if string(result) == "" { |
||||
return metricName + "_" + stat |
||||
} |
||||
|
||||
return string(result) |
||||
} |
@ -0,0 +1,61 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
"github.com/aws/aws-sdk-go/service/cloudwatch" |
||||
"github.com/grafana/grafana/pkg/components/null" |
||||
. "github.com/smartystreets/goconvey/convey" |
||||
) |
||||
|
||||
func TestCloudWatchResponseParser(t *testing.T) { |
||||
Convey("TestCloudWatchResponseParser", t, func() { |
||||
|
||||
Convey("can parse cloudwatch response", func() { |
||||
timestamp := time.Unix(0, 0) |
||||
resp := map[string]*cloudwatch.MetricDataResult{ |
||||
"lb": { |
||||
Id: aws.String("id1"), |
||||
Label: aws.String("lb"), |
||||
Timestamps: []*time.Time{ |
||||
aws.Time(timestamp), |
||||
aws.Time(timestamp.Add(60 * time.Second)), |
||||
aws.Time(timestamp.Add(180 * time.Second)), |
||||
}, |
||||
Values: []*float64{ |
||||
aws.Float64(10), |
||||
aws.Float64(20), |
||||
aws.Float64(30), |
||||
}, |
||||
StatusCode: aws.String("Complete"), |
||||
}, |
||||
} |
||||
|
||||
query := &cloudWatchQuery{ |
||||
RefId: "refId1", |
||||
Region: "us-east-1", |
||||
Namespace: "AWS/ApplicationELB", |
||||
MetricName: "TargetResponseTime", |
||||
Dimensions: map[string][]string{ |
||||
"LoadBalancer": {"lb"}, |
||||
"TargetGroup": {"tg"}, |
||||
}, |
||||
Stats: "Average", |
||||
Period: 60, |
||||
Alias: "{{namespace}}_{{metric}}_{{stat}}", |
||||
} |
||||
series, err := parseGetMetricDataTimeSeries(resp, query) |
||||
timeSeries := (*series)[0] |
||||
|
||||
So(err, ShouldBeNil) |
||||
So(timeSeries.Name, ShouldEqual, "AWS/ApplicationELB_TargetResponseTime_Average") |
||||
So(timeSeries.Tags["LoadBalancer"], ShouldEqual, "lb") |
||||
So(timeSeries.Points[0][0].String(), ShouldEqual, null.FloatFrom(10.0).String()) |
||||
So(timeSeries.Points[1][0].String(), ShouldEqual, null.FloatFrom(20.0).String()) |
||||
So(timeSeries.Points[2][0].String(), ShouldEqual, null.FloatFromPtr(nil).String()) |
||||
So(timeSeries.Points[3][0].String(), ShouldEqual, null.FloatFrom(30.0).String()) |
||||
}) |
||||
}) |
||||
} |
@ -0,0 +1,101 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log" |
||||
"github.com/grafana/grafana/pkg/tsdb" |
||||
"golang.org/x/sync/errgroup" |
||||
) |
||||
|
||||
func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) { |
||||
results := &tsdb.Response{ |
||||
Results: make(map[string]*tsdb.QueryResult), |
||||
} |
||||
|
||||
requestQueriesByRegion, err := e.parseQueries(queryContext) |
||||
if err != nil { |
||||
return results, err |
||||
} |
||||
resultChan := make(chan *tsdb.QueryResult, len(queryContext.Queries)) |
||||
eg, ectx := errgroup.WithContext(ctx) |
||||
|
||||
if len(requestQueriesByRegion) > 0 { |
||||
for r, q := range requestQueriesByRegion { |
||||
requestQueries := q |
||||
region := r |
||||
eg.Go(func() error { |
||||
defer func() { |
||||
if err := recover(); err != nil { |
||||
plog.Error("Execute Get Metric Data Query Panic", "error", err, "stack", log.Stack(1)) |
||||
if theErr, ok := err.(error); ok { |
||||
resultChan <- &tsdb.QueryResult{ |
||||
Error: theErr, |
||||
} |
||||
} |
||||
} |
||||
}() |
||||
|
||||
client, err := e.getClient(region) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
queries, err := e.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
if err != nil { |
||||
for _, query := range requestQueries { |
||||
resultChan <- &tsdb.QueryResult{ |
||||
RefId: query.RefId, |
||||
Error: err, |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
metricDataInput, err := e.buildMetricDataInput(queryContext, queries) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
cloudwatchResponses := make([]*cloudwatchResponse, 0) |
||||
mdo, err := e.executeRequest(ectx, client, metricDataInput) |
||||
if err != nil { |
||||
for _, query := range requestQueries { |
||||
resultChan <- &tsdb.QueryResult{ |
||||
RefId: query.RefId, |
||||
Error: err, |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
responses, err := e.parseResponse(mdo, queries) |
||||
if err != nil { |
||||
for _, query := range requestQueries { |
||||
resultChan <- &tsdb.QueryResult{ |
||||
RefId: query.RefId, |
||||
Error: err, |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
cloudwatchResponses = append(cloudwatchResponses, responses...) |
||||
res := e.transformQueryResponseToQueryResult(cloudwatchResponses) |
||||
for _, queryRes := range res { |
||||
resultChan <- queryRes |
||||
} |
||||
return nil |
||||
}) |
||||
} |
||||
} |
||||
|
||||
if err := eg.Wait(); err != nil { |
||||
return nil, err |
||||
} |
||||
close(resultChan) |
||||
for result := range resultChan { |
||||
results.Results[result.RefId] = result |
||||
} |
||||
|
||||
return results, nil |
||||
} |
@ -1,21 +1,49 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"fmt" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
"github.com/aws/aws-sdk-go/aws/request" |
||||
"github.com/aws/aws-sdk-go/service/cloudwatch" |
||||
"github.com/grafana/grafana/pkg/tsdb" |
||||
) |
||||
|
||||
type CloudWatchQuery struct { |
||||
type cloudWatchClient interface { |
||||
GetMetricDataWithContext(ctx aws.Context, input *cloudwatch.GetMetricDataInput, opts ...request.Option) (*cloudwatch.GetMetricDataOutput, error) |
||||
} |
||||
|
||||
type requestQuery struct { |
||||
RefId string |
||||
Region string |
||||
Id string |
||||
Namespace string |
||||
MetricName string |
||||
Dimensions []*cloudwatch.Dimension |
||||
Statistics []*string |
||||
QueryType string |
||||
Expression string |
||||
ReturnData bool |
||||
Dimensions map[string][]string |
||||
ExtendedStatistics []*string |
||||
Period int |
||||
Alias string |
||||
Id string |
||||
Expression string |
||||
ReturnData bool |
||||
HighResolution bool |
||||
MatchExact bool |
||||
} |
||||
|
||||
type cloudwatchResponse struct { |
||||
series *tsdb.TimeSeriesSlice |
||||
Id string |
||||
RefId string |
||||
Expression string |
||||
RequestExceededMaxLimit bool |
||||
} |
||||
|
||||
type queryError struct { |
||||
err error |
||||
RefID string |
||||
} |
||||
|
||||
func (e *queryError) Error() string { |
||||
return fmt.Sprintf("Error parsing query %s, %s", e.RefID, e.err) |
||||
} |
||||
|
@ -0,0 +1,10 @@ |
||||
import React from 'react'; |
||||
import renderer from 'react-test-renderer'; |
||||
import { Alias } from './Alias'; |
||||
|
||||
describe('Alias', () => { |
||||
it('should render component', () => { |
||||
const tree = renderer.create(<Alias value={'legend'} onChange={() => {}} />).toJSON(); |
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
}); |
@ -0,0 +1,21 @@ |
||||
import React, { FunctionComponent, useState } from 'react'; |
||||
import { debounce } from 'lodash'; |
||||
import { Input } from '@grafana/ui'; |
||||
|
||||
export interface Props { |
||||
onChange: (alias: any) => void; |
||||
value: string; |
||||
} |
||||
|
||||
export const Alias: FunctionComponent<Props> = ({ value = '', onChange }) => { |
||||
const [alias, setAlias] = useState(value); |
||||
|
||||
const propagateOnChange = debounce(onChange, 1500); |
||||
|
||||
onChange = (e: any) => { |
||||
setAlias(e.target.value); |
||||
propagateOnChange(e.target.value); |
||||
}; |
||||
|
||||
return <Input type="text" className="gf-form-input width-16" value={alias} onChange={onChange} />; |
||||
}; |
@ -0,0 +1,106 @@ |
||||
import React from 'react'; |
||||
import { shallow } from 'enzyme'; |
||||
import ConfigEditor, { Props } from './ConfigEditor'; |
||||
|
||||
jest.mock('app/features/plugins/datasource_srv', () => ({ |
||||
getDatasourceSrv: () => ({ |
||||
loadDatasource: jest.fn().mockImplementation(() => |
||||
Promise.resolve({ |
||||
getRegions: jest.fn().mockReturnValue([ |
||||
{ |
||||
label: 'ap-east-1', |
||||
value: 'ap-east-1', |
||||
}, |
||||
]), |
||||
}) |
||||
), |
||||
}), |
||||
})); |
||||
|
||||
const setup = (propOverrides?: object) => { |
||||
const props: Props = { |
||||
options: { |
||||
id: 1, |
||||
orgId: 1, |
||||
typeLogoUrl: '', |
||||
name: 'CloudWatch', |
||||
access: 'proxy', |
||||
url: '', |
||||
database: '', |
||||
type: 'cloudwatch', |
||||
user: '', |
||||
password: '', |
||||
basicAuth: false, |
||||
basicAuthPassword: '', |
||||
basicAuthUser: '', |
||||
isDefault: true, |
||||
readOnly: false, |
||||
withCredentials: false, |
||||
secureJsonFields: { |
||||
accessKey: false, |
||||
secretKey: false, |
||||
}, |
||||
jsonData: { |
||||
assumeRoleArn: '', |
||||
database: '', |
||||
customMetricsNamespaces: '', |
||||
authType: 'keys', |
||||
defaultRegion: 'us-east-2', |
||||
timeField: '@timestamp', |
||||
}, |
||||
secureJsonData: { |
||||
secretKey: '', |
||||
accessKey: '', |
||||
}, |
||||
}, |
||||
onOptionsChange: jest.fn(), |
||||
}; |
||||
|
||||
Object.assign(props, propOverrides); |
||||
|
||||
return shallow(<ConfigEditor {...props} />); |
||||
}; |
||||
|
||||
describe('Render', () => { |
||||
it('should render component', () => { |
||||
const wrapper = setup(); |
||||
|
||||
expect(wrapper).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('should disable access key id field', () => { |
||||
const wrapper = setup({ |
||||
secureJsonFields: { |
||||
secretKey: true, |
||||
}, |
||||
}); |
||||
expect(wrapper).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('should should show credentials profile name field', () => { |
||||
const wrapper = setup({ |
||||
jsonData: { |
||||
authType: 'credentials', |
||||
}, |
||||
}); |
||||
expect(wrapper).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('should should show access key and secret access key fields', () => { |
||||
const wrapper = setup({ |
||||
jsonData: { |
||||
authType: 'keys', |
||||
}, |
||||
}); |
||||
expect(wrapper).toMatchSnapshot(); |
||||
}); |
||||
|
||||
it('should should show arn role field', () => { |
||||
const wrapper = setup({ |
||||
jsonData: { |
||||
authType: 'arn', |
||||
}, |
||||
}); |
||||
expect(wrapper).toMatchSnapshot(); |
||||
}); |
||||
}); |
@ -0,0 +1,390 @@ |
||||
import React, { PureComponent, ChangeEvent } from 'react'; |
||||
import { FormLabel, Select, Input, Button } from '@grafana/ui'; |
||||
import { DataSourcePluginOptionsEditorProps, DataSourceSettings } from '@grafana/data'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; |
||||
import CloudWatchDatasource from '../datasource'; |
||||
import { CloudWatchJsonData, CloudWatchSecureJsonData } from '../types'; |
||||
|
||||
export type Props = DataSourcePluginOptionsEditorProps<CloudWatchJsonData>; |
||||
|
||||
type CloudwatchSettings = DataSourceSettings<CloudWatchJsonData, CloudWatchSecureJsonData>; |
||||
|
||||
export interface State { |
||||
config: CloudwatchSettings; |
||||
authProviderOptions: SelectableValue[]; |
||||
regions: SelectableValue[]; |
||||
} |
||||
|
||||
export class ConfigEditor extends PureComponent<Props, State> { |
||||
constructor(props: Props) { |
||||
super(props); |
||||
|
||||
const { options } = this.props; |
||||
|
||||
this.state = { |
||||
config: ConfigEditor.defaults(options), |
||||
authProviderOptions: [ |
||||
{ label: 'Access & secret key', value: 'keys' }, |
||||
{ label: 'Credentials file', value: 'credentials' }, |
||||
{ label: 'ARN', value: 'arn' }, |
||||
], |
||||
regions: [], |
||||
}; |
||||
|
||||
this.updateDatasource(this.state.config); |
||||
} |
||||
|
||||
static getDerivedStateFromProps(props: Props, state: State) { |
||||
return { |
||||
...state, |
||||
config: ConfigEditor.defaults(props.options), |
||||
}; |
||||
} |
||||
|
||||
static defaults = (options: any) => { |
||||
options.jsonData.authType = options.jsonData.authType || 'credentials'; |
||||
options.jsonData.timeField = options.jsonData.timeField || '@timestamp'; |
||||
|
||||
if (!options.hasOwnProperty('secureJsonData')) { |
||||
options.secureJsonData = {}; |
||||
} |
||||
|
||||
if (!options.hasOwnProperty('jsonData')) { |
||||
options.jsonData = {}; |
||||
} |
||||
|
||||
if (!options.hasOwnProperty('secureJsonFields')) { |
||||
options.secureJsonFields = {}; |
||||
} |
||||
|
||||
return options; |
||||
}; |
||||
|
||||
async componentDidMount() { |
||||
this.loadRegions(); |
||||
} |
||||
|
||||
loadRegions() { |
||||
getDatasourceSrv() |
||||
.loadDatasource(this.state.config.name) |
||||
.then((ds: CloudWatchDatasource) => { |
||||
return ds.getRegions(); |
||||
}) |
||||
.then( |
||||
(regions: any) => { |
||||
this.setState({ |
||||
regions: regions.map((region: any) => { |
||||
return { |
||||
value: region.value, |
||||
label: region.text, |
||||
}; |
||||
}), |
||||
}); |
||||
}, |
||||
(err: any) => { |
||||
const regions = [ |
||||
'ap-east-1', |
||||
'ap-northeast-1', |
||||
'ap-northeast-2', |
||||
'ap-northeast-3', |
||||
'ap-south-1', |
||||
'ap-southeast-1', |
||||
'ap-southeast-2', |
||||
'ca-central-1', |
||||
'cn-north-1', |
||||
'cn-northwest-1', |
||||
'eu-central-1', |
||||
'eu-north-1', |
||||
'eu-west-1', |
||||
'eu-west-2', |
||||
'eu-west-3', |
||||
'me-south-1', |
||||
'sa-east-1', |
||||
'us-east-1', |
||||
'us-east-2', |
||||
'us-gov-east-1', |
||||
'us-gov-west-1', |
||||
'us-iso-east-1', |
||||
'us-isob-east-1', |
||||
'us-west-1', |
||||
'us-west-2', |
||||
]; |
||||
|
||||
this.setState({ |
||||
regions: regions.map((region: string) => { |
||||
return { |
||||
value: region, |
||||
label: region, |
||||
}; |
||||
}), |
||||
}); |
||||
|
||||
// expected to fail when creating new datasource
|
||||
// console.error('failed to get latest regions', err);
|
||||
} |
||||
); |
||||
} |
||||
|
||||
updateDatasource = async (config: any) => { |
||||
for (const j in config.jsonData) { |
||||
if (config.jsonData[j].length === 0) { |
||||
delete config.jsonData[j]; |
||||
} |
||||
} |
||||
|
||||
for (const k in config.secureJsonData) { |
||||
if (config.secureJsonData[k].length === 0) { |
||||
delete config.secureJsonData[k]; |
||||
} |
||||
} |
||||
|
||||
this.props.onOptionsChange({ |
||||
...config, |
||||
}); |
||||
}; |
||||
|
||||
onAuthProviderChange = (authType: SelectableValue<string>) => { |
||||
this.updateDatasource({ |
||||
...this.state.config, |
||||
jsonData: { |
||||
...this.state.config.jsonData, |
||||
authType: authType.value, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
onRegionChange = (defaultRegion: SelectableValue<string>) => { |
||||
this.updateDatasource({ |
||||
...this.state.config, |
||||
jsonData: { |
||||
...this.state.config.jsonData, |
||||
defaultRegion: defaultRegion.value, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
onResetAccessKey = () => { |
||||
this.updateDatasource({ |
||||
...this.state.config, |
||||
secureJsonFields: { |
||||
...this.state.config.secureJsonFields, |
||||
accessKey: false, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
onAccessKeyChange = (accessKey: string) => { |
||||
this.updateDatasource({ |
||||
...this.state.config, |
||||
secureJsonData: { |
||||
...this.state.config.secureJsonData, |
||||
accessKey, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
onResetSecretKey = () => { |
||||
this.updateDatasource({ |
||||
...this.state.config, |
||||
secureJsonFields: { |
||||
...this.state.config.secureJsonFields, |
||||
secretKey: false, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
onSecretKeyChange = (secretKey: string) => { |
||||
this.updateDatasource({ |
||||
...this.state.config, |
||||
secureJsonData: { |
||||
...this.state.config.secureJsonData, |
||||
secretKey, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
onCredentialProfileNameChange = (database: string) => { |
||||
this.updateDatasource({ |
||||
...this.state.config, |
||||
database, |
||||
}); |
||||
}; |
||||
|
||||
onArnAssumeRoleChange = (assumeRoleArn: string) => { |
||||
this.updateDatasource({ |
||||
...this.state.config, |
||||
jsonData: { |
||||
...this.state.config.jsonData, |
||||
assumeRoleArn, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
onCustomMetricsNamespacesChange = (customMetricsNamespaces: string) => { |
||||
this.updateDatasource({ |
||||
...this.state.config, |
||||
jsonData: { |
||||
...this.state.config.jsonData, |
||||
customMetricsNamespaces, |
||||
}, |
||||
}); |
||||
}; |
||||
|
||||
render() { |
||||
const { config, authProviderOptions, regions } = this.state; |
||||
|
||||
return ( |
||||
<> |
||||
<h3 className="page-heading">CloudWatch Details</h3> |
||||
<div className="gf-form-group"> |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form"> |
||||
<FormLabel className="width-14">Auth Provider</FormLabel> |
||||
<Select |
||||
className="width-30" |
||||
value={authProviderOptions.find(authProvider => authProvider.value === config.jsonData.authType)} |
||||
options={authProviderOptions} |
||||
defaultValue={config.jsonData.authType} |
||||
onChange={this.onAuthProviderChange} |
||||
/> |
||||
</div> |
||||
</div> |
||||
{config.jsonData.authType === 'credentials' && ( |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form"> |
||||
<FormLabel |
||||
className="width-14" |
||||
tooltip="Credentials profile name, as specified in ~/.aws/credentials, leave blank for default." |
||||
> |
||||
Credentials Profile Name |
||||
</FormLabel> |
||||
<div className="width-30"> |
||||
<Input |
||||
className="width-30" |
||||
placeholder="default" |
||||
value={config.jsonData.database} |
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => |
||||
this.onCredentialProfileNameChange(event.target.value) |
||||
} |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
)} |
||||
{config.jsonData.authType === 'keys' && ( |
||||
<div> |
||||
{config.secureJsonFields.accessKey ? ( |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form"> |
||||
<FormLabel className="width-14">Access Key ID</FormLabel> |
||||
<Input className="width-25" placeholder="Configured" disabled={true} /> |
||||
</div> |
||||
<div className="gf-form"> |
||||
<div className="max-width-30 gf-form-inline"> |
||||
<Button variant="secondary" type="button" onClick={this.onResetAccessKey}> |
||||
Reset |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) : ( |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form"> |
||||
<FormLabel className="width-14">Access Key ID</FormLabel> |
||||
<div className="width-30"> |
||||
<Input |
||||
className="width-30" |
||||
value={config.secureJsonData.accessKey || ''} |
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => this.onAccessKeyChange(event.target.value)} |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
)} |
||||
{config.secureJsonFields.secretKey ? ( |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form"> |
||||
<FormLabel className="width-14">Secret Access Key</FormLabel> |
||||
<Input className="width-25" placeholder="Configured" disabled={true} /> |
||||
</div> |
||||
<div className="gf-form"> |
||||
<div className="max-width-30 gf-form-inline"> |
||||
<Button variant="secondary" type="button" onClick={this.onResetSecretKey}> |
||||
Reset |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
) : ( |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form"> |
||||
<FormLabel className="width-14">Secret Access Key</FormLabel> |
||||
<div className="width-30"> |
||||
<Input |
||||
className="width-30" |
||||
value={config.secureJsonData.secretKey || ''} |
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => this.onSecretKeyChange(event.target.value)} |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
)} |
||||
</div> |
||||
)} |
||||
{config.jsonData.authType === 'arn' && ( |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form"> |
||||
<FormLabel className="width-14" tooltip="ARN of Assume Role"> |
||||
Assume Role ARN |
||||
</FormLabel> |
||||
<div className="width-30"> |
||||
<Input |
||||
className="width-30" |
||||
placeholder="arn:aws:iam:*" |
||||
value={config.jsonData.assumeRoleArn || ''} |
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => this.onArnAssumeRoleChange(event.target.value)} |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
)} |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form"> |
||||
<FormLabel |
||||
className="width-14" |
||||
tooltip="Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region." |
||||
> |
||||
Default Region |
||||
</FormLabel> |
||||
<Select |
||||
className="width-30" |
||||
value={regions.find(region => region.value === config.jsonData.defaultRegion)} |
||||
options={regions} |
||||
defaultValue={config.jsonData.defaultRegion} |
||||
onChange={this.onRegionChange} |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form"> |
||||
<FormLabel className="width-14" tooltip="Namespaces of Custom Metrics."> |
||||
Custom Metrics |
||||
</FormLabel> |
||||
<Input |
||||
className="width-30" |
||||
placeholder="Namespace1,Namespace2" |
||||
value={config.jsonData.customMetricsNamespaces || ''} |
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => |
||||
this.onCustomMetricsNamespacesChange(event.target.value) |
||||
} |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export default ConfigEditor; |
@ -0,0 +1,50 @@ |
||||
import React from 'react'; |
||||
import { mount, shallow } from 'enzyme'; |
||||
import { Dimensions } from './'; |
||||
import { SelectableStrings } from '../types'; |
||||
|
||||
describe('Dimensions', () => { |
||||
it('renders', () => { |
||||
mount( |
||||
<Dimensions |
||||
dimensions={{}} |
||||
onChange={dimensions => console.log(dimensions)} |
||||
loadKeys={() => Promise.resolve<SelectableStrings>([])} |
||||
loadValues={() => Promise.resolve<SelectableStrings>([])} |
||||
/> |
||||
); |
||||
}); |
||||
|
||||
describe('and no dimension were passed to the component', () => { |
||||
it('initially displays just an add button', () => { |
||||
const wrapper = shallow( |
||||
<Dimensions |
||||
dimensions={{}} |
||||
onChange={() => {}} |
||||
loadKeys={() => Promise.resolve<SelectableStrings>([])} |
||||
loadValues={() => Promise.resolve<SelectableStrings>([])} |
||||
/> |
||||
); |
||||
|
||||
expect(wrapper.html()).toEqual( |
||||
`<div class="gf-form"><a class="gf-form-label query-part"><i class="fa fa-plus"></i></a></div>` |
||||
); |
||||
}); |
||||
}); |
||||
|
||||
describe('and one dimension key along with a value were passed to the component', () => { |
||||
it('initially displays the dimension key, value and an add button', () => { |
||||
const wrapper = shallow( |
||||
<Dimensions |
||||
dimensions={{ somekey: 'somevalue' }} |
||||
onChange={() => {}} |
||||
loadKeys={() => Promise.resolve<SelectableStrings>([])} |
||||
loadValues={() => Promise.resolve<SelectableStrings>([])} |
||||
/> |
||||
); |
||||
expect(wrapper.html()).toEqual( |
||||
`<div class="gf-form"><a class="gf-form-label query-part">somekey</a></div><label class="gf-form-label query-segment-operator">=</label><div class="gf-form"><a class="gf-form-label query-part">somevalue</a></div><div class="gf-form"><a class="gf-form-label query-part"><i class="fa fa-plus"></i></a></div>` |
||||
); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,80 @@ |
||||
import React, { FunctionComponent, Fragment, useState, useEffect } from 'react'; |
||||
import isEqual from 'lodash/isEqual'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { SegmentAsync } from '@grafana/ui'; |
||||
import { SelectableStrings } from '../types'; |
||||
|
||||
export interface Props { |
||||
dimensions: { [key: string]: string | string[] }; |
||||
onChange: (dimensions: { [key: string]: string }) => void; |
||||
loadValues: (key: string) => Promise<SelectableStrings>; |
||||
loadKeys: () => Promise<SelectableStrings>; |
||||
} |
||||
|
||||
const removeText = '-- remove dimension --'; |
||||
const removeOption: SelectableValue<string> = { label: removeText, value: removeText }; |
||||
|
||||
// The idea of this component is that is should only trigger the onChange event in the case
|
||||
// there is a complete dimension object. E.g, when a new key is added is doesn't have a value.
|
||||
// That should not trigger onChange.
|
||||
export const Dimensions: FunctionComponent<Props> = ({ dimensions, loadValues, loadKeys, onChange }) => { |
||||
const [data, setData] = useState(dimensions); |
||||
|
||||
useEffect(() => { |
||||
const completeDimensions = Object.entries(data).reduce( |
||||
(res, [key, value]) => (value ? { ...res, [key]: value } : res), |
||||
{} |
||||
); |
||||
if (!isEqual(completeDimensions, dimensions)) { |
||||
onChange(completeDimensions); |
||||
} |
||||
}, [data]); |
||||
|
||||
const excludeUsedKeys = (options: SelectableStrings) => { |
||||
return options.filter(({ value }) => !Object.keys(data).includes(value)); |
||||
}; |
||||
|
||||
return ( |
||||
<> |
||||
{Object.entries(data).map(([key, value], index) => ( |
||||
<Fragment key={index}> |
||||
<SegmentAsync |
||||
allowCustomValue |
||||
value={key} |
||||
loadOptions={() => loadKeys().then(keys => [removeOption, ...excludeUsedKeys(keys)])} |
||||
onChange={newKey => { |
||||
const { [key]: value, ...newDimensions } = data; |
||||
if (newKey === removeText) { |
||||
setData({ ...newDimensions }); |
||||
} else { |
||||
setData({ ...newDimensions, [newKey]: '' }); |
||||
} |
||||
}} |
||||
/> |
||||
<label className="gf-form-label query-segment-operator">=</label> |
||||
<SegmentAsync |
||||
allowCustomValue |
||||
value={value || 'select dimension value'} |
||||
loadOptions={() => loadValues(key)} |
||||
onChange={newValue => setData({ ...data, [key]: newValue })} |
||||
/> |
||||
{Object.values(data).length > 1 && index + 1 !== Object.values(data).length && ( |
||||
<label className="gf-form-label query-keyword">AND</label> |
||||
)} |
||||
</Fragment> |
||||
))} |
||||
{Object.values(data).every(v => v) && ( |
||||
<SegmentAsync |
||||
allowCustomValue |
||||
Component={ |
||||
<a className="gf-form-label query-part"> |
||||
<i className="fa fa-plus" /> |
||||
</a> |
||||
} |
||||
loadOptions={() => loadKeys().then(excludeUsedKeys)} |
||||
onChange={(newKey: string) => setData({ ...data, [newKey]: '' })} |
||||
/> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
@ -0,0 +1,28 @@ |
||||
import React, { InputHTMLAttributes, FunctionComponent } from 'react'; |
||||
import { FormLabel } from '@grafana/ui'; |
||||
|
||||
export interface Props extends InputHTMLAttributes<HTMLInputElement> { |
||||
label: string; |
||||
tooltip?: string; |
||||
children?: React.ReactNode; |
||||
} |
||||
|
||||
export const QueryField: FunctionComponent<Partial<Props>> = ({ label, tooltip, children }) => ( |
||||
<> |
||||
<FormLabel width={8} className="query-keyword" tooltip={tooltip}> |
||||
{label} |
||||
</FormLabel> |
||||
{children} |
||||
</> |
||||
); |
||||
|
||||
export const QueryInlineField: FunctionComponent<Props> = ({ ...props }) => { |
||||
return ( |
||||
<div className={'gf-form-inline'}> |
||||
<QueryField {...props} /> |
||||
<div className="gf-form gf-form--grow"> |
||||
<div className="gf-form-label gf-form-label--grow" /> |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
@ -0,0 +1,102 @@ |
||||
import React from 'react'; |
||||
import renderer from 'react-test-renderer'; |
||||
import { mount } from 'enzyme'; |
||||
import { DataSourceInstanceSettings } from '@grafana/data'; |
||||
import { TemplateSrv } from 'app/features/templating/template_srv'; |
||||
import { CustomVariable } from 'app/features/templating/all'; |
||||
import { QueryEditor, Props } from './QueryEditor'; |
||||
import CloudWatchDatasource from '../datasource'; |
||||
|
||||
const setup = () => { |
||||
const instanceSettings = { |
||||
jsonData: { defaultRegion: 'us-east-1' }, |
||||
} as DataSourceInstanceSettings; |
||||
|
||||
const templateSrv = new TemplateSrv(); |
||||
templateSrv.init([ |
||||
new CustomVariable( |
||||
{ |
||||
name: 'var3', |
||||
options: [ |
||||
{ selected: true, value: 'var3-foo' }, |
||||
{ selected: false, value: 'var3-bar' }, |
||||
{ selected: true, value: 'var3-baz' }, |
||||
], |
||||
current: { |
||||
value: ['var3-foo', 'var3-baz'], |
||||
}, |
||||
multi: true, |
||||
}, |
||||
{} as any |
||||
), |
||||
]); |
||||
|
||||
const datasource = new CloudWatchDatasource(instanceSettings, {} as any, {} as any, templateSrv as any, {} as any); |
||||
datasource.metricFindQuery = async param => [{ value: 'test', label: 'test' }]; |
||||
|
||||
const props: Props = { |
||||
query: { |
||||
refId: '', |
||||
id: '', |
||||
region: 'us-east-1', |
||||
namespace: 'ec2', |
||||
metricName: 'CPUUtilization', |
||||
dimensions: { somekey: 'somevalue' }, |
||||
statistics: new Array<string>(), |
||||
period: '', |
||||
expression: '', |
||||
alias: '', |
||||
highResolution: false, |
||||
matchExact: true, |
||||
}, |
||||
datasource, |
||||
onChange: jest.fn(), |
||||
onRunQuery: jest.fn(), |
||||
}; |
||||
|
||||
return props; |
||||
}; |
||||
|
||||
describe('QueryEditor', () => { |
||||
it('should render component', () => { |
||||
const props = setup(); |
||||
const tree = renderer.create(<QueryEditor {...props} />).toJSON(); |
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
|
||||
describe('should use correct default values', () => { |
||||
it('when region is null is display default in the label', () => { |
||||
const props = setup(); |
||||
props.query.region = null; |
||||
const wrapper = mount(<QueryEditor {...props} />); |
||||
expect( |
||||
wrapper |
||||
.find('.gf-form-inline') |
||||
.first() |
||||
.find('.gf-form-label.query-part') |
||||
.first() |
||||
.text() |
||||
).toEqual('default'); |
||||
}); |
||||
|
||||
it('should init props correctly', () => { |
||||
const props = setup(); |
||||
props.query.namespace = null; |
||||
props.query.metricName = null; |
||||
props.query.expression = null; |
||||
props.query.dimensions = null; |
||||
props.query.region = null; |
||||
props.query.statistics = null; |
||||
const wrapper = mount(<QueryEditor {...props} />); |
||||
const { |
||||
query: { namespace, region, metricName, dimensions, statistics, expression }, |
||||
} = wrapper.props(); |
||||
expect(namespace).toEqual(''); |
||||
expect(metricName).toEqual(''); |
||||
expect(expression).toEqual(''); |
||||
expect(region).toEqual('default'); |
||||
expect(statistics).toEqual(['Average']); |
||||
expect(dimensions).toEqual({}); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,277 @@ |
||||
import React, { PureComponent, ChangeEvent } from 'react'; |
||||
import { SelectableValue, QueryEditorProps } from '@grafana/data'; |
||||
import { Input, Segment, SegmentAsync, ValidationEvents, EventsWithValidation, Switch } from '@grafana/ui'; |
||||
import { CloudWatchQuery } from '../types'; |
||||
import CloudWatchDatasource from '../datasource'; |
||||
import { SelectableStrings } from '../types'; |
||||
import { Stats, Dimensions, QueryInlineField, QueryField, Alias } from './'; |
||||
|
||||
export type Props = QueryEditorProps<CloudWatchDatasource, CloudWatchQuery>; |
||||
|
||||
interface State { |
||||
regions: SelectableStrings; |
||||
namespaces: SelectableStrings; |
||||
metricNames: SelectableStrings; |
||||
variableOptionGroup: SelectableValue<string>; |
||||
showMeta: boolean; |
||||
} |
||||
|
||||
const idValidationEvents: ValidationEvents = { |
||||
[EventsWithValidation.onBlur]: [ |
||||
{ |
||||
rule: value => new RegExp(/^$|^[a-z][a-zA-Z0-9_]*$/).test(value), |
||||
errorMessage: 'Invalid format. Only alphanumeric characters and underscores are allowed', |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
export class QueryEditor extends PureComponent<Props, State> { |
||||
state: State = { regions: [], namespaces: [], metricNames: [], variableOptionGroup: {}, showMeta: false }; |
||||
|
||||
componentWillMount() { |
||||
const { query } = this.props; |
||||
|
||||
if (!query.namespace) { |
||||
query.namespace = ''; |
||||
} |
||||
|
||||
if (!query.metricName) { |
||||
query.metricName = ''; |
||||
} |
||||
|
||||
if (!query.expression) { |
||||
query.expression = ''; |
||||
} |
||||
|
||||
if (!query.dimensions) { |
||||
query.dimensions = {}; |
||||
} |
||||
|
||||
if (!query.region) { |
||||
query.region = 'default'; |
||||
} |
||||
|
||||
if (!query.statistics || !query.statistics.length) { |
||||
query.statistics = ['Average']; |
||||
} |
||||
|
||||
if (!query.hasOwnProperty('highResolution')) { |
||||
query.highResolution = false; |
||||
} |
||||
|
||||
if (!query.hasOwnProperty('matchExact')) { |
||||
query.matchExact = true; |
||||
} |
||||
} |
||||
|
||||
componentDidMount() { |
||||
const { datasource } = this.props; |
||||
const variableOptionGroup = { |
||||
label: 'Template Variables', |
||||
options: this.props.datasource.variables.map(this.toOption), |
||||
}; |
||||
Promise.all([datasource.metricFindQuery('regions()'), datasource.metricFindQuery('namespaces()')]).then( |
||||
([regions, namespaces]) => { |
||||
this.setState({ |
||||
...this.state, |
||||
regions: [...regions, variableOptionGroup], |
||||
namespaces: [...namespaces, variableOptionGroup], |
||||
variableOptionGroup, |
||||
}); |
||||
} |
||||
); |
||||
} |
||||
|
||||
loadMetricNames = async () => { |
||||
const { namespace, region } = this.props.query; |
||||
return this.props.datasource.metricFindQuery(`metrics(${namespace},${region})`).then(this.appendTemplateVariables); |
||||
}; |
||||
|
||||
appendTemplateVariables = (values: SelectableValue[]) => [ |
||||
...values, |
||||
{ label: 'Template Variables', options: this.props.datasource.variables.map(this.toOption) }, |
||||
]; |
||||
|
||||
toOption = (value: any) => ({ label: value, value }); |
||||
|
||||
onChange(query: CloudWatchQuery) { |
||||
const { onChange, onRunQuery } = this.props; |
||||
onChange(query); |
||||
onRunQuery(); |
||||
} |
||||
|
||||
render() { |
||||
const { query, datasource, onChange, onRunQuery, data } = this.props; |
||||
const { regions, namespaces, variableOptionGroup: variableOptionGroup, showMeta } = this.state; |
||||
const metaDataExist = data && Object.values(data).length && data.state === 'Done'; |
||||
return ( |
||||
<> |
||||
<QueryInlineField label="Region"> |
||||
<Segment |
||||
value={query.region || 'Select region'} |
||||
options={regions} |
||||
allowCustomValue |
||||
onChange={region => this.onChange({ ...query, region })} |
||||
/> |
||||
</QueryInlineField> |
||||
|
||||
{query.expression.length === 0 && ( |
||||
<> |
||||
<QueryInlineField label="Namespace"> |
||||
<Segment |
||||
value={query.namespace || 'Select namespace'} |
||||
allowCustomValue |
||||
options={namespaces} |
||||
onChange={namespace => this.onChange({ ...query, namespace })} |
||||
/> |
||||
</QueryInlineField> |
||||
|
||||
<QueryInlineField label="Metric Name"> |
||||
<SegmentAsync |
||||
value={query.metricName || 'Select metric name'} |
||||
allowCustomValue |
||||
loadOptions={this.loadMetricNames} |
||||
onChange={metricName => this.onChange({ ...query, metricName })} |
||||
/> |
||||
</QueryInlineField> |
||||
|
||||
<QueryInlineField label="Stats"> |
||||
<Stats |
||||
stats={datasource.standardStatistics.map(this.toOption)} |
||||
values={query.statistics} |
||||
onChange={statistics => this.onChange({ ...query, statistics })} |
||||
variableOptionGroup={variableOptionGroup} |
||||
/> |
||||
</QueryInlineField> |
||||
|
||||
<QueryInlineField label="Dimensions"> |
||||
<Dimensions |
||||
dimensions={query.dimensions} |
||||
onChange={dimensions => this.onChange({ ...query, dimensions })} |
||||
loadKeys={() => |
||||
datasource.getDimensionKeys(query.namespace, query.region).then(this.appendTemplateVariables) |
||||
} |
||||
loadValues={newKey => { |
||||
const { [newKey]: value, ...newDimensions } = query.dimensions; |
||||
return datasource |
||||
.getDimensionValues(query.region, query.namespace, query.metricName, newKey, newDimensions) |
||||
.then(this.appendTemplateVariables); |
||||
}} |
||||
/> |
||||
</QueryInlineField> |
||||
</> |
||||
)} |
||||
{query.statistics.length <= 1 && ( |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form"> |
||||
<QueryField |
||||
className="query-keyword" |
||||
label="Id" |
||||
tooltip="Id can include numbers, letters, and underscore, and must start with a lowercase letter." |
||||
> |
||||
<Input |
||||
className="gf-form-input width-8" |
||||
onBlur={onRunQuery} |
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => onChange({ ...query, id: event.target.value })} |
||||
validationEvents={idValidationEvents} |
||||
value={query.id || ''} |
||||
/> |
||||
</QueryField> |
||||
</div> |
||||
<div className="gf-form gf-form--grow"> |
||||
<QueryField |
||||
className="gf-form--grow" |
||||
label="Expression" |
||||
tooltip="Optionally you can add an expression here. Please note that if a math expression that is referencing other queries is being used, it will not be possible to create an alert rule based on this query" |
||||
> |
||||
<Input |
||||
className="gf-form-input" |
||||
onBlur={onRunQuery} |
||||
value={query.expression || ''} |
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => |
||||
onChange({ ...query, expression: event.target.value }) |
||||
} |
||||
/> |
||||
</QueryField> |
||||
</div> |
||||
</div> |
||||
)} |
||||
<div className="gf-form-inline"> |
||||
<div className="gf-form"> |
||||
<QueryField |
||||
className="query-keyword" |
||||
label="Min Period" |
||||
tooltip="Minimum interval between points in seconds" |
||||
> |
||||
<Input |
||||
className="gf-form-input width-8" |
||||
value={query.period || ''} |
||||
placeholder="auto" |
||||
onBlur={onRunQuery} |
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => onChange({ ...query, period: event.target.value })} |
||||
/> |
||||
</QueryField> |
||||
</div> |
||||
<div className="gf-form"> |
||||
<QueryField |
||||
className="query-keyword" |
||||
label="Alias" |
||||
tooltip="Alias replacement variables: {{metric}}, {{stat}}, {{namespace}}, {{region}}, {{period}}, {{label}}, {{YOUR_DIMENSION_NAME}}" |
||||
> |
||||
<Alias value={query.alias} onChange={(value: string) => this.onChange({ ...query, alias: value })} /> |
||||
</QueryField> |
||||
<Switch |
||||
label="HighRes" |
||||
labelClass="query-keyword" |
||||
checked={query.highResolution} |
||||
onChange={() => this.onChange({ ...query, highResolution: !query.highResolution })} |
||||
/> |
||||
<Switch |
||||
label="Match Exact" |
||||
labelClass="query-keyword" |
||||
tooltip="Only show metrics that exactly match all defined dimension names." |
||||
checked={query.matchExact} |
||||
onChange={() => this.onChange({ ...query, matchExact: !query.matchExact })} |
||||
/> |
||||
<label className="gf-form-label"> |
||||
<a |
||||
onClick={() => |
||||
metaDataExist && |
||||
this.setState({ |
||||
...this.state, |
||||
showMeta: !showMeta, |
||||
}) |
||||
} |
||||
> |
||||
<i className={`fa fa-caret-${showMeta ? 'down' : 'right'}`} /> {showMeta ? 'Hide' : 'Show'} Query |
||||
Preview |
||||
</a> |
||||
</label> |
||||
</div> |
||||
<div className="gf-form gf-form--grow"> |
||||
<div className="gf-form-label gf-form-label--grow" /> |
||||
</div> |
||||
{showMeta && metaDataExist && ( |
||||
<table className="filter-table form-inline"> |
||||
<thead> |
||||
<tr> |
||||
<th>Metric Data Query ID</th> |
||||
<th>Metric Data Query Expression</th> |
||||
<th /> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{data.series[0].meta.gmdMeta.map(({ ID, Expression }: any) => ( |
||||
<tr key={ID}> |
||||
<td>{ID}</td> |
||||
<td>{Expression}</td> |
||||
</tr> |
||||
))} |
||||
</tbody> |
||||
</table> |
||||
)} |
||||
</div> |
||||
</> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,21 @@ |
||||
import React from 'react'; |
||||
import renderer from 'react-test-renderer'; |
||||
import { Stats } from './Stats'; |
||||
|
||||
const toOption = (value: any) => ({ label: value, value }); |
||||
|
||||
describe('Stats', () => { |
||||
it('should render component', () => { |
||||
const tree = renderer |
||||
.create( |
||||
<Stats |
||||
values={['Average', 'Minimum']} |
||||
variableOptionGroup={{ label: 'templateVar', value: 'templateVar' }} |
||||
onChange={() => {}} |
||||
stats={['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount'].map(toOption)} |
||||
/> |
||||
) |
||||
.toJSON(); |
||||
expect(tree).toMatchSnapshot(); |
||||
}); |
||||
}); |
@ -0,0 +1,47 @@ |
||||
import React, { FunctionComponent } from 'react'; |
||||
import { SelectableStrings } from '../types'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { Segment } from '@grafana/ui'; |
||||
|
||||
export interface Props { |
||||
values: string[]; |
||||
onChange: (values: string[]) => void; |
||||
variableOptionGroup: SelectableValue<string>; |
||||
stats: SelectableStrings; |
||||
} |
||||
|
||||
const removeText = '-- remove stat --'; |
||||
const removeOption: SelectableValue<string> = { label: removeText, value: removeText }; |
||||
|
||||
export const Stats: FunctionComponent<Props> = ({ stats, values, onChange, variableOptionGroup }) => ( |
||||
<> |
||||
{values && |
||||
values.map((value, index) => ( |
||||
<Segment |
||||
allowCustomValue |
||||
key={value + index} |
||||
value={value} |
||||
options={[removeOption, ...stats, variableOptionGroup]} |
||||
onChange={value => |
||||
onChange( |
||||
value === removeText |
||||
? values.filter((_, i) => i !== index) |
||||
: values.map((v, i) => (i === index ? value : v)) |
||||
) |
||||
} |
||||
/> |
||||
))} |
||||
{values.length !== stats.length && ( |
||||
<Segment |
||||
Component={ |
||||
<a className="gf-form-label query-part"> |
||||
<i className="fa fa-plus" /> |
||||
</a> |
||||
} |
||||
allowCustomValue |
||||
onChange={(value: string) => onChange([...values, value])} |
||||
options={[...stats.filter(({ value }) => !values.includes(value)), variableOptionGroup]} |
||||
/> |
||||
)} |
||||
</> |
||||
); |
@ -0,0 +1,27 @@ |
||||
import React, { FunctionComponent } from 'react'; |
||||
|
||||
export interface Props { |
||||
region: string; |
||||
} |
||||
|
||||
export const ThrottlingErrorMessage: FunctionComponent<Props> = ({ region }) => ( |
||||
<p> |
||||
Please visit the |
||||
<a |
||||
target="_blank" |
||||
className="text-link" |
||||
href={`https://${region}.console.aws.amazon.com/servicequotas/home?region=${region}#!/services/monitoring/quotas/L-5E141212`} |
||||
> |
||||
AWS Service Quotas console |
||||
</a> |
||||
to request a quota increase or see our |
||||
<a |
||||
target="_blank" |
||||
className="text-link" |
||||
href={`https://grafana.com/docs/features/datasources/cloudwatch/#service-quotas`} |
||||
> |
||||
documentation |
||||
</a> |
||||
to learn more. |
||||
</p> |
||||
); |
@ -0,0 +1,18 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`Alias should render component 1`] = ` |
||||
<div |
||||
style={ |
||||
Object { |
||||
"flexGrow": 1, |
||||
} |
||||
} |
||||
> |
||||
<input |
||||
className="gf-form-input gf-form-input width-16" |
||||
onChange={[Function]} |
||||
type="text" |
||||
value="legend" |
||||
/> |
||||
</div> |
||||
`; |
@ -0,0 +1,901 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`Render should disable access key id field 1`] = ` |
||||
<Fragment> |
||||
<h3 |
||||
className="page-heading" |
||||
> |
||||
CloudWatch Details |
||||
</h3> |
||||
<div |
||||
className="gf-form-group" |
||||
> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Auth Provider |
||||
</Component> |
||||
<Select |
||||
allowCustomValue={false} |
||||
autoFocus={false} |
||||
backspaceRemovesValue={true} |
||||
className="width-30" |
||||
components={ |
||||
Object { |
||||
"Group": [Function], |
||||
"IndicatorsContainer": [Function], |
||||
"MenuList": [Function], |
||||
"Option": [Function], |
||||
"SingleValue": [Function], |
||||
} |
||||
} |
||||
defaultValue="keys" |
||||
isClearable={false} |
||||
isDisabled={false} |
||||
isLoading={false} |
||||
isMulti={false} |
||||
isSearchable={true} |
||||
maxMenuHeight={300} |
||||
onChange={[Function]} |
||||
openMenuOnFocus={false} |
||||
options={ |
||||
Array [ |
||||
Object { |
||||
"label": "Access & secret key", |
||||
"value": "keys", |
||||
}, |
||||
Object { |
||||
"label": "Credentials file", |
||||
"value": "credentials", |
||||
}, |
||||
Object { |
||||
"label": "ARN", |
||||
"value": "arn", |
||||
}, |
||||
] |
||||
} |
||||
tabSelectsValue={true} |
||||
value={ |
||||
Object { |
||||
"label": "Access & secret key", |
||||
"value": "keys", |
||||
} |
||||
} |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Access Key ID |
||||
</Component> |
||||
<div |
||||
className="width-30" |
||||
> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Secret Access Key |
||||
</Component> |
||||
<div |
||||
className="width-30" |
||||
> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
tooltip="Specify the region, such as for US West (Oregon) use \` us-west-2 \` as the region." |
||||
> |
||||
Default Region |
||||
</Component> |
||||
<Select |
||||
allowCustomValue={false} |
||||
autoFocus={false} |
||||
backspaceRemovesValue={true} |
||||
className="width-30" |
||||
components={ |
||||
Object { |
||||
"Group": [Function], |
||||
"IndicatorsContainer": [Function], |
||||
"MenuList": [Function], |
||||
"Option": [Function], |
||||
"SingleValue": [Function], |
||||
} |
||||
} |
||||
defaultValue="us-east-2" |
||||
isClearable={false} |
||||
isDisabled={false} |
||||
isLoading={false} |
||||
isMulti={false} |
||||
isSearchable={true} |
||||
maxMenuHeight={300} |
||||
onChange={[Function]} |
||||
openMenuOnFocus={false} |
||||
options={Array []} |
||||
tabSelectsValue={true} |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
tooltip="Namespaces of Custom Metrics." |
||||
> |
||||
Custom Metrics |
||||
</Component> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
placeholder="Namespace1,Namespace2" |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</Fragment> |
||||
`; |
||||
|
||||
exports[`Render should render component 1`] = ` |
||||
<Fragment> |
||||
<h3 |
||||
className="page-heading" |
||||
> |
||||
CloudWatch Details |
||||
</h3> |
||||
<div |
||||
className="gf-form-group" |
||||
> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Auth Provider |
||||
</Component> |
||||
<Select |
||||
allowCustomValue={false} |
||||
autoFocus={false} |
||||
backspaceRemovesValue={true} |
||||
className="width-30" |
||||
components={ |
||||
Object { |
||||
"Group": [Function], |
||||
"IndicatorsContainer": [Function], |
||||
"MenuList": [Function], |
||||
"Option": [Function], |
||||
"SingleValue": [Function], |
||||
} |
||||
} |
||||
defaultValue="keys" |
||||
isClearable={false} |
||||
isDisabled={false} |
||||
isLoading={false} |
||||
isMulti={false} |
||||
isSearchable={true} |
||||
maxMenuHeight={300} |
||||
onChange={[Function]} |
||||
openMenuOnFocus={false} |
||||
options={ |
||||
Array [ |
||||
Object { |
||||
"label": "Access & secret key", |
||||
"value": "keys", |
||||
}, |
||||
Object { |
||||
"label": "Credentials file", |
||||
"value": "credentials", |
||||
}, |
||||
Object { |
||||
"label": "ARN", |
||||
"value": "arn", |
||||
}, |
||||
] |
||||
} |
||||
tabSelectsValue={true} |
||||
value={ |
||||
Object { |
||||
"label": "Access & secret key", |
||||
"value": "keys", |
||||
} |
||||
} |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Access Key ID |
||||
</Component> |
||||
<div |
||||
className="width-30" |
||||
> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Secret Access Key |
||||
</Component> |
||||
<div |
||||
className="width-30" |
||||
> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
tooltip="Specify the region, such as for US West (Oregon) use \` us-west-2 \` as the region." |
||||
> |
||||
Default Region |
||||
</Component> |
||||
<Select |
||||
allowCustomValue={false} |
||||
autoFocus={false} |
||||
backspaceRemovesValue={true} |
||||
className="width-30" |
||||
components={ |
||||
Object { |
||||
"Group": [Function], |
||||
"IndicatorsContainer": [Function], |
||||
"MenuList": [Function], |
||||
"Option": [Function], |
||||
"SingleValue": [Function], |
||||
} |
||||
} |
||||
defaultValue="us-east-2" |
||||
isClearable={false} |
||||
isDisabled={false} |
||||
isLoading={false} |
||||
isMulti={false} |
||||
isSearchable={true} |
||||
maxMenuHeight={300} |
||||
onChange={[Function]} |
||||
openMenuOnFocus={false} |
||||
options={Array []} |
||||
tabSelectsValue={true} |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
tooltip="Namespaces of Custom Metrics." |
||||
> |
||||
Custom Metrics |
||||
</Component> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
placeholder="Namespace1,Namespace2" |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</Fragment> |
||||
`; |
||||
|
||||
exports[`Render should should show access key and secret access key fields 1`] = ` |
||||
<Fragment> |
||||
<h3 |
||||
className="page-heading" |
||||
> |
||||
CloudWatch Details |
||||
</h3> |
||||
<div |
||||
className="gf-form-group" |
||||
> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Auth Provider |
||||
</Component> |
||||
<Select |
||||
allowCustomValue={false} |
||||
autoFocus={false} |
||||
backspaceRemovesValue={true} |
||||
className="width-30" |
||||
components={ |
||||
Object { |
||||
"Group": [Function], |
||||
"IndicatorsContainer": [Function], |
||||
"MenuList": [Function], |
||||
"Option": [Function], |
||||
"SingleValue": [Function], |
||||
} |
||||
} |
||||
defaultValue="keys" |
||||
isClearable={false} |
||||
isDisabled={false} |
||||
isLoading={false} |
||||
isMulti={false} |
||||
isSearchable={true} |
||||
maxMenuHeight={300} |
||||
onChange={[Function]} |
||||
openMenuOnFocus={false} |
||||
options={ |
||||
Array [ |
||||
Object { |
||||
"label": "Access & secret key", |
||||
"value": "keys", |
||||
}, |
||||
Object { |
||||
"label": "Credentials file", |
||||
"value": "credentials", |
||||
}, |
||||
Object { |
||||
"label": "ARN", |
||||
"value": "arn", |
||||
}, |
||||
] |
||||
} |
||||
tabSelectsValue={true} |
||||
value={ |
||||
Object { |
||||
"label": "Access & secret key", |
||||
"value": "keys", |
||||
} |
||||
} |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Access Key ID |
||||
</Component> |
||||
<div |
||||
className="width-30" |
||||
> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Secret Access Key |
||||
</Component> |
||||
<div |
||||
className="width-30" |
||||
> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
tooltip="Specify the region, such as for US West (Oregon) use \` us-west-2 \` as the region." |
||||
> |
||||
Default Region |
||||
</Component> |
||||
<Select |
||||
allowCustomValue={false} |
||||
autoFocus={false} |
||||
backspaceRemovesValue={true} |
||||
className="width-30" |
||||
components={ |
||||
Object { |
||||
"Group": [Function], |
||||
"IndicatorsContainer": [Function], |
||||
"MenuList": [Function], |
||||
"Option": [Function], |
||||
"SingleValue": [Function], |
||||
} |
||||
} |
||||
defaultValue="us-east-2" |
||||
isClearable={false} |
||||
isDisabled={false} |
||||
isLoading={false} |
||||
isMulti={false} |
||||
isSearchable={true} |
||||
maxMenuHeight={300} |
||||
onChange={[Function]} |
||||
openMenuOnFocus={false} |
||||
options={Array []} |
||||
tabSelectsValue={true} |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
tooltip="Namespaces of Custom Metrics." |
||||
> |
||||
Custom Metrics |
||||
</Component> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
placeholder="Namespace1,Namespace2" |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</Fragment> |
||||
`; |
||||
|
||||
exports[`Render should should show arn role field 1`] = ` |
||||
<Fragment> |
||||
<h3 |
||||
className="page-heading" |
||||
> |
||||
CloudWatch Details |
||||
</h3> |
||||
<div |
||||
className="gf-form-group" |
||||
> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Auth Provider |
||||
</Component> |
||||
<Select |
||||
allowCustomValue={false} |
||||
autoFocus={false} |
||||
backspaceRemovesValue={true} |
||||
className="width-30" |
||||
components={ |
||||
Object { |
||||
"Group": [Function], |
||||
"IndicatorsContainer": [Function], |
||||
"MenuList": [Function], |
||||
"Option": [Function], |
||||
"SingleValue": [Function], |
||||
} |
||||
} |
||||
defaultValue="keys" |
||||
isClearable={false} |
||||
isDisabled={false} |
||||
isLoading={false} |
||||
isMulti={false} |
||||
isSearchable={true} |
||||
maxMenuHeight={300} |
||||
onChange={[Function]} |
||||
openMenuOnFocus={false} |
||||
options={ |
||||
Array [ |
||||
Object { |
||||
"label": "Access & secret key", |
||||
"value": "keys", |
||||
}, |
||||
Object { |
||||
"label": "Credentials file", |
||||
"value": "credentials", |
||||
}, |
||||
Object { |
||||
"label": "ARN", |
||||
"value": "arn", |
||||
}, |
||||
] |
||||
} |
||||
tabSelectsValue={true} |
||||
value={ |
||||
Object { |
||||
"label": "Access & secret key", |
||||
"value": "keys", |
||||
} |
||||
} |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Access Key ID |
||||
</Component> |
||||
<div |
||||
className="width-30" |
||||
> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Secret Access Key |
||||
</Component> |
||||
<div |
||||
className="width-30" |
||||
> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
tooltip="Specify the region, such as for US West (Oregon) use \` us-west-2 \` as the region." |
||||
> |
||||
Default Region |
||||
</Component> |
||||
<Select |
||||
allowCustomValue={false} |
||||
autoFocus={false} |
||||
backspaceRemovesValue={true} |
||||
className="width-30" |
||||
components={ |
||||
Object { |
||||
"Group": [Function], |
||||
"IndicatorsContainer": [Function], |
||||
"MenuList": [Function], |
||||
"Option": [Function], |
||||
"SingleValue": [Function], |
||||
} |
||||
} |
||||
defaultValue="us-east-2" |
||||
isClearable={false} |
||||
isDisabled={false} |
||||
isLoading={false} |
||||
isMulti={false} |
||||
isSearchable={true} |
||||
maxMenuHeight={300} |
||||
onChange={[Function]} |
||||
openMenuOnFocus={false} |
||||
options={Array []} |
||||
tabSelectsValue={true} |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
tooltip="Namespaces of Custom Metrics." |
||||
> |
||||
Custom Metrics |
||||
</Component> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
placeholder="Namespace1,Namespace2" |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</Fragment> |
||||
`; |
||||
|
||||
exports[`Render should should show credentials profile name field 1`] = ` |
||||
<Fragment> |
||||
<h3 |
||||
className="page-heading" |
||||
> |
||||
CloudWatch Details |
||||
</h3> |
||||
<div |
||||
className="gf-form-group" |
||||
> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Auth Provider |
||||
</Component> |
||||
<Select |
||||
allowCustomValue={false} |
||||
autoFocus={false} |
||||
backspaceRemovesValue={true} |
||||
className="width-30" |
||||
components={ |
||||
Object { |
||||
"Group": [Function], |
||||
"IndicatorsContainer": [Function], |
||||
"MenuList": [Function], |
||||
"Option": [Function], |
||||
"SingleValue": [Function], |
||||
} |
||||
} |
||||
defaultValue="keys" |
||||
isClearable={false} |
||||
isDisabled={false} |
||||
isLoading={false} |
||||
isMulti={false} |
||||
isSearchable={true} |
||||
maxMenuHeight={300} |
||||
onChange={[Function]} |
||||
openMenuOnFocus={false} |
||||
options={ |
||||
Array [ |
||||
Object { |
||||
"label": "Access & secret key", |
||||
"value": "keys", |
||||
}, |
||||
Object { |
||||
"label": "Credentials file", |
||||
"value": "credentials", |
||||
}, |
||||
Object { |
||||
"label": "ARN", |
||||
"value": "arn", |
||||
}, |
||||
] |
||||
} |
||||
tabSelectsValue={true} |
||||
value={ |
||||
Object { |
||||
"label": "Access & secret key", |
||||
"value": "keys", |
||||
} |
||||
} |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Access Key ID |
||||
</Component> |
||||
<div |
||||
className="width-30" |
||||
> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
> |
||||
Secret Access Key |
||||
</Component> |
||||
<div |
||||
className="width-30" |
||||
> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
tooltip="Specify the region, such as for US West (Oregon) use \` us-west-2 \` as the region." |
||||
> |
||||
Default Region |
||||
</Component> |
||||
<Select |
||||
allowCustomValue={false} |
||||
autoFocus={false} |
||||
backspaceRemovesValue={true} |
||||
className="width-30" |
||||
components={ |
||||
Object { |
||||
"Group": [Function], |
||||
"IndicatorsContainer": [Function], |
||||
"MenuList": [Function], |
||||
"Option": [Function], |
||||
"SingleValue": [Function], |
||||
} |
||||
} |
||||
defaultValue="us-east-2" |
||||
isClearable={false} |
||||
isDisabled={false} |
||||
isLoading={false} |
||||
isMulti={false} |
||||
isSearchable={true} |
||||
maxMenuHeight={300} |
||||
onChange={[Function]} |
||||
openMenuOnFocus={false} |
||||
options={Array []} |
||||
tabSelectsValue={true} |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<Component |
||||
className="width-14" |
||||
tooltip="Namespaces of Custom Metrics." |
||||
> |
||||
Custom Metrics |
||||
</Component> |
||||
<Input |
||||
className="width-30" |
||||
onChange={[Function]} |
||||
placeholder="Namespace1,Namespace2" |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</Fragment> |
||||
`; |
@ -0,0 +1,396 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`QueryEditor should render component 1`] = ` |
||||
Array [ |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<label |
||||
className="gf-form-label width-8 query-keyword" |
||||
> |
||||
Region |
||||
</label> |
||||
<div |
||||
className="gf-form" |
||||
onClick={[Function]} |
||||
> |
||||
<a |
||||
className="gf-form-label query-part" |
||||
> |
||||
us-east-1 |
||||
</a> |
||||
</div> |
||||
<div |
||||
className="gf-form gf-form--grow" |
||||
> |
||||
<div |
||||
className="gf-form-label gf-form-label--grow" |
||||
/> |
||||
</div> |
||||
</div>, |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<label |
||||
className="gf-form-label width-8 query-keyword" |
||||
> |
||||
Namespace |
||||
</label> |
||||
<div |
||||
className="gf-form" |
||||
onClick={[Function]} |
||||
> |
||||
<a |
||||
className="gf-form-label query-part" |
||||
> |
||||
ec2 |
||||
</a> |
||||
</div> |
||||
<div |
||||
className="gf-form gf-form--grow" |
||||
> |
||||
<div |
||||
className="gf-form-label gf-form-label--grow" |
||||
/> |
||||
</div> |
||||
</div>, |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<label |
||||
className="gf-form-label width-8 query-keyword" |
||||
> |
||||
Metric Name |
||||
</label> |
||||
<div |
||||
className="gf-form" |
||||
onClick={[Function]} |
||||
> |
||||
<a |
||||
className="gf-form-label query-part" |
||||
> |
||||
CPUUtilization |
||||
</a> |
||||
</div> |
||||
<div |
||||
className="gf-form gf-form--grow" |
||||
> |
||||
<div |
||||
className="gf-form-label gf-form-label--grow" |
||||
/> |
||||
</div> |
||||
</div>, |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<label |
||||
className="gf-form-label width-8 query-keyword" |
||||
> |
||||
Stats |
||||
</label> |
||||
<div |
||||
className="gf-form" |
||||
onClick={[Function]} |
||||
> |
||||
<a |
||||
className="gf-form-label query-part" |
||||
> |
||||
Average |
||||
</a> |
||||
</div> |
||||
<div |
||||
className="gf-form" |
||||
onClick={[Function]} |
||||
> |
||||
<a |
||||
className="gf-form-label query-part" |
||||
> |
||||
<i |
||||
className="fa fa-plus" |
||||
/> |
||||
</a> |
||||
</div> |
||||
<div |
||||
className="gf-form gf-form--grow" |
||||
> |
||||
<div |
||||
className="gf-form-label gf-form-label--grow" |
||||
/> |
||||
</div> |
||||
</div>, |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<label |
||||
className="gf-form-label width-8 query-keyword" |
||||
> |
||||
Dimensions |
||||
</label> |
||||
<div |
||||
className="gf-form" |
||||
onClick={[Function]} |
||||
> |
||||
<a |
||||
className="gf-form-label query-part" |
||||
> |
||||
somekey |
||||
</a> |
||||
</div> |
||||
<label |
||||
className="gf-form-label query-segment-operator" |
||||
> |
||||
= |
||||
</label> |
||||
<div |
||||
className="gf-form" |
||||
onClick={[Function]} |
||||
> |
||||
<a |
||||
className="gf-form-label query-part" |
||||
> |
||||
somevalue |
||||
</a> |
||||
</div> |
||||
<div |
||||
className="gf-form" |
||||
onClick={[Function]} |
||||
> |
||||
<a |
||||
className="gf-form-label query-part" |
||||
> |
||||
<i |
||||
className="fa fa-plus" |
||||
/> |
||||
</a> |
||||
</div> |
||||
<div |
||||
className="gf-form gf-form--grow" |
||||
> |
||||
<div |
||||
className="gf-form-label gf-form-label--grow" |
||||
/> |
||||
</div> |
||||
</div>, |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<label |
||||
className="gf-form-label width-8 query-keyword" |
||||
> |
||||
Id |
||||
<div |
||||
className="gf-form-help-icon gf-form-help-icon--right-normal" |
||||
onMouseEnter={[Function]} |
||||
onMouseLeave={[Function]} |
||||
> |
||||
<i |
||||
className="fa fa-info-circle" |
||||
/> |
||||
</div> |
||||
</label> |
||||
<div |
||||
style={ |
||||
Object { |
||||
"flexGrow": 1, |
||||
} |
||||
} |
||||
> |
||||
<input |
||||
className="gf-form-input gf-form-input width-8" |
||||
onBlur={[Function]} |
||||
onChange={[Function]} |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form gf-form--grow" |
||||
> |
||||
<label |
||||
className="gf-form-label width-8 query-keyword" |
||||
> |
||||
Expression |
||||
<div |
||||
className="gf-form-help-icon gf-form-help-icon--right-normal" |
||||
onMouseEnter={[Function]} |
||||
onMouseLeave={[Function]} |
||||
> |
||||
<i |
||||
className="fa fa-info-circle" |
||||
/> |
||||
</div> |
||||
</label> |
||||
<div |
||||
style={ |
||||
Object { |
||||
"flexGrow": 1, |
||||
} |
||||
} |
||||
> |
||||
<input |
||||
className="gf-form-input gf-form-input" |
||||
onBlur={[MockFunction]} |
||||
onChange={[Function]} |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div>, |
||||
<div |
||||
className="gf-form-inline" |
||||
> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<label |
||||
className="gf-form-label width-8 query-keyword" |
||||
> |
||||
Min Period |
||||
<div |
||||
className="gf-form-help-icon gf-form-help-icon--right-normal" |
||||
onMouseEnter={[Function]} |
||||
onMouseLeave={[Function]} |
||||
> |
||||
<i |
||||
className="fa fa-info-circle" |
||||
/> |
||||
</div> |
||||
</label> |
||||
<div |
||||
style={ |
||||
Object { |
||||
"flexGrow": 1, |
||||
} |
||||
} |
||||
> |
||||
<input |
||||
className="gf-form-input gf-form-input width-8" |
||||
onBlur={[MockFunction]} |
||||
onChange={[Function]} |
||||
placeholder="auto" |
||||
value="" |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form" |
||||
> |
||||
<label |
||||
className="gf-form-label width-8 query-keyword" |
||||
> |
||||
Alias |
||||
<div |
||||
className="gf-form-help-icon gf-form-help-icon--right-normal" |
||||
onMouseEnter={[Function]} |
||||
onMouseLeave={[Function]} |
||||
> |
||||
<i |
||||
className="fa fa-info-circle" |
||||
/> |
||||
</div> |
||||
</label> |
||||
<div |
||||
style={ |
||||
Object { |
||||
"flexGrow": 1, |
||||
} |
||||
} |
||||
> |
||||
<input |
||||
className="gf-form-input gf-form-input width-16" |
||||
onChange={[Function]} |
||||
type="text" |
||||
value="" |
||||
/> |
||||
</div> |
||||
<div |
||||
className="gf-form-switch-container-react" |
||||
> |
||||
<label |
||||
className="gf-form gf-form-switch-container " |
||||
htmlFor="1" |
||||
> |
||||
<div |
||||
className="gf-form-label query-keyword pointer" |
||||
> |
||||
HighRes |
||||
</div> |
||||
<div |
||||
className="gf-form-switch " |
||||
> |
||||
<input |
||||
checked={false} |
||||
id="1" |
||||
onChange={[Function]} |
||||
type="checkbox" |
||||
/> |
||||
<span |
||||
className="gf-form-switch__slider" |
||||
/> |
||||
</div> |
||||
</label> |
||||
</div> |
||||
<div |
||||
className="gf-form-switch-container-react" |
||||
> |
||||
<label |
||||
className="gf-form gf-form-switch-container " |
||||
htmlFor="2" |
||||
> |
||||
<div |
||||
className="gf-form-label query-keyword pointer" |
||||
> |
||||
Match Exact |
||||
<div |
||||
className="gf-form-help-icon gf-form-help-icon--right-normal" |
||||
onMouseEnter={[Function]} |
||||
onMouseLeave={[Function]} |
||||
> |
||||
<i |
||||
className="fa fa-info-circle" |
||||
/> |
||||
</div> |
||||
</div> |
||||
<div |
||||
className="gf-form-switch " |
||||
> |
||||
<input |
||||
checked={true} |
||||
id="2" |
||||
onChange={[Function]} |
||||
type="checkbox" |
||||
/> |
||||
<span |
||||
className="gf-form-switch__slider" |
||||
/> |
||||
</div> |
||||
</label> |
||||
</div> |
||||
<label |
||||
className="gf-form-label" |
||||
> |
||||
<a |
||||
onClick={[Function]} |
||||
> |
||||
<i |
||||
className="fa fa-caret-right" |
||||
/> |
||||
|
||||
Show |
||||
Query Preview |
||||
</a> |
||||
</label> |
||||
</div> |
||||
<div |
||||
className="gf-form gf-form--grow" |
||||
> |
||||
<div |
||||
className="gf-form-label gf-form-label--grow" |
||||
/> |
||||
</div> |
||||
</div>, |
||||
] |
||||
`; |
@ -0,0 +1,38 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`Stats should render component 1`] = ` |
||||
Array [ |
||||
<div |
||||
className="gf-form" |
||||
onClick={[Function]} |
||||
> |
||||
<a |
||||
className="gf-form-label query-part" |
||||
> |
||||
Average |
||||
</a> |
||||
</div>, |
||||
<div |
||||
className="gf-form" |
||||
onClick={[Function]} |
||||
> |
||||
<a |
||||
className="gf-form-label query-part" |
||||
> |
||||
Minimum |
||||
</a> |
||||
</div>, |
||||
<div |
||||
className="gf-form" |
||||
onClick={[Function]} |
||||
> |
||||
<a |
||||
className="gf-form-label query-part" |
||||
> |
||||
<i |
||||
className="fa fa-plus" |
||||
/> |
||||
</a> |
||||
</div>, |
||||
] |
||||
`; |
@ -0,0 +1,4 @@ |
||||
export { Stats } from './Stats'; |
||||
export { Dimensions } from './Dimensions'; |
||||
export { QueryInlineField, QueryField } from './Forms'; |
||||
export { Alias } from './Alias'; |
@ -1,89 +0,0 @@ |
||||
import _ from 'lodash'; |
||||
import DatasourceSrv from 'app/features/plugins/datasource_srv'; |
||||
import CloudWatchDatasource from './datasource'; |
||||
export class CloudWatchConfigCtrl { |
||||
static templateUrl = 'partials/config.html'; |
||||
current: any; |
||||
datasourceSrv: any; |
||||
|
||||
accessKeyExist = false; |
||||
secretKeyExist = false; |
||||
|
||||
/** @ngInject */ |
||||
constructor($scope: any, datasourceSrv: DatasourceSrv) { |
||||
this.current.jsonData.timeField = this.current.jsonData.timeField || '@timestamp'; |
||||
this.current.jsonData.authType = this.current.jsonData.authType || 'credentials'; |
||||
|
||||
this.accessKeyExist = this.current.secureJsonFields.accessKey; |
||||
this.secretKeyExist = this.current.secureJsonFields.secretKey; |
||||
this.datasourceSrv = datasourceSrv; |
||||
this.getRegions(); |
||||
} |
||||
|
||||
resetAccessKey() { |
||||
this.accessKeyExist = false; |
||||
} |
||||
|
||||
resetSecretKey() { |
||||
this.secretKeyExist = false; |
||||
} |
||||
|
||||
authTypes = [ |
||||
{ name: 'Access & secret key', value: 'keys' }, |
||||
{ name: 'Credentials file', value: 'credentials' }, |
||||
{ name: 'ARN', value: 'arn' }, |
||||
]; |
||||
|
||||
indexPatternTypes: any = [ |
||||
{ name: 'No pattern', value: undefined }, |
||||
{ name: 'Hourly', value: 'Hourly', example: '[logstash-]YYYY.MM.DD.HH' }, |
||||
{ name: 'Daily', value: 'Daily', example: '[logstash-]YYYY.MM.DD' }, |
||||
{ name: 'Weekly', value: 'Weekly', example: '[logstash-]GGGG.WW' }, |
||||
{ name: 'Monthly', value: 'Monthly', example: '[logstash-]YYYY.MM' }, |
||||
{ name: 'Yearly', value: 'Yearly', example: '[logstash-]YYYY' }, |
||||
]; |
||||
|
||||
regions = [ |
||||
'ap-east-1', |
||||
'ap-northeast-1', |
||||
'ap-northeast-2', |
||||
'ap-northeast-3', |
||||
'ap-south-1', |
||||
'ap-southeast-1', |
||||
'ap-southeast-2', |
||||
'ca-central-1', |
||||
'cn-north-1', |
||||
'cn-northwest-1', |
||||
'eu-central-1', |
||||
'eu-north-1', |
||||
'eu-west-1', |
||||
'eu-west-2', |
||||
'eu-west-3', |
||||
'me-south-1', |
||||
'sa-east-1', |
||||
'us-east-1', |
||||
'us-east-2', |
||||
'us-gov-east-1', |
||||
'us-gov-west-1', |
||||
'us-iso-east-1', |
||||
'us-isob-east-1', |
||||
'us-west-1', |
||||
'us-west-2', |
||||
]; |
||||
|
||||
getRegions() { |
||||
this.datasourceSrv |
||||
.loadDatasource(this.current.name) |
||||
.then((ds: CloudWatchDatasource) => { |
||||
return ds.getRegions(); |
||||
}) |
||||
.then( |
||||
(regions: any) => { |
||||
this.regions = _.map(regions, 'value'); |
||||
}, |
||||
(err: any) => { |
||||
console.error('failed to get latest regions'); |
||||
} |
||||
); |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,545 @@ |
||||
{ |
||||
"__inputs": [ |
||||
{ |
||||
"name": "DS_CLOUDWATCH", |
||||
"label": "CloudWatch", |
||||
"description": "", |
||||
"type": "datasource", |
||||
"pluginId": "cloudwatch", |
||||
"pluginName": "CloudWatch" |
||||
} |
||||
], |
||||
"__requires": [ |
||||
{ |
||||
"type": "datasource", |
||||
"id": "cloudwatch", |
||||
"name": "CloudWatch", |
||||
"version": "1.0.0" |
||||
}, |
||||
{ |
||||
"type": "grafana", |
||||
"id": "grafana", |
||||
"name": "Grafana", |
||||
"version": "6.6.0-pre" |
||||
}, |
||||
{ |
||||
"type": "panel", |
||||
"id": "graph", |
||||
"name": "Graph", |
||||
"version": "" |
||||
} |
||||
], |
||||
"annotations": { |
||||
"list": [ |
||||
{ |
||||
"builtIn": 1, |
||||
"datasource": "-- Grafana --", |
||||
"enable": true, |
||||
"hide": true, |
||||
"iconColor": "rgba(0, 211, 255, 1)", |
||||
"name": "Annotations & Alerts", |
||||
"type": "dashboard" |
||||
} |
||||
] |
||||
}, |
||||
"editable": true, |
||||
"gnetId": null, |
||||
"graphTooltip": 0, |
||||
"id": null, |
||||
"iteration": 1573631164529, |
||||
"links": [], |
||||
"panels": [ |
||||
{ |
||||
"aliasColors": {}, |
||||
"bars": false, |
||||
"dashLength": 10, |
||||
"dashes": false, |
||||
"datasource": "$datasource", |
||||
"fill": 1, |
||||
"fillGradient": 0, |
||||
"gridPos": { |
||||
"h": 9, |
||||
"w": 12, |
||||
"x": 0, |
||||
"y": 0 |
||||
}, |
||||
"hiddenSeries": false, |
||||
"id": 2, |
||||
"legend": { |
||||
"alignAsTable": false, |
||||
"avg": false, |
||||
"current": false, |
||||
"max": false, |
||||
"min": false, |
||||
"show": true, |
||||
"total": false, |
||||
"values": false |
||||
}, |
||||
"lines": true, |
||||
"linewidth": 1, |
||||
"nullPointMode": "null", |
||||
"options": { |
||||
"dataLinks": [] |
||||
}, |
||||
"percentage": false, |
||||
"pointradius": 2, |
||||
"points": false, |
||||
"renderer": "flot", |
||||
"seriesOverrides": [], |
||||
"spaceLength": 10, |
||||
"stack": false, |
||||
"steppedLine": false, |
||||
"targets": [ |
||||
{ |
||||
"dimensions": { |
||||
"FunctionName": "$function" |
||||
}, |
||||
"expression": "", |
||||
"highResolution": false, |
||||
"matchExact": true, |
||||
"metricName": "Invocations", |
||||
"namespace": "AWS/Lambda", |
||||
"refId": "A", |
||||
"region": "$region", |
||||
"statistics": ["$statistic"] |
||||
} |
||||
], |
||||
"thresholds": [], |
||||
"timeFrom": null, |
||||
"timeRegions": [], |
||||
"timeShift": null, |
||||
"title": "Invocations $statistic", |
||||
"tooltip": { |
||||
"shared": true, |
||||
"sort": 0, |
||||
"value_type": "individual" |
||||
}, |
||||
"type": "graph", |
||||
"xaxis": { |
||||
"buckets": null, |
||||
"mode": "time", |
||||
"name": null, |
||||
"show": true, |
||||
"values": [] |
||||
}, |
||||
"yaxes": [ |
||||
{ |
||||
"format": "short", |
||||
"label": null, |
||||
"logBase": 1, |
||||
"max": null, |
||||
"min": null, |
||||
"show": true |
||||
}, |
||||
{ |
||||
"format": "short", |
||||
"label": null, |
||||
"logBase": 1, |
||||
"max": null, |
||||
"min": null, |
||||
"show": true |
||||
} |
||||
], |
||||
"yaxis": { |
||||
"align": false, |
||||
"alignLevel": null |
||||
} |
||||
}, |
||||
{ |
||||
"aliasColors": {}, |
||||
"bars": false, |
||||
"dashLength": 10, |
||||
"dashes": false, |
||||
"datasource": "$datasource", |
||||
"fill": 1, |
||||
"fillGradient": 0, |
||||
"gridPos": { |
||||
"h": 9, |
||||
"w": 12, |
||||
"x": 12, |
||||
"y": 0 |
||||
}, |
||||
"hiddenSeries": false, |
||||
"id": 3, |
||||
"legend": { |
||||
"alignAsTable": false, |
||||
"avg": false, |
||||
"current": false, |
||||
"max": false, |
||||
"min": false, |
||||
"show": true, |
||||
"total": false, |
||||
"values": false |
||||
}, |
||||
"lines": true, |
||||
"linewidth": 1, |
||||
"nullPointMode": "null", |
||||
"options": { |
||||
"dataLinks": [] |
||||
}, |
||||
"percentage": false, |
||||
"pointradius": 2, |
||||
"points": false, |
||||
"renderer": "flot", |
||||
"seriesOverrides": [], |
||||
"spaceLength": 10, |
||||
"stack": false, |
||||
"steppedLine": false, |
||||
"targets": [ |
||||
{ |
||||
"dimensions": { |
||||
"FunctionName": "$function" |
||||
}, |
||||
"expression": "", |
||||
"highResolution": false, |
||||
"matchExact": true, |
||||
"metricName": "Duration", |
||||
"namespace": "AWS/Lambda", |
||||
"refId": "A", |
||||
"region": "$region", |
||||
"statistics": ["Average"] |
||||
} |
||||
], |
||||
"thresholds": [], |
||||
"timeFrom": null, |
||||
"timeRegions": [], |
||||
"timeShift": null, |
||||
"title": "Duration Average", |
||||
"tooltip": { |
||||
"shared": true, |
||||
"sort": 0, |
||||
"value_type": "individual" |
||||
}, |
||||
"type": "graph", |
||||
"xaxis": { |
||||
"buckets": null, |
||||
"mode": "time", |
||||
"name": null, |
||||
"show": true, |
||||
"values": [] |
||||
}, |
||||
"yaxes": [ |
||||
{ |
||||
"format": "short", |
||||
"label": null, |
||||
"logBase": 1, |
||||
"max": null, |
||||
"min": null, |
||||
"show": true |
||||
}, |
||||
{ |
||||
"format": "short", |
||||
"label": null, |
||||
"logBase": 1, |
||||
"max": null, |
||||
"min": null, |
||||
"show": true |
||||
} |
||||
], |
||||
"yaxis": { |
||||
"align": false, |
||||
"alignLevel": null |
||||
} |
||||
}, |
||||
{ |
||||
"aliasColors": {}, |
||||
"bars": false, |
||||
"dashLength": 10, |
||||
"dashes": false, |
||||
"datasource": "$datasource", |
||||
"fill": 1, |
||||
"fillGradient": 0, |
||||
"gridPos": { |
||||
"h": 9, |
||||
"w": 12, |
||||
"x": 0, |
||||
"y": 9 |
||||
}, |
||||
"hiddenSeries": false, |
||||
"id": 4, |
||||
"legend": { |
||||
"alignAsTable": false, |
||||
"avg": false, |
||||
"current": false, |
||||
"max": false, |
||||
"min": false, |
||||
"show": true, |
||||
"total": false, |
||||
"values": false |
||||
}, |
||||
"lines": true, |
||||
"linewidth": 1, |
||||
"nullPointMode": "null", |
||||
"options": { |
||||
"dataLinks": [] |
||||
}, |
||||
"percentage": false, |
||||
"pointradius": 2, |
||||
"points": false, |
||||
"renderer": "flot", |
||||
"seriesOverrides": [], |
||||
"spaceLength": 10, |
||||
"stack": false, |
||||
"steppedLine": false, |
||||
"targets": [ |
||||
{ |
||||
"dimensions": { |
||||
"FunctionName": "$function" |
||||
}, |
||||
"expression": "", |
||||
"highResolution": false, |
||||
"matchExact": true, |
||||
"metricName": "Errors", |
||||
"namespace": "AWS/Lambda", |
||||
"refId": "A", |
||||
"region": "$region", |
||||
"statistics": ["Average"] |
||||
} |
||||
], |
||||
"thresholds": [], |
||||
"timeFrom": null, |
||||
"timeRegions": [], |
||||
"timeShift": null, |
||||
"title": "Errors $statistic", |
||||
"tooltip": { |
||||
"shared": true, |
||||
"sort": 0, |
||||
"value_type": "individual" |
||||
}, |
||||
"type": "graph", |
||||
"xaxis": { |
||||
"buckets": null, |
||||
"mode": "time", |
||||
"name": null, |
||||
"show": true, |
||||
"values": [] |
||||
}, |
||||
"yaxes": [ |
||||
{ |
||||
"format": "short", |
||||
"label": null, |
||||
"logBase": 1, |
||||
"max": null, |
||||
"min": null, |
||||
"show": true |
||||
}, |
||||
{ |
||||
"format": "short", |
||||
"label": null, |
||||
"logBase": 1, |
||||
"max": null, |
||||
"min": null, |
||||
"show": true |
||||
} |
||||
], |
||||
"yaxis": { |
||||
"align": false, |
||||
"alignLevel": null |
||||
} |
||||
}, |
||||
{ |
||||
"aliasColors": {}, |
||||
"bars": false, |
||||
"dashLength": 10, |
||||
"dashes": false, |
||||
"datasource": "$datasource", |
||||
"fill": 1, |
||||
"fillGradient": 0, |
||||
"gridPos": { |
||||
"h": 9, |
||||
"w": 12, |
||||
"x": 12, |
||||
"y": 9 |
||||
}, |
||||
"hiddenSeries": false, |
||||
"id": 5, |
||||
"legend": { |
||||
"alignAsTable": false, |
||||
"avg": false, |
||||
"current": false, |
||||
"max": false, |
||||
"min": false, |
||||
"show": true, |
||||
"total": false, |
||||
"values": false |
||||
}, |
||||
"lines": true, |
||||
"linewidth": 1, |
||||
"nullPointMode": "null", |
||||
"options": { |
||||
"dataLinks": [] |
||||
}, |
||||
"percentage": false, |
||||
"pointradius": 2, |
||||
"points": false, |
||||
"renderer": "flot", |
||||
"seriesOverrides": [], |
||||
"spaceLength": 10, |
||||
"stack": false, |
||||
"steppedLine": false, |
||||
"targets": [ |
||||
{ |
||||
"dimensions": { |
||||
"FunctionName": "$function" |
||||
}, |
||||
"expression": "", |
||||
"highResolution": false, |
||||
"matchExact": true, |
||||
"metricName": "Throttles", |
||||
"namespace": "AWS/Lambda", |
||||
"refId": "A", |
||||
"region": "$region", |
||||
"statistics": ["Average"] |
||||
} |
||||
], |
||||
"thresholds": [], |
||||
"timeFrom": null, |
||||
"timeRegions": [], |
||||
"timeShift": null, |
||||
"title": "Throttles $statistic", |
||||
"tooltip": { |
||||
"shared": true, |
||||
"sort": 0, |
||||
"value_type": "individual" |
||||
}, |
||||
"type": "graph", |
||||
"xaxis": { |
||||
"buckets": null, |
||||
"mode": "time", |
||||
"name": null, |
||||
"show": true, |
||||
"values": [] |
||||
}, |
||||
"yaxes": [ |
||||
{ |
||||
"format": "short", |
||||
"label": null, |
||||
"logBase": 1, |
||||
"max": null, |
||||
"min": null, |
||||
"show": true |
||||
}, |
||||
{ |
||||
"format": "short", |
||||
"label": null, |
||||
"logBase": 1, |
||||
"max": null, |
||||
"min": null, |
||||
"show": true |
||||
} |
||||
], |
||||
"yaxis": { |
||||
"align": false, |
||||
"alignLevel": null |
||||
} |
||||
} |
||||
], |
||||
"schemaVersion": 21, |
||||
"style": "dark", |
||||
"tags": [], |
||||
"templating": { |
||||
"list": [ |
||||
{ |
||||
"current": { |
||||
"selected": true, |
||||
"text": "CloudWatch", |
||||
"value": "CloudWatch" |
||||
}, |
||||
"hide": 0, |
||||
"includeAll": false, |
||||
"label": "Datasource", |
||||
"multi": false, |
||||
"name": "datasource", |
||||
"options": [], |
||||
"query": "cloudwatch", |
||||
"refresh": 1, |
||||
"regex": "", |
||||
"skipUrlSync": false, |
||||
"type": "datasource" |
||||
}, |
||||
{ |
||||
"allValue": null, |
||||
"current": { |
||||
"text": "default", |
||||
"value": "default" |
||||
}, |
||||
"datasource": "${DS_CLOUDWATCH}", |
||||
"definition": "regions()", |
||||
"hide": 0, |
||||
"includeAll": false, |
||||
"label": "Region", |
||||
"multi": false, |
||||
"name": "region", |
||||
"options": [], |
||||
"query": "regions()", |
||||
"refresh": 1, |
||||
"regex": "", |
||||
"skipUrlSync": false, |
||||
"sort": 0, |
||||
"tagValuesQuery": "", |
||||
"tags": [], |
||||
"tagsQuery": "", |
||||
"type": "query", |
||||
"useTags": false |
||||
}, |
||||
{ |
||||
"allValue": null, |
||||
"current": {}, |
||||
"datasource": "${DS_CLOUDWATCH}", |
||||
"definition": "statistics()", |
||||
"hide": 0, |
||||
"includeAll": false, |
||||
"label": "Statistic", |
||||
"multi": false, |
||||
"name": "statistic", |
||||
"options": [], |
||||
"query": "statistics()", |
||||
"refresh": 1, |
||||
"regex": "", |
||||
"skipUrlSync": false, |
||||
"sort": 0, |
||||
"tagValuesQuery": "", |
||||
"tags": [], |
||||
"tagsQuery": "", |
||||
"type": "query", |
||||
"useTags": false |
||||
}, |
||||
{ |
||||
"allValue": null, |
||||
"current": { |
||||
"text": "*", |
||||
"value": ["*"] |
||||
}, |
||||
"datasource": "${DS_CLOUDWATCH}", |
||||
"definition": "dimension_values($region, AWS/Lambda, , FunctionName)", |
||||
"hide": 0, |
||||
"includeAll": true, |
||||
"label": "FunctionName", |
||||
"multi": true, |
||||
"name": "function", |
||||
"options": [], |
||||
"query": "dimension_values($region, AWS/Lambda, , FunctionName)", |
||||
"refresh": 1, |
||||
"regex": "", |
||||
"skipUrlSync": false, |
||||
"sort": 0, |
||||
"tagValuesQuery": "", |
||||
"tags": [], |
||||
"tagsQuery": "", |
||||
"type": "query", |
||||
"useTags": false |
||||
} |
||||
] |
||||
}, |
||||
"time": { |
||||
"from": "now-6h", |
||||
"to": "now" |
||||
}, |
||||
"timepicker": { |
||||
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] |
||||
}, |
||||
"timezone": "", |
||||
"title": "Lambda", |
||||
"uid": "VgpJGb1Zg", |
||||
"version": 6 |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,13 @@ |
||||
import { debounce, memoize } from 'lodash'; |
||||
|
||||
export default (func: (...args: any[]) => void, wait = 7000) => { |
||||
const mem = memoize( |
||||
(...args) => |
||||
debounce(func, wait, { |
||||
leading: true, |
||||
}), |
||||
(...args) => JSON.stringify(args) |
||||
); |
||||
|
||||
return (...args: any[]) => mem(...args)(...args); |
||||
}; |
@ -1,16 +0,0 @@ |
||||
import './query_parameter_ctrl'; |
||||
|
||||
import CloudWatchDatasource from './datasource'; |
||||
import { CloudWatchQueryCtrl } from './query_ctrl'; |
||||
import { CloudWatchConfigCtrl } from './config_ctrl'; |
||||
|
||||
class CloudWatchAnnotationsQueryCtrl { |
||||
static templateUrl = 'partials/annotations.editor.html'; |
||||
} |
||||
|
||||
export { |
||||
CloudWatchDatasource as Datasource, |
||||
CloudWatchQueryCtrl as QueryCtrl, |
||||
CloudWatchConfigCtrl as ConfigCtrl, |
||||
CloudWatchAnnotationsQueryCtrl as AnnotationsQueryCtrl, |
||||
}; |
@ -0,0 +1,16 @@ |
||||
import { DataSourcePlugin } from '@grafana/data'; |
||||
import { ConfigEditor } from './components/ConfigEditor'; |
||||
import { QueryEditor } from './components/QueryEditor'; |
||||
import CloudWatchDatasource from './datasource'; |
||||
import { CloudWatchJsonData, CloudWatchQuery } from './types'; |
||||
|
||||
class CloudWatchAnnotationsQueryCtrl { |
||||
static templateUrl = 'partials/annotations.editor.html'; |
||||
} |
||||
|
||||
export const plugin = new DataSourcePlugin<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData>( |
||||
CloudWatchDatasource |
||||
) |
||||
.setConfigEditor(ConfigEditor) |
||||
.setQueryEditor(QueryEditor) |
||||
.setAnnotationQueryCtrl(CloudWatchAnnotationsQueryCtrl); |
@ -1,55 +0,0 @@ |
||||
<h3 class="page-heading">CloudWatch details</h3> |
||||
|
||||
<div class="gf-form-group max-width-30"> |
||||
<div class="gf-form gf-form-select-wrapper"> |
||||
<label class="gf-form-label width-13">Auth Provider</label> |
||||
<select class="gf-form-input gf-max-width-13" ng-model="ctrl.current.jsonData.authType" ng-options="f.value as f.name for f in ctrl.authTypes"></select> |
||||
</div> |
||||
|
||||
<div class="gf-form" ng-show='ctrl.current.jsonData.authType == "credentials"'> |
||||
<label class="gf-form-label width-13">Credentials profile name</label> |
||||
<input type="text" class="gf-form-input max-width-18 gf-form-input--has-help-icon" ng-model='ctrl.current.database' placeholder="default"></input> |
||||
<info-popover mode="right-absolute"> |
||||
Credentials profile name, as specified in ~/.aws/credentials, leave blank for default |
||||
</info-popover> |
||||
</div> |
||||
|
||||
<div class="gf-form" ng-show='ctrl.current.jsonData.authType == "keys"'> |
||||
<label class="gf-form-label width-13">Access key ID </label> |
||||
<label class="gf-form-label width-13" ng-show="ctrl.accessKeyExist">Configured</label> |
||||
<a class="btn btn-secondary gf-form-btn" type="submit" ng-click="ctrl.resetAccessKey()" ng-show="ctrl.accessKeyExist">Reset</a> |
||||
<input type="text" class="gf-form-input max-width-18" ng-hide="ctrl.accessKeyExist" ng-model='ctrl.current.secureJsonData.accessKey'></input> |
||||
</div> |
||||
|
||||
<div class="gf-form" ng-show='ctrl.current.jsonData.authType == "keys"'> |
||||
<label class="gf-form-label width-13">Secret access key</label> |
||||
<label class="gf-form-label width-13" ng-show="ctrl.secretKeyExist">Configured</label> |
||||
<a class="btn btn-secondary gf-form-btn" type="submit" ng-click="ctrl.resetSecretKey()" ng-show="ctrl.secretKeyExist">Reset</a> |
||||
<input type="text" class="gf-form-input max-width-18" ng-hide="ctrl.secretKeyExist" ng-model='ctrl.current.secureJsonData.secretKey'></input> |
||||
</div> |
||||
|
||||
<div class="gf-form" ng-show='ctrl.current.jsonData.authType == "arn"'> |
||||
<label class="gf-form-label width-13">Assume Role ARN</label> |
||||
<input type="text" class="gf-form-input max-width-18 gf-form-input--has-help-icon" ng-model='ctrl.current.jsonData.assumeRoleArn' placeholder="arn:aws:iam:*"></input> |
||||
<info-popover mode="right-absolute"> |
||||
ARN of Assume Role |
||||
</info-popover> |
||||
</div> |
||||
|
||||
<div class="gf-form"> |
||||
<label class="gf-form-label width-13">Default Region</label> |
||||
<div class="gf-form-select-wrapper max-width-18 gf-form-select-wrapper--has-help-icon"> |
||||
<select class="gf-form-input" ng-model="ctrl.current.jsonData.defaultRegion" ng-options="region for region in ctrl.regions"></select> |
||||
<info-popover mode="right-absolute"> |
||||
Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region. |
||||
</info-popover> |
||||
</div> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<label class="gf-form-label width-13">Custom Metrics</label> |
||||
<input type="text" class="gf-form-input max-width-18 gf-form-input--has-help-icon" ng-model='ctrl.current.jsonData.customMetricsNamespaces' placeholder="Namespace1,Namespace2"></input> |
||||
<info-popover mode="right-absolute"> |
||||
Namespaces of Custom Metrics |
||||
</info-popover> |
||||
</div> |
||||
</div> |
@ -1,4 +0,0 @@ |
||||
<query-editor-row query-ctrl="ctrl" can-collapse="false"> |
||||
<cloudwatch-query-parameter target="ctrl.target" datasource="ctrl.datasource" on-change="ctrl.refresh()"></cloudwatch-query-parameter> |
||||
</query-editor-row> |
||||
|
@ -1,92 +1,143 @@ |
||||
<div class="gf-form-inline"> |
||||
<div class="gf-form"> |
||||
<label class="gf-form-label query-keyword width-8">Region</label> |
||||
<metric-segment segment="regionSegment" get-options="getRegions()" on-change="regionChanged()"></metric-segment> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<label class="gf-form-label query-keyword width-8">Region</label> |
||||
<metric-segment segment="regionSegment" get-options="getRegions()" on-change="regionChanged()"></metric-segment> |
||||
</div> |
||||
|
||||
<div class="gf-form gf-form--grow"> |
||||
<div class="gf-form-label gf-form-label--grow"></div> |
||||
</div> |
||||
<div class="gf-form gf-form--grow"> |
||||
<div class="gf-form-label gf-form-label--grow"></div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-inline" ng-if="target.expression.length === 0"> |
||||
<div class="gf-form"> |
||||
<label class="gf-form-label query-keyword width-8">Metric</label> |
||||
<div class="gf-form"> |
||||
<label class="gf-form-label query-keyword width-8">Metric</label> |
||||
|
||||
<metric-segment segment="namespaceSegment" get-options="getNamespaces()" on-change="namespaceChanged()"></metric-segment> |
||||
<metric-segment segment="metricSegment" get-options="getMetrics()" on-change="metricChanged()"></metric-segment> |
||||
</div> |
||||
<metric-segment |
||||
segment="namespaceSegment" |
||||
get-options="getNamespaces()" |
||||
on-change="namespaceChanged()" |
||||
></metric-segment> |
||||
<metric-segment segment="metricSegment" get-options="getMetrics()" on-change="metricChanged()"></metric-segment> |
||||
</div> |
||||
|
||||
<div class="gf-form"> |
||||
<label class="gf-form-label query-keyword">Stats</label> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<label class="gf-form-label query-keyword">Stats</label> |
||||
</div> |
||||
|
||||
<div class="gf-form" ng-repeat="segment in statSegments"> |
||||
<metric-segment segment="segment" get-options="getStatSegments(segment, $index)" on-change="statSegmentChanged(segment, $index)"></metric-segment> |
||||
</div> |
||||
<div class="gf-form" ng-repeat="segment in statSegments"> |
||||
<metric-segment |
||||
segment="segment" |
||||
get-options="getStatSegments(segment, $index)" |
||||
on-change="statSegmentChanged(segment, $index)" |
||||
></metric-segment> |
||||
</div> |
||||
|
||||
<div class="gf-form gf-form--grow"> |
||||
<div class="gf-form-label gf-form-label--grow"></div> |
||||
</div> |
||||
<div class="gf-form gf-form--grow"> |
||||
<div class="gf-form-label gf-form-label--grow"></div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-inline" ng-if="target.expression.length === 0"> |
||||
<div class="gf-form"> |
||||
<label class="gf-form-label query-keyword width-8">Dimensions</label> |
||||
<metric-segment ng-repeat="segment in dimSegments" segment="segment" get-options="getDimSegments(segment, $index)" on-change="dimSegmentChanged(segment, $index)"></metric-segment> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<label class="gf-form-label query-keyword width-8">Dimensions</label> |
||||
<metric-segment |
||||
ng-repeat="segment in dimSegments" |
||||
segment="segment" |
||||
get-options="getDimSegments(segment, $index)" |
||||
on-change="dimSegmentChanged(segment, $index)" |
||||
></metric-segment> |
||||
</div> |
||||
|
||||
<div class="gf-form gf-form--grow"> |
||||
<div class="gf-form-label gf-form-label--grow"></div> |
||||
</div> |
||||
<div class="gf-form gf-form--grow"> |
||||
<div class="gf-form-label gf-form-label--grow"></div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-inline" ng-if="target.statistics.length === 1"> |
||||
<div class="gf-form"> |
||||
<label class=" gf-form-label query-keyword width-8 "> |
||||
Id |
||||
<info-popover mode="right-normal ">Id can include numbers, letters, and underscore, and must start with a lowercase letter.</info-popover> |
||||
</label> |
||||
<input type="text " class="gf-form-input " ng-model="target.id " spellcheck='false' ng-pattern='/^[a-z][a-zA-Z0-9_]*$/' ng-model-onblur ng-change="onChange() "> |
||||
</div> |
||||
<div class="gf-form max-width-30 "> |
||||
<label class="gf-form-label query-keyword width-7 ">Expression</label> |
||||
<input type="text " class="gf-form-input " ng-model="target.expression |
||||
" spellcheck='false' ng-model-onblur ng-change="onChange() "> |
||||
</div> |
||||
<div class="gf-form"> |
||||
<label class=" gf-form-label query-keyword width-8 "> |
||||
Id |
||||
<info-popover mode="right-normal " |
||||
>Id can include numbers, letters, and underscore, and must start with a lowercase letter.</info-popover |
||||
> |
||||
</label> |
||||
<input |
||||
type="text " |
||||
class="gf-form-input " |
||||
ng-model="target.id " |
||||
spellcheck="false" |
||||
ng-pattern="/^[a-z][a-zA-Z0-9_]*$/" |
||||
ng-model-onblur |
||||
ng-change="onChange() " |
||||
/> |
||||
</div> |
||||
<div class="gf-form max-width-30 "> |
||||
<label class="gf-form-label query-keyword width-7 ">Expression</label> |
||||
<input |
||||
type="text " |
||||
class="gf-form-input " |
||||
ng-model="target.expression |
||||
" |
||||
spellcheck="false" |
||||
ng-model-onblur |
||||
ng-change="onChange() " |
||||
/> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="gf-form-inline "> |
||||
<div class="gf-form "> |
||||
<label class="gf-form-label query-keyword width-8 "> |
||||
Min period |
||||
<info-popover mode="right-normal ">Minimum interval between points in seconds</info-popover> |
||||
</label> |
||||
<input type="text " class="gf-form-input " ng-model="target.period " spellcheck='false' placeholder="auto |
||||
" ng-model-onblur ng-change="onChange() " /> |
||||
</div> |
||||
<div class="gf-form max-width-30 "> |
||||
<label class="gf-form-label query-keyword width-7 ">Alias</label> |
||||
<input type="text " class="gf-form-input " ng-model="target.alias " spellcheck='false' ng-model-onblur ng-change="onChange() "> |
||||
<info-popover mode="right-absolute "> |
||||
Alias replacement variables: |
||||
<ul ng-non-bindable> |
||||
<li>{{metric}}</li> |
||||
<li>{{stat}}</li> |
||||
<li>{{namespace}}</li> |
||||
<li>{{region}}</li> |
||||
<li>{{period}}</li> |
||||
<li>{{label}}</li> |
||||
<li>{{YOUR_DIMENSION_NAME}}</li> |
||||
</ul> |
||||
</info-popover> |
||||
</div> |
||||
<div class="gf-form "> |
||||
<gf-form-switch class="gf-form " label="HighRes " label-class="width-5 " checked="target.highResolution " on-change="onChange() "> |
||||
</gf-form-switch> |
||||
</div> |
||||
<div class="gf-form "> |
||||
<label class="gf-form-label query-keyword width-8 "> |
||||
Min period |
||||
<info-popover mode="right-normal ">Minimum interval between points in seconds</info-popover> |
||||
</label> |
||||
<input |
||||
type="text " |
||||
class="gf-form-input " |
||||
ng-model="target.period " |
||||
spellcheck="false" |
||||
placeholder="auto |
||||
" |
||||
ng-model-onblur |
||||
ng-change="onChange() " |
||||
/> |
||||
</div> |
||||
<div class="gf-form max-width-30 "> |
||||
<label class="gf-form-label query-keyword width-7 ">Alias</label> |
||||
<input |
||||
type="text " |
||||
class="gf-form-input " |
||||
ng-model="target.alias " |
||||
spellcheck="false" |
||||
ng-model-onblur |
||||
ng-change="onChange() " |
||||
/> |
||||
<info-popover mode="right-absolute "> |
||||
Alias replacement variables: |
||||
<ul ng-non-bindable> |
||||
<li>{{ metric }}</li> |
||||
<li>{{ stat }}</li> |
||||
<li>{{ namespace }}</li> |
||||
<li>{{ region }}</li> |
||||
<li>{{ period }}</li> |
||||
<li>{{ label }}</li> |
||||
<li>{{ YOUR_DIMENSION_NAME }}</li> |
||||
</ul> |
||||
</info-popover> |
||||
</div> |
||||
<div class="gf-form "> |
||||
<gf-form-switch |
||||
class="gf-form " |
||||
label="HighRes " |
||||
label-class="width-5 " |
||||
checked="target.highResolution " |
||||
on-change="onChange()" |
||||
> |
||||
</gf-form-switch> |
||||
</div> |
||||
|
||||
<div class="gf-form gf-form--grow "> |
||||
<div class="gf-form-label gf-form-label--grow "></div> |
||||
</div> |
||||
<div class="gf-form gf-form--grow "> |
||||
<div class="gf-form-label gf-form-label--grow "></div> |
||||
</div> |
||||
</div> |
||||
|
@ -1,15 +0,0 @@ |
||||
import './query_parameter_ctrl'; |
||||
import { QueryCtrl } from 'app/plugins/sdk'; |
||||
import { auto } from 'angular'; |
||||
|
||||
export class CloudWatchQueryCtrl extends QueryCtrl { |
||||
static templateUrl = 'partials/query.editor.html'; |
||||
|
||||
aliasSyntax: string; |
||||
|
||||
/** @ngInject */ |
||||
constructor($scope: any, $injector: auto.IInjectorService) { |
||||
super($scope, $injector); |
||||
this.aliasSyntax = '{{metric}} {{stat}} {{namespace}} {{region}} {{<dimension name>}}'; |
||||
} |
||||
} |
@ -1,12 +1,29 @@ |
||||
import { DataQuery } from '@grafana/data'; |
||||
import { DataQuery, SelectableValue, DataSourceJsonData } from '@grafana/data'; |
||||
|
||||
export interface CloudWatchQuery extends DataQuery { |
||||
id: string; |
||||
region: string; |
||||
namespace: string; |
||||
metricName: string; |
||||
dimensions: { [key: string]: string }; |
||||
dimensions: { [key: string]: string | string[] }; |
||||
statistics: string[]; |
||||
period: string; |
||||
expression: string; |
||||
alias: string; |
||||
highResolution: boolean; |
||||
matchExact: boolean; |
||||
} |
||||
|
||||
export type SelectableStrings = Array<SelectableValue<string>>; |
||||
|
||||
export interface CloudWatchJsonData extends DataSourceJsonData { |
||||
timeField?: string; |
||||
assumeRoleArn?: string; |
||||
database?: string; |
||||
customMetricsNamespaces?: string; |
||||
} |
||||
|
||||
export interface CloudWatchSecureJsonData { |
||||
accessKey: string; |
||||
secretKey: string; |
||||
} |
||||
|
Loading…
Reference in new issue