Commit 00bef917 by Erik Sundell Committed by GitHub

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 region
parent 1f018adb
......@@ -24,7 +24,7 @@ func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryCo
namespace := parameters.Get("namespace").MustString("")
metricName := parameters.Get("metricName").MustString("")
dimensions := parameters.Get("dimensions").MustMap()
statistics, extendedStatistics, err := parseStatistics(parameters)
statistics, err := parseStatistics(parameters)
if err != nil {
return nil, err
}
......@@ -51,7 +51,7 @@ func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryCo
if err != nil {
return nil, errors.New("Failed to call cloudwatch:DescribeAlarms")
}
alarmNames = filterAlarms(resp, namespace, metricName, dimensions, statistics, extendedStatistics, period)
alarmNames = filterAlarms(resp, namespace, metricName, dimensions, statistics, period)
} else {
if region == "" || namespace == "" || metricName == "" || len(statistics) == 0 {
return result, nil
......@@ -82,22 +82,6 @@ func (e *CloudWatchExecutor) executeAnnotationQuery(ctx context.Context, queryCo
alarmNames = append(alarmNames, alarm.AlarmName)
}
}
for _, s := range extendedStatistics {
params := &cloudwatch.DescribeAlarmsForMetricInput{
Namespace: aws.String(namespace),
MetricName: aws.String(metricName),
Dimensions: qd,
ExtendedStatistic: aws.String(s),
Period: aws.Int64(period),
}
resp, err := svc.DescribeAlarmsForMetric(params)
if err != nil {
return nil, errors.New("Failed to call cloudwatch:DescribeAlarmsForMetric")
}
for _, alarm := range resp.MetricAlarms {
alarmNames = append(alarmNames, alarm.AlarmName)
}
}
}
startTime, err := queryContext.TimeRange.ParseFrom()
......@@ -158,7 +142,7 @@ func transformAnnotationToTable(data []map[string]string, result *tsdb.QueryResu
result.Meta.Set("rowCount", len(data))
}
func filterAlarms(alarms *cloudwatch.DescribeAlarmsOutput, namespace string, metricName string, dimensions map[string]interface{}, statistics []string, extendedStatistics []string, period int64) []*string {
func filterAlarms(alarms *cloudwatch.DescribeAlarmsOutput, namespace string, metricName string, dimensions map[string]interface{}, statistics []string, period int64) []*string {
alarmNames := make([]*string, 0)
for _, alarm := range alarms.MetricAlarms {
......@@ -197,18 +181,6 @@ func filterAlarms(alarms *cloudwatch.DescribeAlarmsOutput, namespace string, met
}
}
if len(extendedStatistics) != 0 {
found := false
for _, s := range extendedStatistics {
if *alarm.Statistic == s {
found = true
}
}
if !found {
continue
}
}
if period != 0 && *alarm.Period != period {
continue
}
......
......@@ -2,18 +2,13 @@ package cloudwatch
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/tsdb"
"golang.org/x/sync/errgroup"
)
type CloudWatchExecutor struct {
......@@ -38,21 +33,13 @@ func NewCloudWatchExecutor(dsInfo *models.DataSource) (tsdb.TsdbQueryEndpoint, e
}
var (
plog log.Logger
standardStatistics map[string]bool
aliasFormat *regexp.Regexp
plog log.Logger
aliasFormat *regexp.Regexp
)
func init() {
plog = log.New("tsdb.cloudwatch")
tsdb.RegisterTsdbQueryEndpoint("cloudwatch", NewCloudWatchExecutor)
standardStatistics = map[string]bool{
"Average": true,
"Maximum": true,
"Minimum": true,
"Sum": true,
"SampleCount": true,
}
aliasFormat = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`)
}
......@@ -75,162 +62,3 @@ func (e *CloudWatchExecutor) Query(ctx context.Context, dsInfo *models.DataSourc
return result, err
}
func (e *CloudWatchExecutor) executeTimeSeriesQuery(ctx context.Context, queryContext *tsdb.TsdbQuery) (*tsdb.Response, error) {
results := &tsdb.Response{
Results: make(map[string]*tsdb.QueryResult),
}
resultChan := make(chan *tsdb.QueryResult, len(queryContext.Queries))
eg, ectx := errgroup.WithContext(ctx)
getMetricDataQueries := make(map[string]map[string]*CloudWatchQuery)
for i, model := range queryContext.Queries {
queryType := model.Model.Get("type").MustString()
if queryType != "timeSeriesQuery" && queryType != "" {
continue
}
RefId := queryContext.Queries[i].RefId
query, err := parseQuery(queryContext.Queries[i].Model)
if err != nil {
results.Results[RefId] = &tsdb.QueryResult{
Error: err,
}
return results, nil
}
query.RefId = RefId
if query.Id != "" {
if _, ok := getMetricDataQueries[query.Region]; !ok {
getMetricDataQueries[query.Region] = make(map[string]*CloudWatchQuery)
}
getMetricDataQueries[query.Region][query.Id] = query
continue
}
if query.Id == "" && query.Expression != "" {
results.Results[query.RefId] = &tsdb.QueryResult{
Error: fmt.Errorf("Invalid query: id should be set if using expression"),
}
return results, nil
}
eg.Go(func() error {
defer func() {
if err := recover(); err != nil {
plog.Error("Execute Query Panic", "error", err, "stack", log.Stack(1))
if theErr, ok := err.(error); ok {
resultChan <- &tsdb.QueryResult{
RefId: query.RefId,
Error: theErr,
}
}
}
}()
queryRes, err := e.executeQuery(ectx, query, queryContext)
if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
return err
}
if err != nil {
resultChan <- &tsdb.QueryResult{
RefId: query.RefId,
Error: err,
}
return nil
}
resultChan <- queryRes
return nil
})
}
if len(getMetricDataQueries) > 0 {
for region, getMetricDataQuery := range getMetricDataQueries {
q := getMetricDataQuery
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,
}
}
}
}()
queryResponses, err := e.executeGetMetricDataQuery(ectx, region, q, queryContext)
if ae, ok := err.(awserr.Error); ok && ae.Code() == "500" {
return err
}
for _, queryRes := range queryResponses {
if err != nil {
queryRes.Error = err
}
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
}
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 len(query.Id) > 0 && len(query.Expression) > 0 {
if strings.Index(query.Expression, "SEARCH(") == 0 {
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], " '")
} else if len(query.Alias) > 0 {
// expand by Alias
} else {
return query.Id
}
}
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)
}
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()
}
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)
})
})
})
}
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": "",
}
......@@ -12,9 +12,11 @@ import (
"github.com/aws/aws-sdk-go/aws/credentials/endpointcreds"
"github.com/aws/aws-sdk-go/aws/defaults"
"github.com/aws/aws-sdk-go/aws/ec2metadata"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/aws/aws-sdk-go/service/sts"
"github.com/grafana/grafana/pkg/setting"
)
type cache struct {
......@@ -180,6 +182,7 @@ func (e *CloudWatchExecutor) getAwsConfig(dsInfo *DatasourceInfo) (*aws.Config,
Region: aws.String(dsInfo.Region),
Credentials: creds,
}
return cfg, nil
}
......@@ -196,5 +199,10 @@ func (e *CloudWatchExecutor) getClient(region string) (*cloudwatch.CloudWatch, e
}
client := cloudwatch.New(sess, cfg)
client.Handlers.Send.PushFront(func(r *request.Request) {
r.HTTPRequest.Header.Set("User-Agent", fmt.Sprintf("Grafana/%s", setting.BuildVersion))
})
return client, nil
}
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
}
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
}
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)
})
})
}
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())
})
})
}
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
}
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())
})
})
}
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
}
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(),
},
}
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 := e.executeQuery(context.Background(), &CloudWatchQuery{}, &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-2h")})
_, 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 := e.executeQuery(context.Background(), &CloudWatchQuery{}, &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("now-1h", "now-1h")})
_, 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")
})
})
......
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
}
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\""`)
})
})
})
}
......@@ -637,10 +637,13 @@ func (e *CloudWatchExecutor) cloudwatchListMetrics(region string, namespace stri
params := &cloudwatch.ListMetricsInput{
Namespace: aws.String(namespace),
MetricName: aws.String(metricName),
Dimensions: dimensions,
}
if metricName != "" {
params.MetricName = aws.String(metricName)
}
var resp cloudwatch.ListMetricsOutput
err = svc.ListMetricsPages(params,
func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool {
......
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
}
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)
})
})
})
}
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
}
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")
})
})
})
}
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)
}
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())
})
})
}
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
}
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)
}
......@@ -26,7 +26,7 @@ export default class AppNotificationItem extends Component<Props> {
<Alert
severity={appNotification.severity}
title={appNotification.title}
children={appNotification.text}
children={appNotification.component || appNotification.text}
onRemove={() => onClearNotification(appNotification.id)}
/>
);
......
......@@ -32,12 +32,13 @@ export const createSuccessNotification = (title: string, text = ''): AppNotifica
id: Date.now(),
});
export const createErrorNotification = (title: string, text = ''): AppNotification => {
export const createErrorNotification = (title: string, text = '', component?: React.ReactElement): AppNotification => {
return {
...defaultErrorNotification,
title: title,
text: getMessageFromError(text),
title,
id: Date.now(),
component,
};
};
......
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();
});
});
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} />;
};
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();
});
});
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>`
);
});
});
});
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]: '' })}
/>
)}
</>
);
};
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>
);
};
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({});
});
});
});
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();
});
});
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]}
/>
)}
</>
);
import React, { FunctionComponent } from 'react';
export interface Props {
region: string;
}
export const ThrottlingErrorMessage: FunctionComponent<Props> = ({ region }) => (
<p>
Please visit the&nbsp;
<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>
&nbsp;to request a quota increase or see our&nbsp;
<a
target="_blank"
className="text-link"
href={`https://grafana.com/docs/features/datasources/cloudwatch/#service-quotas`}
>
documentation
</a>
&nbsp;to learn more.
</p>
);
// 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>
`;
// 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>,
]
`;
// 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>,
]
`;
export { Stats } from './Stats';
export { Dimensions } from './Dimensions';
export { QueryInlineField, QueryField } from './Forms';
export { Alias } from './Alias';
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');
}
);
}
}
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);
};
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,
};
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);
<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>
<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>
<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>
......@@ -7,7 +7,6 @@
"metrics": true,
"alerting": true,
"annotations": true,
"info": {
"description": "Data source for Amazon AWS monitoring service",
"author": {
......
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>}}';
}
}
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;
}
......@@ -4,6 +4,7 @@ export interface AppNotification {
icon: string;
title: string;
text: string;
component?: React.ReactElement;
timeout: AppNotificationTimeout;
}
......
......@@ -3,13 +3,12 @@ module github.com/grafana/grafana/scripts/go
go 1.13
require (
github.com/golangci/golangci-lint v1.20.0
github.com/golangci/golangci-lint v1.19.2-0.20191006164544-7577d548a389
github.com/mgechev/revive v0.0.0-20190917153825-40564c5052ae
github.com/securego/gosec v0.0.0-20191002120514-e680875ea14d
github.com/securego/gosec v0.0.0-20190912120752-140048b2a218
github.com/unknwon/bra v0.0.0-20190805204333-bb0929b6cca0
github.com/unknwon/com v1.0.1 // indirect
github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect
github.com/urfave/cli v1.20.0 // indirect
golang.org/x/tools v0.0.0-20191015150414-f936694f27bf // indirect
gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment