mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Cloudwatch: Add support for template variables in new log group picker (#61243)
This commit is contained in:
@@ -5159,8 +5159,7 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
],
|
||||
"public/app/plugins/datasource/cloudwatch/query-runner/CloudWatchLogsQueryRunner.ts:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/plugins/datasource/cloudwatch/types.ts:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -257,152 +256,6 @@ func Test_executeLogAlertQuery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestQuery_ResourceRequest_DescribeAllLogGroups(t *testing.T) {
|
||||
origNewCWLogsClient := NewCWLogsClient
|
||||
t.Cleanup(func() {
|
||||
NewCWLogsClient = origNewCWLogsClient
|
||||
})
|
||||
|
||||
var cli fakeCWLogsClient
|
||||
|
||||
NewCWLogsClient = func(sess *session.Session) cloudwatchlogsiface.CloudWatchLogsAPI {
|
||||
return &cli
|
||||
}
|
||||
|
||||
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
|
||||
return DataSource{Settings: models.CloudWatchSettings{}}, nil
|
||||
})
|
||||
|
||||
executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures())
|
||||
sender := &mockedCallResourceResponseSenderForOauth{}
|
||||
|
||||
t.Run("multiple batches", func(t *testing.T) {
|
||||
token := "foo"
|
||||
cli = fakeCWLogsClient{
|
||||
logGroups: []cloudwatchlogs.DescribeLogGroupsOutput{
|
||||
{
|
||||
LogGroups: []*cloudwatchlogs.LogGroup{
|
||||
{
|
||||
LogGroupName: aws.String("group_a"),
|
||||
},
|
||||
{
|
||||
LogGroupName: aws.String("group_b"),
|
||||
},
|
||||
{
|
||||
LogGroupName: aws.String("group_c"),
|
||||
},
|
||||
},
|
||||
NextToken: &token,
|
||||
},
|
||||
{
|
||||
LogGroups: []*cloudwatchlogs.LogGroup{
|
||||
{
|
||||
LogGroupName: aws.String("group_x"),
|
||||
},
|
||||
{
|
||||
LogGroupName: aws.String("group_y"),
|
||||
},
|
||||
{
|
||||
LogGroupName: aws.String("group_z"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := &backend.CallResourceRequest{
|
||||
Method: "GET",
|
||||
Path: "/all-log-groups?limit=50",
|
||||
PluginContext: backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
ID: 0,
|
||||
},
|
||||
PluginID: "cloudwatch",
|
||||
},
|
||||
}
|
||||
err := executor.CallResource(context.Background(), req, sender)
|
||||
require.NoError(t, err)
|
||||
sent := sender.Response
|
||||
require.NotNil(t, sent)
|
||||
require.Equal(t, http.StatusOK, sent.Status)
|
||||
|
||||
suggestDataResponse := []suggestData{}
|
||||
err = json.Unmarshal(sent.Body, &suggestDataResponse)
|
||||
require.Nil(t, err)
|
||||
|
||||
assert.Equal(t, stringsToSuggestData([]string{
|
||||
"group_a", "group_b", "group_c", "group_x", "group_y", "group_z",
|
||||
}), suggestDataResponse)
|
||||
})
|
||||
|
||||
t.Run("Should call api with LogGroupNamePrefix if passed in resource call", func(t *testing.T) {
|
||||
cli = fakeCWLogsClient{
|
||||
logGroups: []cloudwatchlogs.DescribeLogGroupsOutput{
|
||||
{LogGroups: []*cloudwatchlogs.LogGroup{}},
|
||||
},
|
||||
}
|
||||
|
||||
executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures())
|
||||
|
||||
req := &backend.CallResourceRequest{
|
||||
Method: "GET",
|
||||
Path: "/all-log-groups?logGroupNamePrefix=test",
|
||||
PluginContext: backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
ID: 0,
|
||||
},
|
||||
PluginID: "cloudwatch",
|
||||
},
|
||||
}
|
||||
err := executor.CallResource(context.Background(), req, sender)
|
||||
|
||||
require.NoError(t, err)
|
||||
sent := sender.Response
|
||||
require.NotNil(t, sent)
|
||||
require.Equal(t, http.StatusOK, sent.Status)
|
||||
|
||||
assert.Equal(t, []*cloudwatchlogs.DescribeLogGroupsInput{
|
||||
{
|
||||
Limit: aws.Int64(defaultLogGroupLimit),
|
||||
LogGroupNamePrefix: aws.String("test"),
|
||||
},
|
||||
}, cli.calls.describeLogGroups)
|
||||
})
|
||||
|
||||
t.Run("Should call api without LogGroupNamePrefix when an empty string is passed in resource call", func(t *testing.T) {
|
||||
cli = fakeCWLogsClient{
|
||||
logGroups: []cloudwatchlogs.DescribeLogGroupsOutput{
|
||||
{LogGroups: []*cloudwatchlogs.LogGroup{}},
|
||||
},
|
||||
}
|
||||
|
||||
executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures())
|
||||
|
||||
req := &backend.CallResourceRequest{
|
||||
Method: "GET",
|
||||
Path: "/all-log-groups?logGroupNamePrefix=",
|
||||
PluginContext: backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{
|
||||
ID: 0,
|
||||
},
|
||||
PluginID: "cloudwatch",
|
||||
},
|
||||
}
|
||||
err := executor.CallResource(context.Background(), req, sender)
|
||||
|
||||
require.NoError(t, err)
|
||||
sent := sender.Response
|
||||
require.NotNil(t, sent)
|
||||
require.Equal(t, http.StatusOK, sent.Status)
|
||||
|
||||
assert.Equal(t, []*cloudwatchlogs.DescribeLogGroupsInput{
|
||||
{
|
||||
Limit: aws.Int64(50),
|
||||
},
|
||||
}, cli.calls.describeLogGroups)
|
||||
})
|
||||
}
|
||||
|
||||
func TestQuery_ResourceRequest_DescribeLogGroups_with_CrossAccountQuerying(t *testing.T) {
|
||||
sender := &mockedCallResourceResponseSenderForOauth{}
|
||||
origNewMetricsAPI := NewMetricsAPI
|
||||
@@ -425,7 +278,7 @@ func TestQuery_ResourceRequest_DescribeLogGroups_with_CrossAccountQuerying(t *te
|
||||
return DataSource{Settings: models.CloudWatchSettings{}}, nil
|
||||
})
|
||||
|
||||
t.Run("maps log group api response to resource response of describe-log-groups", func(t *testing.T) {
|
||||
t.Run("maps log group api response to resource response of log-groups", func(t *testing.T) {
|
||||
logsApi = mocks.LogsAPI{}
|
||||
logsApi.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{
|
||||
LogGroups: []*cloudwatchlogs.LogGroup{
|
||||
@@ -434,7 +287,7 @@ func TestQuery_ResourceRequest_DescribeLogGroups_with_CrossAccountQuerying(t *te
|
||||
}, nil)
|
||||
req := &backend.CallResourceRequest{
|
||||
Method: "GET",
|
||||
Path: `/describe-log-groups?logGroupPattern=some-pattern&accountId=some-account-id`,
|
||||
Path: `/log-groups?logGroupPattern=some-pattern&accountId=some-account-id`,
|
||||
PluginContext: backend.PluginContext{
|
||||
DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ID: 0},
|
||||
PluginID: "cloudwatch",
|
||||
@@ -464,11 +317,3 @@ func TestQuery_ResourceRequest_DescribeLogGroups_with_CrossAccountQuerying(t *te
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func stringsToSuggestData(values []string) []suggestData {
|
||||
suggestDataArray := make([]suggestData, 0)
|
||||
for _, v := range values {
|
||||
suggestDataArray = append(suggestDataArray, suggestData{Text: v, Value: v, Label: v})
|
||||
}
|
||||
return suggestDataArray
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/cloudwatchlogs"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
@@ -288,44 +287,3 @@ func (e *cloudWatchExecutor) resourceGroupsGetResources(pluginCtx backend.Plugin
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (e *cloudWatchExecutor) handleGetAllLogGroups(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
|
||||
var nextToken *string
|
||||
|
||||
logGroupNamePrefix := parameters.Get("logGroupNamePrefix")
|
||||
|
||||
var err error
|
||||
logsClient, err := e.getCWLogsClient(pluginCtx, parameters.Get("region"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response *cloudwatchlogs.DescribeLogGroupsOutput
|
||||
result := make([]suggestData, 0)
|
||||
for {
|
||||
input := &cloudwatchlogs.DescribeLogGroupsInput{
|
||||
Limit: aws.Int64(defaultLogGroupLimit),
|
||||
NextToken: nextToken,
|
||||
}
|
||||
if len(logGroupNamePrefix) > 0 {
|
||||
input.LogGroupNamePrefix = aws.String(logGroupNamePrefix)
|
||||
}
|
||||
response, err = logsClient.DescribeLogGroups(input)
|
||||
|
||||
if err != nil || response == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, logGroup := range response.LogGroups {
|
||||
logGroupName := *logGroup.LogGroupName
|
||||
result = append(result, suggestData{Text: logGroupName, Value: logGroupName, Label: logGroupName})
|
||||
}
|
||||
|
||||
if response.NextToken == nil {
|
||||
break
|
||||
}
|
||||
nextToken = response.NextToken
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type LogGroupsRequest struct {
|
||||
ResourceRequest
|
||||
Limit int64
|
||||
LogGroupNamePrefix, LogGroupNamePattern *string
|
||||
ListAllLogGroups bool
|
||||
}
|
||||
|
||||
func (r LogGroupsRequest) IsTargetingAllAccounts() bool {
|
||||
@@ -33,6 +34,7 @@ func ParseLogGroupsRequest(parameters url.Values) (LogGroupsRequest, error) {
|
||||
},
|
||||
LogGroupNamePrefix: logGroupNamePrefix,
|
||||
LogGroupNamePattern: logGroupPattern,
|
||||
ListAllLogGroups: parameters.Get("listAllLogGroups") == "true",
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ func (e *cloudWatchExecutor) newResourceMux() *http.ServeMux {
|
||||
mux.HandleFunc("/ebs-volume-ids", handleResourceReq(e.handleGetEbsVolumeIds))
|
||||
mux.HandleFunc("/ec2-instance-attribute", handleResourceReq(e.handleGetEc2InstanceAttribute))
|
||||
mux.HandleFunc("/resource-arns", handleResourceReq(e.handleGetResourceArns))
|
||||
mux.HandleFunc("/describe-log-groups", routes.ResourceRequestMiddleware(routes.LogGroupsHandler, logger, e.getRequestContext))
|
||||
mux.HandleFunc("/all-log-groups", handleResourceReq(e.handleGetAllLogGroups))
|
||||
mux.HandleFunc("/log-groups", routes.ResourceRequestMiddleware(routes.LogGroupsHandler, logger, e.getRequestContext))
|
||||
mux.HandleFunc("/metrics", routes.ResourceRequestMiddleware(routes.MetricsHandler, logger, e.getRequestContext))
|
||||
mux.HandleFunc("/dimension-values", routes.ResourceRequestMiddleware(routes.DimensionValuesHandler, logger, e.getRequestContext))
|
||||
mux.HandleFunc("/dimension-keys", routes.ResourceRequestMiddleware(routes.DimensionKeysHandler, logger, e.getRequestContext))
|
||||
|
||||
@@ -33,20 +33,28 @@ func (s *LogGroupsService) GetLogGroups(req resources.LogGroupsRequest) ([]resou
|
||||
input.AccountIdentifiers = []*string{req.AccountId}
|
||||
}
|
||||
}
|
||||
response, err := s.logGroupsAPI.DescribeLogGroups(input)
|
||||
if err != nil || response == nil {
|
||||
return nil, err
|
||||
}
|
||||
result := []resources.ResourceResponse[resources.LogGroup]{}
|
||||
|
||||
var result []resources.ResourceResponse[resources.LogGroup]
|
||||
for _, logGroup := range response.LogGroups {
|
||||
result = append(result, resources.ResourceResponse[resources.LogGroup]{
|
||||
Value: resources.LogGroup{
|
||||
Arn: *logGroup.Arn,
|
||||
Name: *logGroup.LogGroupName,
|
||||
},
|
||||
AccountId: utils.Pointer(getAccountId(*logGroup.Arn)),
|
||||
})
|
||||
for {
|
||||
response, err := s.logGroupsAPI.DescribeLogGroups(input)
|
||||
if err != nil || response == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, logGroup := range response.LogGroups {
|
||||
result = append(result, resources.ResourceResponse[resources.LogGroup]{
|
||||
Value: resources.LogGroup{
|
||||
Arn: *logGroup.Arn,
|
||||
Name: *logGroup.LogGroupName,
|
||||
},
|
||||
AccountId: utils.Pointer(getAccountId(*logGroup.Arn)),
|
||||
})
|
||||
}
|
||||
|
||||
if !req.ListAllLogGroups || response.NextToken == nil {
|
||||
break
|
||||
}
|
||||
input.NextToken = response.NextToken
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/cloudwatchlogs"
|
||||
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/mocks"
|
||||
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models/resources"
|
||||
@@ -44,6 +45,17 @@ func TestGetLogGroups(t *testing.T) {
|
||||
}, resp)
|
||||
})
|
||||
|
||||
t.Run("Should return an empty error if api doesn't return any data", func(t *testing.T) {
|
||||
mockLogsAPI := &mocks.LogsAPI{}
|
||||
mockLogsAPI.On("DescribeLogGroups", mock.Anything).Return(&cloudwatchlogs.DescribeLogGroupsOutput{}, nil)
|
||||
service := NewLogGroupsService(mockLogsAPI, false)
|
||||
|
||||
resp, err := service.GetLogGroups(resources.LogGroupsRequest{})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []resources.ResourceResponse[resources.LogGroup]{}, resp)
|
||||
})
|
||||
|
||||
t.Run("Should only use LogGroupNamePrefix even if LogGroupNamePattern passed in resource call", func(t *testing.T) {
|
||||
// TODO: use LogGroupNamePattern when we have accounted for its behavior, still a little unexpected at the moment
|
||||
mockLogsAPI := &mocks.LogsAPI{}
|
||||
@@ -86,6 +98,82 @@ func TestGetLogGroups(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "some error", err.Error())
|
||||
})
|
||||
|
||||
t.Run("Should only call the api once in case ListAllLogGroups is set to false", func(t *testing.T) {
|
||||
mockLogsAPI := &mocks.LogsAPI{}
|
||||
req := resources.LogGroupsRequest{
|
||||
Limit: 2,
|
||||
LogGroupNamePrefix: utils.Pointer("test"),
|
||||
ListAllLogGroups: false,
|
||||
}
|
||||
|
||||
mockLogsAPI.On("DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{
|
||||
Limit: aws.Int64(req.Limit),
|
||||
LogGroupNamePrefix: req.LogGroupNamePrefix,
|
||||
}).Return(&cloudwatchlogs.DescribeLogGroupsOutput{
|
||||
LogGroups: []*cloudwatchlogs.LogGroup{
|
||||
{Arn: utils.Pointer("arn:aws:logs:us-east-1:111:log-group:group_a"), LogGroupName: utils.Pointer("group_a")},
|
||||
},
|
||||
NextToken: aws.String("next_token"),
|
||||
}, nil)
|
||||
|
||||
service := NewLogGroupsService(mockLogsAPI, false)
|
||||
resp, err := service.GetLogGroups(req)
|
||||
|
||||
assert.NoError(t, err)
|
||||
mockLogsAPI.AssertNumberOfCalls(t, "DescribeLogGroups", 1)
|
||||
assert.Equal(t, []resources.ResourceResponse[resources.LogGroup]{
|
||||
{
|
||||
AccountId: utils.Pointer("111"),
|
||||
Value: resources.LogGroup{Arn: "arn:aws:logs:us-east-1:111:log-group:group_a", Name: "group_a"},
|
||||
},
|
||||
}, resp)
|
||||
})
|
||||
|
||||
t.Run("Should keep on calling the api until NextToken is empty in case ListAllLogGroups is set to true", func(t *testing.T) {
|
||||
mockLogsAPI := &mocks.LogsAPI{}
|
||||
req := resources.LogGroupsRequest{
|
||||
Limit: 2,
|
||||
LogGroupNamePrefix: utils.Pointer("test"),
|
||||
ListAllLogGroups: true,
|
||||
}
|
||||
|
||||
// first call
|
||||
mockLogsAPI.On("DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{
|
||||
Limit: aws.Int64(req.Limit),
|
||||
LogGroupNamePrefix: req.LogGroupNamePrefix,
|
||||
}).Return(&cloudwatchlogs.DescribeLogGroupsOutput{
|
||||
LogGroups: []*cloudwatchlogs.LogGroup{
|
||||
{Arn: utils.Pointer("arn:aws:logs:us-east-1:111:log-group:group_a"), LogGroupName: utils.Pointer("group_a")},
|
||||
},
|
||||
NextToken: utils.Pointer("token"),
|
||||
}, nil)
|
||||
|
||||
// second call
|
||||
mockLogsAPI.On("DescribeLogGroups", &cloudwatchlogs.DescribeLogGroupsInput{
|
||||
Limit: aws.Int64(req.Limit),
|
||||
LogGroupNamePrefix: req.LogGroupNamePrefix,
|
||||
NextToken: utils.Pointer("token"),
|
||||
}).Return(&cloudwatchlogs.DescribeLogGroupsOutput{
|
||||
LogGroups: []*cloudwatchlogs.LogGroup{
|
||||
{Arn: utils.Pointer("arn:aws:logs:us-east-1:222:log-group:group_b"), LogGroupName: utils.Pointer("group_b")},
|
||||
},
|
||||
}, nil)
|
||||
service := NewLogGroupsService(mockLogsAPI, false)
|
||||
resp, err := service.GetLogGroups(req)
|
||||
assert.NoError(t, err)
|
||||
mockLogsAPI.AssertNumberOfCalls(t, "DescribeLogGroups", 2)
|
||||
assert.Equal(t, []resources.ResourceResponse[resources.LogGroup]{
|
||||
{
|
||||
AccountId: utils.Pointer("111"),
|
||||
Value: resources.LogGroup{Arn: "arn:aws:logs:us-east-1:111:log-group:group_a", Name: "group_a"},
|
||||
},
|
||||
{
|
||||
AccountId: utils.Pointer("222"),
|
||||
Value: resources.LogGroup{Arn: "arn:aws:logs:us-east-1:222:log-group:group_b", Name: "group_b"},
|
||||
},
|
||||
}, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLogGroupsCrossAccountQuerying(t *testing.T) {
|
||||
|
||||
@@ -85,7 +85,7 @@ export function setupMockedDataSource({
|
||||
datasource.api.getDimensionKeys = jest.fn().mockResolvedValue([]);
|
||||
datasource.api.getMetrics = jest.fn().mockResolvedValue([]);
|
||||
datasource.api.getAccounts = jest.fn().mockResolvedValue([]);
|
||||
datasource.api.describeLogGroups = jest.fn().mockResolvedValue([]);
|
||||
datasource.api.getLogGroups = jest.fn().mockResolvedValue([]);
|
||||
const fetchMock = jest.fn().mockReturnValue(of({}));
|
||||
setBackendSrv({
|
||||
...getBackendSrv(),
|
||||
@@ -193,7 +193,7 @@ export const logGroupNamesVariable: CustomVariableModel = {
|
||||
id: 'groups',
|
||||
name: 'groups',
|
||||
current: {
|
||||
value: ['templatedGroup-1', 'templatedGroup-2'],
|
||||
value: ['templatedGroup-arn-1', 'templatedGroup-arn-2'],
|
||||
text: ['templatedGroup-1', 'templatedGroup-2'],
|
||||
selected: true,
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { CustomVariableModel, DataFrame } from '@grafana/data';
|
||||
import { CustomVariableModel, DataFrame, DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { BackendDataSourceResponse, getBackendSrv, setBackendSrv } from '@grafana/runtime';
|
||||
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
|
||||
import { CloudWatchLogsQueryRunner } from '../query-runner/CloudWatchLogsQueryRunner';
|
||||
import { CloudWatchLogsQueryStatus } from '../types';
|
||||
import { CloudWatchJsonData, CloudWatchLogsQueryStatus } from '../types';
|
||||
|
||||
import { CloudWatchSettings, setupMockedTemplateService } from './CloudWatchDataSource';
|
||||
|
||||
@@ -16,7 +16,13 @@ export function setupMockedLogsQueryRunner({
|
||||
},
|
||||
variables,
|
||||
mockGetVariableName = true,
|
||||
}: { data?: BackendDataSourceResponse; variables?: CustomVariableModel[]; mockGetVariableName?: boolean } = {}) {
|
||||
settings = CloudWatchSettings,
|
||||
}: {
|
||||
data?: BackendDataSourceResponse;
|
||||
variables?: CustomVariableModel[];
|
||||
mockGetVariableName?: boolean;
|
||||
settings?: DataSourceInstanceSettings<CloudWatchJsonData>;
|
||||
} = {}) {
|
||||
let templateService = new TemplateSrv();
|
||||
if (variables) {
|
||||
templateService = setupMockedTemplateService(variables);
|
||||
@@ -25,7 +31,7 @@ export function setupMockedLogsQueryRunner({
|
||||
}
|
||||
}
|
||||
|
||||
const runner = new CloudWatchLogsQueryRunner(CloudWatchSettings, templateService, getTimeSrv());
|
||||
const runner = new CloudWatchLogsQueryRunner(settings, templateService, getTimeSrv());
|
||||
const fetchMock = jest.fn().mockReturnValue(of({ data }));
|
||||
setBackendSrv({
|
||||
...getBackendSrv(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DataQueryRequest } from '@grafana/data';
|
||||
|
||||
import { CloudWatchQuery } from '../types';
|
||||
import { CloudWatchQuery, CloudWatchLogsQuery } from '../types';
|
||||
|
||||
import { TimeRangeMock } from './timeRange';
|
||||
|
||||
@@ -16,3 +16,16 @@ export const RequestMock: DataQueryRequest<CloudWatchQuery> = {
|
||||
app: '',
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
export const LogsRequestMock: DataQueryRequest<CloudWatchLogsQuery> = {
|
||||
range: TimeRangeMock,
|
||||
rangeRaw: { from: TimeRangeMock.from, to: TimeRangeMock.to },
|
||||
targets: [],
|
||||
requestId: '',
|
||||
interval: '',
|
||||
intervalMs: 0,
|
||||
scopedVars: {},
|
||||
timezone: '',
|
||||
app: '',
|
||||
startTime: 0,
|
||||
};
|
||||
|
||||
@@ -4,10 +4,10 @@ describe('api', () => {
|
||||
describe('describeLogGroup', () => {
|
||||
it('replaces region correctly in the query', async () => {
|
||||
const { api, resourceRequestMock } = setupMockedAPI();
|
||||
await api.describeLogGroups({ region: 'default' });
|
||||
await api.getLogGroups({ region: 'default' });
|
||||
expect(resourceRequestMock.mock.calls[0][1].region).toBe('us-west-1');
|
||||
|
||||
await api.describeLogGroups({ region: 'eu-east' });
|
||||
await api.getLogGroups({ region: 'eu-east' });
|
||||
expect(resourceRequestMock.mock.calls[1][1].region).toBe('eu-east');
|
||||
});
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('api', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const logGroups = await api.describeLogGroups({ region: 'default' });
|
||||
const logGroups = await api.getLogGroups({ region: 'default' });
|
||||
|
||||
expect(logGroups).toEqual(expectedLogGroups);
|
||||
});
|
||||
|
||||
@@ -64,15 +64,16 @@ export class CloudWatchAPI extends CloudWatchRequest {
|
||||
);
|
||||
}
|
||||
|
||||
async describeLogGroups(params: DescribeLogGroupsRequest): Promise<Array<ResourceResponse<LogGroupResponse>>> {
|
||||
return this.memoizedGetRequest<Array<ResourceResponse<LogGroupResponse>>>('describe-log-groups', {
|
||||
getLogGroups(params: DescribeLogGroupsRequest): Promise<Array<ResourceResponse<LogGroupResponse>>> {
|
||||
return this.memoizedGetRequest<Array<ResourceResponse<LogGroupResponse>>>('log-groups', {
|
||||
...params,
|
||||
region: this.templateSrv.replace(this.getActualRegion(params.region)),
|
||||
accountId: this.templateSrv.replace(params.accountId),
|
||||
listAllLogGroups: params.listAllLogGroups ? 'true' : 'false',
|
||||
});
|
||||
}
|
||||
|
||||
async getLogGroupFields({
|
||||
getLogGroupFields({
|
||||
region,
|
||||
arn,
|
||||
logGroupName,
|
||||
@@ -84,16 +85,9 @@ export class CloudWatchAPI extends CloudWatchRequest {
|
||||
});
|
||||
}
|
||||
|
||||
async describeAllLogGroups(params: DescribeLogGroupsRequest) {
|
||||
return this.memoizedGetRequest<SelectableResourceValue[]>('all-log-groups', {
|
||||
...params,
|
||||
region: this.templateSrv.replace(this.getActualRegion(params.region)),
|
||||
});
|
||||
}
|
||||
|
||||
async getMetrics({ region, namespace, accountId }: GetMetricsRequest): Promise<Array<SelectableValue<string>>> {
|
||||
getMetrics({ region, namespace, accountId }: GetMetricsRequest): Promise<Array<SelectableValue<string>>> {
|
||||
if (!namespace) {
|
||||
return [];
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return this.memoizedGetRequest<Array<ResourceResponse<MetricResponse>>>('metrics', {
|
||||
@@ -103,17 +97,14 @@ export class CloudWatchAPI extends CloudWatchRequest {
|
||||
}).then((metrics) => metrics.map((m) => ({ label: m.value.name, value: m.value.name })));
|
||||
}
|
||||
|
||||
async getAllMetrics({
|
||||
region,
|
||||
accountId,
|
||||
}: GetMetricsRequest): Promise<Array<{ metricName?: string; namespace: string }>> {
|
||||
getAllMetrics({ region, accountId }: GetMetricsRequest): Promise<Array<{ metricName?: string; namespace: string }>> {
|
||||
return this.memoizedGetRequest<Array<ResourceResponse<MetricResponse>>>('metrics', {
|
||||
region: this.templateSrv.replace(this.getActualRegion(region)),
|
||||
accountId: this.templateSrv.replace(accountId),
|
||||
}).then((metrics) => metrics.map((m) => ({ metricName: m.value.name, namespace: m.value.namespace })));
|
||||
}
|
||||
|
||||
async getDimensionKeys({
|
||||
getDimensionKeys({
|
||||
region,
|
||||
namespace = '',
|
||||
dimensionFilters = {},
|
||||
@@ -129,7 +120,7 @@ export class CloudWatchAPI extends CloudWatchRequest {
|
||||
}).then((r) => r.map((r) => ({ label: r.value, value: r.value })));
|
||||
}
|
||||
|
||||
async getDimensionValues({
|
||||
getDimensionValues({
|
||||
dimensionKey,
|
||||
region,
|
||||
namespace,
|
||||
@@ -138,10 +129,10 @@ export class CloudWatchAPI extends CloudWatchRequest {
|
||||
accountId,
|
||||
}: GetDimensionValuesRequest) {
|
||||
if (!namespace || !metricName) {
|
||||
return [];
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const values = await this.memoizedGetRequest<Array<ResourceResponse<string>>>('dimension-values', {
|
||||
return this.memoizedGetRequest<Array<ResourceResponse<string>>>('dimension-values', {
|
||||
region: this.templateSrv.replace(this.getActualRegion(region)),
|
||||
namespace: this.templateSrv.replace(namespace),
|
||||
metricName: this.templateSrv.replace(metricName.trim()),
|
||||
@@ -149,7 +140,6 @@ export class CloudWatchAPI extends CloudWatchRequest {
|
||||
dimensionFilters: JSON.stringify(this.convertDimensionFormat(dimensionFilters, {})),
|
||||
accountId: this.templateSrv.replace(accountId),
|
||||
}).then((r) => r.map((r) => ({ label: r.value, value: r.value })));
|
||||
return values;
|
||||
}
|
||||
|
||||
getEbsVolumeIds(region: string, instanceId: string) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import React from 'react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
|
||||
import { logGroupNamesVariable, setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
|
||||
|
||||
import { LogGroupsField } from './LogGroupsField';
|
||||
|
||||
@@ -30,29 +30,49 @@ describe('LogGroupSelection', () => {
|
||||
lodash.debounce = originalDebounce;
|
||||
});
|
||||
|
||||
it('call describeCrossAccountLogGroups to get associated log group arns and then update props if rendered with legacy log group names', async () => {
|
||||
it('should call getLogGroups to get associated log group arns and then update props if rendered with legacy log group names', async () => {
|
||||
config.featureToggles.cloudWatchCrossAccountQuerying = true;
|
||||
defaultProps.datasource.api.describeLogGroups = jest
|
||||
defaultProps.datasource.api.getLogGroups = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ value: { arn: 'arn', name: 'loggroupname' } }]);
|
||||
render(<LogGroupsField {...defaultProps} legacyLogGroupNames={['loggroupname']} />);
|
||||
|
||||
await waitFor(async () => expect(screen.getByText('Select Log Groups')).toBeInTheDocument());
|
||||
expect(defaultProps.datasource.api.describeLogGroups).toHaveBeenCalledWith({
|
||||
expect(defaultProps.datasource.api.getLogGroups).toHaveBeenCalledWith({
|
||||
region: defaultProps.region,
|
||||
logGroupNamePrefix: 'loggroupname',
|
||||
});
|
||||
expect(defaultProps.onChange).toHaveBeenCalledWith([{ arn: 'arn', name: 'loggroupname' }]);
|
||||
});
|
||||
|
||||
it('should not call describeCrossAccountLogGroups and update props if rendered with log groups', async () => {
|
||||
it('should not call getLogGroups to get associated log group arns for template variables that were part of the legacy log group names array, only include them in the call to onChange', async () => {
|
||||
config.featureToggles.cloudWatchCrossAccountQuerying = true;
|
||||
defaultProps.datasource.api.describeLogGroups = jest
|
||||
defaultProps.datasource = setupMockedDataSource({ variables: [logGroupNamesVariable] }).datasource;
|
||||
defaultProps.datasource.api.getLogGroups = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ value: { arn: 'arn', name: 'loggroupname' } }]);
|
||||
render(<LogGroupsField {...defaultProps} legacyLogGroupNames={['loggroupname', logGroupNamesVariable.name]} />);
|
||||
|
||||
await waitFor(async () => expect(screen.getByText('Select Log Groups')).toBeInTheDocument());
|
||||
expect(defaultProps.datasource.api.getLogGroups).toHaveBeenCalledTimes(1);
|
||||
expect(defaultProps.datasource.api.getLogGroups).toHaveBeenCalledWith({
|
||||
region: defaultProps.region,
|
||||
logGroupNamePrefix: 'loggroupname',
|
||||
});
|
||||
expect(defaultProps.onChange).toHaveBeenCalledWith([
|
||||
{ arn: 'arn', name: 'loggroupname' },
|
||||
{ arn: logGroupNamesVariable.name, name: logGroupNamesVariable.name },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not call getLogGroups and update props if rendered with log groups', async () => {
|
||||
config.featureToggles.cloudWatchCrossAccountQuerying = true;
|
||||
defaultProps.datasource.api.getLogGroups = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ value: { arn: 'arn', name: 'loggroupname' } }]);
|
||||
render(<LogGroupsField {...defaultProps} logGroups={[{ arn: 'arn', name: 'loggroupname' }]} />);
|
||||
await waitFor(() => expect(screen.getByText('Select Log Groups')).toBeInTheDocument());
|
||||
expect(defaultProps.datasource.api.describeLogGroups).not.toHaveBeenCalled();
|
||||
expect(defaultProps.datasource.api.getLogGroups).not.toHaveBeenCalled();
|
||||
expect(defaultProps.onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { CloudWatchDatasource } from '../../datasource';
|
||||
import { useAccountOptions } from '../../hooks';
|
||||
import { DescribeLogGroupsRequest, LogGroup } from '../../types';
|
||||
import { isTemplateVariable } from '../../utils/templateVariableUtils';
|
||||
|
||||
import { LogGroupsSelector } from './LogGroupsSelector';
|
||||
import { SelectedLogGroups } from './SelectedLogGroups';
|
||||
@@ -38,11 +39,18 @@ export const LogGroupsField = ({
|
||||
// If log group names are stored in the query model, make a new DescribeLogGroups request for each log group to load the arn. Then update the query model.
|
||||
if (datasource && !loadingLogGroupsStarted && !logGroups?.length && legacyLogGroupNames?.length) {
|
||||
setLoadingLogGroupsStarted(true);
|
||||
|
||||
// there's no need to migrate variables, they will be taken care of in the logs query runner
|
||||
const variables = legacyLogGroupNames.filter((lgn) => isTemplateVariable(datasource.api.templateSrv, lgn));
|
||||
const legacyLogGroupNameValues = legacyLogGroupNames.filter(
|
||||
(lgn) => !isTemplateVariable(datasource.api.templateSrv, lgn)
|
||||
);
|
||||
|
||||
Promise.all(
|
||||
legacyLogGroupNames.map((lg) => datasource.api.describeLogGroups({ region: region, logGroupNamePrefix: lg }))
|
||||
legacyLogGroupNameValues.map((lg) => datasource.api.getLogGroups({ region: region, logGroupNamePrefix: lg }))
|
||||
)
|
||||
.then((results) => {
|
||||
const a = results.flatMap((r) =>
|
||||
const logGroups = results.flatMap((r) =>
|
||||
r.map((lg) => ({
|
||||
arn: lg.value.arn,
|
||||
name: lg.value.name,
|
||||
@@ -50,9 +58,11 @@ export const LogGroupsField = ({
|
||||
}))
|
||||
);
|
||||
|
||||
onChange(a);
|
||||
onChange([...logGroups, ...variables.map((v) => ({ name: v, arn: v }))]);
|
||||
})
|
||||
.catch(console.error);
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
}, [datasource, legacyLogGroupNames, logGroups, onChange, region, loadingLogGroupsStarted]);
|
||||
|
||||
@@ -60,12 +70,13 @@ export const LogGroupsField = ({
|
||||
<div className={`gf-form gf-form--grow flex-grow-1 ${rowGap}`}>
|
||||
<LogGroupsSelector
|
||||
fetchLogGroups={async (params: Partial<DescribeLogGroupsRequest>) =>
|
||||
datasource?.api.describeLogGroups({ region: region, ...params }) ?? []
|
||||
datasource?.api.getLogGroups({ region: region, ...params }) ?? []
|
||||
}
|
||||
onChange={onChange}
|
||||
accountOptions={accountState.value}
|
||||
selectedLogGroups={logGroups}
|
||||
onBeforeOpen={onBeforeOpen}
|
||||
variables={datasource?.getVariables()}
|
||||
/>
|
||||
<SelectedLogGroups
|
||||
selectedLogGroups={logGroups ?? []}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ResourceResponse, LogGroupResponse } from '../../types';
|
||||
import { LogGroupsSelector } from './LogGroupsSelector';
|
||||
|
||||
const defaultProps = {
|
||||
variables: [],
|
||||
selectedLogGroups: [],
|
||||
accountOptions: [
|
||||
{
|
||||
@@ -57,7 +58,7 @@ class Deferred {
|
||||
}
|
||||
}
|
||||
|
||||
describe('CrossAccountLogsQueryField', () => {
|
||||
describe('LogGroupsSelector', () => {
|
||||
beforeEach(() => {
|
||||
lodash.debounce = jest.fn().mockImplementation((fn) => {
|
||||
fn.cancel = () => {};
|
||||
@@ -152,6 +153,7 @@ describe('CrossAccountLogsQueryField', () => {
|
||||
await userEvent.click(screen.getByLabelText('logGroup2'));
|
||||
expect(screen.getByLabelText('logGroup2')).toBeChecked();
|
||||
});
|
||||
|
||||
it('calls onChange with the selected log group when checked and the user clicks the Add button', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(<LogGroupsSelector {...defaultProps} onChange={onChange} />);
|
||||
@@ -226,4 +228,86 @@ describe('CrossAccountLogsQueryField', () => {
|
||||
await userEvent.click(screen.getByLabelText('logGroup2'));
|
||||
await waitFor(() => expect(screen.getByText('1 log group selected')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should not include selected template variables in the counter label', async () => {
|
||||
render(
|
||||
<LogGroupsSelector
|
||||
{...defaultProps}
|
||||
selectedLogGroups={[
|
||||
{ name: 'logGroup1', arn: 'arn:partition:service:region:account-id456:loggroup:someotherloggroup' },
|
||||
{ name: '$logGroupVariable', arn: '$logGroupVariable' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
await waitFor(() => expect(screen.getByText('1 log group selected')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should be possible to select a template variable and add it to selected log groups when the user clicks the Add button', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<LogGroupsSelector
|
||||
{...defaultProps}
|
||||
selectedLogGroups={[
|
||||
{ name: 'logGroup1', arn: 'arn:partition:service:region:account-id456:loggroup:someotherloggroup' },
|
||||
]}
|
||||
variables={['$regionVariable', '$logGroupVariable']}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
await selectEvent.select(screen.getByLabelText('Template variable'), '$logGroupVariable', {
|
||||
container: document.body,
|
||||
});
|
||||
await userEvent.click(screen.getByText('Add log groups'));
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{
|
||||
arn: 'arn:partition:service:region:account-id456:loggroup:someotherloggroup',
|
||||
name: 'logGroup1',
|
||||
},
|
||||
{
|
||||
arn: '$logGroupVariable',
|
||||
name: '$logGroupVariable',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should be possible to remove template variable from selected log groups', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<LogGroupsSelector
|
||||
{...defaultProps}
|
||||
selectedLogGroups={[
|
||||
{ name: 'logGroup1', arn: 'arn:partition:service:region:account-id456:loggroup:someotherloggroup' },
|
||||
]}
|
||||
variables={['$regionVariable', '$logGroupVariable']}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
await screen.getByRole('button', { name: 'select-clear-value' }).click();
|
||||
await userEvent.click(screen.getByText('Add log groups'));
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{
|
||||
arn: 'arn:partition:service:region:account-id456:loggroup:someotherloggroup',
|
||||
name: 'logGroup1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should display account label if account options prop has values', async () => {
|
||||
render(<LogGroupsSelector {...defaultProps} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
expect(screen.getByText('Log group name prefix')).toBeInTheDocument();
|
||||
expect(screen.getByText('Account label')).toBeInTheDocument();
|
||||
waitFor(() => expect(screen.getByText('Account Name 123')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should not display account label if account options prop doesnt has values', async () => {
|
||||
render(<LogGroupsSelector {...defaultProps} accountOptions={[]} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
expect(screen.getByText('Log group name prefix')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Account label')).not.toBeInTheDocument();
|
||||
waitFor(() => expect(screen.queryByText('Account Name 123')).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { EditorField, Space } from '@grafana/experimental';
|
||||
import { Button, Checkbox, Icon, Label, LoadingPlaceholder, Modal, useStyles2 } from '@grafana/ui';
|
||||
import { Button, Checkbox, Icon, Label, LoadingPlaceholder, Modal, Select, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import Search from '../../Search';
|
||||
import { DescribeLogGroupsRequest, LogGroup, LogGroupResponse, ResourceResponse } from '../../types';
|
||||
@@ -13,12 +13,14 @@ type CrossAccountLogsQueryProps = {
|
||||
selectedLogGroups?: LogGroup[];
|
||||
accountOptions?: Array<SelectableValue<string>>;
|
||||
fetchLogGroups: (params: Partial<DescribeLogGroupsRequest>) => Promise<Array<ResourceResponse<LogGroupResponse>>>;
|
||||
variables?: string[];
|
||||
onChange: (selectedLogGroups: LogGroup[]) => void;
|
||||
onBeforeOpen?: () => void;
|
||||
};
|
||||
|
||||
export const LogGroupsSelector = ({
|
||||
accountOptions = [],
|
||||
variables = [],
|
||||
fetchLogGroups,
|
||||
onChange,
|
||||
onBeforeOpen,
|
||||
@@ -31,6 +33,19 @@ export const LogGroupsSelector = ({
|
||||
const [searchAccountId, setSearchAccountId] = useState(ALL_ACCOUNTS_OPTION.value);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const styles = useStyles2(getStyles);
|
||||
const selectedLogGroupsCounter = useMemo(
|
||||
() => selectedLogGroups.filter((lg) => !lg.name?.startsWith('$')).length,
|
||||
[selectedLogGroups]
|
||||
);
|
||||
const variableOptions = useMemo(() => variables.map((v) => ({ label: v, value: v })), [variables]);
|
||||
const selectedVariable = useMemo(
|
||||
() => selectedLogGroups.find((lg) => lg.name?.startsWith('$'))?.name,
|
||||
[selectedLogGroups]
|
||||
);
|
||||
const currentVariableOption = {
|
||||
label: selectedVariable,
|
||||
value: selectedVariable,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedLogGroups(props.selectedLogGroups ?? []);
|
||||
@@ -136,7 +151,7 @@ export const LogGroupsSelector = ({
|
||||
<thead>
|
||||
<tr className={styles.row}>
|
||||
<td className={styles.cell}>Log Group</td>
|
||||
<td className={styles.cell}>Account name</td>
|
||||
{accountOptions.length > 0 && <td className={styles.cell}>Account label</td>}
|
||||
<td className={styles.cell}>Account ID</td>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -169,7 +184,7 @@ export const LogGroupsSelector = ({
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td className={styles.cell}>{row.accountLabel}</td>
|
||||
{accountOptions.length > 0 && <td className={styles.cell}>{row.accountLabel}</td>}
|
||||
<td className={styles.cell}>{row.accountId}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -179,9 +194,30 @@ export const LogGroupsSelector = ({
|
||||
</div>
|
||||
<Space layout="block" v={2} />
|
||||
<Label className={styles.logGroupCountLabel}>
|
||||
{selectedLogGroups.length} log group{selectedLogGroups.length !== 1 && 's'} selected
|
||||
{selectedLogGroupsCounter} log group{selectedLogGroupsCounter !== 1 && 's'} selected
|
||||
</Label>
|
||||
<Space layout="block" v={1.5} />
|
||||
<Space layout="block" v={1} />
|
||||
<EditorField
|
||||
label="Template variable"
|
||||
width={26}
|
||||
tooltip="Optionally you can specify a single or multi-valued template variable. Select a variable separately or in conjunction with log groups."
|
||||
>
|
||||
<Select
|
||||
isClearable
|
||||
aria-label="Template variable"
|
||||
value={currentVariableOption}
|
||||
allowCustomValue
|
||||
options={variableOptions}
|
||||
onChange={(option) => {
|
||||
const newValues = selectedLogGroups.filter((lg) => !lg.name?.startsWith('$'));
|
||||
if (option?.label) {
|
||||
newValues.push({ name: option.label, arn: option.label });
|
||||
}
|
||||
setSelectedLogGroups(newValues);
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
<Space layout="block" v={2} />
|
||||
<div>
|
||||
<Button onClick={handleApply} type="button" className={styles.addBtn}>
|
||||
Add log groups
|
||||
|
||||
@@ -42,7 +42,7 @@ export const SelectedLogGroups = ({
|
||||
onChange(selectedLogGroups.filter((slg) => slg.arn !== lg.arn));
|
||||
}}
|
||||
>
|
||||
{`${lg.name}`}
|
||||
{`${lg.name}${lg.accountLabel ? `(${lg.accountLabel})` : ''}`}
|
||||
</Button>
|
||||
))}
|
||||
{visibleSelectecLogGroups.length !== selectedLogGroups.length && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { lastValueFrom } from 'rxjs';
|
||||
|
||||
import { AbsoluteTimeRange, HistoryItem, LanguageProvider } from '@grafana/data';
|
||||
import { CompletionItemGroup, SearchFunctionType, Token, TypeaheadInput, TypeaheadOutput } from '@grafana/ui';
|
||||
import { getTemplateSrv } from 'app/features/templating/template_srv';
|
||||
|
||||
import { CloudWatchDatasource } from './datasource';
|
||||
import syntax, {
|
||||
@@ -16,6 +17,7 @@ import syntax, {
|
||||
STRING_FUNCTIONS,
|
||||
} from './syntax';
|
||||
import { CloudWatchQuery, LogGroup, TSDBResponse } from './types';
|
||||
import { interpolateStringArrayUsingSingleOrMultiValuedVariable } from './utils/templateVariableUtils';
|
||||
|
||||
export type CloudWatchHistoryItem = HistoryItem<CloudWatchQuery>;
|
||||
|
||||
@@ -127,10 +129,15 @@ export class CloudWatchLanguageProvider extends LanguageProvider {
|
||||
}
|
||||
|
||||
private fetchFields = async (logGroups: LogGroup[], region: string): Promise<string[]> => {
|
||||
const interpolatedLogGroups = interpolateStringArrayUsingSingleOrMultiValuedVariable(
|
||||
getTemplateSrv(),
|
||||
logGroups.map((lg) => lg.name),
|
||||
'text'
|
||||
);
|
||||
const results = await Promise.all(
|
||||
logGroups.map((logGroup) =>
|
||||
interpolatedLogGroups.map((logGroupName) =>
|
||||
this.datasource.api
|
||||
.getLogGroupFields({ logGroupName: logGroup.name, arn: logGroup.arn, region })
|
||||
.getLogGroupFields({ logGroupName, region })
|
||||
.then((fields) => fields.filter((f) => f).map((f) => f.value.name ?? ''))
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2,9 +2,16 @@ import { interval, lastValueFrom, of } from 'rxjs';
|
||||
|
||||
import { DataQueryErrorType, FieldType, LogLevel, LogRowModel, MutableDataFrame } from '@grafana/data';
|
||||
|
||||
import {
|
||||
CloudWatchSettings,
|
||||
limitVariable,
|
||||
logGroupNamesVariable,
|
||||
regionVariable,
|
||||
} from '../__mocks__/CloudWatchDataSource';
|
||||
import { genMockFrames, setupMockedLogsQueryRunner } from '../__mocks__/LogsQueryRunner';
|
||||
import { LogsRequestMock } from '../__mocks__/Request';
|
||||
import { validLogsQuery } from '../__mocks__/queries';
|
||||
import { LogAction } from '../types';
|
||||
import { CloudWatchLogsQuery, LogAction, StartQueryRequest } from '../types';
|
||||
import * as rxjsUtils from '../utils/rxjs/increasingInterval';
|
||||
|
||||
import { LOG_IDENTIFIER_INTERNAL, LOGSTREAM_IDENTIFIER_INTERNAL } from './CloudWatchLogsQueryRunner';
|
||||
@@ -185,4 +192,67 @@ describe('CloudWatchLogsQueryRunner', () => {
|
||||
expect(i).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
const legacyLogGroupNamesQuery: CloudWatchLogsQuery = {
|
||||
queryMode: 'Logs',
|
||||
logGroupNames: ['group-A', 'templatedGroup-1', logGroupNamesVariable.name],
|
||||
hide: false,
|
||||
id: '',
|
||||
region: 'us-east-2',
|
||||
refId: 'A',
|
||||
expression: `fields @timestamp, @message | sort @timestamp desc | limit $${limitVariable.name}`,
|
||||
};
|
||||
|
||||
const logGroupNamesQuery: CloudWatchLogsQuery = {
|
||||
queryMode: 'Logs',
|
||||
logGroups: [
|
||||
{ arn: 'arn:aws:logs:us-east-2:123456789012:log-group:group-A:*', name: 'group-A' },
|
||||
{ arn: logGroupNamesVariable.name, name: logGroupNamesVariable.name },
|
||||
],
|
||||
hide: false,
|
||||
id: '',
|
||||
region: '$' + regionVariable.name,
|
||||
refId: 'A',
|
||||
expression: `fields @timestamp, @message | sort @timestamp desc | limit 1`,
|
||||
};
|
||||
|
||||
describe('handleLogQueries', () => {
|
||||
it('should map log queries to start query requests correctly', async () => {
|
||||
const { runner } = setupMockedLogsQueryRunner({
|
||||
variables: [logGroupNamesVariable, regionVariable, limitVariable],
|
||||
settings: {
|
||||
...CloudWatchSettings,
|
||||
jsonData: {
|
||||
...CloudWatchSettings.jsonData,
|
||||
logsTimeout: '500ms',
|
||||
},
|
||||
},
|
||||
});
|
||||
const spy = jest.spyOn(runner, 'makeLogActionRequest');
|
||||
await lastValueFrom(runner.handleLogQueries([legacyLogGroupNamesQuery, logGroupNamesQuery], LogsRequestMock));
|
||||
const startQueryRequests: StartQueryRequest[] = [
|
||||
{
|
||||
queryString: `fields @timestamp, @message | sort @timestamp desc | limit ${limitVariable.current.value}`,
|
||||
logGroupNames: ['group-A', ...logGroupNamesVariable.current.text],
|
||||
logGroups: [],
|
||||
refId: legacyLogGroupNamesQuery.refId,
|
||||
region: legacyLogGroupNamesQuery.region,
|
||||
},
|
||||
{
|
||||
queryString: logGroupNamesQuery.expression!,
|
||||
logGroupNames: [],
|
||||
logGroups: [
|
||||
{
|
||||
arn: 'arn:aws:logs:us-east-2:123456789012:log-group:group-A:*',
|
||||
name: 'arn:aws:logs:us-east-2:123456789012:log-group:group-A:*',
|
||||
},
|
||||
...(logGroupNamesVariable.current.value as string[]).map((v) => ({ arn: v, name: v })),
|
||||
],
|
||||
refId: legacyLogGroupNamesQuery.refId,
|
||||
region: regionVariable.current.value as string,
|
||||
},
|
||||
];
|
||||
expect(spy).toHaveBeenNthCalledWith(1, 'StartQuery', startQueryRequests);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { set } from 'lodash';
|
||||
import { set, uniq } from 'lodash';
|
||||
import {
|
||||
catchError,
|
||||
concatMap,
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
LoadingState,
|
||||
LogRowModel,
|
||||
rangeUtil,
|
||||
ScopedVars,
|
||||
} from '@grafana/data';
|
||||
import { BackendDataSourceResponse, config, FetchError, FetchResponse, toDataQueryResponse } from '@grafana/runtime';
|
||||
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
|
||||
@@ -39,15 +38,15 @@ import {
|
||||
CloudWatchLogsQueryStatus,
|
||||
CloudWatchLogsRequest,
|
||||
CloudWatchQuery,
|
||||
DescribeLogGroupsRequest,
|
||||
GetLogEventsRequest,
|
||||
GetLogGroupFieldsRequest,
|
||||
LogAction,
|
||||
QueryParam,
|
||||
StartQueryRequest,
|
||||
} from '../types';
|
||||
import { addDataLinksToLogsResponse } from '../utils/datalinks';
|
||||
import { runWithRetry } from '../utils/logsRetry';
|
||||
import { increasingInterval } from '../utils/rxjs/increasingInterval';
|
||||
import { interpolateStringArrayUsingSingleOrMultiValuedVariable } from '../utils/templateVariableUtils';
|
||||
|
||||
import { CloudWatchRequest } from './CloudWatchRequest';
|
||||
|
||||
@@ -83,18 +82,32 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
|
||||
): Observable<DataQueryResponse> => {
|
||||
const validLogQueries = logQueries.filter(this.filterQuery);
|
||||
|
||||
const startQueryRequests: StartQueryRequest[] = validLogQueries.map((target: CloudWatchLogsQuery) => ({
|
||||
queryString: target.expression || '',
|
||||
refId: target.refId,
|
||||
logGroupNames: target.logGroupNames || this.instanceSettings.jsonData.defaultLogGroups || [],
|
||||
logGroups: target.logGroups || this.instanceSettings.jsonData.logGroups,
|
||||
region: super.replaceVariableAndDisplayWarningIfMulti(
|
||||
this.getActualRegion(target.region),
|
||||
options.scopedVars,
|
||||
true,
|
||||
'region'
|
||||
),
|
||||
}));
|
||||
const startQueryRequests: StartQueryRequest[] = validLogQueries.map((target: CloudWatchLogsQuery) => {
|
||||
const interpolatedLogGroupArns = interpolateStringArrayUsingSingleOrMultiValuedVariable(
|
||||
this.templateSrv,
|
||||
(target.logGroups || this.instanceSettings.jsonData.logGroups || []).map((lg) => lg.arn)
|
||||
);
|
||||
|
||||
// need to support legacy format variables too
|
||||
const interpolatedLogGroupNames = interpolateStringArrayUsingSingleOrMultiValuedVariable(
|
||||
this.templateSrv,
|
||||
target.logGroupNames || this.instanceSettings.jsonData.defaultLogGroups || [],
|
||||
'text'
|
||||
);
|
||||
|
||||
// if a log group template variable expands to log group that has already been selected in the log group picker, we need to remove duplicates.
|
||||
// Otherwise the StartLogQuery API will return a permission error
|
||||
const logGroups = uniq(interpolatedLogGroupArns).map((arn) => ({ arn, name: arn }));
|
||||
const logGroupNames = uniq(interpolatedLogGroupNames);
|
||||
|
||||
return {
|
||||
refId: target.refId,
|
||||
region: this.templateSrv.replace(this.getActualRegion(target.region)),
|
||||
queryString: this.templateSrv.replace(target.expression || ''),
|
||||
logGroups,
|
||||
logGroupNames,
|
||||
};
|
||||
});
|
||||
|
||||
const startTime = new Date();
|
||||
const timeoutFunc = () => {
|
||||
@@ -103,11 +116,7 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
|
||||
|
||||
return runWithRetry(
|
||||
(targets: StartQueryRequest[]) => {
|
||||
return this.makeLogActionRequest('StartQuery', targets, {
|
||||
makeReplacements: true,
|
||||
scopedVars: options.scopedVars,
|
||||
skipCache: true,
|
||||
});
|
||||
return this.makeLogActionRequest('StartQuery', targets);
|
||||
},
|
||||
startQueryRequests,
|
||||
timeoutFunc
|
||||
@@ -155,16 +164,7 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
|
||||
* Checks progress and polls data of a started logs query with some retry logic.
|
||||
* @param queryParams
|
||||
*/
|
||||
logsQuery(
|
||||
queryParams: Array<{
|
||||
queryId: string;
|
||||
refId: string;
|
||||
limit?: number;
|
||||
region: string;
|
||||
statsGroups?: string[];
|
||||
}>,
|
||||
timeoutFunc: () => boolean
|
||||
): Observable<DataQueryResponse> {
|
||||
logsQuery(queryParams: QueryParam[], timeoutFunc: () => boolean): Observable<DataQueryResponse> {
|
||||
this.logQueries = {};
|
||||
queryParams.forEach((param) => {
|
||||
this.logQueries[param.refId] = {
|
||||
@@ -175,7 +175,7 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
|
||||
});
|
||||
|
||||
const dataFrames = increasingInterval({ startPeriod: 100, endPeriod: 1000, step: 300 }).pipe(
|
||||
concatMap((_) => this.makeLogActionRequest('GetQueryResults', queryParams, { skipCache: true })),
|
||||
concatMap((_) => this.makeLogActionRequest('GetQueryResults', queryParams)),
|
||||
repeat(),
|
||||
share()
|
||||
);
|
||||
@@ -253,11 +253,12 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
|
||||
if (Object.keys(this.logQueries).length > 0) {
|
||||
this.makeLogActionRequest(
|
||||
'StopQuery',
|
||||
Object.values(this.logQueries).map((logQuery) => ({ queryId: logQuery.id, region: logQuery.region })),
|
||||
{
|
||||
makeReplacements: false,
|
||||
skipCache: true,
|
||||
}
|
||||
Object.values(this.logQueries).map((logQuery) => ({
|
||||
queryId: logQuery.id,
|
||||
region: logQuery.region,
|
||||
queryString: '',
|
||||
refId: '',
|
||||
}))
|
||||
).pipe(
|
||||
finalize(() => {
|
||||
this.logQueries = {};
|
||||
@@ -266,18 +267,7 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
|
||||
}
|
||||
}
|
||||
|
||||
makeLogActionRequest(
|
||||
subtype: LogAction,
|
||||
queryParams: CloudWatchLogsRequest[],
|
||||
options: {
|
||||
scopedVars?: ScopedVars;
|
||||
makeReplacements?: boolean;
|
||||
skipCache?: boolean;
|
||||
} = {
|
||||
makeReplacements: true,
|
||||
skipCache: false,
|
||||
}
|
||||
): Observable<DataFrame[]> {
|
||||
makeLogActionRequest(subtype: LogAction, queryParams: CloudWatchLogsRequest[]): Observable<DataFrame[]> {
|
||||
const range = this.timeSrv.timeRange();
|
||||
|
||||
const requestParams = {
|
||||
@@ -295,60 +285,16 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
|
||||
})),
|
||||
};
|
||||
|
||||
if (options.makeReplacements) {
|
||||
requestParams.queries.forEach((query: CloudWatchLogsRequest) => {
|
||||
const fieldsToReplace: Array<
|
||||
keyof (GetLogEventsRequest & StartQueryRequest & DescribeLogGroupsRequest & GetLogGroupFieldsRequest)
|
||||
> = ['queryString', 'logGroupNames', 'logGroupName', 'logGroupNamePrefix'];
|
||||
|
||||
// eslint-ignore-next-line
|
||||
const anyQuery: any = query;
|
||||
for (const fieldName of fieldsToReplace) {
|
||||
if (query.hasOwnProperty(fieldName)) {
|
||||
if (Array.isArray(anyQuery[fieldName])) {
|
||||
anyQuery[fieldName] = anyQuery[fieldName].flatMap((val: string) => {
|
||||
if (fieldName === 'logGroupNames') {
|
||||
return this.expandVariableToArray(val, options.scopedVars || {});
|
||||
}
|
||||
return this.replaceVariableAndDisplayWarningIfMulti(val, options.scopedVars, true, fieldName);
|
||||
});
|
||||
} else {
|
||||
anyQuery[fieldName] = this.replaceVariableAndDisplayWarningIfMulti(
|
||||
anyQuery[fieldName],
|
||||
options.scopedVars,
|
||||
true,
|
||||
fieldName
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (anyQuery.region) {
|
||||
anyQuery.region = this.replaceVariableAndDisplayWarningIfMulti(
|
||||
anyQuery.region,
|
||||
options.scopedVars,
|
||||
true,
|
||||
'region'
|
||||
);
|
||||
anyQuery.region = this.getActualRegion(anyQuery.region);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const resultsToDataFrames = (
|
||||
val:
|
||||
| { data: BackendDataSourceResponse | undefined }
|
||||
| FetchResponse<BackendDataSourceResponse | undefined>
|
||||
| DataQueryError
|
||||
): DataFrame[] => toDataQueryResponse(val).data || [];
|
||||
let headers = {};
|
||||
if (options.skipCache) {
|
||||
headers = {
|
||||
'X-Cache-Skip': true,
|
||||
};
|
||||
}
|
||||
|
||||
return this.awsRequest(this.dsQueryEndpoint, requestParams, headers).pipe(
|
||||
return this.awsRequest(this.dsQueryEndpoint, requestParams, {
|
||||
'X-Cache-Skip': 'true',
|
||||
}).pipe(
|
||||
map((response) => resultsToDataFrames({ data: response })),
|
||||
catchError((err: FetchError) => {
|
||||
if (config.featureToggles.datasourceQueryMultiStatus && err.status === 207) {
|
||||
|
||||
@@ -76,14 +76,7 @@ export interface CloudWatchMathExpressionQuery extends DataQuery {
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export type LogAction =
|
||||
| 'DescribeLogGroups'
|
||||
| 'DescribeAllLogGroups'
|
||||
| 'GetQueryResults'
|
||||
| 'GetLogGroupFields'
|
||||
| 'GetLogEvents'
|
||||
| 'StartQuery'
|
||||
| 'StopQuery';
|
||||
export type LogAction = 'GetQueryResults' | 'GetLogEvents' | 'StartQuery' | 'StopQuery';
|
||||
|
||||
export enum CloudWatchLogsQueryStatus {
|
||||
Scheduled = 'Scheduled',
|
||||
@@ -144,46 +137,7 @@ export interface CloudWatchSecureJsonData extends AwsAuthDataSourceSecureJsonDat
|
||||
secretKey?: string;
|
||||
}
|
||||
|
||||
export interface GetQueryResultsRequest {
|
||||
/**
|
||||
* The ID number of the query.
|
||||
*/
|
||||
queryId: string;
|
||||
}
|
||||
|
||||
export interface ResultField {
|
||||
/**
|
||||
* The log event field.
|
||||
*/
|
||||
field?: string;
|
||||
/**
|
||||
* The value of this field.
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface QueryStatistics {
|
||||
/**
|
||||
* The number of log events that matched the query string.
|
||||
*/
|
||||
recordsMatched?: number;
|
||||
/**
|
||||
* The total number of log events scanned during the query.
|
||||
*/
|
||||
recordsScanned?: number;
|
||||
/**
|
||||
* The total number of bytes in the log events scanned during the query.
|
||||
*/
|
||||
bytesScanned?: number;
|
||||
}
|
||||
|
||||
export type QueryStatus = 'Scheduled' | 'Running' | 'Complete' | 'Failed' | 'Cancelled' | string;
|
||||
|
||||
export type CloudWatchLogsRequest =
|
||||
| GetLogEventsRequest
|
||||
| StartQueryRequest
|
||||
| DescribeLogGroupsRequest
|
||||
| GetLogGroupFieldsRequest;
|
||||
export type CloudWatchLogsRequest = GetLogEventsRequest | StartQueryRequest | QueryParam;
|
||||
|
||||
export interface GetLogEventsRequest {
|
||||
/**
|
||||
@@ -217,20 +171,6 @@ export interface GetLogEventsRequest {
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface GetQueryResultsResponse {
|
||||
/**
|
||||
* The log events that matched the query criteria during the most recent time it ran. The results value is an array of arrays. Each log event is one object in the top-level array. Each of these log event objects is an array of field/value pairs.
|
||||
*/
|
||||
results?: ResultField[][];
|
||||
/**
|
||||
* Includes the number of log events scanned by the query, the number of log events that matched the query criteria, and the total number of bytes in the log events that were scanned.
|
||||
*/
|
||||
statistics?: QueryStatistics;
|
||||
/**
|
||||
* The status of the most recent running of the query. Possible values are Cancelled, Complete, Failed, Running, Scheduled, Timeout, and Unknown. Queries time out after 15 minutes of execution. To avoid having your queries time out, reduce the time range being searched, or partition your query into a number of queries.
|
||||
*/
|
||||
status?: QueryStatus;
|
||||
}
|
||||
export interface TSDBResponse<T = any> {
|
||||
results: Record<string, TSDBQueryResult<T>>;
|
||||
message?: string;
|
||||
@@ -299,11 +239,13 @@ export interface StartQueryRequest {
|
||||
refId: string;
|
||||
region: string;
|
||||
}
|
||||
export interface StartQueryResponse {
|
||||
/**
|
||||
* The unique ID of the query.
|
||||
*/
|
||||
queryId?: string;
|
||||
|
||||
export interface QueryParam {
|
||||
queryId: string;
|
||||
refId: string;
|
||||
limit?: number;
|
||||
region: string;
|
||||
statsGroups?: string[];
|
||||
}
|
||||
|
||||
export interface MetricRequest {
|
||||
@@ -435,9 +377,8 @@ export interface GetMetricsRequest extends ResourceRequest {
|
||||
export interface DescribeLogGroupsRequest extends ResourceRequest {
|
||||
logGroupNamePrefix?: string;
|
||||
logGroupPattern?: string;
|
||||
// used by legacy requests, in the future deprecate these fields
|
||||
refId?: string;
|
||||
limit?: number;
|
||||
listAllLogGroups?: boolean;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { CustomVariableModel } from '@grafana/data';
|
||||
|
||||
import { logGroupNamesVariable, setupMockedTemplateService, regionVariable } from '../__mocks__/CloudWatchDataSource';
|
||||
|
||||
import { interpolateStringArrayUsingSingleOrMultiValuedVariable } from './templateVariableUtils';
|
||||
|
||||
interface TestCase {
|
||||
name: string;
|
||||
variable: CustomVariableModel;
|
||||
expected: string[];
|
||||
key?: 'value' | 'text';
|
||||
}
|
||||
|
||||
describe('templateVariableUtils', () => {
|
||||
const multiValuedRepresentedAsArray = {
|
||||
...logGroupNamesVariable,
|
||||
current: {
|
||||
value: ['templatedGroup-arn-2'],
|
||||
text: ['templatedGroup-2'],
|
||||
selected: true,
|
||||
},
|
||||
};
|
||||
|
||||
const multiValuedRepresentedAsString = {
|
||||
...logGroupNamesVariable,
|
||||
current: {
|
||||
value: 'templatedGroup-arn-2',
|
||||
text: 'templatedGroup-2',
|
||||
selected: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe('interpolateStringArrayUsingSingleOrMultiValuedVariable', () => {
|
||||
const cases: TestCase[] = [
|
||||
{
|
||||
name: 'should expand multi-valued variable with two values and use the metric find values',
|
||||
variable: logGroupNamesVariable,
|
||||
expected: logGroupNamesVariable.current.value as string[],
|
||||
},
|
||||
{
|
||||
name: 'should expand multi-valued variable with two values and use the metric find texts',
|
||||
variable: logGroupNamesVariable,
|
||||
expected: logGroupNamesVariable.current.text as string[],
|
||||
key: 'text',
|
||||
},
|
||||
{
|
||||
name: 'should expand multi-valued variable with one selected value represented as array and use metric find values',
|
||||
variable: multiValuedRepresentedAsArray,
|
||||
expected: multiValuedRepresentedAsArray.current.value as string[],
|
||||
},
|
||||
{
|
||||
name: 'should expand multi-valued variable with one selected value represented as array and use metric find texts',
|
||||
variable: multiValuedRepresentedAsArray,
|
||||
expected: multiValuedRepresentedAsArray.current.text as string[],
|
||||
key: 'text',
|
||||
},
|
||||
{
|
||||
name: 'should expand multi-valued variable with one selected value represented as a string and use metric find value',
|
||||
variable: multiValuedRepresentedAsString,
|
||||
expected: [multiValuedRepresentedAsString.current.value as string],
|
||||
},
|
||||
{
|
||||
name: 'should expand multi-valued variable with one selected value represented as a string and use metric find text',
|
||||
variable: multiValuedRepresentedAsString,
|
||||
expected: [multiValuedRepresentedAsString.current.text as string],
|
||||
key: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
test.each(cases)('$name', async ({ variable, expected, key }) => {
|
||||
const templateService = setupMockedTemplateService([variable]);
|
||||
const strings = ['$' + variable.name, 'log-group'];
|
||||
const result = interpolateStringArrayUsingSingleOrMultiValuedVariable(templateService, strings, key);
|
||||
expect(result).toEqual([...expected, 'log-group']);
|
||||
});
|
||||
|
||||
it('should expand single-valued variable', () => {
|
||||
const templateService = setupMockedTemplateService([regionVariable]);
|
||||
const strings = ['$' + regionVariable.name, 'us-east-2'];
|
||||
const result = interpolateStringArrayUsingSingleOrMultiValuedVariable(templateService, strings);
|
||||
expect(result).toEqual([regionVariable.current.value, 'us-east-2']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { VariableOption, UserProps, OrgProps, DashboardProps } from '@grafana/data';
|
||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||
|
||||
/**
|
||||
* @remarks
|
||||
* Takes a string array of variables and non-variables and returns a string array with the raw values of the variable(s)
|
||||
* A few examples:
|
||||
* single-valued variable + non-variable item. ['$singleValuedVariable', 'log-group'] => ['value', 'log-group']
|
||||
* multi-valued variable + non-variable item. ['$multiValuedVariable', 'log-group'] => ['value1', 'value2', 'log-group']
|
||||
* @param templateSrv - The template service
|
||||
* @param strings - The array of strings to interpolate. May contain variables and non-variables.
|
||||
* @param key - Allows you to specify whether the variable MetricFindValue.text or MetricFindValue.value should be used when interpolating the variable. Optional, defaults to 'value'.
|
||||
**/
|
||||
export const interpolateStringArrayUsingSingleOrMultiValuedVariable = (
|
||||
templateSrv: TemplateSrv,
|
||||
strings: string[],
|
||||
key?: 'value' | 'text'
|
||||
) => {
|
||||
key = key ?? 'value';
|
||||
let result: string[] = [];
|
||||
for (const string of strings) {
|
||||
const variableName = templateSrv.getVariableName(string);
|
||||
const valueVar = templateSrv.getVariables().find(({ name }) => name === variableName);
|
||||
|
||||
if (valueVar && 'current' in valueVar && isVariableOption(valueVar.current)) {
|
||||
const rawValue = valueVar.current[key];
|
||||
if (Array.isArray(rawValue)) {
|
||||
result = [...result, ...rawValue];
|
||||
} else if (typeof rawValue === 'string') {
|
||||
result.push(rawValue);
|
||||
}
|
||||
} else {
|
||||
// if it's not a variable, just add the raw value
|
||||
result.push(string);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const isTemplateVariable = (templateSrv: TemplateSrv, string: string) => {
|
||||
const variableName = templateSrv.getVariableName(string);
|
||||
return templateSrv.getVariables().some(({ name }) => name === variableName);
|
||||
};
|
||||
|
||||
const isVariableOption = (
|
||||
current: VariableOption | { value: UserProps } | { value: OrgProps } | { value: DashboardProps }
|
||||
): current is VariableOption => {
|
||||
return current.hasOwnProperty('value') && current.hasOwnProperty('text');
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
import { toOption } from '@grafana/data';
|
||||
|
||||
import { setupMockedAPI } from './__mocks__/API';
|
||||
import { dimensionVariable, labelsVariable, setupMockedDataSource } from './__mocks__/CloudWatchDataSource';
|
||||
import { VariableQuery, VariableQueryType } from './types';
|
||||
@@ -22,7 +20,9 @@ mock.datasource.api.getRegions = jest.fn().mockResolvedValue([{ label: 'a', valu
|
||||
mock.datasource.api.getNamespaces = jest.fn().mockResolvedValue([{ label: 'b', value: 'b' }]);
|
||||
mock.datasource.api.getMetrics = jest.fn().mockResolvedValue([{ label: 'c', value: 'c' }]);
|
||||
mock.datasource.api.getDimensionKeys = jest.fn().mockResolvedValue([{ label: 'd', value: 'd' }]);
|
||||
mock.datasource.api.describeAllLogGroups = jest.fn().mockResolvedValue(['a', 'b'].map(toOption));
|
||||
mock.datasource.api.getLogGroups = jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ value: { arn: 'a', name: 'a' } }, { value: { arn: 'b', name: 'b' } }]);
|
||||
mock.datasource.api.getAccounts = jest.fn().mockResolvedValue([]);
|
||||
const getDimensionValues = jest.fn().mockResolvedValue([{ label: 'e', value: 'e' }]);
|
||||
const getEbsVolumeIds = jest.fn().mockResolvedValue([{ label: 'f', value: 'f' }]);
|
||||
|
||||
@@ -63,11 +63,20 @@ export class CloudWatchVariableSupport extends CustomVariableSupport<CloudWatchD
|
||||
}
|
||||
async handleLogGroupsQuery({ region, logGroupPrefix }: VariableQuery) {
|
||||
return this.api
|
||||
.describeAllLogGroups({
|
||||
.getLogGroups({
|
||||
region,
|
||||
logGroupNamePrefix: logGroupPrefix,
|
||||
listAllLogGroups: true,
|
||||
})
|
||||
.then((logGroups) => logGroups.map(selectableValueToMetricFindOption));
|
||||
.then((logGroups) =>
|
||||
logGroups.map((lg) => {
|
||||
return {
|
||||
text: lg.value.name,
|
||||
value: lg.value.arn,
|
||||
expandable: true,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async handleRegionsQuery() {
|
||||
|
||||
Reference in New Issue
Block a user