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:
Erik Sundell 2023-01-09 16:30:21 +01:00 committed by GitHub
parent 2b61fb6e4a
commit c3378aff8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 497 additions and 824 deletions

View File

@ -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"]

View File

@ -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

View File

@ -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)
}

View File

@ -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

View File

@ -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))

View File

@ -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(),

View File

@ -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)),

View File

@ -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());
});
});

View File

@ -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;
}

View File

@ -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();
});
});

View File

@ -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>
);
};

View File

@ -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']);
});
});

View File

@ -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}
/>
);
};

View File

@ -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();
});
});

View File

@ -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>
);
};

View File

@ -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'));

View File

@ -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>
</>
);
};

View File

@ -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());
});
});

View File

@ -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 (
<>

View File

@ -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();
});
});
});

View File

@ -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

View File

@ -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();
});
});

View File

@ -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),
},
}),

View File

@ -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,
};
}

View File

@ -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 ?? [],
});

View File

@ -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',

View File

@ -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,

View File

@ -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[];
}