mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Cloudwatch: Use new log group picker also for non cross-account queries (#60913)
* use new log group picker also for non cross-account queries * cleanup and add comment * remove not used code * remove not used test * add error message when trying to set log groups before saving * fix bugs from pr feedback * add more tests * fix broken test
This commit is contained in:
parent
2b61fb6e4a
commit
c3378aff8b
@ -5183,18 +5183,9 @@ exports[`better eslint`] = {
|
||||
"public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||
],
|
||||
"public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
],
|
||||
"public/app/plugins/datasource/cloudwatch/components/LogsQueryEditor.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
||||
],
|
||||
"public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx:5381": [
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
|
@ -403,142 +403,6 @@ func TestQuery_ResourceRequest_DescribeAllLogGroups(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestQuery_ResourceRequest_DescribeLogGroups(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("Should map log groups to SuggestData response", func(t *testing.T) {
|
||||
cli = fakeCWLogsClient{
|
||||
logGroups: []cloudwatchlogs.DescribeLogGroupsOutput{
|
||||
{LogGroups: []*cloudwatchlogs.LogGroup{
|
||||
{
|
||||
LogGroupName: aws.String("group_a"),
|
||||
},
|
||||
{
|
||||
LogGroupName: aws.String("group_b"),
|
||||
},
|
||||
{
|
||||
LogGroupName: aws.String("group_c"),
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
req := &backend.CallResourceRequest{
|
||||
Method: "GET",
|
||||
Path: "/log-groups",
|
||||
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"}), 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{}},
|
||||
},
|
||||
}
|
||||
|
||||
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
|
||||
return DataSource{Settings: models.CloudWatchSettings{}}, nil
|
||||
})
|
||||
|
||||
executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures())
|
||||
|
||||
req := &backend.CallResourceRequest{
|
||||
Method: "GET",
|
||||
Path: "/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 if not passed in resource call", func(t *testing.T) {
|
||||
cli = fakeCWLogsClient{
|
||||
logGroups: []cloudwatchlogs.DescribeLogGroupsOutput{
|
||||
{LogGroups: []*cloudwatchlogs.LogGroup{}},
|
||||
},
|
||||
}
|
||||
|
||||
im := datasource.NewInstanceManager(func(s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
|
||||
return DataSource{Settings: models.CloudWatchSettings{}}, nil
|
||||
})
|
||||
|
||||
executor := newExecutor(im, newTestConfig(), &fakeSessionCache{}, featuremgmt.WithFeatures())
|
||||
|
||||
req := &backend.CallResourceRequest{
|
||||
Method: "GET",
|
||||
Path: "/log-groups?limit=100",
|
||||
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(100),
|
||||
},
|
||||
}, cli.calls.describeLogGroups)
|
||||
})
|
||||
}
|
||||
|
||||
func TestQuery_ResourceRequest_DescribeLogGroups_with_CrossAccountQuerying(t *testing.T) {
|
||||
sender := &mockedCallResourceResponseSenderForOauth{}
|
||||
origNewMetricsAPI := NewMetricsAPI
|
||||
|
@ -18,7 +18,6 @@ import (
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/tsdb/cloudwatch/models"
|
||||
)
|
||||
|
||||
@ -213,19 +212,16 @@ func (e *cloudWatchExecutor) executeStartQuery(ctx context.Context, logsClient c
|
||||
QueryString: aws.String(modifiedQueryString),
|
||||
}
|
||||
|
||||
if e.features.IsEnabled(featuremgmt.FlagCloudWatchCrossAccountQuerying) {
|
||||
if logsQuery.LogGroups != nil && len(logsQuery.LogGroups) > 0 {
|
||||
var logGroupIdentifiers []string
|
||||
for _, lg := range logsQuery.LogGroups {
|
||||
arn := lg.ARN
|
||||
// due to a bug in the startQuery api, we remove * from the arn, otherwise it throws an error
|
||||
logGroupIdentifiers = append(logGroupIdentifiers, strings.TrimSuffix(arn, "*"))
|
||||
}
|
||||
startQueryInput.LogGroupIdentifiers = aws.StringSlice(logGroupIdentifiers)
|
||||
if logsQuery.LogGroups != nil && len(logsQuery.LogGroups) > 0 {
|
||||
var logGroupIdentifiers []string
|
||||
for _, lg := range logsQuery.LogGroups {
|
||||
arn := lg.ARN
|
||||
// due to a bug in the startQuery api, we remove * from the arn, otherwise it throws an error
|
||||
logGroupIdentifiers = append(logGroupIdentifiers, strings.TrimSuffix(arn, "*"))
|
||||
}
|
||||
}
|
||||
|
||||
if startQueryInput.LogGroupIdentifiers == nil {
|
||||
startQueryInput.LogGroupIdentifiers = aws.StringSlice(logGroupIdentifiers)
|
||||
} else {
|
||||
// even though log group names are being phased out, we still need to support them for backwards compatibility and alert queries
|
||||
startQueryInput.LogGroupNames = aws.StringSlice(logsQuery.LogGroupNames)
|
||||
}
|
||||
|
||||
|
@ -7,7 +7,6 @@ import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@ -290,39 +289,6 @@ func (e *cloudWatchExecutor) resourceGroupsGetResources(pluginCtx backend.Plugin
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (e *cloudWatchExecutor) handleGetLogGroups(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
|
||||
region := parameters.Get("region")
|
||||
limit := parameters.Get("limit")
|
||||
logGroupNamePrefix := parameters.Get("logGroupNamePrefix")
|
||||
|
||||
logsClient, err := e.getCWLogsClient(pluginCtx, region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logGroupLimit := defaultLogGroupLimit
|
||||
intLimit, err := strconv.ParseInt(limit, 10, 64)
|
||||
if err == nil && intLimit > 0 {
|
||||
logGroupLimit = intLimit
|
||||
}
|
||||
|
||||
var response *cloudwatchlogs.DescribeLogGroupsOutput = nil
|
||||
input := &cloudwatchlogs.DescribeLogGroupsInput{Limit: aws.Int64(logGroupLimit)}
|
||||
if len(logGroupNamePrefix) > 0 {
|
||||
input.LogGroupNamePrefix = aws.String(logGroupNamePrefix)
|
||||
}
|
||||
response, err = logsClient.DescribeLogGroups(input)
|
||||
if err != nil || response == nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]suggestData, 0)
|
||||
for _, logGroup := range response.LogGroups {
|
||||
logGroupName := *logGroup.LogGroupName
|
||||
result = append(result, suggestData{Text: logGroupName, Value: logGroupName, Label: logGroupName})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
func (e *cloudWatchExecutor) handleGetAllLogGroups(pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
|
||||
var nextToken *string
|
||||
|
||||
|
@ -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("/log-groups", handleResourceReq(e.handleGetLogGroups))
|
||||
mux.HandleFunc("/describe-log-groups", routes.ResourceRequestMiddleware(routes.LogGroupsHandler, logger, e.getRequestContext)) // supports CrossAccountQuerying
|
||||
mux.HandleFunc("/describe-log-groups", routes.ResourceRequestMiddleware(routes.LogGroupsHandler, logger, e.getRequestContext))
|
||||
mux.HandleFunc("/all-log-groups", handleResourceReq(e.handleGetAllLogGroups))
|
||||
mux.HandleFunc("/metrics", routes.ResourceRequestMiddleware(routes.MetricsHandler, logger, e.getRequestContext))
|
||||
mux.HandleFunc("/dimension-values", routes.ResourceRequestMiddleware(routes.DimensionValuesHandler, logger, e.getRequestContext))
|
||||
|
@ -48,7 +48,7 @@ export const meta: DataSourcePluginMeta<CloudWatchJsonData> = {
|
||||
};
|
||||
|
||||
export const CloudWatchSettings: DataSourceInstanceSettings<CloudWatchJsonData> = {
|
||||
jsonData: { defaultRegion: 'us-west-1', tracingDatasourceUid: 'xray' },
|
||||
jsonData: { defaultRegion: 'us-west-1', tracingDatasourceUid: 'xray', logGroups: [] },
|
||||
id: 0,
|
||||
uid: '',
|
||||
type: '',
|
||||
@ -80,13 +80,12 @@ export function setupMockedDataSource({
|
||||
const timeSrv = getTimeSrv();
|
||||
const datasource = new CloudWatchDatasource(customInstanceSettings, templateService, timeSrv);
|
||||
datasource.getVariables = () => ['test'];
|
||||
datasource.api.describeLogGroups = jest.fn().mockResolvedValue([]);
|
||||
datasource.api.getNamespaces = jest.fn().mockResolvedValue([]);
|
||||
datasource.api.getRegions = jest.fn().mockResolvedValue([]);
|
||||
datasource.api.getDimensionKeys = jest.fn().mockResolvedValue([]);
|
||||
datasource.api.getMetrics = jest.fn().mockResolvedValue([]);
|
||||
datasource.api.getAccounts = jest.fn().mockResolvedValue([]);
|
||||
datasource.logsQueryRunner.defaultLogGroups = [];
|
||||
datasource.api.describeLogGroups = jest.fn().mockResolvedValue([]);
|
||||
const fetchMock = jest.fn().mockReturnValue(of({}));
|
||||
setBackendSrv({
|
||||
...getBackendSrv(),
|
||||
|
@ -62,16 +62,7 @@ export class CloudWatchAPI extends CloudWatchRequest {
|
||||
);
|
||||
}
|
||||
|
||||
async describeLogGroups(params: DescribeLogGroupsRequest) {
|
||||
return this.memoizedGetRequest<SelectableResourceValue[]>('log-groups', {
|
||||
...params,
|
||||
region: this.templateSrv.replace(this.getActualRegion(params.region)),
|
||||
});
|
||||
}
|
||||
|
||||
async describeCrossAccountLogGroups(
|
||||
params: DescribeLogGroupsRequest
|
||||
): Promise<Array<ResourceResponse<LogGroupResponse>>> {
|
||||
async describeLogGroups(params: DescribeLogGroupsRequest): Promise<Array<ResourceResponse<LogGroupResponse>>> {
|
||||
return this.memoizedGetRequest<Array<ResourceResponse<LogGroupResponse>>>('describe-log-groups', {
|
||||
...params,
|
||||
region: this.templateSrv.replace(this.getActualRegion(params.region)),
|
||||
|
@ -1,31 +1,21 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import selectEvent from 'react-select-event';
|
||||
|
||||
import { AwsAuthType } from '@grafana/aws-sdk';
|
||||
import { toOption } from '@grafana/data';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
|
||||
import { CloudWatchSettings, setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
|
||||
import { CloudWatchDatasource } from '../datasource';
|
||||
|
||||
import { ConfigEditor, Props } from './ConfigEditor';
|
||||
|
||||
const datasource = new CloudWatchDatasource(CloudWatchSettings);
|
||||
const loadDataSourceMock = jest.fn();
|
||||
jest.mock('app/features/plugins/datasource_srv', () => ({
|
||||
getDatasourceSrv: () => ({
|
||||
loadDatasource: jest.fn().mockResolvedValue({
|
||||
api: {
|
||||
describeLogGroups: jest.fn().mockResolvedValue(['logGroup-foo', 'logGroup-bar'].map(toOption)),
|
||||
getRegions: jest.fn().mockResolvedValue([
|
||||
{
|
||||
label: 'ap-east-1',
|
||||
value: 'ap-east-1',
|
||||
},
|
||||
]),
|
||||
},
|
||||
getActualRegion: jest.fn().mockReturnValue('ap-east-1'),
|
||||
getVariables: jest.fn().mockReturnValue([]),
|
||||
}),
|
||||
loadDatasource: loadDataSourceMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -34,10 +24,12 @@ jest.mock('./XrayLinkConfig', () => ({
|
||||
}));
|
||||
|
||||
const putMock = jest.fn();
|
||||
const getMock = jest.fn();
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getBackendSrv: () => ({
|
||||
put: putMock,
|
||||
get: getMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -59,6 +51,7 @@ const props: Props = {
|
||||
isDefault: true,
|
||||
readOnly: false,
|
||||
withCredentials: false,
|
||||
version: 2,
|
||||
secureJsonFields: {
|
||||
accessKey: false,
|
||||
secretKey: false,
|
||||
@ -104,53 +97,184 @@ describe('Render', () => {
|
||||
};
|
||||
jest.resetAllMocks();
|
||||
putMock.mockImplementation(async () => ({ datasource: setupMockedDataSource().datasource }));
|
||||
getMock.mockImplementation(async () => ({ datasource: setupMockedDataSource().datasource }));
|
||||
loadDataSourceMock.mockResolvedValue(datasource);
|
||||
datasource.api.getRegions = jest.fn().mockResolvedValue([
|
||||
{
|
||||
label: 'ap-east-1',
|
||||
value: 'ap-east-1',
|
||||
},
|
||||
]);
|
||||
datasource.getActualRegion = jest.fn().mockReturnValue('ap-east-1');
|
||||
datasource.getVariables = jest.fn().mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('should render component without blowing up', () => {
|
||||
expect(() => setup()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should disable access key id field', () => {
|
||||
it('it should disable access key id field when the datasource has been previously configured', async () => {
|
||||
setup({
|
||||
secureJsonFields: {
|
||||
secretKey: true,
|
||||
},
|
||||
});
|
||||
expect(screen.getByPlaceholderText('Configured')).toBeDisabled();
|
||||
await waitFor(async () => expect(screen.getByPlaceholderText('Configured')).toBeDisabled());
|
||||
});
|
||||
|
||||
it('should show credentials profile name field', () => {
|
||||
it('should show credentials profile name field', async () => {
|
||||
setup({
|
||||
jsonData: {
|
||||
authType: AwsAuthType.Credentials,
|
||||
},
|
||||
});
|
||||
expect(screen.getByLabelText('Credentials Profile Name')).toBeInTheDocument();
|
||||
await waitFor(async () => expect(screen.getByLabelText('Credentials Profile Name')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should show access key and secret access key fields', () => {
|
||||
it('should show access key and secret access key fields when the datasource has not been configured before', async () => {
|
||||
setup({
|
||||
jsonData: {
|
||||
authType: AwsAuthType.Keys,
|
||||
},
|
||||
});
|
||||
expect(screen.getByLabelText('Access Key ID')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Secret Access Key')).toBeInTheDocument();
|
||||
await waitFor(async () => {
|
||||
expect(screen.getByLabelText('Access Key ID')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Secret Access Key')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show arn role field', () => {
|
||||
it('should show arn role field', async () => {
|
||||
setup({
|
||||
jsonData: {
|
||||
authType: AwsAuthType.ARN,
|
||||
},
|
||||
});
|
||||
expect(screen.getByLabelText('Assume Role ARN')).toBeInTheDocument();
|
||||
await waitFor(async () => expect(screen.getByLabelText('Assume Role ARN')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should load log groups when multiselect is opened', async () => {
|
||||
it('should display log group selector field', async () => {
|
||||
setup();
|
||||
const multiselect = await screen.findByLabelText('Log Groups');
|
||||
selectEvent.openMenu(multiselect);
|
||||
expect(await screen.findByText('logGroup-foo')).toBeInTheDocument();
|
||||
await waitFor(async () => expect(await screen.getByText('Select Log Groups')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should only display the first two default log groups and show all of them when clicking "Show all" button', async () => {
|
||||
setup({
|
||||
version: 2,
|
||||
jsonData: {
|
||||
logGroups: [
|
||||
{ arn: 'arn:aws:logs:us-east-2:123456789012:log-group:logGroup-foo:*', name: 'logGroup-foo' },
|
||||
{ arn: 'arn:aws:logs:us-east-2:123456789012:log-group:logGroup-bar:*', name: 'logGroup-bar' },
|
||||
{ arn: 'arn:aws:logs:us-east-2:123456789012:log-group:logGroup-baz:*', name: 'logGroup-baz' },
|
||||
],
|
||||
},
|
||||
});
|
||||
await waitFor(async () => {
|
||||
expect(await screen.getByText('logGroup-foo')).toBeInTheDocument();
|
||||
expect(await screen.getByText('logGroup-bar')).toBeInTheDocument();
|
||||
expect(await screen.queryByText('logGroup-baz')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('Show all'));
|
||||
|
||||
expect(await screen.getByText('logGroup-baz')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should load the data source if it was saved before', async () => {
|
||||
const SAVED_VERSION = 2;
|
||||
const newProps = {
|
||||
...props,
|
||||
options: {
|
||||
...props.options,
|
||||
version: SAVED_VERSION,
|
||||
},
|
||||
};
|
||||
|
||||
render(<ConfigEditor {...newProps} />);
|
||||
await waitFor(async () => expect(loadDataSourceMock).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('should not load the data source if it wasnt saved before', async () => {
|
||||
const SAVED_VERSION = undefined;
|
||||
const newProps = {
|
||||
...props,
|
||||
options: {
|
||||
...props.options,
|
||||
version: SAVED_VERSION,
|
||||
},
|
||||
};
|
||||
|
||||
render(<ConfigEditor {...newProps} />);
|
||||
await waitFor(async () => expect(loadDataSourceMock).not.toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('should show error message if Select log group button is clicked when data source is never saved', async () => {
|
||||
const SAVED_VERSION = undefined;
|
||||
const newProps = {
|
||||
...props,
|
||||
options: {
|
||||
...props.options,
|
||||
version: SAVED_VERSION,
|
||||
},
|
||||
};
|
||||
|
||||
render(<ConfigEditor {...newProps} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Select Log Groups')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('You need to save the data source before adding log groups.')).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error message if Select log group button is clicked when data source is saved before but have unsaved changes', async () => {
|
||||
const SAVED_VERSION = 3;
|
||||
const newProps = {
|
||||
...props,
|
||||
options: {
|
||||
...props.options,
|
||||
version: SAVED_VERSION,
|
||||
},
|
||||
};
|
||||
const { rerender } = render(<ConfigEditor {...newProps} />);
|
||||
await waitFor(() => expect(screen.getByText('Select Log Groups')).toBeInTheDocument());
|
||||
const rerenderProps = {
|
||||
...newProps,
|
||||
options: {
|
||||
...newProps.options,
|
||||
jsonData: {
|
||||
...newProps.options.jsonData,
|
||||
authType: AwsAuthType.Default,
|
||||
},
|
||||
},
|
||||
};
|
||||
rerender(<ConfigEditor {...rerenderProps} />);
|
||||
await waitFor(() => expect(screen.getByText('AWS SDK Default')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
'You have unsaved connection detail changes. You need to save the data source before adding log groups.'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('should open log group selector if Select log group button is clicked when data source has saved changes', async () => {
|
||||
const SAVED_VERSION = undefined;
|
||||
const newProps = {
|
||||
...props,
|
||||
options: {
|
||||
...props.options,
|
||||
version: SAVED_VERSION,
|
||||
},
|
||||
};
|
||||
const { rerender } = render(<ConfigEditor {...newProps} />);
|
||||
await waitFor(() => expect(screen.getByText('Select Log Groups')).toBeInTheDocument());
|
||||
const rerenderProps = {
|
||||
...newProps,
|
||||
options: {
|
||||
...newProps.options,
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
rerender(<ConfigEditor {...rerenderProps} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
await waitFor(() => expect(screen.getByText('Log group name prefix')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
@ -7,10 +7,8 @@ import {
|
||||
DataSourcePluginOptionsEditorProps,
|
||||
onUpdateDatasourceJsonDataOption,
|
||||
updateDatasourcePluginJsonDataOption,
|
||||
updateDatasourcePluginOption,
|
||||
} from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { Input, InlineField } from '@grafana/ui';
|
||||
import { Input, InlineField, FieldProps } from '@grafana/ui';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createWarningNotification } from 'app/core/copy/appNotification';
|
||||
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||
@ -20,43 +18,24 @@ import { SelectableResourceValue } from '../api';
|
||||
import { CloudWatchDatasource } from '../datasource';
|
||||
import { CloudWatchJsonData, CloudWatchSecureJsonData } from '../types';
|
||||
|
||||
import { LogGroupSelector } from './LogGroupSelector';
|
||||
import { LogGroupsField } from './LogGroups/LogGroupsField';
|
||||
import { XrayLinkConfig } from './XrayLinkConfig';
|
||||
|
||||
export type Props = DataSourcePluginOptionsEditorProps<CloudWatchJsonData, CloudWatchSecureJsonData>;
|
||||
|
||||
export const ConfigEditor: FC<Props> = (props: Props) => {
|
||||
const { options } = props;
|
||||
const { defaultLogGroups, logsTimeout, defaultRegion } = options.jsonData;
|
||||
const [saved, setSaved] = useState(!!options.version && options.version > 1);
|
||||
type LogGroupFieldState = Pick<FieldProps, 'invalid'> & { error?: string | null };
|
||||
|
||||
const datasource = useDatasource(options.name, saved);
|
||||
export const ConfigEditor: FC<Props> = (props: Props) => {
|
||||
const { options, onOptionsChange } = props;
|
||||
const { defaultLogGroups, logsTimeout, defaultRegion, logGroups } = options.jsonData;
|
||||
const datasource = useDatasource(props);
|
||||
useAuthenticationWarning(options.jsonData);
|
||||
const logsTimeoutError = useTimoutValidation(logsTimeout);
|
||||
useEffect(() => {
|
||||
setSaved(false);
|
||||
}, [
|
||||
props.options.jsonData.assumeRoleArn,
|
||||
props.options.jsonData.authType,
|
||||
props.options.jsonData.defaultRegion,
|
||||
props.options.jsonData.endpoint,
|
||||
props.options.jsonData.externalId,
|
||||
props.options.jsonData.profile,
|
||||
props.options.secureJsonData?.accessKey,
|
||||
props.options.secureJsonData?.secretKey,
|
||||
]);
|
||||
|
||||
const saveOptions = async (): Promise<void> => {
|
||||
if (saved) {
|
||||
return;
|
||||
}
|
||||
await getBackendSrv()
|
||||
.put(`/api/datasources/${options.id}`, options)
|
||||
.then((result: { datasource: any }) => {
|
||||
updateDatasourcePluginOption(props, 'version', result.datasource.version);
|
||||
});
|
||||
setSaved(true);
|
||||
};
|
||||
const saved = useDataSourceSavedState(props);
|
||||
const [logGroupFieldState, setLogGroupFieldState] = useState<LogGroupFieldState>({
|
||||
invalid: false,
|
||||
});
|
||||
useEffect(() => setLogGroupFieldState({ invalid: false }), [props.options]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -107,17 +86,41 @@ export const ConfigEditor: FC<Props> = (props: Props) => {
|
||||
label="Default Log Groups"
|
||||
labelWidth={28}
|
||||
tooltip="Optionally, specify default log groups for CloudWatch Logs queries."
|
||||
shrink={true}
|
||||
{...logGroupFieldState}
|
||||
>
|
||||
<LogGroupSelector
|
||||
<LogGroupsField
|
||||
region={defaultRegion ?? ''}
|
||||
selectedLogGroups={defaultLogGroups ?? []}
|
||||
datasource={datasource}
|
||||
onChange={(logGroups) => {
|
||||
updateDatasourcePluginJsonDataOption(props, 'defaultLogGroups', logGroups);
|
||||
onBeforeOpen={() => {
|
||||
if (saved) {
|
||||
return;
|
||||
}
|
||||
|
||||
let error = 'You need to save the data source before adding log groups.';
|
||||
if (props.options.version && props.options.version > 1) {
|
||||
error =
|
||||
'You have unsaved connection detail changes. You need to save the data source before adding log groups.';
|
||||
}
|
||||
setLogGroupFieldState({
|
||||
invalid: true,
|
||||
error,
|
||||
});
|
||||
throw new Error(error);
|
||||
}}
|
||||
onOpenMenu={saveOptions}
|
||||
width={60}
|
||||
saved={saved}
|
||||
legacyLogGroupNames={defaultLogGroups}
|
||||
logGroups={logGroups}
|
||||
onChange={(updatedLogGroups) => {
|
||||
onOptionsChange({
|
||||
...props.options,
|
||||
jsonData: {
|
||||
...props.options.jsonData,
|
||||
logGroups: updatedLogGroups,
|
||||
defaultLogGroups: undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
maxNoOfVisibleLogGroups={2}
|
||||
/>
|
||||
</InlineField>
|
||||
</div>
|
||||
@ -148,22 +151,20 @@ function useAuthenticationWarning(jsonData: CloudWatchJsonData) {
|
||||
}, [jsonData.authType, jsonData.database, jsonData.profile]);
|
||||
}
|
||||
|
||||
function useDatasource(datasourceName: string, saved: boolean) {
|
||||
function useDatasource(props: Props) {
|
||||
const [datasource, setDatasource] = useState<CloudWatchDatasource>();
|
||||
|
||||
useEffect(() => {
|
||||
// reload the datasource when it's saved
|
||||
if (!saved) {
|
||||
return;
|
||||
if (props.options.version) {
|
||||
getDatasourceSrv()
|
||||
.loadDatasource(props.options.name)
|
||||
.then((datasource) => {
|
||||
if (datasource instanceof CloudWatchDatasource) {
|
||||
setDatasource(datasource);
|
||||
}
|
||||
});
|
||||
}
|
||||
getDatasourceSrv()
|
||||
.loadDatasource(datasourceName)
|
||||
.then((datasource) => {
|
||||
// It's really difficult to type .loadDatasource() because it's inherently untyped as it involves two JSON.parse()'s
|
||||
// So a "as" type assertion here is a necessary evil.
|
||||
setDatasource(datasource as CloudWatchDatasource);
|
||||
});
|
||||
}, [datasourceName, saved]);
|
||||
}, [props.options.version, props.options.name]);
|
||||
|
||||
return datasource;
|
||||
}
|
||||
@ -190,3 +191,25 @@ function useTimoutValidation(value: string | undefined) {
|
||||
);
|
||||
return err;
|
||||
}
|
||||
|
||||
function useDataSourceSavedState(props: Props) {
|
||||
const [saved, setSaved] = useState(!!props.options.version && props.options.version > 1);
|
||||
useEffect(() => {
|
||||
setSaved(false);
|
||||
}, [
|
||||
props.options.jsonData.assumeRoleArn,
|
||||
props.options.jsonData.authType,
|
||||
props.options.jsonData.defaultRegion,
|
||||
props.options.jsonData.endpoint,
|
||||
props.options.jsonData.externalId,
|
||||
props.options.jsonData.profile,
|
||||
props.options.secureJsonData?.accessKey,
|
||||
props.options.secureJsonData?.secretKey,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
props.options.version && setSaved(true);
|
||||
}, [props.options.version]);
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
@ -1,85 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
// eslint-disable-next-line lodash/import-scope
|
||||
import lodash from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
|
||||
import { CloudWatchLogsQuery } from '../types';
|
||||
|
||||
import { LogGroupSelection } from './LogGroupSelection';
|
||||
|
||||
const originalFeatureToggleValue = config.featureToggles.cloudWatchCrossAccountQuerying;
|
||||
const originalDebounce = lodash.debounce;
|
||||
|
||||
const defaultProps = {
|
||||
datasource: setupMockedDataSource().datasource,
|
||||
query: {
|
||||
queryMode: 'Logs',
|
||||
id: '',
|
||||
region: '',
|
||||
refId: '',
|
||||
} as CloudWatchLogsQuery,
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
describe('LogGroupSelection', () => {
|
||||
beforeEach(() => {
|
||||
lodash.debounce = jest.fn().mockImplementation((fn) => {
|
||||
fn.cancel = () => {};
|
||||
return fn;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue;
|
||||
lodash.debounce = originalDebounce;
|
||||
});
|
||||
it('renders the old logGroupSelector when the feature toggle is disabled and there are no linked accounts', async () => {
|
||||
config.featureToggles.cloudWatchCrossAccountQuerying = false;
|
||||
render(<LogGroupSelection {...defaultProps} />);
|
||||
await waitFor(() => screen.getByText('Choose Log Groups'));
|
||||
expect(screen.queryByText('Select Log Groups')).not.toBeInTheDocument();
|
||||
});
|
||||
it('renders the old logGroupSelector when the feature toggle is disabled but there are linked accounts', async () => {
|
||||
config.featureToggles.cloudWatchCrossAccountQuerying = false;
|
||||
const ds = setupMockedDataSource().datasource;
|
||||
ds.api.getAccounts = () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
arn: 'arn',
|
||||
id: 'accountId',
|
||||
label: 'label',
|
||||
isMonitoringAccount: true,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<LogGroupSelection {...defaultProps} datasource={ds} />);
|
||||
await waitFor(() => screen.getByText('Choose Log Groups'));
|
||||
expect(screen.queryByText('Select Log Groups')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the old logGroupSelector when the feature toggle is enabled but there are no linked accounts', async () => {
|
||||
config.featureToggles.cloudWatchCrossAccountQuerying = true;
|
||||
render(<LogGroupSelection {...defaultProps} />);
|
||||
await waitFor(() => screen.getByText('Choose Log Groups'));
|
||||
expect(screen.queryByText('Select Log Groups')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the new logGroupSelector when the feature toggle is enabled and there are linked accounts', async () => {
|
||||
config.featureToggles.cloudWatchCrossAccountQuerying = true;
|
||||
const ds = setupMockedDataSource().datasource;
|
||||
ds.api.getAccounts = () =>
|
||||
Promise.resolve([
|
||||
{
|
||||
arn: 'arn',
|
||||
id: 'accountId',
|
||||
label: 'label',
|
||||
isMonitoringAccount: true,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<LogGroupSelection {...defaultProps} datasource={ds} />);
|
||||
await waitFor(() => screen.getByText('Select Log Groups'));
|
||||
expect(screen.queryByText('Choose Log Groups')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -1,60 +0,0 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { LegacyForms } from '@grafana/ui';
|
||||
|
||||
import { CloudWatchDatasource } from '../datasource';
|
||||
import { useAccountOptions } from '../hooks';
|
||||
import { CloudWatchLogsQuery, CloudWatchQuery, DescribeLogGroupsRequest, LogGroup } from '../types';
|
||||
|
||||
import { CrossAccountLogsQueryField } from './CrossAccountLogsQueryField';
|
||||
import { LogGroupSelector } from './LogGroupSelector';
|
||||
|
||||
type Props = {
|
||||
datasource: CloudWatchDatasource;
|
||||
query: CloudWatchLogsQuery;
|
||||
onChange: (value: CloudWatchQuery) => void;
|
||||
};
|
||||
|
||||
const rowGap = css`
|
||||
gap: 3px;
|
||||
`;
|
||||
|
||||
export const LogGroupSelection = ({ datasource, query, onChange }: Props) => {
|
||||
const accountState = useAccountOptions(datasource.api, query.region);
|
||||
|
||||
return (
|
||||
<div className={`gf-form gf-form--grow flex-grow-1 ${rowGap}`}>
|
||||
{config.featureToggles.cloudWatchCrossAccountQuerying && accountState?.value?.length ? (
|
||||
<CrossAccountLogsQueryField
|
||||
fetchLogGroups={(params: Partial<DescribeLogGroupsRequest>) =>
|
||||
datasource.api.describeCrossAccountLogGroups({ region: query.region, ...params })
|
||||
}
|
||||
onChange={(selectedLogGroups: LogGroup[]) => {
|
||||
onChange({ ...query, logGroups: selectedLogGroups, logGroupNames: [] });
|
||||
}}
|
||||
accountOptions={accountState.value}
|
||||
selectedLogGroups={query.logGroups ?? []} /* todo handle defaults */
|
||||
/>
|
||||
) : (
|
||||
<LegacyForms.FormField
|
||||
label="Log Groups"
|
||||
labelWidth={6}
|
||||
className="flex-grow-1"
|
||||
inputEl={
|
||||
<LogGroupSelector
|
||||
region={query.region}
|
||||
selectedLogGroups={query.logGroupNames ?? datasource.logsQueryRunner.defaultLogGroups}
|
||||
datasource={datasource}
|
||||
onChange={function (logGroupNames: string[]): void {
|
||||
onChange({ ...query, logGroupNames, logGroups: [] });
|
||||
}}
|
||||
refId={query.refId}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,122 +0,0 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import lodash from 'lodash'; // eslint-disable-line lodash/import-scope
|
||||
import React from 'react';
|
||||
import { openMenu, select } from 'react-select-event';
|
||||
|
||||
import { toOption } from '@grafana/data';
|
||||
|
||||
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
|
||||
import { DescribeLogGroupsRequest } from '../types';
|
||||
|
||||
import { LogGroupSelector, LogGroupSelectorProps } from './LogGroupSelector';
|
||||
|
||||
const ds = setupMockedDataSource();
|
||||
|
||||
describe('LogGroupSelector', () => {
|
||||
const onChange = jest.fn();
|
||||
const defaultProps: LogGroupSelectorProps = {
|
||||
region: 'region1',
|
||||
datasource: ds.datasource,
|
||||
selectedLogGroups: [],
|
||||
onChange,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('does not clear previously selected log groups after region change', async () => {
|
||||
ds.datasource.api.describeLogGroups = jest.fn().mockImplementation(async (params: DescribeLogGroupsRequest) => {
|
||||
if (params.region === 'region1') {
|
||||
return Promise.resolve(['log_group_1'].map(toOption));
|
||||
} else {
|
||||
return Promise.resolve(['log_group_2'].map(toOption));
|
||||
}
|
||||
});
|
||||
const props = {
|
||||
...defaultProps,
|
||||
selectedLogGroups: ['log_group_1'],
|
||||
};
|
||||
|
||||
const { rerender } = render(<LogGroupSelector {...props} />);
|
||||
expect(await screen.findByText('log_group_1')).toBeInTheDocument();
|
||||
|
||||
act(() => rerender(<LogGroupSelector {...props} region="region2" />));
|
||||
expect(await screen.findByText('log_group_1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should merge results of remote log groups search with existing results', async () => {
|
||||
lodash.debounce = jest.fn().mockImplementation((fn) => fn);
|
||||
const allLogGroups = [
|
||||
'AmazingGroup',
|
||||
'AmazingGroup2',
|
||||
'AmazingGroup3',
|
||||
'BeautifulGroup',
|
||||
'BeautifulGroup2',
|
||||
'BeautifulGroup3',
|
||||
'CrazyGroup',
|
||||
'CrazyGroup2',
|
||||
'CrazyGroup3',
|
||||
'DeliciousGroup',
|
||||
'DeliciousGroup2',
|
||||
'DeliciousGroup3',
|
||||
'VelvetGroup',
|
||||
'VelvetGroup2',
|
||||
'VelvetGroup3',
|
||||
'WaterGroup',
|
||||
'WaterGroup2',
|
||||
'WaterGroup3',
|
||||
];
|
||||
const testLimit = 10;
|
||||
|
||||
ds.datasource.api.describeLogGroups = jest.fn().mockImplementation(async (params: DescribeLogGroupsRequest) => {
|
||||
const theLogGroups = allLogGroups
|
||||
.filter((logGroupName) => logGroupName.startsWith(params.logGroupNamePrefix ?? ''))
|
||||
.slice(0, Math.max(params.limit ?? testLimit, testLimit));
|
||||
return Promise.resolve(theLogGroups.map(toOption));
|
||||
});
|
||||
const props = {
|
||||
...defaultProps,
|
||||
};
|
||||
render(<LogGroupSelector {...props} />);
|
||||
const multiselect = await screen.findByLabelText('Log Groups');
|
||||
|
||||
// Adds the 3 Water groups to the 10 loaded in initially
|
||||
await userEvent.type(multiselect, 'Water');
|
||||
// The 3 Water groups + the create option
|
||||
expect(screen.getAllByLabelText('Select option').length).toBe(4);
|
||||
await userEvent.clear(multiselect);
|
||||
expect(screen.getAllByLabelText('Select option').length).toBe(testLimit + 3);
|
||||
|
||||
// Adds the three Velvet groups to the previous 13
|
||||
await userEvent.type(multiselect, 'Velv');
|
||||
// The 3 Velvet groups + the create option
|
||||
expect(screen.getAllByLabelText('Select option').length).toBe(4);
|
||||
await userEvent.clear(multiselect);
|
||||
expect(screen.getAllByLabelText('Select option').length).toBe(testLimit + 6);
|
||||
});
|
||||
|
||||
it('should render template variables a selectable option', async () => {
|
||||
lodash.debounce = jest.fn().mockImplementation((fn) => fn);
|
||||
ds.datasource.api.describeLogGroups = jest.fn().mockResolvedValue([]);
|
||||
const onChange = jest.fn();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
onChange,
|
||||
};
|
||||
render(<LogGroupSelector {...props} />);
|
||||
|
||||
const logGroupSelector = await screen.findByLabelText('Log Groups');
|
||||
expect(logGroupSelector).toBeInTheDocument();
|
||||
|
||||
await openMenu(logGroupSelector);
|
||||
const templateVariableSelector = await screen.findByText('Template Variables');
|
||||
expect(templateVariableSelector).toBeInTheDocument();
|
||||
|
||||
userEvent.click(templateVariableSelector);
|
||||
await select(await screen.findByLabelText('Select option'), 'test');
|
||||
|
||||
expect(onChange).toBeCalledWith(['test']);
|
||||
});
|
||||
});
|
@ -1,150 +0,0 @@
|
||||
import { debounce, unionBy } from 'lodash';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { SelectableValue, toOption } from '@grafana/data';
|
||||
import { MultiSelect } from '@grafana/ui';
|
||||
import { InputActionMeta } from '@grafana/ui/src/components/Select/types';
|
||||
import { notifyApp } from 'app/core/actions';
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { dispatch } from 'app/store/store';
|
||||
|
||||
import { CloudWatchDatasource } from '../datasource';
|
||||
import { appendTemplateVariables } from '../utils/utils';
|
||||
|
||||
const MAX_LOG_GROUPS = 20;
|
||||
const MAX_VISIBLE_LOG_GROUPS = 4;
|
||||
const DEBOUNCE_TIMER = 300;
|
||||
|
||||
export interface LogGroupSelectorProps {
|
||||
region: string;
|
||||
selectedLogGroups: string[];
|
||||
onChange: (logGroups: string[]) => void;
|
||||
|
||||
datasource?: CloudWatchDatasource;
|
||||
onOpenMenu?: () => Promise<void>;
|
||||
refId?: string;
|
||||
width?: number | 'auto';
|
||||
saved?: boolean; // is only used in the config editor
|
||||
}
|
||||
|
||||
export const LogGroupSelector: React.FC<LogGroupSelectorProps> = ({
|
||||
region,
|
||||
selectedLogGroups,
|
||||
onChange,
|
||||
datasource,
|
||||
onOpenMenu,
|
||||
refId,
|
||||
width,
|
||||
saved = true,
|
||||
}) => {
|
||||
const [loadingLogGroups, setLoadingLogGroups] = useState(false);
|
||||
const [availableLogGroups, setAvailableLogGroups] = useState<Array<SelectableValue<string>>>([]);
|
||||
const logGroupOptions = useMemo(
|
||||
() => unionBy(availableLogGroups, selectedLogGroups?.map(toOption), 'value'),
|
||||
[availableLogGroups, selectedLogGroups]
|
||||
);
|
||||
|
||||
const fetchLogGroupOptions = useCallback(
|
||||
async (region: string, logGroupNamePrefix?: string) => {
|
||||
if (!datasource) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const logGroups = await datasource.api.describeLogGroups({
|
||||
refId,
|
||||
region,
|
||||
logGroupNamePrefix,
|
||||
});
|
||||
return logGroups;
|
||||
} catch (err) {
|
||||
dispatch(notifyApp(createErrorNotification(typeof err === 'string' ? err : JSON.stringify(err))));
|
||||
return [];
|
||||
}
|
||||
},
|
||||
[datasource, refId]
|
||||
);
|
||||
|
||||
const onLogGroupSearch = async (searchTerm: string, region: string, actionMeta: InputActionMeta) => {
|
||||
if (actionMeta.action !== 'input-change' || !datasource) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No need to fetch matching log groups if the search term isn't valid
|
||||
// This is also useful for preventing searches when a user is typing out a log group with template vars
|
||||
// See https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_LogGroup.html for the source of the pattern below
|
||||
const logGroupNamePattern = /^[\.\-_/#A-Za-z0-9]+$/;
|
||||
if (!logGroupNamePattern.test(searchTerm)) {
|
||||
if (searchTerm !== '') {
|
||||
dispatch(notifyApp(createErrorNotification('Invalid Log Group name: ' + searchTerm)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingLogGroups(true);
|
||||
const matchingLogGroups = await fetchLogGroupOptions(region, searchTerm);
|
||||
setAvailableLogGroups(unionBy(availableLogGroups, matchingLogGroups, 'value'));
|
||||
setLoadingLogGroups(false);
|
||||
};
|
||||
|
||||
// Reset the log group options if the datasource or region change and are saved
|
||||
useEffect(() => {
|
||||
async function getAvailableLogGroupOptions() {
|
||||
// Don't call describeLogGroups if datasource or region is undefined
|
||||
if (!datasource || !datasource.getActualRegion(region)) {
|
||||
setAvailableLogGroups([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingLogGroups(true);
|
||||
return fetchLogGroupOptions(datasource.getActualRegion(region))
|
||||
.then((logGroups) => {
|
||||
setAvailableLogGroups(logGroups);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingLogGroups(false);
|
||||
});
|
||||
}
|
||||
|
||||
// Config editor does not fetch new log group options unless changes have been saved
|
||||
saved && getAvailableLogGroupOptions();
|
||||
|
||||
// if component unmounts in the middle of setting state, we reset state and unsubscribe from fetchLogGroupOptions
|
||||
return () => {
|
||||
setAvailableLogGroups([]);
|
||||
setLoadingLogGroups(false);
|
||||
};
|
||||
// this hook shouldn't get called every time selectedLogGroups or onChange updates
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [datasource, region, saved]);
|
||||
|
||||
const onOpenLogGroupMenu = async () => {
|
||||
if (onOpenMenu) {
|
||||
await onOpenMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const onLogGroupSearchDebounced = debounce(onLogGroupSearch, DEBOUNCE_TIMER);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
inputId="default-log-groups"
|
||||
aria-label="Log Groups"
|
||||
allowCustomValue
|
||||
options={datasource ? appendTemplateVariables(datasource, logGroupOptions) : logGroupOptions}
|
||||
value={selectedLogGroups}
|
||||
onChange={(v) => onChange(v.filter(({ value }) => value).map(({ value }) => value))}
|
||||
closeMenuOnSelect={false}
|
||||
isClearable
|
||||
isOptionDisabled={() => selectedLogGroups.length >= MAX_LOG_GROUPS}
|
||||
placeholder="Choose Log Groups"
|
||||
maxVisibleValues={MAX_VISIBLE_LOG_GROUPS}
|
||||
noOptionsMessage="No log groups available"
|
||||
isLoading={loadingLogGroups}
|
||||
onOpenMenu={onOpenLogGroupMenu}
|
||||
onInputChange={(value, actionMeta) => {
|
||||
onLogGroupSearchDebounced(value, region, actionMeta);
|
||||
}}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,58 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
// eslint-disable-next-line lodash/import-scope
|
||||
import lodash from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
|
||||
|
||||
import { LogGroupsField } from './LogGroupsField';
|
||||
|
||||
const originalFeatureToggleValue = config.featureToggles.cloudWatchCrossAccountQuerying;
|
||||
const originalDebounce = lodash.debounce;
|
||||
|
||||
const defaultProps = {
|
||||
datasource: setupMockedDataSource().datasource,
|
||||
region: '',
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
describe('LogGroupSelection', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
lodash.debounce = jest.fn().mockImplementation((fn) => {
|
||||
fn.cancel = () => {};
|
||||
return fn;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
config.featureToggles.cloudWatchCrossAccountQuerying = originalFeatureToggleValue;
|
||||
lodash.debounce = originalDebounce;
|
||||
});
|
||||
|
||||
it('call describeCrossAccountLogGroups 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
|
||||
.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({
|
||||
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 () => {
|
||||
config.featureToggles.cloudWatchCrossAccountQuerying = true;
|
||||
defaultProps.datasource.api.describeLogGroups = 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.onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
@ -0,0 +1,77 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { CloudWatchDatasource } from '../../datasource';
|
||||
import { useAccountOptions } from '../../hooks';
|
||||
import { DescribeLogGroupsRequest, LogGroup } from '../../types';
|
||||
|
||||
import { LogGroupsSelector } from './LogGroupsSelector';
|
||||
import { SelectedLogGroups } from './SelectedLogGroups';
|
||||
|
||||
type Props = {
|
||||
datasource?: CloudWatchDatasource;
|
||||
onChange: (logGroups: LogGroup[]) => void;
|
||||
legacyLogGroupNames?: string[];
|
||||
logGroups?: LogGroup[];
|
||||
region: string;
|
||||
maxNoOfVisibleLogGroups?: number;
|
||||
onBeforeOpen?: () => void;
|
||||
};
|
||||
|
||||
const rowGap = css`
|
||||
gap: 3px;
|
||||
`;
|
||||
|
||||
export const LogGroupsField = ({
|
||||
datasource,
|
||||
onChange,
|
||||
legacyLogGroupNames,
|
||||
logGroups,
|
||||
region,
|
||||
maxNoOfVisibleLogGroups,
|
||||
onBeforeOpen,
|
||||
}: Props) => {
|
||||
const accountState = useAccountOptions(datasource?.api, region);
|
||||
const [loadingLogGroupsStarted, setLoadingLogGroupsStarted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 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);
|
||||
Promise.all(
|
||||
legacyLogGroupNames.map((lg) => datasource.api.describeLogGroups({ region: region, logGroupNamePrefix: lg }))
|
||||
)
|
||||
.then((results) => {
|
||||
const a = results.flatMap((r) =>
|
||||
r.map((lg) => ({
|
||||
arn: lg.value.arn,
|
||||
name: lg.value.name,
|
||||
accountId: lg.accountId,
|
||||
}))
|
||||
);
|
||||
|
||||
onChange(a);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}, [datasource, legacyLogGroupNames, logGroups, onChange, region, loadingLogGroupsStarted]);
|
||||
|
||||
return (
|
||||
<div className={`gf-form gf-form--grow flex-grow-1 ${rowGap}`}>
|
||||
<LogGroupsSelector
|
||||
fetchLogGroups={async (params: Partial<DescribeLogGroupsRequest>) =>
|
||||
datasource?.api.describeLogGroups({ region: region, ...params }) ?? []
|
||||
}
|
||||
onChange={onChange}
|
||||
accountOptions={accountState.value}
|
||||
selectedLogGroups={logGroups}
|
||||
onBeforeOpen={onBeforeOpen}
|
||||
/>
|
||||
<SelectedLogGroups
|
||||
selectedLogGroups={logGroups ?? []}
|
||||
onChange={onChange}
|
||||
maxNoOfVisibleLogGroups={maxNoOfVisibleLogGroups}
|
||||
></SelectedLogGroups>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -5,9 +5,9 @@ import lodash from 'lodash';
|
||||
import React from 'react';
|
||||
import selectEvent from 'react-select-event';
|
||||
|
||||
import { ResourceResponse, LogGroupResponse } from '../types';
|
||||
import { ResourceResponse, LogGroupResponse } from '../../types';
|
||||
|
||||
import { CrossAccountLogsQueryField } from './CrossAccountLogsQueryField';
|
||||
import { LogGroupsSelector } from './LogGroupsSelector';
|
||||
|
||||
const defaultProps = {
|
||||
selectedLogGroups: [],
|
||||
@ -68,9 +68,9 @@ describe('CrossAccountLogsQueryField', () => {
|
||||
lodash.debounce = originalDebounce;
|
||||
});
|
||||
it('opens a modal with a search field when the Select Log Groups Button is clicked', async () => {
|
||||
render(<CrossAccountLogsQueryField {...defaultProps} />);
|
||||
render(<LogGroupsSelector {...defaultProps} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
expect(screen.getByText('Log Group Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Log group name prefix')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls fetchLogGroups the first time the modal opens and renders a loading widget and then a checkbox for every log group', async () => {
|
||||
@ -79,7 +79,7 @@ describe('CrossAccountLogsQueryField', () => {
|
||||
await Promise.all([defer.promise]);
|
||||
return defaultProps.fetchLogGroups();
|
||||
});
|
||||
render(<CrossAccountLogsQueryField {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||
render(<LogGroupsSelector {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
defer.resolve();
|
||||
@ -94,7 +94,7 @@ describe('CrossAccountLogsQueryField', () => {
|
||||
await Promise.all([defer.promise]);
|
||||
return [];
|
||||
});
|
||||
render(<CrossAccountLogsQueryField {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||
render(<LogGroupsSelector {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
defer.resolve();
|
||||
@ -106,9 +106,9 @@ describe('CrossAccountLogsQueryField', () => {
|
||||
|
||||
it('calls fetchLogGroups with a search phrase when it is typed in the Search Field', async () => {
|
||||
const fetchLogGroups = jest.fn(() => defaultProps.fetchLogGroups());
|
||||
render(<CrossAccountLogsQueryField {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||
render(<LogGroupsSelector {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
expect(screen.getByText('Log Group Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Log group name prefix')).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText('log group search'), 'something');
|
||||
await waitFor(() => screen.getByDisplayValue('something'));
|
||||
expect(fetchLogGroups).toBeCalledWith({ accountId: 'all', logGroupPattern: 'something' });
|
||||
@ -127,7 +127,7 @@ describe('CrossAccountLogsQueryField', () => {
|
||||
once = true;
|
||||
return defaultProps.fetchLogGroups();
|
||||
});
|
||||
render(<CrossAccountLogsQueryField {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||
render(<LogGroupsSelector {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
firstCall.resolve();
|
||||
@ -145,18 +145,18 @@ describe('CrossAccountLogsQueryField', () => {
|
||||
|
||||
it('shows a log group as checked after the user checks it', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(<CrossAccountLogsQueryField {...defaultProps} onChange={onChange} />);
|
||||
render(<LogGroupsSelector {...defaultProps} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
expect(screen.getByText('Log Group Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Log group name prefix')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('logGroup2')).not.toBeChecked();
|
||||
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(<CrossAccountLogsQueryField {...defaultProps} onChange={onChange} />);
|
||||
render(<LogGroupsSelector {...defaultProps} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
expect(screen.getByText('Log Group Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Log group name prefix')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByLabelText('logGroup2'));
|
||||
await userEvent.click(screen.getByText('Add log groups'));
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
@ -171,9 +171,9 @@ describe('CrossAccountLogsQueryField', () => {
|
||||
|
||||
it('does not call onChange after a selection if the user hits the cancel button', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(<CrossAccountLogsQueryField {...defaultProps} onChange={onChange} />);
|
||||
render(<LogGroupsSelector {...defaultProps} onChange={onChange} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
expect(screen.getByText('Log Group Name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Log group name prefix')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByLabelText('logGroup2'));
|
||||
await userEvent.click(screen.getByText('Cancel'));
|
||||
expect(onChange).not.toHaveBeenCalledWith([
|
||||
@ -194,7 +194,7 @@ describe('CrossAccountLogsQueryField', () => {
|
||||
await Promise.all([defer.promise]);
|
||||
return [];
|
||||
});
|
||||
render(<CrossAccountLogsQueryField {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||
render(<LogGroupsSelector {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
expect(screen.queryByText(labelText)).not.toBeInTheDocument();
|
||||
defer.resolve();
|
||||
@ -212,7 +212,7 @@ describe('CrossAccountLogsQueryField', () => {
|
||||
},
|
||||
}));
|
||||
});
|
||||
render(<CrossAccountLogsQueryField {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||
render(<LogGroupsSelector {...defaultProps} fetchLogGroups={fetchLogGroups} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
expect(screen.queryByText(labelText)).not.toBeInTheDocument();
|
||||
defer.resolve();
|
||||
@ -220,7 +220,7 @@ describe('CrossAccountLogsQueryField', () => {
|
||||
});
|
||||
|
||||
it('should display log groups counter label', async () => {
|
||||
render(<CrossAccountLogsQueryField {...defaultProps} selectedLogGroups={[]} />);
|
||||
render(<LogGroupsSelector {...defaultProps} selectedLogGroups={[]} />);
|
||||
await userEvent.click(screen.getByText('Select Log Groups'));
|
||||
await waitFor(() => expect(screen.getByText('0 log groups selected')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByLabelText('logGroup2'));
|
@ -1,54 +1,64 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
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 Search from '../Search';
|
||||
import { DescribeLogGroupsRequest, LogGroup, LogGroupResponse, ResourceResponse } from '../types';
|
||||
|
||||
import { Account, ALL_ACCOUNTS_OPTION } from './Account';
|
||||
import { SelectedLogsGroups } from './SelectedLogsGroups';
|
||||
import getStyles from './styles';
|
||||
import Search from '../../Search';
|
||||
import { DescribeLogGroupsRequest, LogGroup, LogGroupResponse, ResourceResponse } from '../../types';
|
||||
import { Account, ALL_ACCOUNTS_OPTION } from '../Account';
|
||||
import getStyles from '../styles';
|
||||
|
||||
type CrossAccountLogsQueryProps = {
|
||||
selectedLogGroups: LogGroup[];
|
||||
accountOptions: Array<SelectableValue<string>>;
|
||||
selectedLogGroups?: LogGroup[];
|
||||
accountOptions?: Array<SelectableValue<string>>;
|
||||
fetchLogGroups: (params: Partial<DescribeLogGroupsRequest>) => Promise<Array<ResourceResponse<LogGroupResponse>>>;
|
||||
onChange: (selectedLogGroups: LogGroup[]) => void;
|
||||
onBeforeOpen?: () => void;
|
||||
};
|
||||
|
||||
export const CrossAccountLogsQueryField = (props: CrossAccountLogsQueryProps) => {
|
||||
export const LogGroupsSelector = ({
|
||||
accountOptions = [],
|
||||
fetchLogGroups,
|
||||
onChange,
|
||||
onBeforeOpen,
|
||||
...props
|
||||
}: CrossAccountLogsQueryProps) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectableLogGroups, setSelectableLogGroups] = useState<LogGroup[]>([]);
|
||||
const [selectedLogGroups, setSelectedLogGroups] = useState(props.selectedLogGroups);
|
||||
const [selectedLogGroups, setSelectedLogGroups] = useState(props.selectedLogGroups ?? []);
|
||||
const [searchPhrase, setSearchPhrase] = useState('');
|
||||
const [searchAccountId, setSearchAccountId] = useState(ALL_ACCOUNTS_OPTION.value);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedLogGroups(props.selectedLogGroups ?? []);
|
||||
}, [props.selectedLogGroups]);
|
||||
|
||||
const toggleModal = () => {
|
||||
setIsModalOpen(!isModalOpen);
|
||||
if (isModalOpen) {
|
||||
} else {
|
||||
setSelectedLogGroups(props.selectedLogGroups);
|
||||
setSelectedLogGroups(selectedLogGroups);
|
||||
searchFn(searchPhrase, searchAccountId);
|
||||
}
|
||||
};
|
||||
|
||||
const accountNameById = useMemo(() => {
|
||||
const idsToNames: Record<string, string> = {};
|
||||
props.accountOptions.forEach((a) => {
|
||||
accountOptions.forEach((a) => {
|
||||
if (a.value && a.label) {
|
||||
idsToNames[a.value] = a.label;
|
||||
}
|
||||
});
|
||||
return idsToNames;
|
||||
}, [props.accountOptions]);
|
||||
}, [accountOptions]);
|
||||
|
||||
const searchFn = async (searchTerm?: string, accountId?: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const possibleLogGroups = await props.fetchLogGroups({
|
||||
const possibleLogGroups = await fetchLogGroups({
|
||||
logGroupPattern: searchTerm,
|
||||
accountId: accountId,
|
||||
});
|
||||
@ -75,12 +85,12 @@ export const CrossAccountLogsQueryField = (props: CrossAccountLogsQueryProps) =>
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
props.onChange(selectedLogGroups);
|
||||
onChange(selectedLogGroups);
|
||||
toggleModal();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectedLogGroups(props.selectedLogGroups);
|
||||
setSelectedLogGroups(selectedLogGroups);
|
||||
toggleModal();
|
||||
};
|
||||
|
||||
@ -89,7 +99,7 @@ export const CrossAccountLogsQueryField = (props: CrossAccountLogsQueryProps) =>
|
||||
<Modal className={styles.modal} title="Select Log Groups" isOpen={isModalOpen} onDismiss={toggleModal}>
|
||||
<div className={styles.logGroupSelectionArea}>
|
||||
<div className={styles.searchField}>
|
||||
<EditorField label="Log Group Name">
|
||||
<EditorField label="Log group name prefix">
|
||||
<Search
|
||||
searchFn={(phrase) => {
|
||||
searchFn(phrase, searchAccountId);
|
||||
@ -105,7 +115,7 @@ export const CrossAccountLogsQueryField = (props: CrossAccountLogsQueryProps) =>
|
||||
searchFn(searchPhrase, accountId);
|
||||
setSearchAccountId(accountId || ALL_ACCOUNTS_OPTION.value);
|
||||
}}
|
||||
accountOptions={props.accountOptions}
|
||||
accountOptions={accountOptions}
|
||||
accountId={searchAccountId}
|
||||
/>
|
||||
</div>
|
||||
@ -154,7 +164,7 @@ export const CrossAccountLogsQueryField = (props: CrossAccountLogsQueryProps) =>
|
||||
value={!!(row.arn && selectedLogGroups.some((lg) => lg.arn === row.arn))}
|
||||
/>
|
||||
<Space layout="inline" h={2} />
|
||||
<label className={styles.logGroupSearchResults} htmlFor={row.arn}>
|
||||
<label className={styles.logGroupSearchResults} htmlFor={row.arn} title={row.name}>
|
||||
{row.name}
|
||||
</label>
|
||||
</div>
|
||||
@ -183,12 +193,19 @@ export const CrossAccountLogsQueryField = (props: CrossAccountLogsQueryProps) =>
|
||||
</Modal>
|
||||
|
||||
<div>
|
||||
<Button variant="secondary" onClick={toggleModal} type="button">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
try {
|
||||
onBeforeOpen?.();
|
||||
toggleModal();
|
||||
} catch (err) {}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Select Log Groups
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SelectedLogsGroups selectedLogGroups={props.selectedLogGroups} onChange={props.onChange}></SelectedLogsGroups>
|
||||
</>
|
||||
);
|
||||
};
|
@ -2,9 +2,9 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { LogGroup } from '../types';
|
||||
import { LogGroup } from '../../types';
|
||||
|
||||
import { SelectedLogsGroups } from './SelectedLogsGroups';
|
||||
import { SelectedLogGroups } from './SelectedLogGroups';
|
||||
|
||||
const selectedLogGroups: LogGroup[] = [
|
||||
{
|
||||
@ -27,11 +27,11 @@ describe('SelectedLogsGroups', () => {
|
||||
});
|
||||
describe("'Show more' button", () => {
|
||||
it('should not be displayed in case 0 logs have been selected', async () => {
|
||||
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={[]} />);
|
||||
render(<SelectedLogGroups {...defaultProps} selectedLogGroups={[]} />);
|
||||
await waitFor(() => expect(screen.queryByText('Show all')).not.toBeInTheDocument());
|
||||
});
|
||||
it('should not be displayed in case logs group have been selected but theyre less than 10', async () => {
|
||||
render(<SelectedLogsGroups {...defaultProps} />);
|
||||
render(<SelectedLogGroups {...defaultProps} />);
|
||||
await waitFor(() => expect(screen.queryByText('Show all')).not.toBeInTheDocument());
|
||||
});
|
||||
it('should be displayed in case more than 10 log groups have been selected', async () => {
|
||||
@ -39,14 +39,14 @@ describe('SelectedLogsGroups', () => {
|
||||
arn: `logGroup${i}`,
|
||||
name: `logGroup${i}`,
|
||||
}));
|
||||
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={selectedLogGroups} />);
|
||||
render(<SelectedLogGroups {...defaultProps} selectedLogGroups={selectedLogGroups} />);
|
||||
await waitFor(() => expect(screen.getByText('Show all')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
describe("'Clear selection' button", () => {
|
||||
it('should not be displayed in case 0 logs have been selected', async () => {
|
||||
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={[]} />);
|
||||
render(<SelectedLogGroups {...defaultProps} selectedLogGroups={[]} />);
|
||||
await waitFor(() => expect(screen.queryByText('Clear selection')).not.toBeInTheDocument());
|
||||
});
|
||||
it('should be displayed in case at least one log group have been selected', async () => {
|
||||
@ -54,7 +54,7 @@ describe('SelectedLogsGroups', () => {
|
||||
arn: `logGroup${i}`,
|
||||
name: `logGroup${i}`,
|
||||
}));
|
||||
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={selectedLogGroups} />);
|
||||
render(<SelectedLogGroups {...defaultProps} selectedLogGroups={selectedLogGroups} />);
|
||||
await waitFor(() => expect(screen.getByText('Clear selection')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
@ -63,7 +63,7 @@ describe('SelectedLogsGroups', () => {
|
||||
arn: `logGroup${i}`,
|
||||
name: `logGroup${i}`,
|
||||
}));
|
||||
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={selectedLogGroups} />);
|
||||
render(<SelectedLogGroups {...defaultProps} selectedLogGroups={selectedLogGroups} />);
|
||||
await waitFor(() => userEvent.click(screen.getByText('Clear selection')));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Are you sure you want to clear all log groups?')).toBeInTheDocument()
|
||||
@ -75,7 +75,7 @@ describe('SelectedLogsGroups', () => {
|
||||
|
||||
describe("'Clear selection' button", () => {
|
||||
it('should not be displayed in case 0 logs have been selected', async () => {
|
||||
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={[]} />);
|
||||
render(<SelectedLogGroups {...defaultProps} selectedLogGroups={[]} />);
|
||||
await waitFor(() => expect(screen.queryByText('Clear selection')).not.toBeInTheDocument());
|
||||
});
|
||||
it('should be displayed in case at least one log group have been selected', async () => {
|
||||
@ -83,7 +83,7 @@ describe('SelectedLogsGroups', () => {
|
||||
arn: `logGroup${i}`,
|
||||
name: `logGroup${i}`,
|
||||
}));
|
||||
render(<SelectedLogsGroups {...defaultProps} selectedLogGroups={selectedLogGroups} />);
|
||||
render(<SelectedLogGroups {...defaultProps} selectedLogGroups={selectedLogGroups} />);
|
||||
await waitFor(() => expect(screen.getByText('Clear selection')).toBeInTheDocument());
|
||||
});
|
||||
});
|
@ -2,27 +2,31 @@ import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { Button, ConfirmModal, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { LogGroup } from '../types';
|
||||
|
||||
import getStyles from './styles';
|
||||
import { LogGroup } from '../../types';
|
||||
import getStyles from '../styles';
|
||||
|
||||
type CrossAccountLogsQueryProps = {
|
||||
selectedLogGroups: LogGroup[];
|
||||
selectedLogGroups?: LogGroup[];
|
||||
onChange: (selectedLogGroups: LogGroup[]) => void;
|
||||
maxNoOfVisibleLogGroups?: number;
|
||||
};
|
||||
|
||||
const MAX_VISIBLE_LOG_GROUPS = 6;
|
||||
const MAX_NO_OF_VISIBLE_LOG_GROUPS = 6;
|
||||
|
||||
export const SelectedLogsGroups = ({ selectedLogGroups, onChange }: CrossAccountLogsQueryProps) => {
|
||||
export const SelectedLogGroups = ({
|
||||
selectedLogGroups = [],
|
||||
onChange,
|
||||
maxNoOfVisibleLogGroups = MAX_NO_OF_VISIBLE_LOG_GROUPS,
|
||||
}: CrossAccountLogsQueryProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [visibleSelectecLogGroups, setVisibleSelectecLogGroups] = useState(
|
||||
selectedLogGroups.slice(0, MAX_VISIBLE_LOG_GROUPS)
|
||||
selectedLogGroups.slice(0, MAX_NO_OF_VISIBLE_LOG_GROUPS)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleSelectecLogGroups(selectedLogGroups.slice(0, MAX_VISIBLE_LOG_GROUPS));
|
||||
}, [selectedLogGroups]);
|
||||
setVisibleSelectecLogGroups(selectedLogGroups.slice(0, maxNoOfVisibleLogGroups));
|
||||
}, [selectedLogGroups, maxNoOfVisibleLogGroups]);
|
||||
|
||||
return (
|
||||
<>
|
@ -1,35 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import _, { DebouncedFunc } from 'lodash'; // eslint-disable-line lodash/import-scope
|
||||
import React from 'react';
|
||||
|
||||
import { ExploreId } from '../../../../types';
|
||||
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
|
||||
|
||||
import CloudWatchLogsQueryField from './LogsQueryField';
|
||||
|
||||
jest
|
||||
.spyOn(_, 'debounce')
|
||||
.mockImplementation((func: (...args: any) => any, wait?: number) => func as DebouncedFunc<typeof func>);
|
||||
|
||||
describe('CloudWatchLogsQueryField', () => {
|
||||
it('loads defaultLogGroups', async () => {
|
||||
const onRunQuery = jest.fn();
|
||||
const ds = setupMockedDataSource();
|
||||
ds.datasource.logsQueryRunner.defaultLogGroups = ['foo'];
|
||||
|
||||
render(
|
||||
<CloudWatchLogsQueryField
|
||||
absoluteRange={{ from: 1, to: 10 }}
|
||||
exploreId={ExploreId.left}
|
||||
datasource={ds.datasource}
|
||||
query={{} as any}
|
||||
onRunQuery={onRunQuery}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('foo')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
@ -23,7 +23,7 @@ import syntax from '../syntax';
|
||||
import { CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchQuery } from '../types';
|
||||
import { getStatsGroups } from '../utils/query/getStatsGroups';
|
||||
|
||||
import { LogGroupSelection } from './LogGroupSelection';
|
||||
import { LogGroupsField } from './LogGroups/LogGroupsField';
|
||||
|
||||
export interface CloudWatchLogsQueryFieldProps
|
||||
extends QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData>,
|
||||
@ -84,7 +84,15 @@ export const CloudWatchLogsQueryField = (props: CloudWatchLogsQueryFieldProps) =
|
||||
|
||||
return (
|
||||
<>
|
||||
<LogGroupSelection datasource={datasource} query={query} onChange={onChange} />
|
||||
<LogGroupsField
|
||||
region={query.region}
|
||||
datasource={datasource}
|
||||
legacyLogGroupNames={query.logGroupNames}
|
||||
logGroups={query.logGroups}
|
||||
onChange={(logGroups) => {
|
||||
onChange({ ...query, logGroups, logGroupNames: undefined });
|
||||
}}
|
||||
/>
|
||||
<div className="gf-form-inline gf-form-inline--nowrap flex-grow-1">
|
||||
<div className="gf-form gf-form--grow flex-shrink-1">
|
||||
<QueryField
|
||||
|
@ -100,7 +100,7 @@ describe('QueryEditor should render right editor', () => {
|
||||
});
|
||||
|
||||
describe('when using grafana 7.0.0 style logs query', () => {
|
||||
it('should render the metrics query editor', async () => {
|
||||
it('should render the logs query editor', async () => {
|
||||
const query = {
|
||||
...migratedFields,
|
||||
alias: '',
|
||||
@ -123,7 +123,7 @@ describe('QueryEditor should render right editor', () => {
|
||||
await act(async () => {
|
||||
render(<QueryEditor {...props} query={query} />);
|
||||
});
|
||||
expect(screen.getByText('Log Groups')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select Log Groups')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -50,7 +50,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
padding: theme.spacing(1, 1, 1, 0),
|
||||
width: '25%',
|
||||
'&:first-of-type': {
|
||||
width: '50%',
|
||||
width: '80%',
|
||||
padding: theme.spacing(1, 1, 1, 2),
|
||||
},
|
||||
}),
|
||||
|
@ -55,7 +55,7 @@ export class CloudWatchDatasource
|
||||
api: CloudWatchAPI;
|
||||
|
||||
constructor(
|
||||
instanceSettings: DataSourceInstanceSettings<CloudWatchJsonData>,
|
||||
private instanceSettings: DataSourceInstanceSettings<CloudWatchJsonData>,
|
||||
readonly templateSrv: TemplateSrv = getTemplateSrv(),
|
||||
timeSrv: TimeSrv = getTimeSrv()
|
||||
) {
|
||||
@ -70,7 +70,6 @@ export class CloudWatchDatasource
|
||||
this.annotationQueryRunner = new CloudWatchAnnotationQueryRunner(instanceSettings, templateSrv);
|
||||
this.variables = new CloudWatchVariableSupport(this.api);
|
||||
this.annotations = CloudWatchAnnotationSupport;
|
||||
this.defaultLogGroups = instanceSettings.jsonData.defaultLogGroups;
|
||||
}
|
||||
|
||||
filterQuery(query: CloudWatchQuery) {
|
||||
@ -180,7 +179,7 @@ export class CloudWatchDatasource
|
||||
|
||||
getDefaultQuery(_: CoreApp): Partial<CloudWatchQuery> {
|
||||
return {
|
||||
...getDefaultLogsQuery(this.defaultLogGroups),
|
||||
...getDefaultLogsQuery(this.instanceSettings.jsonData.logGroups, this.instanceSettings.jsonData.defaultLogGroups),
|
||||
...DEFAULT_METRICS_QUERY,
|
||||
};
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { CloudWatchLogsQuery, CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType } from './types';
|
||||
import { CloudWatchLogsQuery, CloudWatchMetricsQuery, LogGroup, MetricEditorMode, MetricQueryType } from './types';
|
||||
|
||||
export const DEFAULT_METRICS_QUERY: Omit<CloudWatchMetricsQuery, 'refId'> = {
|
||||
queryMode: 'Metrics',
|
||||
@ -16,9 +16,15 @@ export const DEFAULT_METRICS_QUERY: Omit<CloudWatchMetricsQuery, 'refId'> = {
|
||||
matchExact: true,
|
||||
};
|
||||
|
||||
export const getDefaultLogsQuery = (defaultLogGroups?: string[]): Omit<CloudWatchLogsQuery, 'refId' | 'queryMode'> => ({
|
||||
export const getDefaultLogsQuery = (
|
||||
defaultLogGroups?: LogGroup[],
|
||||
legacyDefaultLogGroups?: string[]
|
||||
): Omit<CloudWatchLogsQuery, 'refId' | 'queryMode'> => ({
|
||||
id: '',
|
||||
region: 'default',
|
||||
expression: '',
|
||||
logGroupNames: defaultLogGroups,
|
||||
// in case legacy default log groups have been defined in the ConfigEditor, they will be migrated in the LogGroupsField component or the next time the ConfigEditor is opened.
|
||||
// the migration requires async backend calls, so we don't want to do it here as it would block the UI.
|
||||
logGroupNames: legacyDefaultLogGroups,
|
||||
logGroups: defaultLogGroups ?? [],
|
||||
});
|
||||
|
@ -119,20 +119,20 @@ export const useIsMonitoringAccount = (api: CloudWatchAPI, region: string) => {
|
||||
};
|
||||
|
||||
export const useAccountOptions = (
|
||||
api: Pick<CloudWatchAPI, 'getAccounts' | 'templateSrv' | 'getVariables'>,
|
||||
api: Pick<CloudWatchAPI, 'getAccounts' | 'templateSrv' | 'getVariables'> | undefined,
|
||||
region: string
|
||||
) => {
|
||||
// we call this before the use effect to ensure dependency array below
|
||||
// receives the interpolated value so that the effect is triggered when a variable is changed
|
||||
if (region) {
|
||||
region = api.templateSrv.replace(region, {});
|
||||
region = api?.templateSrv.replace(region, {}) ?? '';
|
||||
}
|
||||
|
||||
const fetchAccountOptions = async () => {
|
||||
if (!config.featureToggles.cloudWatchCrossAccountQuerying) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
const accounts = await api.getAccounts({ region });
|
||||
const accounts = (await api?.getAccounts({ region })) ?? [];
|
||||
if (accounts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@ -143,7 +143,7 @@ export const useAccountOptions = (
|
||||
description: a.id,
|
||||
}));
|
||||
|
||||
const variableOptions = api.getVariables().map(toOption);
|
||||
const variableOptions = api?.getVariables().map(toOption) || [];
|
||||
|
||||
const variableOptionGroup: SelectableValue<string> = {
|
||||
label: 'Template Variables',
|
||||
|
@ -58,7 +58,6 @@ export const LOGSTREAM_IDENTIFIER_INTERNAL = '__logstream__grafana_internal__';
|
||||
// This class handles execution of CloudWatch logs query data queries
|
||||
export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
|
||||
logsTimeout: string;
|
||||
defaultLogGroups: string[];
|
||||
logQueries: Record<string, { id: string; region: string; statsQuery: boolean }> = {};
|
||||
tracingDataSourceUid?: string;
|
||||
|
||||
@ -71,7 +70,6 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
|
||||
|
||||
this.tracingDataSourceUid = instanceSettings.jsonData.tracingDatasourceUid;
|
||||
this.logsTimeout = instanceSettings.jsonData.logsTimeout || '15m';
|
||||
this.defaultLogGroups = instanceSettings.jsonData.defaultLogGroups || [];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -89,8 +87,8 @@ export class CloudWatchLogsQueryRunner extends CloudWatchRequest {
|
||||
const startQueryRequests: StartQueryRequest[] = validLogQueries.map((target: CloudWatchLogsQuery) => ({
|
||||
queryString: target.expression || '',
|
||||
refId: target.refId,
|
||||
logGroupNames: target.logGroupNames || this.defaultLogGroups,
|
||||
logGroups: target.logGroups || [], //todo handle defaults
|
||||
logGroupNames: target.logGroupNames || this.instanceSettings.jsonData.defaultLogGroups || [],
|
||||
logGroups: target.logGroups || this.instanceSettings.jsonData.logGroups,
|
||||
region: super.replaceVariableAndDisplayWarningIfMulti(
|
||||
this.getActualRegion(target.region),
|
||||
options.scopedVars,
|
||||
|
@ -131,6 +131,11 @@ export interface CloudWatchJsonData extends AwsAuthDataSourceJsonData {
|
||||
logsTimeout?: string;
|
||||
// Used to create links if logs contain traceId.
|
||||
tracingDatasourceUid?: string;
|
||||
|
||||
logGroups?: LogGroup[];
|
||||
/**
|
||||
* @deprecated use logGroups
|
||||
*/
|
||||
defaultLogGroups?: string[];
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user