Cloudwatch: Add back support for old Log Group picker (#73524)

Cloudwatch: Add back support for old Log Group picker behind feature toggle. 

Fixes issue for aws gov cloud users
This commit is contained in:
Sarah Zinger 2023-08-31 10:13:24 -04:00 committed by GitHub
parent 62821c69b3
commit 34a831c83e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 328 additions and 48 deletions

View File

@ -8,10 +8,12 @@ import (
"net/url"
"reflect"
"sort"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/cloudwatchlogs"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi"
"github.com/grafana/grafana-plugin-sdk-go/backend"
@ -282,3 +284,38 @@ func (e *cloudWatchExecutor) resourceGroupsGetResources(ctx context.Context, plu
return &resp, nil
}
// legacy route, will be removed once GovCloud supports Cross Account Observability
func (e *cloudWatchExecutor) handleGetLogGroups(ctx context.Context, pluginCtx backend.PluginContext, parameters url.Values) ([]suggestData, error) {
region := parameters.Get("region")
limit := parameters.Get("limit")
logGroupNamePrefix := parameters.Get("logGroupNamePrefix")
logsClient, err := e.getCWLogsClient(ctx, pluginCtx, region)
if err != nil {
return nil, err
}
logGroupLimit := defaultLogGroupLimit
intLimit, err := strconv.ParseInt(limit, 10, 64)
if err == nil && intLimit > 0 {
logGroupLimit = intLimit
}
input := &cloudwatchlogs.DescribeLogGroupsInput{Limit: aws.Int64(logGroupLimit)}
if len(logGroupNamePrefix) > 0 {
input.LogGroupNamePrefix = aws.String(logGroupNamePrefix)
}
var response *cloudwatchlogs.DescribeLogGroupsOutput
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
}

View File

@ -28,6 +28,9 @@ func (e *cloudWatchExecutor) newResourceMux() *http.ServeMux {
mux.HandleFunc("/log-group-fields", routes.ResourceRequestMiddleware(routes.LogGroupFieldsHandler, logger, e.getRequestContext))
mux.HandleFunc("/external-id", routes.ResourceRequestMiddleware(routes.ExternalIdHandler, logger, e.getRequestContext))
// remove this once AWS's Cross Account Observability is supported in GovCloud
mux.HandleFunc("/legacy-log-groups", handleResourceReq(e.handleGetLogGroups))
return mux
}

View File

@ -39,6 +39,9 @@ jest.mock('@grafana/runtime', () => ({
config: {
...jest.requireActual('@grafana/runtime').config,
awsAssumeRoleEnabled: true,
featureToggles: {
cloudWatchCrossAccountQuerying: true,
},
},
}));
@ -166,7 +169,7 @@ describe('Render', () => {
it('should display log group selector field', async () => {
setup();
await waitFor(async () => expect(await screen.getByText('Select log groups')).toBeInTheDocument());
await waitFor(async () => expect(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 () => {
@ -204,9 +207,7 @@ describe('Render', () => {
});
it('should show error message if Select log group button is clicked when data source is never saved', async () => {
const SAVED_VERSION = undefined;
setup({ version: SAVED_VERSION });
setup({ version: 1 });
await waitFor(() => expect(screen.getByText('Select log groups')).toBeInTheDocument());
await userEvent.click(screen.getByText('Select log groups'));
await waitFor(() =>
@ -265,12 +266,11 @@ describe('Render', () => {
});
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,
version: 1,
},
};
const meta: PluginMeta = {
@ -291,7 +291,7 @@ describe('Render', () => {
...newProps,
options: {
...newProps.options,
version: 1,
version: 2,
},
};
rerender(

View File

@ -21,7 +21,7 @@ import { store } from 'app/store/store';
import { CloudWatchDatasource } from '../../datasource';
import { SelectableResourceValue } from '../../resources/types';
import { CloudWatchJsonData, CloudWatchSecureJsonData } from '../../types';
import { LogGroupsField } from '../shared/LogGroups/LogGroupsField';
import { LogGroupsFieldWrapper } from '../shared/LogGroups/LogGroupsField';
import { XrayLinkConfig } from './XrayLinkConfig';
@ -124,39 +124,47 @@ export const ConfigEditor = (props: Props) => {
shrink={true}
{...logGroupFieldState}
>
<LogGroupsField
region={defaultRegion ?? ''}
datasource={datasource}
onBeforeOpen={() => {
if (saved) {
return;
}
{datasource ? (
<LogGroupsFieldWrapper
region={defaultRegion ?? ''}
datasource={datasource}
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);
}}
legacyLogGroupNames={defaultLogGroups}
logGroups={logGroups}
onChange={(updatedLogGroups) => {
onOptionsChange({
...props.options,
jsonData: {
...props.options.jsonData,
logGroups: updatedLogGroups,
defaultLogGroups: undefined,
},
});
}}
maxNoOfVisibleLogGroups={2}
/>
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);
}}
legacyLogGroupNames={defaultLogGroups}
logGroups={logGroups}
onChange={(updatedLogGroups) => {
onOptionsChange({
...props.options,
jsonData: {
...props.options.jsonData,
logGroups: updatedLogGroups,
defaultLogGroups: undefined,
},
});
}}
maxNoOfVisibleLogGroups={2}
//legacy props
legacyOnChange={(logGroups) => {
updateDatasourcePluginJsonDataOption(props, 'defaultLogGroups', logGroups);
}}
/>
) : (
<></>
)}
</InlineField>
</div>
<XrayLinkConfig
@ -242,7 +250,7 @@ function useDataSourceSavedState(props: Props) {
]);
useEffect(() => {
props.options.version && setSaved(true);
props.options.version && props.options.version > 1 && setSaved(true);
}, [props.options.version]);
return saved;

View File

@ -10,7 +10,7 @@ import { TRIGGER_SUGGEST } from '../../../language/monarch/commands';
import { registerLanguage } from '../../../language/monarch/register';
import { CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchQuery } from '../../../types';
import { getStatsGroups } from '../../../utils/query/getStatsGroups';
import { LogGroupsField } from '../../shared/LogGroups/LogGroupsField';
import { LogGroupsFieldWrapper } from '../../shared/LogGroups/LogGroupsField';
export interface CloudWatchLogsQueryFieldProps
extends QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData>,
@ -47,7 +47,7 @@ export const CloudWatchLogsQueryFieldMonaco = (props: CloudWatchLogsQueryFieldPr
return (
<>
<LogGroupsField
<LogGroupsFieldWrapper
region={query.region}
datasource={datasource}
legacyLogGroupNames={query.logGroupNames}
@ -55,6 +55,10 @@ export const CloudWatchLogsQueryFieldMonaco = (props: CloudWatchLogsQueryFieldPr
onChange={(logGroups) => {
onChange({ ...query, logGroups, logGroupNames: undefined });
}}
//legacy props
legacyOnChange={(logGroupNames) => {
onChange({ ...query, logGroupNames });
}}
/>
<div className="gf-form-inline gf-form-inline--nowrap flex-grow-1">
<div className="gf-form--grow flex-shrink-1">

View File

@ -18,9 +18,9 @@ import {
// dom also includes Element polyfills
import { CloudWatchDatasource } from '../../../datasource';
import syntax from '../../../language/cloudwatch-logs/syntax';
import { CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchQuery } from '../../../types';
import { CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchQuery, LogGroup } from '../../../types';
import { getStatsGroups } from '../../../utils/query/getStatsGroups';
import { LogGroupsField } from '../../shared/LogGroups/LogGroupsField';
import { LogGroupsFieldWrapper } from '../../shared/LogGroups/LogGroupsField';
export interface CloudWatchLogsQueryFieldProps
extends QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData>,
@ -81,14 +81,18 @@ export const CloudWatchLogsQueryField = (props: CloudWatchLogsQueryFieldProps) =
return (
<>
<LogGroupsField
<LogGroupsFieldWrapper
region={query.region}
datasource={datasource}
legacyLogGroupNames={query.logGroupNames}
logGroups={query.logGroups}
onChange={(logGroups) => {
onChange={(logGroups: LogGroup[]) => {
onChange({ ...query, logGroups, logGroupNames: undefined });
}}
//legacy props can be removed once we remove support for Legacy Log Group Selector
legacyOnChange={(logGroups: string[]) => {
onChange({ ...query, logGroupNames: logGroups });
}}
/>
<div className="gf-form-inline gf-form-inline--nowrap flex-grow-1">
<div className="gf-form gf-form--grow flex-shrink-1">

View File

@ -45,6 +45,16 @@ jest.mock('./MetricsQueryEditor/SQLCodeEditor', () => ({
},
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
featureToggles: {
cloudWatchCrossAccountQuerying: true,
},
},
}));
export { SQLCodeEditor } from './MetricsQueryEditor/SQLCodeEditor';
describe('QueryEditor should render right editor', () => {

View File

@ -0,0 +1,30 @@
import { css } from '@emotion/css';
import React from 'react';
import { CloudWatchDatasource } from '../../../datasource';
import { LogGroupSelector } from './LegacyLogGroupSelector';
type Props = {
datasource: CloudWatchDatasource;
onChange: (logGroups: string[]) => void;
region: string;
legacyLogGroupNames: string[];
};
const rowGap = css`
gap: 3px;
`;
export const LegacyLogGroupSelection = ({ datasource, region, legacyLogGroupNames, onChange }: Props) => {
return (
<div className={`gf-form gf-form--grow flex-grow-1 ${rowGap}`}>
<LogGroupSelector
region={region}
selectedLogGroups={legacyLogGroupNames}
datasource={datasource}
onChange={onChange}
/>
</div>
);
};

View File

@ -0,0 +1,144 @@
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>;
width?: number | 'auto';
saved?: boolean; // is only used in the config editor
}
export const LogGroupSelector: React.FC<LogGroupSelectorProps> = ({
region,
selectedLogGroups,
onChange,
datasource,
onOpenMenu,
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.resources.legacyDescribeLogGroups(region, logGroupNamePrefix);
return logGroups;
} catch (err) {
dispatch(notifyApp(createErrorNotification(typeof err === 'string' ? err : JSON.stringify(err))));
return [];
}
},
[datasource]
);
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

@ -1,17 +1,20 @@
import { css } from '@emotion/css';
import React, { useEffect, useState } from 'react';
import { config } from '@grafana/runtime';
import { CloudWatchDatasource } from '../../../datasource';
import { useAccountOptions } from '../../../hooks';
import { DescribeLogGroupsRequest } from '../../../resources/types';
import { LogGroup } from '../../../types';
import { isTemplateVariable } from '../../../utils/templateVariableUtils';
import { LegacyLogGroupSelection } from './LegacyLogGroupNamesSelection';
import { LogGroupsSelector } from './LogGroupsSelector';
import { SelectedLogGroups } from './SelectedLogGroups';
type Props = {
datasource?: CloudWatchDatasource;
datasource: CloudWatchDatasource;
onChange: (logGroups: LogGroup[]) => void;
legacyLogGroupNames?: string[];
logGroups?: LogGroup[];
@ -90,3 +93,32 @@ export const LogGroupsField = ({
</div>
);
};
// We had to bring back the Legacy Log Group selector to support due to an issue where GovClouds do not support the new Log Group API
// when that is fixed we can get rid of this wrapper component and just export the LogGroupsField
type WrapperProps = {
datasource: CloudWatchDatasource;
onChange: (logGroups: LogGroup[]) => void;
legacyLogGroupNames?: string[]; // will need this for a while for migration purposes
logGroups?: LogGroup[];
region: string;
maxNoOfVisibleLogGroups?: number;
onBeforeOpen?: () => void;
// Legacy Props, can remove once we remove support for Legacy Log Group Selector
legacyOnChange: (logGroups: string[]) => void;
};
export const LogGroupsFieldWrapper = (props: WrapperProps) => {
if (!config.featureToggles.cloudWatchCrossAccountQuerying) {
return (
<LegacyLogGroupSelection
{...props}
onChange={props.legacyOnChange}
legacyLogGroupNames={props.legacyLogGroupNames || []}
/>
);
}
return <LogGroupsField {...props} />;
};

View File

@ -72,6 +72,7 @@ export class CloudWatchDatasource
this.annotationQueryRunner = new CloudWatchAnnotationQueryRunner(instanceSettings, templateSrv);
this.variables = new CloudWatchVariableSupport(this.resources);
this.annotations = CloudWatchAnnotationSupport;
this.defaultLogGroups = instanceSettings.jsonData.defaultLogGroups;
}
filterQuery(query: CloudWatchQuery) {

View File

@ -164,4 +164,11 @@ export class ResourcesAPI extends CloudWatchRequest {
tags: JSON.stringify(this.convertMultiFilterFormat(tags, 'tag name')),
});
}
legacyDescribeLogGroups(region: string, logGroupNamePrefix?: string) {
return this.memoizedGetRequest<SelectableResourceValue[]>('legacy-log-groups', {
region: this.templateSrv.replace(this.getActualRegion(region)),
logGroupNamePrefix: logGroupNamePrefix || '',
});
}
}