mirror of https://github.com/grafana/grafana
Cloudwatch: Migrate queries that use multiple stats to one query per stat (#36925)
* migrate queries that use multiple stats - squash commits * fix typopull/38998/head
parent
ae9343f8ae
commit
5e38b02f94
@ -0,0 +1,49 @@ |
||||
package cloudwatch |
||||
|
||||
import "github.com/aws/aws-sdk-go/service/cloudwatch" |
||||
|
||||
// queryRowResponse represents the GetMetricData response for a query row in the query editor.
|
||||
type queryRowResponse struct { |
||||
ID string |
||||
RequestExceededMaxLimit bool |
||||
PartialData bool |
||||
Labels []string |
||||
HasArithmeticError bool |
||||
ArithmeticErrorMessage string |
||||
Metrics map[string]*cloudwatch.MetricDataResult |
||||
StatusCode string |
||||
} |
||||
|
||||
func newQueryRowResponse(id string) queryRowResponse { |
||||
return queryRowResponse{ |
||||
ID: id, |
||||
RequestExceededMaxLimit: false, |
||||
PartialData: false, |
||||
HasArithmeticError: false, |
||||
ArithmeticErrorMessage: "", |
||||
Labels: []string{}, |
||||
Metrics: map[string]*cloudwatch.MetricDataResult{}, |
||||
} |
||||
} |
||||
|
||||
func (q *queryRowResponse) addMetricDataResult(mdr *cloudwatch.MetricDataResult) { |
||||
label := *mdr.Label |
||||
q.Labels = append(q.Labels, label) |
||||
q.Metrics[label] = mdr |
||||
q.StatusCode = *mdr.StatusCode |
||||
} |
||||
|
||||
func (q *queryRowResponse) appendTimeSeries(mdr *cloudwatch.MetricDataResult) { |
||||
if _, exists := q.Metrics[*mdr.Label]; !exists { |
||||
q.Metrics[*mdr.Label] = &cloudwatch.MetricDataResult{} |
||||
} |
||||
metric := q.Metrics[*mdr.Label] |
||||
metric.Timestamps = append(metric.Timestamps, mdr.Timestamps...) |
||||
metric.Values = append(metric.Values, mdr.Values...) |
||||
q.StatusCode = *mdr.StatusCode |
||||
} |
||||
|
||||
func (q *queryRowResponse) addArithmeticError(message *string) { |
||||
q.HasArithmeticError = true |
||||
q.ArithmeticErrorMessage = *message |
||||
} |
||||
@ -1,236 +0,0 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"fmt" |
||||
"net/url" |
||||
"sort" |
||||
"strings" |
||||
"time" |
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend" |
||||
"github.com/grafana/grafana-plugin-sdk-go/data" |
||||
) |
||||
|
||||
// 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) { |
||||
plog.Debug("Transforming CloudWatch request queries") |
||||
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, ".", "_")) |
||||
} |
||||
|
||||
if _, ok := cloudwatchQueries[id]; ok { |
||||
return nil, fmt.Errorf("error in query %q - query ID %q is not unique", requestQuery.RefId, id) |
||||
} |
||||
|
||||
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, |
||||
MatchExact: requestQuery.MatchExact, |
||||
} |
||||
cloudwatchQueries[id] = query |
||||
} |
||||
} |
||||
|
||||
return cloudwatchQueries, nil |
||||
} |
||||
|
||||
func (e *cloudWatchExecutor) transformQueryResponsesToQueryResult(cloudwatchResponses []*cloudwatchResponse, requestQueries []*requestQuery, startTime time.Time, endTime time.Time) (map[string]*backend.DataResponse, error) { |
||||
responsesByRefID := make(map[string][]*cloudwatchResponse) |
||||
refIDs := sort.StringSlice{} |
||||
for _, res := range cloudwatchResponses { |
||||
refIDs = append(refIDs, res.RefId) |
||||
responsesByRefID[res.RefId] = append(responsesByRefID[res.RefId], res) |
||||
} |
||||
// Ensure stable results
|
||||
refIDs.Sort() |
||||
|
||||
results := make(map[string]*backend.DataResponse) |
||||
for _, refID := range refIDs { |
||||
responses := responsesByRefID[refID] |
||||
queryResult := backend.DataResponse{} |
||||
frames := make(data.Frames, 0, len(responses)) |
||||
|
||||
requestExceededMaxLimit := false |
||||
partialData := false |
||||
var executedQueries []executedQuery |
||||
|
||||
for _, response := range responses { |
||||
frames = append(frames, response.DataFrames...) |
||||
requestExceededMaxLimit = requestExceededMaxLimit || response.RequestExceededMaxLimit |
||||
partialData = partialData || response.PartialData |
||||
|
||||
if requestExceededMaxLimit { |
||||
frames[0].AppendNotices(data.Notice{ |
||||
Severity: data.NoticeSeverityWarning, |
||||
Text: "cloudwatch GetMetricData error: Maximum number of allowed metrics exceeded. Your search may have been limited", |
||||
}) |
||||
} |
||||
|
||||
if partialData { |
||||
frames[0].AppendNotices(data.Notice{ |
||||
Severity: data.NoticeSeverityWarning, |
||||
Text: "cloudwatch GetMetricData error: Too many datapoints requested - your search has been limited. Please try to reduce the time range", |
||||
}) |
||||
} |
||||
|
||||
executedQueries = append(executedQueries, executedQuery{ |
||||
Expression: response.Expression, |
||||
ID: response.Id, |
||||
Period: response.Period, |
||||
}) |
||||
} |
||||
|
||||
sort.Slice(frames, func(i, j int) bool { |
||||
return frames[i].Name < frames[j].Name |
||||
}) |
||||
|
||||
eq, err := json.Marshal(executedQueries) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("could not marshal executedString struct: %w", err) |
||||
} |
||||
|
||||
link, err := buildDeepLink(refID, requestQueries, executedQueries, startTime, endTime) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("could not build deep link: %w", err) |
||||
} |
||||
|
||||
createDataLinks := func(link string) []data.DataLink { |
||||
return []data.DataLink{{ |
||||
Title: "View in CloudWatch console", |
||||
TargetBlank: true, |
||||
URL: link, |
||||
}} |
||||
} |
||||
|
||||
for _, frame := range frames { |
||||
if frame.Meta != nil { |
||||
frame.Meta.ExecutedQueryString = string(eq) |
||||
} else { |
||||
frame.Meta = &data.FrameMeta{ |
||||
ExecutedQueryString: string(eq), |
||||
} |
||||
} |
||||
|
||||
if link == "" || len(frame.Fields) < 2 { |
||||
continue |
||||
} |
||||
|
||||
if frame.Fields[1].Config == nil { |
||||
frame.Fields[1].Config = &data.FieldConfig{} |
||||
} |
||||
|
||||
frame.Fields[1].Config.Links = createDataLinks(link) |
||||
} |
||||
|
||||
queryResult.Frames = frames |
||||
results[refID] = &queryResult |
||||
} |
||||
|
||||
return results, nil |
||||
} |
||||
|
||||
// buildDeepLink generates a deep link from Grafana to the CloudWatch console. The link params are based on
|
||||
// metric(s) for a given query row in the Query Editor.
|
||||
func buildDeepLink(refID string, requestQueries []*requestQuery, executedQueries []executedQuery, startTime time.Time, |
||||
endTime time.Time) (string, error) { |
||||
if isMathExpression(executedQueries) { |
||||
return "", nil |
||||
} |
||||
|
||||
requestQuery := &requestQuery{} |
||||
for _, rq := range requestQueries { |
||||
if rq.RefId == refID { |
||||
requestQuery = rq |
||||
break |
||||
} |
||||
} |
||||
|
||||
metricItems := []interface{}{} |
||||
cloudWatchLinkProps := &cloudWatchLink{ |
||||
Title: refID, |
||||
View: "timeSeries", |
||||
Stacked: false, |
||||
Region: requestQuery.Region, |
||||
Start: startTime.UTC().Format(time.RFC3339), |
||||
End: endTime.UTC().Format(time.RFC3339), |
||||
} |
||||
|
||||
expressions := []interface{}{} |
||||
for _, meta := range executedQueries { |
||||
if strings.Contains(meta.Expression, "SEARCH(") { |
||||
expressions = append(expressions, &metricExpression{Expression: meta.Expression}) |
||||
} |
||||
} |
||||
|
||||
if len(expressions) != 0 { |
||||
cloudWatchLinkProps.Metrics = expressions |
||||
} else { |
||||
for _, stat := range requestQuery.Statistics { |
||||
metricStat := []interface{}{requestQuery.Namespace, requestQuery.MetricName} |
||||
for dimensionKey, dimensionValues := range requestQuery.Dimensions { |
||||
metricStat = append(metricStat, dimensionKey, dimensionValues[0]) |
||||
} |
||||
metricStat = append(metricStat, &metricStatMeta{ |
||||
Stat: *stat, |
||||
Period: requestQuery.Period, |
||||
}) |
||||
metricItems = append(metricItems, metricStat) |
||||
} |
||||
cloudWatchLinkProps.Metrics = metricItems |
||||
} |
||||
|
||||
linkProps, err := json.Marshal(cloudWatchLinkProps) |
||||
if err != nil { |
||||
return "", fmt.Errorf("could not marshal link: %w", err) |
||||
} |
||||
|
||||
url, err := url.Parse(fmt.Sprintf(`https://%s.console.aws.amazon.com/cloudwatch/deeplink.js`, requestQuery.Region)) |
||||
if err != nil { |
||||
return "", fmt.Errorf("unable to parse CloudWatch console deep link") |
||||
} |
||||
|
||||
fragment := url.Query() |
||||
fragment.Set("", string(linkProps)) |
||||
|
||||
q := url.Query() |
||||
q.Set("region", requestQuery.Region) |
||||
url.RawQuery = q.Encode() |
||||
|
||||
link := fmt.Sprintf(`%s#metricsV2:graph%s`, url.String(), fragment.Encode()) |
||||
|
||||
return link, nil |
||||
} |
||||
|
||||
func isMathExpression(executedQueries []executedQuery) bool { |
||||
isMathExpression := false |
||||
for _, query := range executedQueries { |
||||
if strings.Contains(query.Expression, "SEARCH(") { |
||||
return false |
||||
} else if query.Expression != "" { |
||||
isMathExpression = true |
||||
} |
||||
} |
||||
|
||||
return isMathExpression |
||||
} |
||||
@ -1,249 +0,0 @@ |
||||
package cloudwatch |
||||
|
||||
import ( |
||||
"net/url" |
||||
"testing" |
||||
"time" |
||||
|
||||
"github.com/aws/aws-sdk-go/aws" |
||||
"github.com/grafana/grafana/pkg/setting" |
||||
"github.com/stretchr/testify/assert" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func TestQueryTransformer(t *testing.T) { |
||||
executor := newExecutor(nil, nil, &setting.Cfg{}, fakeSessionCache{}) |
||||
t.Run("One cloudwatchQuery is generated when its request query has one stat", func(t *testing.T) { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average"}), |
||||
Period: 600, |
||||
Id: "", |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
require.NoError(t, err) |
||||
assert.Len(t, res, 1) |
||||
}) |
||||
|
||||
t.Run("Two cloudwatchQuery is generated when there's two stats", func(t *testing.T) { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average", "Sum"}), |
||||
Period: 600, |
||||
Id: "", |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
require.NoError(t, err) |
||||
assert.Len(t, res, 2) |
||||
}) |
||||
t.Run("id is given by user that will be used in the cloudwatch query", func(t *testing.T) { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average"}), |
||||
Period: 600, |
||||
Id: "myid", |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
require.Nil(t, err) |
||||
assert.Equal(t, len(res), 1) |
||||
assert.Contains(t, res, "myid") |
||||
}) |
||||
|
||||
t.Run("ID is not given by user", func(t *testing.T) { |
||||
t.Run("ID will be generated based on ref ID if query only has one stat", func(t *testing.T) { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average"}), |
||||
Period: 600, |
||||
Id: "", |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
require.NoError(t, err) |
||||
assert.Len(t, res, 1) |
||||
assert.Contains(t, res, "queryD") |
||||
}) |
||||
|
||||
t.Run("ID will be generated based on ref and stat name if query has two stats", func(t *testing.T) { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average", "Sum"}), |
||||
Period: 600, |
||||
Id: "", |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
require.NoError(t, err) |
||||
assert.Len(t, res, 2) |
||||
assert.Contains(t, res, "queryD_Sum") |
||||
assert.Contains(t, res, "queryD_Average") |
||||
}) |
||||
}) |
||||
|
||||
t.Run("dot should be removed when query has more than one stat and one of them is a percentile", func(t *testing.T) { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average", "p46.32"}), |
||||
Period: 600, |
||||
Id: "", |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
require.NoError(t, err) |
||||
assert.Len(t, res, 2) |
||||
assert.Contains(t, res, "queryD_p46_32") |
||||
}) |
||||
|
||||
t.Run("should return an error if two queries have the same id", func(t *testing.T) { |
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average", "p46.32"}), |
||||
Period: 600, |
||||
Id: "myId", |
||||
}, |
||||
{ |
||||
RefId: "E", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average", "p46.32"}), |
||||
Period: 600, |
||||
Id: "myId", |
||||
}, |
||||
} |
||||
|
||||
res, err := executor.transformRequestQueriesToCloudWatchQueries(requestQueries) |
||||
require.Nil(t, res) |
||||
assert.Error(t, err) |
||||
}) |
||||
|
||||
requestQueries := []*requestQuery{ |
||||
{ |
||||
RefId: "D", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Sum"}), |
||||
Period: 600, |
||||
Id: "myId", |
||||
}, |
||||
{ |
||||
RefId: "E", |
||||
Region: "us-east-1", |
||||
Namespace: "ec2", |
||||
MetricName: "CPUUtilization", |
||||
Statistics: aws.StringSlice([]string{"Average", "p46.32"}), |
||||
Period: 600, |
||||
Id: "myId", |
||||
}, |
||||
} |
||||
|
||||
t.Run("A deep link that reference two metric stat metrics is created based on a request query with two stats", func(t *testing.T) { |
||||
start, err := time.Parse(time.RFC3339, "2018-03-15T13:00:00Z") |
||||
require.NoError(t, err) |
||||
end, err := time.Parse(time.RFC3339, "2018-03-18T13:34:00Z") |
||||
require.NoError(t, err) |
||||
|
||||
executedQueries := []executedQuery{{ |
||||
Expression: ``, |
||||
ID: "D", |
||||
Period: 600, |
||||
}} |
||||
|
||||
link, err := buildDeepLink("E", requestQueries, executedQueries, start, end) |
||||
require.NoError(t, err) |
||||
|
||||
parsedURL, err := url.Parse(link) |
||||
require.NoError(t, err) |
||||
|
||||
decodedLink, err := url.PathUnescape(parsedURL.String()) |
||||
require.NoError(t, err) |
||||
expected := `https://us-east-1.console.aws.amazon.com/cloudwatch/deeplink.js?region=us-east-1#metricsV2:graph={"view":"timeSeries","stacked":false,"title":"E","start":"2018-03-15T13:00:00Z","end":"2018-03-18T13:34:00Z","region":"us-east-1","metrics":[["ec2","CPUUtilization",{"stat":"Average","period":600}],["ec2","CPUUtilization",{"stat":"p46.32","period":600}]]}` |
||||
assert.Equal(t, expected, decodedLink) |
||||
}) |
||||
|
||||
t.Run("A deep link that reference an expression based metric is created based on a request query with one stat", func(t *testing.T) { |
||||
start, err := time.Parse(time.RFC3339, "2018-03-15T13:00:00Z") |
||||
require.NoError(t, err) |
||||
end, err := time.Parse(time.RFC3339, "2018-03-18T13:34:00Z") |
||||
require.NoError(t, err) |
||||
|
||||
executedQueries := []executedQuery{{ |
||||
Expression: `REMOVE_EMPTY(SEARCH('Namespace="AWS/EC2" MetricName="CPUUtilization"', 'Sum', 600))`, |
||||
ID: "D", |
||||
Period: 600, |
||||
}} |
||||
|
||||
link, err := buildDeepLink("E", requestQueries, executedQueries, start, end) |
||||
require.NoError(t, err) |
||||
|
||||
parsedURL, err := url.Parse(link) |
||||
require.NoError(t, err) |
||||
|
||||
decodedLink, err := url.PathUnescape(parsedURL.String()) |
||||
require.NoError(t, err) |
||||
|
||||
expected := `https://us-east-1.console.aws.amazon.com/cloudwatch/deeplink.js?region=us-east-1#metricsV2:graph={"view":"timeSeries","stacked":false,"title":"E","start":"2018-03-15T13:00:00Z","end":"2018-03-18T13:34:00Z","region":"us-east-1","metrics":[{"expression":"REMOVE_EMPTY(SEARCH('Namespace=\"AWS/EC2\"+MetricName=\"CPUUtilization\"',+'Sum',+600))"}]}` |
||||
assert.Equal(t, expected, decodedLink) |
||||
}) |
||||
|
||||
t.Run("A deep link is not built in case any of the executedQueries are math expressions", func(t *testing.T) { |
||||
start, err := time.Parse(time.RFC3339, "2018-03-15T13:00:00Z") |
||||
require.NoError(t, err) |
||||
end, err := time.Parse(time.RFC3339, "2018-03-18T13:34:00Z") |
||||
require.NoError(t, err) |
||||
|
||||
executedQueries := []executedQuery{{ |
||||
Expression: `a * 2`, |
||||
ID: "D", |
||||
Period: 600, |
||||
}} |
||||
|
||||
link, err := buildDeepLink("E", requestQueries, executedQueries, start, end) |
||||
require.NoError(t, err) |
||||
|
||||
parsedURL, err := url.Parse(link) |
||||
require.NoError(t, err) |
||||
|
||||
decodedLink, err := url.PathUnescape(parsedURL.String()) |
||||
require.NoError(t, err) |
||||
assert.Equal(t, "", decodedLink) |
||||
}) |
||||
} |
||||
@ -0,0 +1,96 @@ |
||||
[ |
||||
{ |
||||
"Messages": null, |
||||
"MetricDataResults": [ |
||||
{ |
||||
"Id": "a", |
||||
"Label": "label1", |
||||
"Messages": null, |
||||
"StatusCode": "Complete", |
||||
"Timestamps": [ |
||||
"2021-01-15T19:44:00Z", |
||||
"2021-01-15T19:59:00Z", |
||||
"2021-01-15T20:14:00Z", |
||||
"2021-01-15T20:29:00Z", |
||||
"2021-01-15T20:44:00Z" |
||||
], |
||||
"Values": [ |
||||
0.1333395078879982, |
||||
0.244268469636633, |
||||
0.15574387947267768, |
||||
0.14447563659125626, |
||||
0.15519743138527173 |
||||
] |
||||
}, |
||||
{ |
||||
"Id": "a", |
||||
"Label": "label2", |
||||
"Messages": null, |
||||
"StatusCode": "Complete", |
||||
"Timestamps": [ |
||||
"2021-01-15T19:44:00Z" |
||||
], |
||||
"Values": [ |
||||
0.1333395078879982 |
||||
] |
||||
}, |
||||
{ |
||||
"Id": "b", |
||||
"Label": "label2", |
||||
"Messages": null, |
||||
"StatusCode": "Complete", |
||||
"Timestamps": [ |
||||
"2021-01-15T19:44:00Z" |
||||
], |
||||
"Values": [ |
||||
0.1333395078879982 |
||||
] |
||||
} |
||||
], |
||||
"NextToken": null |
||||
}, |
||||
{ |
||||
"Messages": [ |
||||
{ "Code": "", "Value": null }, |
||||
{ "Code": "MaxMetricsExceeded", "Value": null } |
||||
], |
||||
"MetricDataResults": [ |
||||
{ |
||||
"Id": "a", |
||||
"Label": "label1", |
||||
"Messages": null, |
||||
"StatusCode": "Complete", |
||||
"Timestamps": [ |
||||
"2021-01-15T19:44:00Z", |
||||
"2021-01-15T19:59:00Z", |
||||
"2021-01-15T20:14:00Z", |
||||
"2021-01-15T20:29:00Z", |
||||
"2021-01-15T20:44:00Z" |
||||
], |
||||
"Values": [ |
||||
0.1333395078879982, |
||||
0.244268469636633, |
||||
0.15574387947267768, |
||||
0.14447563659125626, |
||||
0.15519743138527173 |
||||
] |
||||
}, |
||||
{ |
||||
"Id": "b", |
||||
"Label": "label2", |
||||
"Messages": [{ |
||||
"Code": "ArithmeticError", |
||||
"Value": "One or more data-points have been dropped due to non-numeric values (NaN, -Infinite, +Infinite)" |
||||
}], |
||||
"StatusCode": "Partial", |
||||
"Timestamps": [ |
||||
"2021-01-15T19:44:00Z" |
||||
], |
||||
"Values": [ |
||||
0.1333395078879982 |
||||
] |
||||
} |
||||
], |
||||
"NextToken": null |
||||
} |
||||
] |
||||
File diff suppressed because it is too large
Load Diff
@ -1,21 +0,0 @@ |
||||
import React from 'react'; |
||||
import { render, screen } from '@testing-library/react'; |
||||
import { Stats } from './Stats'; |
||||
|
||||
const toOption = (value: any) => ({ label: value, value }); |
||||
|
||||
describe('Stats', () => { |
||||
it('should render component', () => { |
||||
render( |
||||
<Stats |
||||
data-testid="stats" |
||||
values={['Average', 'Minimum']} |
||||
variableOptionGroup={{ label: 'templateVar', value: 'templateVar' }} |
||||
onChange={() => {}} |
||||
stats={['Average', 'Maximum', 'Minimum', 'Sum', 'SampleCount'].map(toOption)} |
||||
/> |
||||
); |
||||
expect(screen.getByText('Average')).toBeInTheDocument(); |
||||
expect(screen.getByText('Minimum')).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
@ -1,45 +0,0 @@ |
||||
import React, { FunctionComponent } from 'react'; |
||||
import { SelectableStrings } from '../types'; |
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { Segment, Icon } 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)) |
||||
) |
||||
} |
||||
/> |
||||
))} |
||||
<Segment |
||||
Component={ |
||||
<a className="gf-form-label query-part"> |
||||
<Icon name="plus" /> |
||||
</a> |
||||
} |
||||
allowCustomValue |
||||
onChange={({ value }) => onChange([...values, value!])} |
||||
options={[...stats.filter(({ value }) => !values.includes(value!)), variableOptionGroup]} |
||||
/> |
||||
</> |
||||
); |
||||
@ -1,3 +0,0 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`QueryEditor should render component 1`] = `null`; |
||||
@ -0,0 +1,118 @@ |
||||
import { DataQuery } from '@grafana/data'; |
||||
import { migrateMultipleStatsAnnotationQuery, migrateMultipleStatsMetricsQuery } from './migrations'; |
||||
import { CloudWatchAnnotationQuery, CloudWatchMetricsQuery } from './types'; |
||||
|
||||
describe('migration', () => { |
||||
describe('migrateMultipleStatsMetricsQuery', () => { |
||||
const queryToMigrate = { |
||||
statistics: ['Average', 'Sum', 'Maximum'], |
||||
refId: 'A', |
||||
}; |
||||
const panelQueries: DataQuery[] = [ |
||||
{ ...queryToMigrate }, |
||||
{ |
||||
refId: 'B', |
||||
}, |
||||
]; |
||||
const newQueries = migrateMultipleStatsMetricsQuery(queryToMigrate as CloudWatchMetricsQuery, panelQueries); |
||||
const newMetricQueries = newQueries as CloudWatchMetricsQuery[]; |
||||
|
||||
it('should create one new query for each stat', () => { |
||||
expect(newQueries.length).toBe(2); |
||||
}); |
||||
|
||||
it('should assign new queries the right stats', () => { |
||||
expect(newMetricQueries[0].statistic).toBe('Sum'); |
||||
expect(newMetricQueries[1].statistic).toBe('Maximum'); |
||||
}); |
||||
|
||||
it('should assign new queries the right ref id', () => { |
||||
expect(newQueries[0].refId).toBe('C'); |
||||
expect(newQueries[1].refId).toBe('D'); |
||||
}); |
||||
|
||||
it('should not have statistics prop anymore', () => { |
||||
expect(queryToMigrate).not.toHaveProperty('statistics'); |
||||
expect(newQueries[0]).not.toHaveProperty('statistics'); |
||||
expect(newQueries[1]).not.toHaveProperty('statistics'); |
||||
}); |
||||
}); |
||||
|
||||
describe('migrateMultipleStatsMetricsQuery with only one stat', () => { |
||||
const queryToMigrate = { |
||||
statistics: ['Average'], |
||||
refId: 'A', |
||||
} as CloudWatchMetricsQuery; |
||||
const panelQueries: DataQuery[] = [ |
||||
{ ...queryToMigrate }, |
||||
{ |
||||
refId: 'B', |
||||
}, |
||||
]; |
||||
const newQueries = migrateMultipleStatsMetricsQuery(queryToMigrate as CloudWatchMetricsQuery, panelQueries); |
||||
|
||||
it('should not create any new queries', () => { |
||||
expect(newQueries.length).toBe(0); |
||||
}); |
||||
|
||||
it('should have the right stats', () => { |
||||
expect(queryToMigrate.statistic).toBe('Average'); |
||||
}); |
||||
|
||||
it('should not have statistics prop anymore', () => { |
||||
expect(queryToMigrate).not.toHaveProperty('statistics'); |
||||
}); |
||||
}); |
||||
|
||||
describe('migrateMultipleStatsAnnotationQuery', () => { |
||||
const annotationToMigrate = { |
||||
statistics: ['p23.23', 'SampleCount'], |
||||
name: 'Test annotation', |
||||
}; |
||||
|
||||
const newAnnotations = migrateMultipleStatsAnnotationQuery(annotationToMigrate as CloudWatchAnnotationQuery); |
||||
const newCloudWatchAnnotations = newAnnotations as CloudWatchAnnotationQuery[]; |
||||
|
||||
it('should create one new annotation for each stat', () => { |
||||
expect(newAnnotations.length).toBe(1); |
||||
}); |
||||
|
||||
it('should assign new queries the right stats', () => { |
||||
expect(newCloudWatchAnnotations[0].statistic).toBe('SampleCount'); |
||||
}); |
||||
|
||||
it('should assign new queries the right ref id', () => { |
||||
expect(newAnnotations[0].name).toBe('Test annotation - SampleCount'); |
||||
}); |
||||
|
||||
it('should not have statistics prop anymore', () => { |
||||
expect(newCloudWatchAnnotations[0]).not.toHaveProperty('statistics'); |
||||
}); |
||||
|
||||
it('should migrate original query correctly', () => { |
||||
expect(annotationToMigrate).not.toHaveProperty('statistics'); |
||||
expect(annotationToMigrate.name).toBe('Test annotation - p23.23'); |
||||
}); |
||||
|
||||
describe('migrateMultipleStatsAnnotationQuery with only with stat', () => { |
||||
const annotationToMigrate = { |
||||
statistics: ['p23.23'], |
||||
name: 'Test annotation', |
||||
} as CloudWatchAnnotationQuery; |
||||
const newAnnotations = migrateMultipleStatsAnnotationQuery(annotationToMigrate as CloudWatchAnnotationQuery); |
||||
|
||||
it('should not create new annotations', () => { |
||||
expect(newAnnotations.length).toBe(0); |
||||
}); |
||||
|
||||
it('should not change the name', () => { |
||||
expect(annotationToMigrate.name).toBe('Test annotation'); |
||||
}); |
||||
|
||||
it('should use statistics prop and remove statistics prop', () => { |
||||
expect(annotationToMigrate.statistic).toEqual('p23.23'); |
||||
expect(annotationToMigrate).not.toHaveProperty('statistics'); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,44 @@ |
||||
import { AnnotationQuery, DataQuery } from '@grafana/data'; |
||||
import { getNextRefIdChar } from 'app/core/utils/query'; |
||||
import { CloudWatchAnnotationQuery, CloudWatchMetricsQuery } from './types'; |
||||
|
||||
export function migrateMultipleStatsMetricsQuery( |
||||
query: CloudWatchMetricsQuery, |
||||
panelQueries: DataQuery[] |
||||
): DataQuery[] { |
||||
const newQueries = []; |
||||
if (query?.statistics && query?.statistics.length) { |
||||
query.statistic = query.statistics[0]; |
||||
for (const stat of query.statistics.splice(1)) { |
||||
newQueries.push({ ...query, statistic: stat }); |
||||
} |
||||
} |
||||
for (const newTarget of newQueries) { |
||||
newTarget.refId = getNextRefIdChar(panelQueries); |
||||
delete newTarget.statistics; |
||||
panelQueries.push(newTarget); |
||||
} |
||||
delete query.statistics; |
||||
|
||||
return newQueries; |
||||
} |
||||
|
||||
export function migrateMultipleStatsAnnotationQuery( |
||||
annotationQuery: CloudWatchAnnotationQuery |
||||
): Array<AnnotationQuery<DataQuery>> { |
||||
const newAnnotations: CloudWatchAnnotationQuery[] = []; |
||||
if (annotationQuery?.statistics && annotationQuery?.statistics.length) { |
||||
for (const stat of annotationQuery.statistics.splice(1)) { |
||||
const { statistics, name, ...newAnnotation } = annotationQuery; |
||||
newAnnotations.push({ ...newAnnotation, statistic: stat, name: `${name} - ${stat}` }); |
||||
} |
||||
annotationQuery.statistic = annotationQuery.statistics[0]; |
||||
// Only change the name of the original if new annotations have been created
|
||||
if (newAnnotations.length !== 0) { |
||||
annotationQuery.name = `${annotationQuery.name} - ${annotationQuery.statistic}`; |
||||
} |
||||
delete annotationQuery.statistics; |
||||
} |
||||
|
||||
return newAnnotations as Array<AnnotationQuery<DataQuery>>; |
||||
} |
||||
@ -1,241 +0,0 @@ |
||||
import angular from 'angular'; |
||||
import coreModule from 'app/core/core_module'; |
||||
import { each, flatten, isEmpty, map, reduce } from 'lodash'; |
||||
import { TemplateSrv } from '@grafana/runtime'; |
||||
|
||||
export class CloudWatchQueryParameterCtrl { |
||||
/** @ngInject */ |
||||
constructor($scope: any, templateSrv: TemplateSrv, uiSegmentSrv: any) { |
||||
$scope.init = () => { |
||||
const target = $scope.target; |
||||
target.namespace = target.namespace || ''; |
||||
target.metricName = target.metricName || ''; |
||||
target.statistics = target.statistics || ['Average']; |
||||
target.dimensions = target.dimensions || {}; |
||||
target.period = target.period || ''; |
||||
target.region = target.region || 'default'; |
||||
target.id = target.id || ''; |
||||
target.expression = target.expression || ''; |
||||
|
||||
$scope.regionSegment = uiSegmentSrv.getSegmentForValue($scope.target.region, 'select region'); |
||||
$scope.namespaceSegment = uiSegmentSrv.getSegmentForValue($scope.target.namespace, 'select namespace'); |
||||
$scope.metricSegment = uiSegmentSrv.getSegmentForValue($scope.target.metricName, 'select metric'); |
||||
|
||||
$scope.dimSegments = reduce( |
||||
$scope.target.dimensions, |
||||
(memo, value, key) => { |
||||
memo.push(uiSegmentSrv.newKey(key)); |
||||
memo.push(uiSegmentSrv.newOperator('=')); |
||||
memo.push(uiSegmentSrv.newKeyValue(value)); |
||||
return memo; |
||||
}, |
||||
[] as any |
||||
); |
||||
|
||||
$scope.statSegments = map($scope.target.statistics, (stat) => { |
||||
return uiSegmentSrv.getSegmentForValue(stat); |
||||
}); |
||||
|
||||
$scope.ensurePlusButton($scope.statSegments); |
||||
$scope.ensurePlusButton($scope.dimSegments); |
||||
$scope.removeDimSegment = uiSegmentSrv.newSegment({ |
||||
fake: true, |
||||
value: '-- remove dimension --', |
||||
}); |
||||
$scope.removeStatSegment = uiSegmentSrv.newSegment({ |
||||
fake: true, |
||||
value: '-- remove stat --', |
||||
}); |
||||
|
||||
if (isEmpty($scope.target.region)) { |
||||
$scope.target.region = 'default'; |
||||
} |
||||
|
||||
if (!$scope.onChange) { |
||||
$scope.onChange = () => {}; |
||||
} |
||||
}; |
||||
|
||||
$scope.getStatSegments = () => { |
||||
return Promise.resolve( |
||||
flatten([ |
||||
angular.copy($scope.removeStatSegment), |
||||
map($scope.datasource.standardStatistics, (s) => { |
||||
return uiSegmentSrv.getSegmentForValue(s); |
||||
}), |
||||
uiSegmentSrv.getSegmentForValue('pNN.NN'), |
||||
]) |
||||
); |
||||
}; |
||||
|
||||
$scope.statSegmentChanged = (segment: any, index: number) => { |
||||
if (segment.value === $scope.removeStatSegment.value) { |
||||
$scope.statSegments.splice(index, 1); |
||||
} else { |
||||
segment.type = 'value'; |
||||
} |
||||
|
||||
$scope.target.statistics = reduce( |
||||
$scope.statSegments, |
||||
(memo, seg) => { |
||||
if (!seg.fake) { |
||||
memo.push(seg.value); |
||||
} |
||||
return memo; |
||||
}, |
||||
[] as any |
||||
); |
||||
|
||||
$scope.ensurePlusButton($scope.statSegments); |
||||
$scope.onChange(); |
||||
}; |
||||
|
||||
$scope.ensurePlusButton = (segments: any) => { |
||||
const count = segments.length; |
||||
const lastSegment = segments[Math.max(count - 1, 0)]; |
||||
|
||||
if (!lastSegment || lastSegment.type !== 'plus-button') { |
||||
segments.push(uiSegmentSrv.newPlusButton()); |
||||
} |
||||
}; |
||||
|
||||
$scope.getDimSegments = (segment: any, $index: number) => { |
||||
if (segment.type === 'operator') { |
||||
return Promise.resolve([]); |
||||
} |
||||
|
||||
const target = $scope.target; |
||||
let query = Promise.resolve([] as any[]); |
||||
|
||||
if (segment.type === 'key' || segment.type === 'plus-button') { |
||||
query = $scope.datasource.getDimensionKeys($scope.target.namespace, $scope.target.region); |
||||
} else if (segment.type === 'value') { |
||||
const dimensionKey = $scope.dimSegments[$index - 2].value; |
||||
delete target.dimensions[dimensionKey]; |
||||
query = $scope.datasource.getDimensionValues( |
||||
target.region, |
||||
target.namespace, |
||||
target.metricName, |
||||
dimensionKey, |
||||
target.dimensions |
||||
); |
||||
} |
||||
|
||||
return query.then($scope.transformToSegments(true)).then((results) => { |
||||
if (segment.type === 'key') { |
||||
results.splice(0, 0, angular.copy($scope.removeDimSegment)); |
||||
} |
||||
return results; |
||||
}); |
||||
}; |
||||
|
||||
$scope.dimSegmentChanged = (segment: any, index: number) => { |
||||
$scope.dimSegments[index] = segment; |
||||
|
||||
if (segment.value === $scope.removeDimSegment.value) { |
||||
$scope.dimSegments.splice(index, 3); |
||||
} else if (segment.type === 'plus-button') { |
||||
$scope.dimSegments.push(uiSegmentSrv.newOperator('=')); |
||||
$scope.dimSegments.push(uiSegmentSrv.newFake('select dimension value', 'value', 'query-segment-value')); |
||||
segment.type = 'key'; |
||||
segment.cssClass = 'query-segment-key'; |
||||
} |
||||
|
||||
$scope.syncDimSegmentsWithModel(); |
||||
$scope.ensurePlusButton($scope.dimSegments); |
||||
$scope.onChange(); |
||||
}; |
||||
|
||||
$scope.syncDimSegmentsWithModel = () => { |
||||
const dims: any = {}; |
||||
const length = $scope.dimSegments.length; |
||||
|
||||
for (let i = 0; i < length - 2; i += 3) { |
||||
const keySegment = $scope.dimSegments[i]; |
||||
const valueSegment = $scope.dimSegments[i + 2]; |
||||
if (!valueSegment.fake) { |
||||
dims[keySegment.value] = valueSegment.value; |
||||
} |
||||
} |
||||
|
||||
$scope.target.dimensions = dims; |
||||
}; |
||||
|
||||
$scope.getRegions = () => { |
||||
return $scope.datasource |
||||
.metricFindQuery('regions()') |
||||
.then((results: any) => { |
||||
results.unshift({ text: 'default' }); |
||||
return results; |
||||
}) |
||||
.then($scope.transformToSegments(true)); |
||||
}; |
||||
|
||||
$scope.getNamespaces = () => { |
||||
return $scope.datasource.metricFindQuery('namespaces()').then($scope.transformToSegments(true)); |
||||
}; |
||||
|
||||
$scope.getMetrics = () => { |
||||
return $scope.datasource |
||||
.metricFindQuery('metrics(' + $scope.target.namespace + ',' + $scope.target.region + ')') |
||||
.then($scope.transformToSegments(true)); |
||||
}; |
||||
|
||||
$scope.regionChanged = () => { |
||||
$scope.target.region = $scope.regionSegment.value; |
||||
$scope.onChange(); |
||||
}; |
||||
|
||||
$scope.namespaceChanged = () => { |
||||
$scope.target.namespace = $scope.namespaceSegment.value; |
||||
$scope.onChange(); |
||||
}; |
||||
|
||||
$scope.metricChanged = () => { |
||||
$scope.target.metricName = $scope.metricSegment.value; |
||||
$scope.onChange(); |
||||
}; |
||||
|
||||
$scope.transformToSegments = (addTemplateVars: any) => { |
||||
return (results: any) => { |
||||
const segments = map(results, (segment) => { |
||||
return uiSegmentSrv.newSegment({ |
||||
value: segment.text, |
||||
expandable: segment.expandable, |
||||
}); |
||||
}); |
||||
|
||||
if (addTemplateVars) { |
||||
each(templateSrv.getVariables(), (variable) => { |
||||
segments.unshift( |
||||
uiSegmentSrv.newSegment({ |
||||
type: 'template', |
||||
value: '$' + variable.name, |
||||
expandable: true, |
||||
}) |
||||
); |
||||
}); |
||||
} |
||||
|
||||
return segments; |
||||
}; |
||||
}; |
||||
|
||||
$scope.init(); |
||||
} |
||||
} |
||||
|
||||
export function cloudWatchQueryParameter() { |
||||
return { |
||||
templateUrl: 'public/app/plugins/datasource/cloudwatch/partials/query.parameter.html', |
||||
controller: CloudWatchQueryParameterCtrl, |
||||
restrict: 'E', |
||||
scope: { |
||||
target: '=', |
||||
datasource: '=', |
||||
onChange: '&', |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
coreModule.directive('cloudwatchQueryParameter', cloudWatchQueryParameter); |
||||
Loading…
Reference in new issue